diff --git a/_diag/p2r-phase5-sweep/01-repo-page.png b/_diag/p2r-phase5-sweep/01-repo-page.png
new file mode 100644
index 0000000..0bfee98
Binary files /dev/null and b/_diag/p2r-phase5-sweep/01-repo-page.png differ
diff --git a/_diag/p2r-phase5-sweep/02-check-job-running.png b/_diag/p2r-phase5-sweep/02-check-job-running.png
new file mode 100644
index 0000000..4f3741e
Binary files /dev/null and b/_diag/p2r-phase5-sweep/02-check-job-running.png differ
diff --git a/_diag/p2r-phase5-sweep/03-check-job-done.png b/_diag/p2r-phase5-sweep/03-check-job-done.png
new file mode 100644
index 0000000..4f3741e
Binary files /dev/null and b/_diag/p2r-phase5-sweep/03-check-job-done.png differ
diff --git a/_diag/p2r-phase5-sweep/04-repo-after-check.png b/_diag/p2r-phase5-sweep/04-repo-after-check.png
new file mode 100644
index 0000000..435cb87
Binary files /dev/null and b/_diag/p2r-phase5-sweep/04-repo-after-check.png differ
diff --git a/_diag/p2r-phase5-sweep/05-unlock-job.png b/_diag/p2r-phase5-sweep/05-unlock-job.png
new file mode 100644
index 0000000..d0c1e7d
Binary files /dev/null and b/_diag/p2r-phase5-sweep/05-unlock-job.png differ
diff --git a/_diag/p2r-phase5-sweep/06-dashboard.png b/_diag/p2r-phase5-sweep/06-dashboard.png
new file mode 100644
index 0000000..377a278
Binary files /dev/null and b/_diag/p2r-phase5-sweep/06-dashboard.png differ
diff --git a/cmd/agent/main.go b/cmd/agent/main.go
index cb38457..d401640 100644
--- a/cmd/agent/main.go
+++ b/cmd/agent/main.go
@@ -2,13 +2,13 @@ package main
import (
"context"
- "encoding/json"
"errors"
"flag"
"fmt"
"log/slog"
"os"
"os/signal"
+ "strconv"
"syscall"
"time"
@@ -199,32 +199,68 @@ func (d *dispatcher) handle(ctx context.Context, env api.Envelope, tx wsclient.S
case api.MsgConfigUpdate:
var p api.ConfigUpdatePayload
_ = env.UnmarshalPayload(&p)
- // Merge with whatever's already in secrets.enc — empty fields
- // in the push mean "leave alone." Atomic write underneath.
- cur, err := d.secrets.Load()
- if err != nil {
- slog.Error("ws agent: load secrets for merge", "err", err)
- return nil
+ slot := p.Slot
+ if slot == "" {
+ slot = "repo"
}
- changed := false
- if p.RepoURL != "" && p.RepoURL != cur.URL {
- cur.URL = p.RepoURL
- changed = true
- }
- if p.RepoUsername != "" && p.RepoUsername != cur.Username {
- cur.Username = p.RepoUsername
- changed = true
- }
- if p.RepoPassword != "" && p.RepoPassword != cur.Password {
- cur.Password = p.RepoPassword
- changed = true
- }
- if changed {
- if err := d.secrets.Save(cur); err != nil {
- slog.Error("ws agent: persist secrets", "err", err)
+ switch slot {
+ case "repo":
+ // Merge with whatever's already in secrets.enc — empty fields
+ // in the push mean "leave alone." Atomic write underneath.
+ cur, err := d.secrets.Load()
+ if err != nil {
+ slog.Error("ws agent: load secrets for merge", "err", err)
return nil
}
- slog.Info("ws agent: repo credentials updated via config.update")
+ changed := false
+ if p.RepoURL != "" && p.RepoURL != cur.URL {
+ cur.URL = p.RepoURL
+ changed = true
+ }
+ if p.RepoUsername != "" && p.RepoUsername != cur.Username {
+ cur.Username = p.RepoUsername
+ changed = true
+ }
+ if p.RepoPassword != "" && p.RepoPassword != cur.Password {
+ cur.Password = p.RepoPassword
+ changed = true
+ }
+ if changed {
+ if err := d.secrets.Save(cur); err != nil {
+ slog.Error("ws agent: persist secrets", "err", err)
+ return nil
+ }
+ slog.Info("ws agent: repo credentials updated via config.update")
+ }
+ case "admin":
+ cur, err := d.secrets.LoadAdmin()
+ if err != nil && !errors.Is(err, secrets.ErrNoAdmin) {
+ slog.Error("ws agent: load admin secrets", "err", err)
+ return nil
+ }
+ // ErrNoAdmin is not an error here — we are creating the slot.
+ changed := false
+ if p.RepoURL != "" && p.RepoURL != cur.URL {
+ cur.URL = p.RepoURL
+ changed = true
+ }
+ if p.RepoUsername != "" && p.RepoUsername != cur.Username {
+ cur.Username = p.RepoUsername
+ changed = true
+ }
+ if p.RepoPassword != "" && p.RepoPassword != cur.Password {
+ cur.Password = p.RepoPassword
+ changed = true
+ }
+ if changed {
+ if err := d.secrets.SaveAdmin(cur); err != nil {
+ slog.Error("ws agent: persist admin secrets", "err", err)
+ return nil
+ }
+ slog.Info("ws agent: admin credentials updated via config.update")
+ }
+ default:
+ slog.Warn("ws agent: unknown config.update slot, ignoring", "slot", p.Slot)
}
case api.MsgAgentUpdateAvail:
@@ -251,6 +287,14 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc
if creds.Empty() {
return fmt.Errorf("repo credentials not configured (waiting for server config.update push)")
}
+ // r is the everyday runner — bound to the host's repo
+ // (append-only) credentials. Reused by every kind except
+ // JobPrune, which builds its own runner against the
+ // admin-credentials slot when p.RequiresAdminCreds is set
+ // (admin creds are not loaded for any other kind, so they're
+ // not on r). If you find yourself adding a new JobKind that
+ // needs delete authority, mirror the JobPrune pattern below
+ // — don't try to overload r.
r := runner.New(runner.Config{
ResticBin: d.resticBin,
RepoURL: creds.URL,
@@ -291,33 +335,81 @@ func (d *dispatcher) runJob(ctx context.Context, p api.CommandRunPayload, tx wsc
slog.Info("agent: init job complete", "job_id", p.JobID)
}()
case api.JobForget:
- var policy restic.ForgetPolicy
- if len(p.RetentionPolicy) > 0 {
- var raw struct {
- KeepLast *int `json:"keep_last,omitempty"`
- KeepHourly *int `json:"keep_hourly,omitempty"`
- KeepDaily *int `json:"keep_daily,omitempty"`
- KeepWeekly *int `json:"keep_weekly,omitempty"`
- KeepMonthly *int `json:"keep_monthly,omitempty"`
- KeepYearly *int `json:"keep_yearly,omitempty"`
- }
- if err := json.Unmarshal(p.RetentionPolicy, &raw); err != nil {
- return fmt.Errorf("forget: decode retention_policy: %w", err)
- }
- policy = restic.ForgetPolicy{
- KeepLast: raw.KeepLast, KeepHourly: raw.KeepHourly,
- KeepDaily: raw.KeepDaily, KeepWeekly: raw.KeepWeekly,
- KeepMonthly: raw.KeepMonthly, KeepYearly: raw.KeepYearly,
- }
+ if len(p.ForgetGroups) == 0 {
+ // Hard-error rather than fall back to a single-policy form:
+ // the server-side dispatch path (maintenance ticker) is the
+ // only writer of forget command.run today, and it always
+ // populates ForgetGroups. A backwards-compatible single-
+ // policy fallback was specced but skipped — see the
+ // Phase 5 plan rationale and version.go's lockstep-deploy
+ // note for why.
+ return fmt.Errorf("forget: command.run carried no forget_groups (server didn't populate them)")
}
- slog.Info("agent: accepting forget job", "job_id", p.JobID, "policy", p.RetentionPolicy)
+ groups := make([]restic.ForgetGroup, 0, len(p.ForgetGroups))
+ for _, g := range p.ForgetGroups {
+ groups = append(groups, restic.ForgetGroup{
+ Tag: g.Tag,
+ Policy: restic.ForgetPolicy{
+ KeepLast: g.Policy.KeepLast,
+ KeepHourly: g.Policy.KeepHourly,
+ KeepDaily: g.Policy.KeepDaily,
+ KeepWeekly: g.Policy.KeepWeekly,
+ KeepMonthly: g.Policy.KeepMonthly,
+ KeepYearly: g.Policy.KeepYearly,
+ },
+ })
+ }
+ slog.Info("agent: accepting forget job", "job_id", p.JobID, "groups", len(groups))
go func() {
- if err := r.RunForget(ctx, p.JobID, policy); err != nil {
+ if err := r.RunForget(ctx, p.JobID, groups); err != nil {
slog.Warn("agent: forget job failed", "job_id", p.JobID, "err", err)
return
}
slog.Info("agent: forget job complete", "job_id", p.JobID)
}()
+ case api.JobPrune:
+ // Prune may require admin creds (delete authority on rest-server).
+ runCreds := creds
+ if p.RequiresAdminCreds {
+ ac, err := d.secrets.LoadAdmin()
+ if err != nil {
+ return fmt.Errorf("prune: admin creds not configured (server didn't push them): %w", err)
+ }
+ if ac.Empty() {
+ return fmt.Errorf("prune: admin creds incomplete")
+ }
+ runCreds = ac
+ }
+ prr := runner.New(runner.Config{
+ ResticBin: d.resticBin,
+ RepoURL: runCreds.URL,
+ RepoUsername: runCreds.Username,
+ RepoPassword: runCreds.Password,
+ }, tx, time.Second)
+ slog.Info("agent: accepting prune job", "job_id", p.JobID, "admin_creds", p.RequiresAdminCreds)
+ go func() {
+ if err := prr.RunPrune(ctx, p.JobID); err != nil {
+ slog.Warn("agent: prune job failed", "job_id", p.JobID, "err", err)
+ }
+ }()
+ case api.JobCheck:
+ subset := 0
+ if len(p.Args) > 0 {
+ subset, _ = strconv.Atoi(p.Args[0])
+ }
+ slog.Info("agent: accepting check job", "job_id", p.JobID, "subset_pct", subset)
+ go func() {
+ if err := r.RunCheck(ctx, p.JobID, subset); err != nil {
+ slog.Warn("agent: check job failed", "job_id", p.JobID, "err", err)
+ }
+ }()
+ case api.JobUnlock:
+ slog.Info("agent: accepting unlock job", "job_id", p.JobID)
+ go func() {
+ if err := r.RunUnlock(ctx, p.JobID); err != nil {
+ slog.Warn("agent: unlock job failed", "job_id", p.JobID, "err", err)
+ }
+ }()
default:
return fmt.Errorf("kind %q not implemented yet (Phase 2 lands the rest)", p.Kind)
}
diff --git a/cmd/server/main.go b/cmd/server/main.go
index 4059bb2..c97b39d 100644
--- a/cmd/server/main.go
+++ b/cmd/server/main.go
@@ -16,6 +16,7 @@ import (
"gitea.dcglab.co.uk/steve/restic-manager/internal/crypto"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/config"
rmhttp "gitea.dcglab.co.uk/steve/restic-manager/internal/server/http"
+ "gitea.dcglab.co.uk/steve/restic-manager/internal/server/maintenance"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ui"
"gitea.dcglab.co.uk/steve/restic-manager/internal/server/ws"
"gitea.dcglab.co.uk/steve/restic-manager/internal/store"
@@ -139,6 +140,23 @@ func run() error {
defer purgeTick.Stop()
offlineTick := time.NewTicker(30 * time.Second)
defer offlineTick.Stop()
+ // Maintenance ticker: drives forget/prune/check on the cadences
+ // operators set per-host. Independent of the agent's local cron
+ // (which only handles backup schedules). 60s cadence — the cron
+ // expressions are minute-grained, so anything finer is wasted
+ // work.
+ maintenanceTick := time.NewTicker(60 * time.Second)
+ defer maintenanceTick.Stop()
+ // Pending-runs drain ticker: 30s cadence sweeps every host with
+ // pending_runs rows whose next_attempt_at <= now (rows accumulate
+ // when a schedule.fire's command.run send fails because the agent
+ // dropped offline mid-flight). The on-reconnect path in
+ // onAgentHello handles the common case; this ticker is the
+ // safety-net for hosts that come back without a fresh hello (they
+ // shouldn't, but the queue exists either way).
+ pendingDrainTick := time.NewTicker(30 * time.Second)
+ defer pendingDrainTick.Stop()
+ mt := maintenance.New(st)
go func() {
for {
select {
@@ -156,6 +174,18 @@ func run() error {
if n, err := st.MarkHostsOfflineStale(ctx, cutoff); err == nil && n > 0 {
slog.Info("marked hosts offline (stale heartbeat)", "n", n)
}
+ case <-pendingDrainTick.C:
+ srv.DrainAllDue(ctx)
+ case <-maintenanceTick.C:
+ decisions, err := mt.Decide(ctx, time.Now().UTC())
+ if err != nil {
+ slog.Warn("maintenance ticker: decide", "err", err)
+ continue
+ }
+ if len(decisions) > 0 {
+ slog.Info("maintenance ticker: dispatching", "n", len(decisions))
+ srv.DispatchMaintenance(ctx, decisions)
+ }
}
}
}()
diff --git a/docs/superpowers/plans/2026-05-03-p2-redesign-phase-5.md b/docs/superpowers/plans/2026-05-03-p2-redesign-phase-5.md
new file mode 100644
index 0000000..543401e
--- /dev/null
+++ b/docs/superpowers/plans/2026-05-03-p2-redesign-phase-5.md
@@ -0,0 +1,1663 @@
+# P2 Redesign — Phase 5 Implementation Plan
+
+> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
+
+**Goal:** Land the operator-facing prune/check/unlock surface, the server-side maintenance ticker that fires forget/prune/check on cadence, the offline-retry queue worker, and the repo-stats panel that surfaces the result. Closes P2R-03, P2R-04, P2R-05, P2R-06, P2R-07, P2R-08.
+
+**Architecture:**
+- New restic-wrapper functions (`RunPrune`, `RunCheck`, `RunUnlock`, `RunStats`) mirror `RunForget`'s pumpPlain pattern. Agent-side runners mirror `RunForget`'s envelope shape (`runner.RunPrune`, `RunCheck`, `RunUnlock`). Wire envelopes already include the `JobKind` constants — no protocol bump.
+- A second per-host credential row (`host_credentials.kind = 'admin'`) carries the prune-capable creds; pushed to the agent only when dispatching a job that needs it (`prune`).
+- Server-side maintenance runs from a single goroutine in `cmd/server/main.go`, ticking every 60s. For each `host_repo_maintenance` row it computes the most-recent cron-fire instant for each kind, compares to the latest persisted job's `created_at`, and dispatches if due. Offline hosts queue to `pending_runs`.
+- A second background goroutine drains `pending_runs` every 30s plus on agent reconnect (via `onAgentHello`).
+- Repo stats are a singleton-per-host projection (`host_repo_stats` table). Agent ships `stats.report` after every successful backup (mirrors `snapshots.report` plumbing). UI reads it on the Repo page.
+
+**Tech Stack:** Go (server + agent), SQLite (modernc.org/sqlite), `github.com/robfig/cron/v3` for cron parsing (already a dep), chi for routing, html/template + HTMX for UI, Playwright for end-to-end smoke.
+
+---
+
+## File Structure
+
+**New files:**
+- `internal/store/migrations/0009_admin_creds_and_repo_stats.sql` — schema for `host_credentials.kind`, new `host_repo_stats` table.
+- `internal/store/host_repo_stats.go` — CRUD for the stats projection.
+- `internal/server/maintenance/ticker.go` — pure-logic maintenance scheduler (parseable, testable without a server).
+- `internal/server/maintenance/ticker_test.go`
+- `internal/server/http/repo_ops.go` — `POST /api/hosts/{id}/repo/{prune,check,unlock}` handlers + their HTMX-form siblings under the UI tree.
+- `internal/server/http/repo_ops_test.go`
+- `internal/server/http/pending_drain.go` — the offline-queue drain logic (server-side, called on tick + on connect).
+- `internal/server/http/pending_drain_test.go`
+
+**Modified files:**
+- `internal/restic/runner.go` — add `RunPrune`, `RunCheck`, `RunUnlock`, `RunStats`. Add `LockState` parsing helper for check stderr.
+- `internal/api/messages.go` — add `MsgStatsReport` constant + `StatsReportPayload`. Add `RequiresAdminCreds` field to `CommandRunPayload`.
+- `internal/api/wire.go` — register the new message type.
+- `internal/agent/runner/runner.go` — add `RunPrune`, `RunCheck`, `RunUnlock`. Add `reportStats` (mirrors `reportSnapshots`).
+- `cmd/agent/main.go` — wire new `JobKind` cases into the dispatcher; load `admin` creds slot from secrets when `RequiresAdminCreds=true`.
+- `internal/agent/secrets/secrets.go` — extend the on-disk shape to carry both `repo` and `admin` blobs (one file, two named slots).
+- `internal/server/http/host_credentials.go` — extend the encrypted blob to support an optional admin-creds field; add `PUT /api/hosts/{id}/admin-credentials`. Adjust `pushRepoCredsToAgent` to take a kind argument; add `pushAdminCredsToAgent` for on-demand admin-cred pushes.
+- `internal/server/http/server.go` — register the new routes.
+- `internal/server/http/jobs.go` — extend `dispatchJobWithPayload` to optionally push admin creds first when kind is `prune`.
+- `internal/server/ws/handler.go` — handle `MsgStatsReport` (persist + broadcast).
+- `internal/store/jobs.go` — add `LatestJobByKind` (returns the most recent terminal job of a kind, used by the ticker).
+- `internal/store/pending.go` — add `ListPendingRunsForHost` (used by the on-reconnect drain).
+- `internal/store/maintenance.go` — no schema, but add `ListAllMaintenance(ctx)` (the ticker iterates every host).
+- `cmd/server/main.go` — wire the maintenance ticker + pending-drain goroutine.
+- `web/templates/pages/host_repo.html` — add three "one-time" Run-now buttons (prune / check / unlock), an admin-creds form, a stats panel.
+- `internal/server/http/ui_repo.go` — render the new panels; pre-fill the admin-creds form from a redacted view.
+- `web/templates/partials/host_chrome.html` — surface "lock detected" banner if `host_repo_stats.lock_present`.
+
+**Deletes:** none.
+
+---
+
+## Slice A — Schema groundwork (admin creds + stats projection)
+
+### Task A1: Add migration 0009 for admin-credentials column + repo-stats table
+
+**Files:**
+- Create: `internal/store/migrations/0009_admin_creds_and_repo_stats.sql`
+
+- [ ] **Step 1: Write the migration**
+
+```sql
+-- 0009_admin_creds_and_repo_stats.sql
+--
+-- Phase 5 of the P2 redesign needs two things in the schema:
+--
+-- 1. A second credential row per host. Today host_credentials is
+-- 1:1 with hosts. For prune (and any future destructive op) we
+-- want a rest-server admin user whose password gives delete
+-- access — separate from the append-only user used on every
+-- backup. Add a `kind` column with default 'repo'; existing rows
+-- become kind='repo'. Future admin rows live alongside.
+--
+-- 2. A small singleton-per-host projection for repo size, snapshot
+-- count, last-prune freed bytes, lock state, and last-check
+-- result. Backed by `restic stats --json` + sniffed `restic
+-- check` stderr.
+--
+-- Use column-level ALTERs only; host_credentials has no inbound
+-- FKs but the rule from CLAUDE.md still applies.
+
+ALTER TABLE host_credentials ADD COLUMN kind TEXT NOT NULL DEFAULT 'repo';
+
+-- The PK on host_credentials is currently (host_id) — we need a
+-- composite (host_id, kind). SQLite has no ALTER TABLE …
+-- ADD/CHANGE PRIMARY KEY, so this is the one place a rebuild is
+-- justified. host_credentials has no inbound FKs, so the cascade
+-- trap doesn't apply here. Verified against schema/0002.
+
+CREATE TABLE host_credentials_new (
+ host_id TEXT NOT NULL REFERENCES hosts(id) ON DELETE CASCADE,
+ kind TEXT NOT NULL DEFAULT 'repo'
+ CHECK (kind IN ('repo', 'admin')),
+ enc_repo_creds TEXT NOT NULL,
+ updated_at TEXT NOT NULL,
+ PRIMARY KEY (host_id, kind)
+);
+INSERT INTO host_credentials_new (host_id, kind, enc_repo_creds, updated_at)
+ SELECT host_id, kind, enc_repo_creds, updated_at FROM host_credentials;
+DROP TABLE host_credentials;
+ALTER TABLE host_credentials_new RENAME TO host_credentials;
+
+-- Repo stats projection. One row per host, upserted by the agent's
+-- stats.report envelope (which fires after every successful backup
+-- and after every check / prune). All fields nullable so a freshly
+-- enrolled host with no jobs yet is representable.
+
+CREATE TABLE host_repo_stats (
+ host_id TEXT PRIMARY KEY REFERENCES hosts(id) ON DELETE CASCADE,
+ total_size_bytes INTEGER,
+ raw_size_bytes INTEGER,
+ unique_files INTEGER,
+ snapshot_count INTEGER,
+ last_check_at TEXT,
+ last_check_status TEXT, -- 'ok' | 'errors_found' | 'failed'
+ lock_present INTEGER NOT NULL DEFAULT 0,
+ last_prune_at TEXT,
+ last_prune_freed_bytes INTEGER,
+ updated_at TEXT NOT NULL
+);
+```
+
+- [ ] **Step 2: Run migrations against an empty DB and verify schema**
+
+Run: `go test ./internal/store/... -run TestMigrations -v`
+Expected: PASS, or write a fresh `TestMigration0009` if no such test harness exists yet (mirror `internal/store/migrations_test.go` if present, else add a minimal one that opens an in-memory DB and asserts the new tables exist).
+
+- [ ] **Step 3: Commit**
+
+```bash
+git add internal/store/migrations/0009_admin_creds_and_repo_stats.sql
+git commit -m "store: migration 0009 — admin-creds kind + host_repo_stats"
+```
+
+### Task A2: Extend the host_credentials store API to be kind-aware
+
+**Files:**
+- Modify: `internal/store/host_credentials.go`
+- Test: `internal/store/host_credentials_test.go`
+
+- [ ] **Step 1: Replace the two existing functions with kind-aware versions**
+
+Existing `GetHostCredentials(ctx, hostID)` and `SetHostCredentials(ctx, hostID, blob)` become:
+
+```go
+type CredentialKind string
+
+const (
+ CredKindRepo CredentialKind = "repo"
+ CredKindAdmin CredentialKind = "admin"
+)
+
+func (s *Store) GetHostCredentials(ctx context.Context, hostID string, kind CredentialKind) (string, error) {
+ row := s.db.QueryRowContext(ctx,
+ `SELECT enc_repo_creds FROM host_credentials WHERE host_id = ? AND kind = ?`,
+ hostID, string(kind))
+ var enc string
+ if err := row.Scan(&enc); err != nil {
+ if errors.Is(err, sql.ErrNoRows) {
+ return "", ErrNotFound
+ }
+ return "", fmt.Errorf("store: get host credentials: %w", err)
+ }
+ return enc, nil
+}
+
+func (s *Store) SetHostCredentials(ctx context.Context, hostID string, kind CredentialKind, encRepoCreds string) error {
+ if encRepoCreds == "" {
+ return fmt.Errorf("store: empty enc_repo_creds")
+ }
+ now := time.Now().UTC().Format(time.RFC3339Nano)
+ _, err := s.db.ExecContext(ctx,
+ `INSERT INTO host_credentials (host_id, kind, enc_repo_creds, updated_at)
+ VALUES (?, ?, ?, ?)
+ ON CONFLICT(host_id, kind) DO UPDATE SET
+ enc_repo_creds = excluded.enc_repo_creds,
+ updated_at = excluded.updated_at`,
+ hostID, string(kind), encRepoCreds, now)
+ if err != nil {
+ return fmt.Errorf("store: set host credentials: %w", err)
+ }
+ return nil
+}
+
+func (s *Store) DeleteHostCredentials(ctx context.Context, hostID string, kind CredentialKind) error {
+ _, err := s.db.ExecContext(ctx,
+ `DELETE FROM host_credentials WHERE host_id = ? AND kind = ?`,
+ hostID, string(kind))
+ return err
+}
+```
+
+- [ ] **Step 2: Update every caller in `internal/server/http/`**
+
+Run: `grep -rn "GetHostCredentials\|SetHostCredentials" internal/ cmd/`
+For each call site, pass `store.CredKindRepo` as the new arg. Admin variants come later.
+
+- [ ] **Step 3: Add a test for the admin-row variant**
+
+In `internal/store/host_credentials_test.go`, add `TestHostCredentialsAdminRowSeparate`:
+- Set repo creds, set admin creds, fetch each by kind, assert blobs differ.
+- Delete admin, verify repo unaffected.
+
+- [ ] **Step 4: Run tests and verify**
+
+Run: `go test ./internal/store/... -v`
+Expected: PASS, including the new test.
+
+- [ ] **Step 5: Run the full server tests to surface call-site fallout**
+
+Run: `go test ./...`
+Expected: PASS. Fix any compile errors at call sites you missed.
+
+- [ ] **Step 6: Commit**
+
+```bash
+git add internal/store/host_credentials.go internal/store/host_credentials_test.go internal/server/http/
+git commit -m "store: host_credentials becomes kind-aware (repo + admin slots)"
+```
+
+### Task A3: Add the host_repo_stats store API
+
+**Files:**
+- Create: `internal/store/host_repo_stats.go`
+- Test: `internal/store/host_repo_stats_test.go`
+
+- [ ] **Step 1: Write the type and CRUD**
+
+```go
+package store
+
+import (
+ "context"
+ "database/sql"
+ "errors"
+ "fmt"
+ "time"
+)
+
+type HostRepoStats struct {
+ HostID string
+ TotalSizeBytes *int64
+ RawSizeBytes *int64
+ UniqueFiles *int64
+ SnapshotCount *int64
+ LastCheckAt *time.Time
+ LastCheckStatus string // "" | "ok" | "errors_found" | "failed"
+ LockPresent bool
+ LastPruneAt *time.Time
+ LastPruneFreedBytes *int64
+ UpdatedAt time.Time
+}
+
+func (s *Store) GetHostRepoStats(ctx context.Context, hostID string) (*HostRepoStats, error) {
+ // SELECT … WHERE host_id = ?; sql.ErrNoRows → ErrNotFound.
+ // Mirror existing nullable-time handling in store/jobs.go.
+}
+
+// UpsertHostRepoStats writes a partial update — only non-nil fields
+// in the input overwrite existing columns. Implemented as a row-fetch
+// + merge + INSERT…ON CONFLICT for clarity (versus building a sparse
+// UPDATE statement at runtime).
+func (s *Store) UpsertHostRepoStats(ctx context.Context, hostID string, patch HostRepoStats) error
+```
+
+- [ ] **Step 2: Write the test**
+
+```go
+func TestHostRepoStatsRoundTrip(t *testing.T) {
+ // Open in-memory store, seed a host, upsert partial fields,
+ // re-read, assert.
+ // Then upsert a different field, assert the first is preserved.
+}
+```
+
+- [ ] **Step 3: Run tests**
+
+Run: `go test ./internal/store/ -run TestHostRepoStats -v`
+Expected: PASS.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/store/host_repo_stats.go internal/store/host_repo_stats_test.go
+git commit -m "store: HostRepoStats projection (size, lock, last-check, last-prune)"
+```
+
+---
+
+## Slice B — Restic wrapper additions
+
+### Task B1: RunPrune
+
+**Files:**
+- Modify: `internal/restic/runner.go`
+- Test: `internal/restic/runner_test.go` (create if absent — there's currently only `url_test.go`)
+
+- [ ] **Step 1: Add `RunPrune` after `RunForget`**
+
+```go
+// RunPrune executes `restic prune` against the configured repo.
+// Requires the *admin* credentials (delete access on the rest-server
+// repo) — the caller is responsible for populating Env.RepoUsername
+// and Env.RepoPassword with the admin pair before calling this.
+//
+// Prune emits human-readable progress on stdout/stderr (no --json
+// support that's useful for our purposes). We tee everything to the
+// handler so the live log is the operator's progress bar.
+func (e Env) RunPrune(ctx context.Context, handle LineHandler) error {
+ cmd := exec.CommandContext(ctx, e.Bin, "prune")
+ cmd.Env = e.envSlice()
+ cmd.Dir = e.WorkDir
+ return runWithPump(cmd, handle)
+}
+```
+
+Add a small private helper to DRY the stdout/stderr pump pattern that's now shared by Forget/Init/Prune/Check/Unlock:
+
+```go
+// runWithPump starts the configured cmd, fans stdout+stderr into
+// pumpPlain, and waits. Errors from the wait are wrapped with the
+// cmd args[0] for context.
+func runWithPump(cmd *exec.Cmd, handle LineHandler) error {
+ label := "restic"
+ if len(cmd.Args) > 1 {
+ label = "restic " + cmd.Args[1]
+ }
+ stdout, err := cmd.StdoutPipe()
+ if err != nil {
+ return fmt.Errorf("%s: stdout pipe: %w", label, err)
+ }
+ stderr, err := cmd.StderrPipe()
+ if err != nil {
+ return fmt.Errorf("%s: stderr pipe: %w", label, err)
+ }
+ if err := cmd.Start(); err != nil {
+ return fmt.Errorf("%s: start: %w", label, err)
+ }
+ done := make(chan error, 2)
+ go func() { done <- pumpPlain(stdout, "stdout", handle) }()
+ go func() { done <- pumpPlain(stderr, "stderr", handle) }()
+ for i := 0; i < 2; i++ {
+ if err := <-done; err != nil && handle != nil {
+ handle("event", fmt.Sprintf("pump error: %v", err), nil)
+ }
+ }
+ if werr := cmd.Wait(); werr != nil {
+ return fmt.Errorf("%s: %w", label, werr)
+ }
+ return nil
+}
+```
+
+Refactor `RunForget` and `RunInit` to use `runWithPump` (init keeps its sniff wrapper). Keep behaviour identical.
+
+- [ ] **Step 2: Write a stub test that exercises arg construction**
+
+Since restic isn't on every developer's PATH, the test should construct an `Env` with `Bin = "/bin/echo"` (or `Bin = "/bin/sh"` running a script that echoes args) and assert the handler observes the expected argv. This is the same pattern as any existing wrapper tests; if none exists, model the test on `TestRunForgetArgs` in another comparable Go project (or just shell out to `echo` and grep stdout).
+
+```go
+func TestRunPruneInvokesPrune(t *testing.T) {
+ var captured []string
+ h := func(stream, line string, _ any) {
+ if stream == "stdout" { captured = append(captured, line) }
+ }
+ env := restic.Env{Bin: "/bin/echo"}
+ if err := env.RunPrune(context.Background(), h); err != nil {
+ t.Fatalf("run: %v", err)
+ }
+ if len(captured) != 1 || captured[0] != "prune" {
+ t.Fatalf("expected stdout 'prune', got %v", captured)
+ }
+}
+```
+
+- [ ] **Step 3: Run tests**
+
+Run: `go test ./internal/restic/ -v`
+Expected: PASS.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/restic/runner.go internal/restic/runner_test.go
+git commit -m "restic: RunPrune + runWithPump helper, refactor Forget/Init onto it"
+```
+
+### Task B2: RunCheck (with subset support + lock-detection)
+
+**Files:**
+- Modify: `internal/restic/runner.go`
+- Test: `internal/restic/runner_test.go`
+
+- [ ] **Step 1: Add RunCheck**
+
+```go
+// CheckResult summarises a `restic check` invocation. LockPresent is
+// true if the stderr stream contained "Found stale lock" (caller is
+// expected to surface this in the UI so the operator can run unlock).
+// ErrorsFound is true if check exited with status 1 (errors detected
+// in repo metadata).
+type CheckResult struct {
+ LockPresent bool
+ ErrorsFound bool
+}
+
+// RunCheck executes `restic check` with optional --read-data-subset.
+// subsetPct of 0 omits the flag (full data check); >0 passes
+// --read-data-subset Npct. Returns a CheckResult summarising what
+// was sniffed from stderr; the bool is set even if check itself
+// returns an error (so the caller can persist the lock-state).
+func (e Env) RunCheck(ctx context.Context, subsetPct int, handle LineHandler) (CheckResult, error) {
+ args := []string{"check"}
+ if subsetPct > 0 {
+ args = append(args, "--read-data-subset", fmt.Sprintf("%d%%", subsetPct))
+ }
+ cmd := exec.CommandContext(ctx, e.Bin, args...)
+ cmd.Env = e.envSlice()
+ cmd.Dir = e.WorkDir
+
+ var res CheckResult
+ sniff := func(stream, line string, ev any) {
+ if stream == "stderr" {
+ if strings.Contains(line, "Found stale lock") || strings.Contains(line, "locked") {
+ res.LockPresent = true
+ }
+ }
+ if handle != nil {
+ handle(stream, line, ev)
+ }
+ }
+
+ err := runWithPumpHandler(cmd, sniff)
+ if err != nil {
+ // restic check exits 1 when corruption is found; that's a
+ // CheckResult, not a wrapper failure. Treat any non-zero
+ // exit as "errors found" but still return the result so the
+ // ticker can persist last_check_status='errors_found'.
+ var ee *exec.ExitError
+ if errors.As(err, &ee) {
+ res.ErrorsFound = true
+ return res, nil
+ }
+ return res, err
+ }
+ return res, nil
+}
+```
+
+`runWithPumpHandler` is a tiny variant that takes the LineHandler directly (so the sniff wrapper can intercept) — split from `runWithPump` (which currently constructs nothing). Implement as the existing function but with the wrapped handler.
+
+- [ ] **Step 2: Test**
+
+```go
+func TestRunCheckParsesLock(t *testing.T) {
+ // Use /bin/sh -c to emit a known stderr line containing
+ // "Found stale lock" and exit 0. Assert LockPresent=true.
+}
+
+func TestRunCheckErrorsFoundOnExit1(t *testing.T) {
+ // /bin/sh -c "exit 1". Assert err==nil, ErrorsFound=true.
+}
+```
+
+- [ ] **Step 3: Run tests**
+
+Run: `go test ./internal/restic/ -v`
+Expected: PASS.
+
+- [ ] **Step 4: Commit**
+
+```bash
+git add internal/restic/runner.go internal/restic/runner_test.go
+git commit -m "restic: RunCheck with subset% + lock-state sniffing"
+```
+
+### Task B3: RunUnlock + RunStats
+
+**Files:**
+- Modify: `internal/restic/runner.go`
+- Test: `internal/restic/runner_test.go`
+
+- [ ] **Step 1: Add RunUnlock (trivial)**
+
+```go
+func (e Env) RunUnlock(ctx context.Context, handle LineHandler) error {
+ cmd := exec.CommandContext(ctx, e.Bin, "unlock")
+ cmd.Env = e.envSlice()
+ cmd.Dir = e.WorkDir
+ return runWithPumpHandler(cmd, handle)
+}
+```
+
+- [ ] **Step 2: Add RunStats with --json parsing**
+
+```go
+// RepoStats mirrors `restic stats --json` output (mode=raw-data is
+// the most useful — gives total/unique sizes + file counts).
+type RepoStats struct {
+ TotalSize int64 `json:"total_size"`
+ TotalUncompressed int64 `json:"total_uncompressed_size"`
+ SnapshotsCount int64 `json:"snapshots_count"`
+ TotalFileCount int64 `json:"total_file_count"`
+ TotalBlobCount int64 `json:"total_blob_count"`
+}
+
+// RunStats executes `restic stats --json --mode raw-data` and parses
+// the (single-line) JSON response. Tees raw output to handle so the
+// caller can still log it.
+func (e Env) RunStats(ctx context.Context, handle LineHandler) (*RepoStats, error) {
+ cmd := exec.CommandContext(ctx, e.Bin, "stats", "--json", "--mode", "raw-data")
+ cmd.Env = e.envSlice()
+ cmd.Dir = e.WorkDir
+
+ var out *RepoStats
+ capture := func(stream, line string, ev any) {
+ if stream == "stdout" && strings.HasPrefix(line, "{") {
+ var s RepoStats
+ if json.Unmarshal([]byte(line), &s) == nil && s.SnapshotsCount > 0 {
+ cp := s
+ out = &cp
+ }
+ }
+ if handle != nil {
+ handle(stream, line, ev)
+ }
+ }
+ if err := runWithPumpHandler(cmd, capture); err != nil {
+ return nil, err
+ }
+ if out == nil {
+ return nil, fmt.Errorf("restic stats: no JSON in output")
+ }
+ return out, nil
+}
+```
+
+- [ ] **Step 3: Tests**
+
+```go
+func TestRunUnlockInvokesUnlock(t *testing.T) { /* /bin/echo + assert "unlock" arg */ }
+func TestRunStatsParsesJSON(t *testing.T) {
+ // /bin/sh -c 'echo "{\"total_size\":123,\"snapshots_count\":4, …}"'
+ // assert parsed RepoStats matches.
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+```bash
+go test ./internal/restic/ -v
+git add internal/restic/runner.go internal/restic/runner_test.go
+git commit -m "restic: RunUnlock + RunStats (raw-data mode)"
+```
+
+---
+
+## Slice C — Wire envelopes + agent runners + dispatcher
+
+### Task C1: Wire — add stats.report + RequiresAdminCreds
+
+**Files:**
+- Modify: `internal/api/wire.go`, `internal/api/messages.go`
+- Test: `internal/api/messages_test.go` (whatever shape pins the wire today — `grep -n "MsgConfigUpdate\|MsgSnapshotsRpt" internal/api/`)
+
+- [ ] **Step 1: Add the new message constant**
+
+In `internal/api/wire.go`:
+
+```go
+MsgStatsReport MessageType = "stats.report"
+```
+
+In `internal/api/messages.go`:
+
+```go
+// StatsReportPayload — agent ships this after every successful
+// backup, prune, or check. Fields are all optional; the server
+// upserts only what's populated. Lock state comes only from check;
+// freed bytes only from prune.
+type StatsReportPayload struct {
+ TotalSizeBytes *int64 `json:"total_size_bytes,omitempty"`
+ RawSizeBytes *int64 `json:"raw_size_bytes,omitempty"`
+ UniqueFiles *int64 `json:"unique_files,omitempty"`
+ SnapshotCount *int64 `json:"snapshot_count,omitempty"`
+ LastCheckAt *time.Time `json:"last_check_at,omitempty"`
+ LastCheckStatus string `json:"last_check_status,omitempty"`
+ LockPresent *bool `json:"lock_present,omitempty"`
+ LastPruneAt *time.Time `json:"last_prune_at,omitempty"`
+ LastPruneFreedBytes *int64 `json:"last_prune_freed_bytes,omitempty"`
+}
+```
+
+Also extend `CommandRunPayload` (in the same file):
+
+```go
+type CommandRunPayload struct {
+ // … existing fields …
+
+ // RequiresAdminCreds tells the agent to load the admin slot of
+ // its secrets store rather than the everyday repo slot. Set by
+ // the server only for `prune` and operator-triggered `unlock`
+ // (kinds that need delete authority on a rest-server repo).
+ RequiresAdminCreds bool `json:"requires_admin_creds,omitempty"`
+}
+```
+
+- [ ] **Step 2: Pin the JSON shape in tests**
+
+Whatever existing test pins the `ConfigUpdatePayload` / `SnapshotsReportPayload` shape, add a sibling test for `StatsReportPayload` and the extended `CommandRunPayload` (assert the new field marshals omitted-when-zero).
+
+- [ ] **Step 3: Run + commit**
+
+```bash
+go test ./internal/api/ -v
+git add internal/api/
+git commit -m "api: stats.report envelope + CommandRun.RequiresAdminCreds"
+```
+
+### Task C2: Agent secrets — split into repo + admin slots
+
+**Files:**
+- Modify: `internal/agent/secrets/secrets.go` (and any tests)
+- Modify: `cmd/agent/main.go` (load path)
+
+- [ ] **Step 1: Read the current shape**
+
+Run: `cat internal/agent/secrets/secrets.go` and identify the on-disk struct and the `Load() (Creds, error)` API. The current shape carries one set of `{URL, Username, Password}` — extend to carry both.
+
+- [ ] **Step 2: Extend the on-disk shape**
+
+```go
+// On-disk JSON (encrypted as a single AEAD blob):
+type bundle struct {
+ Repo Creds `json:"repo,omitempty"`
+ Admin *Creds `json:"admin,omitempty"`
+}
+```
+
+Public API:
+
+```go
+// Load returns the everyday repo slot. Existing callers compile
+// unchanged.
+func (s *Store) Load() (Creds, error) { /* returns bundle.Repo */ }
+
+// LoadAdmin returns the admin slot, or (Creds{}, ErrNoAdmin) if
+// the agent has never received an admin push. Caller decides what
+// to do (typically: refuse the prune job with a clear error).
+func (s *Store) LoadAdmin() (Creds, error)
+
+// Save replaces the repo slot (admin slot preserved). Used by the
+// existing config.update handler.
+func (s *Store) Save(c Creds) error
+
+// SaveAdmin replaces the admin slot. Called by the new config.update
+// path that ships admin creds.
+func (s *Store) SaveAdmin(c Creds) error
+```
+
+Migration path on first boot: if the existing on-disk shape decodes to a flat `Creds`, treat that as the repo slot and write back the new bundle shape. Mirror the secrets-package migration logic that landed in P1-33.
+
+- [ ] **Step 3: Test**
+
+In `internal/agent/secrets/secrets_test.go` (or whatever the test file is), add `TestSecretsAdminSlotIndependent`:
+- Save repo creds. Load admin → ErrNoAdmin.
+- SaveAdmin. Load admin → those creds. Load → still the original repo creds.
+- Restart (re-open the store from disk). Both slots survive.
+
+- [ ] **Step 4: Run + commit**
+
+```bash
+go test ./internal/agent/secrets/ -v
+git add internal/agent/secrets/
+git commit -m "agent/secrets: separate admin slot + back-compat decode"
+```
+
+### Task C3: Agent runners — RunPrune, RunCheck, RunUnlock + reportStats
+
+**Files:**
+- Modify: `internal/agent/runner/runner.go`
+- Test: `internal/agent/runner/runner_test.go` (create if absent)
+
+- [ ] **Step 1: Add RunPrune**
+
+Mirror `RunForget` (`internal/agent/runner/runner.go:220-282`) verbatim, swap `restic.JobForget`/`env.RunForget` for `JobPrune`/`env.RunPrune`. After success, call `r.reportStats(ctx, env, statsPatch{LastPruneAt: &now})` — see step 4.
+
+```go
+func (r *Runner) RunPrune(ctx context.Context, jobID string) error {
+ startedAt := time.Now().UTC()
+ startEnv, _ := api.Marshal(api.MsgJobStarted, jobID, api.JobStartedPayload{
+ JobID: jobID, Kind: api.JobPrune, StartedAt: startedAt,
+ })
+ if err := r.tx.Send(startEnv); err != nil {
+ slog.Warn("runner: send job.started (prune)", "err", err)
+ }
+ env := r.resticEnv()
+ var seq atomic.Int64
+ handle := r.streamHandler(jobID, &seq)
+
+ err := env.RunPrune(ctx, handle)
+ finishedAt := time.Now().UTC()
+ r.sendFinished(jobID, finishedAt, err)
+ if err == nil {
+ now := finishedAt
+ // Stats refresh — prune freed-bytes is hard to extract
+ // reliably from text output; for now just timestamp it
+ // and let RunStats refresh size.
+ if rerr := r.reportStats(ctx, env, api.StatsReportPayload{LastPruneAt: &now}); rerr != nil {
+ slog.Warn("runner: stats.report after prune failed", "job_id", jobID, "err", rerr)
+ }
+ }
+ if err != nil {
+ return fmt.Errorf("runner prune: %w", err)
+ }
+ return nil
+}
+```
+
+Pull the duplicated `resticEnv()`/`streamHandler()`/`sendFinished()` out of the existing methods into helpers in the same file. The repeated start-env/finish-env scaffolding across Backup/Init/Forget/Prune/Check/Unlock is exactly the kind of duplication that hurts every time a payload field is added.
+
+- [ ] **Step 2: Add RunCheck**
+
+```go
+func (r *Runner) RunCheck(ctx context.Context, jobID string, subsetPct int) error {
+ // start envelope (kind=check) → run → finished
+ // On any outcome, ship stats.report with last_check_at + status + lock_present.
+ res, err := env.RunCheck(ctx, subsetPct, handle)
+ status := "ok"
+ if res.ErrorsFound { status = "errors_found" }
+ if err != nil { status = "failed" }
+ now := time.Now().UTC()
+ lock := res.LockPresent
+ _ = r.reportStats(ctx, env, api.StatsReportPayload{
+ LastCheckAt: &now, LastCheckStatus: status, LockPresent: &lock,
+ })
+ // job.finished
+}
+```
+
+- [ ] **Step 3: Add RunUnlock**
+
+Trivial mirror of RunInit. After success, also fire `reportStats` with `LockPresent: &false` so the UI banner clears.
+
+- [ ] **Step 4: Add reportStats**
+
+Sibling to `reportSnapshots` (runner.go:288-317). Build a `StatsReportPayload`, invoke `env.RunStats` to fill in size fields if the patch doesn't already cover them, marshal `MsgStatsReport`, send. Bound by a 60s timeout.
+
+```go
+func (r *Runner) reportStats(ctx context.Context, env restic.Env, patch api.StatsReportPayload) error {
+ listCtx, cancel := context.WithTimeout(ctx, 60*time.Second)
+ defer cancel()
+ // Fill in sizes via restic stats unless the caller is just
+ // updating a metadata field (e.g. lock-cleared from unlock).
+ if patch.TotalSizeBytes == nil {
+ if s, err := env.RunStats(listCtx, nil); err == nil {
+ patch.TotalSizeBytes = &s.TotalSize
+ patch.RawSizeBytes = &s.TotalUncompressed
+ patch.UniqueFiles = &s.TotalFileCount
+ patch.SnapshotCount = &s.SnapshotsCount
+ }
+ }
+ envOut, err := api.Marshal(api.MsgStatsReport, "", patch)
+ if err != nil { return err }
+ return r.tx.Send(envOut)
+}
+```
+
+Also call `reportStats(ctx, env, api.StatsReportPayload{})` from the existing `RunBackup` success path (next to `reportSnapshots`) so size refreshes after every backup.
+
+- [ ] **Step 5: Update Config + cmd/agent dispatcher to support admin creds**
+
+`runner.Config` grows an optional `AdminUsername`/`AdminPassword`. The dispatcher in `cmd/agent/main.go:243-325` builds the runner with the right creds based on `payload.RequiresAdminCreds`:
+
+```go
+case api.JobPrune:
+ creds := repoCreds
+ if p.RequiresAdminCreds {
+ adminCreds, err := d.secrets.LoadAdmin()
+ if err != nil {
+ return fmt.Errorf("prune: admin creds not configured (server didn't push them)")
+ }
+ creds = adminCreds
+ }
+ r := runner.New(runner.Config{
+ ResticBin: d.resticBin,
+ RepoURL: creds.URL,
+ RepoUsername: creds.Username,
+ RepoPassword: creds.Password,
+ }, tx, time.Second)
+ go func() { _ = r.RunPrune(ctx, p.JobID) }()
+case api.JobCheck:
+ // subset% comes from CommandRunPayload.Args[0] (e.g. "5") to
+ // avoid bloating the wire — server stringifies the int from
+ // host_repo_maintenance.check_subset_pct.
+ subset := 0
+ if len(p.Args) > 0 { subset, _ = strconv.Atoi(p.Args[0]) }
+ go func() { _ = r.RunCheck(ctx, p.JobID, subset) }()
+case api.JobUnlock:
+ go func() { _ = r.RunUnlock(ctx, p.JobID) }()
+```
+
+Also: wire `MsgConfigUpdate` to call `secrets.SaveAdmin` when the new admin-fields are populated. Extend `ConfigUpdatePayload` with a `Slot` discriminator (default empty = repo, "admin" = admin).
+
+```go
+// in api/messages.go
+type ConfigUpdatePayload struct {
+ Slot string `json:"slot,omitempty"` // "" = repo, "admin" = admin slot
+ // … existing fields …
+}
+```
+
+- [ ] **Step 6: Test**
+
+Add `TestRunPruneEnvelopes`, `TestRunCheckShipsStatsReport`, `TestRunUnlockClearsLockState` — drive the runner with a fake `Sender` and a `Bin = /bin/echo` env, capture the envelopes shipped, assert ordering: `job.started → log.stream(s) → stats.report (if applicable) → job.finished`.
+
+- [ ] **Step 7: Run + commit**
+
+```bash
+go test ./internal/agent/... ./cmd/agent/... -v
+git add internal/agent/runner/ internal/agent/secrets/ cmd/agent/main.go internal/api/messages.go
+git commit -m "agent: RunPrune/RunCheck/RunUnlock + reportStats + admin-cred slot"
+```
+
+---
+
+## Slice D — Server: HTTP run-now endpoints (P2R-03/04/05)
+
+### Task D1: Admin credentials REST + push helper
+
+**Files:**
+- Modify: `internal/server/http/host_credentials.go`
+- Modify: `internal/server/http/server.go`
+- Test: `internal/server/http/host_credentials_test.go`
+
+- [ ] **Step 1: Add admin-credentials endpoints**
+
+Mirror the existing `handleGetHostCredentials` / `handleSetHostCredentials` for the admin slot. Reuse the redacted-view shape (URL + username + has_password).
+
+```go
+// PUT /api/hosts/{id}/admin-credentials → SetHostCredentials(host, admin, …)
+// GET /api/hosts/{id}/admin-credentials → redacted view, or 404 if unset
+// DELETE /api/hosts/{id}/admin-credentials → clear the admin slot
+```
+
+Validation: same length / charset rules as repo creds. AEAD additional-data is `"host:" + hostID + ":admin"` (different AAD per slot so a corrupted DB can't cross-bind).
+
+Audit-log all three.
+
+- [ ] **Step 2: Add `pushAdminCredsToAgent`**
+
+```go
+func (s *Server) pushAdminCredsToAgent(ctx context.Context, hostID string) error {
+ enc, err := s.deps.Store.GetHostCredentials(ctx, hostID, store.CredKindAdmin)
+ if err != nil { return err } // ErrNotFound bubbles
+ plain, err := s.deps.AEAD.Decrypt(enc, []byte("host:"+hostID+":admin"))
+ if err != nil { return err }
+ var blob repoCredsBlob
+ if err := json.Unmarshal(plain, &blob); err != nil { return err }
+ env, err := api.Marshal(api.MsgConfigUpdate, "", api.ConfigUpdatePayload{
+ Slot: "admin",
+ RepoURL: blob.RepoURL, RepoUsername: blob.RepoUsername, RepoPassword: blob.RepoPassword,
+ })
+ if err != nil { return err }
+ sendCtx, cancel := context.WithTimeout(ctx, 5*time.Second)
+ defer cancel()
+ return s.deps.Hub.Send(sendCtx, hostID, env)
+}
+```
+
+- [ ] **Step 3: Register routes in `server.go`**
+
+```go
+r.Get("/hosts/{id}/admin-credentials", s.handleGetAdminCredentials)
+r.Put("/hosts/{id}/admin-credentials", s.handleSetAdminCredentials)
+r.Delete("/hosts/{id}/admin-credentials", s.handleDeleteAdminCredentials)
+```
+
+- [ ] **Step 4: Test**
+
+Set admin creds, fetch redacted, assert. Clear, assert 404. Encrypt/decrypt round-trip with different AAD.
+
+- [ ] **Step 5: Commit**
+
+```bash
+git add internal/server/http/host_credentials.go internal/server/http/server.go internal/server/http/host_credentials_test.go
+git commit -m "server: admin-credentials REST + push helper"
+```
+
+### Task D2: HTTP run-now for prune / check / unlock
+
+**Files:**
+- Create: `internal/server/http/repo_ops.go`
+- Create: `internal/server/http/repo_ops_test.go`
+- Modify: `internal/server/http/server.go`
+
+- [ ] **Step 1: Write the handlers**
+
+```go
+// repo_ops.go — operator-triggered Run-now for repo-level
+// operations: prune, check, unlock. Backed by the same
+// dispatchJobWithPayload pipeline as backup, with an extra step
+// for prune: push the admin creds first if they're set, refuse
+// loudly if they aren't.
+package http
+
+func (s *Server) handleRunRepoPrune(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ user, ok := s.requireUser(r)
+ if !ok { /* same auth handling as run_group.go */ return }
+ hostID := chi.URLParam(r, "id")
+
+ // Admin creds are required. Push them down before dispatching.
+ if err := s.pushAdminCredsToAgent(r.Context(), hostID); err != nil {
+ if errors.Is(err, store.ErrNotFound) {
+ s.runOpError(w, r, stdhttp.StatusBadRequest, "admin_creds_required",
+ "set admin credentials on the Repo page before running prune")
+ return
+ }
+ s.runOpError(w, r, stdhttp.StatusServiceUnavailable, "host_offline",
+ "agent not connected; reconnect and try again")
+ return
+ }
+
+ res, status, code, msg := s.dispatchJobWithPayload(r.Context(), user, hostID, api.JobPrune,
+ api.CommandRunPayload{RequiresAdminCreds: true})
+ if code != "" { s.runOpError(w, r, status, code, msg); return }
+ s.runOpRedirect(w, r, res)
+}
+
+func (s *Server) handleRunRepoCheck(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ // Resolve subset% from host_repo_maintenance for this host (or
+ // optional ?subset=N override on the query string for ad-hoc).
+ m, _ := s.deps.Store.GetRepoMaintenance(r.Context(), hostID)
+ subset := m.CheckSubsetPct
+ if q := r.URL.Query().Get("subset"); q != "" { /* parse + clamp */ }
+
+ res, status, code, msg := s.dispatchJobWithPayload(r.Context(), user, hostID, api.JobCheck,
+ api.CommandRunPayload{Args: []string{strconv.Itoa(subset)}})
+ // …
+}
+
+func (s *Server) handleRunRepoUnlock(w stdhttp.ResponseWriter, r *stdhttp.Request) {
+ // No admin creds required (unlock works with the everyday user).
+ res, status, code, msg := s.dispatchJobWithPayload(r.Context(), user, hostID, api.JobUnlock,
+ api.CommandRunPayload{})
+ // …
+}
+
+func (s *Server) runOpRedirect(...) { /* HX-Redirect to /jobs/{id} for HTMX, JSON otherwise. Mirror run_group.go */ }
+func (s *Server) runOpError(...) { /* mirror run_group.go runGroupError */ }
+```
+
+- [ ] **Step 2: Register routes**
+
+In `server.go`, both inside `/api` and at the outer router for HTMX:
+
+```go
+// Inside r.Route("/api", …):
+r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune)
+r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck)
+r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
+
+// Outer (for HTMX form posts):
+r.Post("/hosts/{id}/repo/prune", s.handleRunRepoPrune)
+r.Post("/hosts/{id}/repo/check", s.handleRunRepoCheck)
+r.Post("/hosts/{id}/repo/unlock", s.handleRunRepoUnlock)
+```
+
+- [ ] **Step 3: Test**
+
+In `repo_ops_test.go`:
+
+```go
+func TestRunPruneRefusesWithoutAdminCreds(t *testing.T) {
+ // Stand up testServer with a connected fake host, no admin creds.
+ // POST /api/hosts/{id}/repo/prune; assert 400 + admin_creds_required.
+ // No job row created.
+}
+
+func TestRunPruneShipsConfigUpdateThenCommandRun(t *testing.T) {
+ // Set admin creds on the host; connect a fake WS; POST prune.
+ // Drain the conn; assert config.update(slot=admin) → command.run(prune,RequiresAdminCreds=true).
+}
+
+func TestRunCheckPullsSubsetFromMaintenanceRow(t *testing.T) {
+ // Update maintenance row, check_subset_pct=25; POST /repo/check.
+ // Assert command.run.Args == ["25"].
+}
+
+func TestRunUnlockNeedsNoAdminCreds(t *testing.T) {
+ // No admin creds set; POST /repo/unlock; assert 202 + command.run shipped.
+}
+```
+
+- [ ] **Step 4: Run + commit**
+
+```bash
+go test ./internal/server/http/ -v -run TestRunRepo -run TestRunPrune -run TestRunCheck -run TestRunUnlock
+git add internal/server/http/repo_ops.go internal/server/http/repo_ops_test.go internal/server/http/server.go
+git commit -m "server: HTTP run-now for prune / check / unlock"
+```
+
+---
+
+## Slice E — UI: Repo page additions
+
+### Task E1: Render admin-creds form + one-time maintenance buttons + stats panel skeleton
+
+**Files:**
+- Modify: `web/templates/pages/host_repo.html`
+- Modify: `internal/server/http/ui_repo.go`
+
+- [ ] **Step 1: Read the current Repo page to understand sections**
+
+Run: `cat web/templates/pages/host_repo.html` and identify section anchors. Also read `internal/server/http/ui_repo.go` to see what data the renderer hands to the template.
+
+- [ ] **Step 2: Add the admin-creds form**
+
+In the Connection section (right after the existing repo creds form), add a sibling form for admin creds. Use a collapsible/optional pattern with a clear note:
+
+```html
+
+
Admin credentials (required for prune)
+
Only needed for rest-server repos that distinguish an
+ append-only user (everyday backups) from a delete-capable user
+ (prune). For S3/B2/local, leave blank.
+
+
+```
+
+The handler `handleUIHostRepo` in `ui_repo.go` needs to pre-fill `AdminCreds` from `GetHostCredentials(host, admin)` using a redacted view.
+
+- [ ] **Step 3: Add one-time maintenance buttons**
+
+Below the cadence rows in the Maintenance section, add a "Run now" subsection with three buttons. Mirror the per-source-group Run-now pattern (inline form, hx-post, HX-Redirect to /jobs/{id} for live tailing).
+
+```html
+
+
Run now
+
+
+
+
+
+
+```
+
+`HasAdminCreds`, `Online` come from the renderer.
+
+- [ ] **Step 4: Render the stats panel (data fields wired up)**
+
+In the right rail (replace or augment the existing snapshots-by-tag breakdown), add a stats card backed by `host_repo_stats`:
+
+```html
+
+