diff --git a/docs/superpowers/plans/2026-08-04-scheduled-workflows.md b/docs/superpowers/plans/2026-08-04-scheduled-workflows.md new file mode 100644 index 0000000..fc0b4c4 --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-scheduled-workflows.md @@ -0,0 +1,1071 @@ +# Scheduled Workflows 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:** Let a workflow carry a cron schedule in a named timezone, and have one leader-elected scheduler fire it as an ordinary run. + +**Architecture:** `Workflow` gains a `schedule` block and a persisted `next_run_at`. A new `server/internal/workflowsched` package runs inside the existing `bus.RunAsLeader("housekeeping", …)` and ticks every 30 seconds, claiming each due workflow with an atomic `findOneAndUpdate` on its `next_run_at` before starting a run through the same `TriggerWorkflow` path a person uses. Cron arithmetic and the fire/skip decision are pure functions, tested without a database. + +**Tech Stack:** Go 1.26, `github.com/robfig/cron/v3` (parser only), mongo-driver v2, gin, Next.js 16 + TanStack Query. + +## Global Constraints + +- Cron expressions are standard **5-field** (minute hour dom month dow). No seconds field, no `@every`. +- Timezones are IANA names (`Europe/London`). Validated at save time; an unknown zone is a 400. +- Grace window for a missed occurrence: **1 hour**. Older misses are recorded and dropped. +- A scheduled run never starts while a run of the same workflow is active. +- A scheduled run uses the same `TriggerWorkflow` path as a manual one, with `triggered_by: "schedule"`. There must be no second dispatch path. +- Every skip writes both `last_skipped` on the workflow and an audit event via `services.LogEvent`. +- All background work runs inside the single `RunAsLeader("housekeeping", …)` and must return when its context is cancelled. +- Tests run under plain `go test ./...` with no database, no network, no build tags. +- No component in `web/` may carry a hex colour; `web/` is dark-only. + +**Depends on:** nothing in the server-tags plan. The two can land in either order. + +--- + +## File Structure + +**Create:** +- `server/internal/workflowsched/cron.go` — expression parsing, next-occurrence arithmetic, the fire/skip decision. Pure. +- `server/internal/workflowsched/cron_test.go` — unit tests for all of the above. +- `server/internal/workflowsched/sched.go` — the ticking loop, the atomic claim, the database side. +- `web/components/workflows/ScheduleCard.tsx` — the schedule editor. + +**Modify:** +- `server/go.mod` — add `github.com/robfig/cron/v3`. +- `server/cmd/main.go` — `import _ "time/tzdata"`; start the scheduler under the leader lock; index builder call. +- `server/internal/models/workflow.go` — `Schedule`, `Skip`, and the three timestamp fields. +- `server/internal/services/workflows.go` — `SetSchedule`, `EnsureWorkflowIndexes` gains `next_run_at`. +- `server/internal/api/workflows.go` — two routes and their handlers. +- `web/lib/api.ts` — types and client methods. +- `web/app/(app)/workflows/[id]/page.tsx` — mount the schedule card. +- `web/app/(app)/workflows/page.tsx` — schedule chip and next run. +- `docsite/docs/vantage/workflows.md`, `CLAUDE.md`. + +--- + +### Task 1: Cron arithmetic + +**Files:** +- Create: `server/internal/workflowsched/cron.go` +- Test: `server/internal/workflowsched/cron_test.go` +- Modify: `server/go.mod` + +**Interfaces:** +- Consumes: nothing. +- Produces: + - `workflowsched.ParseSchedule(expr, tz string) (cron.Schedule, error)` + - `workflowsched.NextOccurrence(expr, tz string, from time.Time) (time.Time, error)` + - `workflowsched.ErrBadSchedule` (an `error` value) + +- [ ] **Step 1: Add the dependency** + +Run: + +```bash +cd server && go get github.com/robfig/cron/v3@v3.0.1 +``` + +Expected: `go.mod` and `go.sum` updated. Only the parser is used; the library's own scheduler and goroutines are not. + +- [ ] **Step 2: Write the failing test** + +Create `server/internal/workflowsched/cron_test.go`: + +```go +package workflowsched + +import ( + "testing" + "time" +) + +func mustTime(t *testing.T, layout, value, zone string) time.Time { + t.Helper() + loc, err := time.LoadLocation(zone) + if err != nil { + t.Fatalf("load %s: %v", zone, err) + } + parsed, err := time.ParseInLocation(layout, value, loc) + if err != nil { + t.Fatalf("parse %s: %v", value, err) + } + return parsed +} + +func TestParseScheduleRejectsBadInput(t *testing.T) { + cases := []struct{ expr, tz string }{ + {"", "UTC"}, + {"not a cron", "UTC"}, + {"0 2 * *", "UTC"}, // four fields + {"0 2 * * * *", "UTC"}, // six fields — no seconds field is supported + {"99 2 * * *", "UTC"}, // minute out of range + {"0 2 * * 0", "Mars/Olympus"}, // unknown zone + {"0 2 * * 0", ""}, // empty zone + } + for _, tc := range cases { + if _, err := ParseSchedule(tc.expr, tc.tz); err == nil { + t.Fatalf("expected %q / %q to be rejected", tc.expr, tc.tz) + } + } +} + +func TestNextOccurrenceUsesTheStoredZone(t *testing.T) { + // 02:00 every Sunday, London. From Friday, the next is Sunday 02:00 local. + from := mustTime(t, "2006-01-02 15:04", "2026-08-07 12:00", "Europe/London") + + got, err := NextOccurrence("0 2 * * 0", "Europe/London", from) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + want := mustTime(t, "2006-01-02 15:04", "2026-08-09 02:00", "Europe/London") + if !got.Equal(want) { + t.Fatalf("got %s, want %s", got, want) + } +} + +func TestNextOccurrenceCrossesDST(t *testing.T) { + // London leaves BST at 02:00 on 2026-10-25. A 02:30 daily job on the 24th + // must next fire at 02:30 GMT on the 25th — an interval of 25 hours, not + // 24. This is the whole reason the zone is stored rather than a UTC offset. + from := mustTime(t, "2006-01-02 15:04", "2026-10-24 03:00", "Europe/London") + + got, err := NextOccurrence("30 2 * * *", "Europe/London", from) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + + loc, _ := time.LoadLocation("Europe/London") + local := got.In(loc) + if local.Day() != 25 || local.Hour() != 2 || local.Minute() != 30 { + t.Fatalf("got %s, want 2026-10-25 02:30 local", local) + } + if delta := got.Sub(from); delta != 24*time.Hour-30*time.Minute+time.Hour { + t.Fatalf("expected the DST hour to be added, got a delta of %s", delta) + } +} + +func TestNextOccurrenceIsStrictlyAfterFrom(t *testing.T) { + exact := mustTime(t, "2006-01-02 15:04", "2026-08-09 02:00", "Europe/London") + + got, err := NextOccurrence("0 2 * * 0", "Europe/London", exact) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !got.After(exact) { + t.Fatalf("next occurrence %s must be strictly after %s, or a fired tick refires forever", got, exact) + } +} +``` + +- [ ] **Step 3: Run test to verify it fails** + +Run: `cd server && go test ./internal/workflowsched/ -v` +Expected: FAIL — `undefined: ParseSchedule` + +- [ ] **Step 4: Write minimal implementation** + +Create `server/internal/workflowsched/cron.go`: + +```go +// Package workflowsched fires workflow runs on a cron schedule. +// +// Only robfig/cron's parser is used — Parse and Next. Its own scheduler is +// not, because this work runs under the housekeeping leader lock and has to +// stop the moment leadership is lost. +package workflowsched + +import ( + "errors" + "fmt" + "time" + + "github.com/robfig/cron/v3" +) + +// ErrBadSchedule covers both a malformed expression and an unknown timezone. +// Handlers map it to 400 — both are the caller's mistake, and both are much +// cheaper to find at save time than at 2am. +var ErrBadSchedule = errors.New("invalid schedule") + +// Standard 5-field cron: minute hour dom month dow. Deliberately no seconds +// field and no descriptors — a schedule a person cannot read back is a +// schedule nobody can audit. +var cronParser = cron.NewParser(cron.Minute | cron.Hour | cron.Dom | cron.Month | cron.Dow) + +func ParseSchedule(expr, tz string) (cron.Schedule, error) { + if tz == "" { + return nil, fmt.Errorf("%w: a timezone is required", ErrBadSchedule) + } + if _, err := time.LoadLocation(tz); err != nil { + return nil, fmt.Errorf("%w: unknown timezone %q", ErrBadSchedule, tz) + } + sched, err := cronParser.Parse(expr) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrBadSchedule, err) + } + return sched, nil +} + +// NextOccurrence returns the first firing strictly after from, computed in the +// schedule's own zone so that a DST boundary moves the wall-clock time the way +// a person expects rather than drifting by an hour for half the year. +func NextOccurrence(expr, tz string, from time.Time) (time.Time, error) { + sched, err := ParseSchedule(expr, tz) + if err != nil { + return time.Time{}, err + } + loc, err := time.LoadLocation(tz) + if err != nil { + return time.Time{}, fmt.Errorf("%w: unknown timezone %q", ErrBadSchedule, tz) + } + return sched.Next(from.In(loc)), nil +} +``` + +- [ ] **Step 5: Run test to verify it passes** + +Run: `cd server && go test ./internal/workflowsched/ -v` +Expected: PASS, four tests. + +If `TestNextOccurrenceCrossesDST` fails with a zone-loading error rather than a wrong time, the tzdata import in Task 4 is the fix — but it must not be needed for `go test` on a developer machine, which has a system zone database. A failure here on a machine *with* zoneinfo is a real bug in the arithmetic. + +- [ ] **Step 6: Commit** + +```bash +git add server/go.mod server/go.sum server/internal/workflowsched/ +git commit -m "feat: cron parsing and next-occurrence arithmetic for workflow schedules" +``` + +--- + +### Task 2: The fire/skip decision + +**Files:** +- Modify: `server/internal/workflowsched/cron.go` +- Test: `server/internal/workflowsched/cron_test.go` + +**Interfaces:** +- Consumes: nothing from Task 1 at runtime. +- Produces: `workflowsched.Decision` (a string type with constants `Fire`, `SkipMissed`, `SkipRunning`) and `workflowsched.Decide(due, now time.Time, runActive bool) Decision`. + +- [ ] **Step 1: Write the failing test** + +Append to `server/internal/workflowsched/cron_test.go`: + +```go +func TestDecide(t *testing.T) { + now := time.Date(2026, 8, 9, 2, 0, 0, 0, time.UTC) + + cases := []struct { + name string + due time.Time + runActive bool + want Decision + }{ + {"on time", now, false, Fire}, + {"ten minutes late still fires", now.Add(-10 * time.Minute), false, Fire}, + {"fifty-nine minutes late still fires", now.Add(-59 * time.Minute), false, Fire}, + {"just past the grace window is missed", now.Add(-61 * time.Minute), false, SkipMissed}, + {"two days late is missed", now.Add(-48 * time.Hour), false, SkipMissed}, + {"an active run wins over on time", now, true, SkipRunning}, + {"an active run wins over a late one", now.Add(-10 * time.Minute), true, SkipRunning}, + {"a missed one is missed even with a run active", now.Add(-48 * time.Hour), true, SkipMissed}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := Decide(tc.due, now, tc.runActive); got != tc.want { + t.Fatalf("got %q, want %q", got, tc.want) + } + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/workflowsched/ -run TestDecide -v` +Expected: FAIL — `undefined: Decide` + +- [ ] **Step 3: Write minimal implementation** + +Append to `server/internal/workflowsched/cron.go`: + +```go +type Decision string + +const ( + Fire Decision = "fire" + SkipMissed Decision = "missed" + SkipRunning Decision = "already_running" +) + +// GraceWindow is how late an occurrence may be and still run. A job missed by +// ten minutes during a deploy should still run; one missed by two days should +// not fire at lunchtime. +const GraceWindow = time.Hour + +// Decide is the whole fire/skip policy, kept pure so it can be tested without +// a database and read without following a loop. +// +// The missed check comes first: an occurrence that is already too old to run +// should be recorded as missed regardless of what is running now, or a slow +// run would relabel a stale occurrence as a fresh conflict. +func Decide(due, now time.Time, runActive bool) Decision { + if now.Sub(due) > GraceWindow { + return SkipMissed + } + if runActive { + return SkipRunning + } + return Fire +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd server && go test ./internal/workflowsched/ -v` +Expected: PASS, all tests including the eight `Decide` subtests. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/workflowsched/cron.go server/internal/workflowsched/cron_test.go +git commit -m "feat: fire, missed and already-running decision for scheduled workflows" +``` + +--- + +### Task 3: Schedule model and persistence + +**Files:** +- Modify: `server/internal/models/workflow.go:47-56` +- Modify: `server/internal/services/workflows.go:39` (`EnsureWorkflowIndexes`) and append `SetSchedule` + +**Interfaces:** +- Consumes: `workflowsched.NextOccurrence`, `workflowsched.ErrBadSchedule`. +- Produces: + - `models.Schedule{Enabled bool; Cron string; TZ string}` + - `models.Skip{Reason string; Due time.Time; At time.Time}` + - `models.Workflow.Schedule *Schedule`, `.NextRunAt *time.Time`, `.LastRunAt *time.Time`, `.LastSkipped *Skip` + - `services.SetSchedule(instanceID, workflowID string, s *models.Schedule) (*time.Time, error)` + +- [ ] **Step 1: Add the model types** + +In `server/internal/models/workflow.go`, above `type Workflow struct`: + +```go +type Schedule struct { + Enabled bool `bson:"enabled" json:"enabled"` + Cron string `bson:"cron" json:"cron"` // 5-field: minute hour dom month dow + TZ string `bson:"tz" json:"tz"` // IANA name, e.g. Europe/London +} + +// Skip records why an occurrence did not run. Recording a reason nobody reads +// is the same as not recording one, so this is surfaced in the UI. +type Skip struct { + Reason string `bson:"reason" json:"reason"` // "missed" | "already_running" + Due time.Time `bson:"due" json:"due"` + At time.Time `bson:"at" json:"at"` +} +``` + +Inside `type Workflow struct`, after `Steps`: + +```go + Schedule *Schedule `bson:"schedule,omitempty" json:"schedule,omitempty"` + NextRunAt *time.Time `bson:"next_run_at,omitempty" json:"next_run_at,omitempty"` + LastRunAt *time.Time `bson:"last_run_at,omitempty" json:"last_run_at,omitempty"` + LastSkipped *Skip `bson:"last_skipped,omitempty" json:"last_skipped,omitempty"` +``` + +- [ ] **Step 2: Add the index** + +Inside `EnsureWorkflowIndexes` in `server/internal/services/workflows.go`, alongside the existing `workflows` index: + +```go + if _, err := db.Col("workflows").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "next_run_at", Value: 1}}, + }); err != nil { + return err + } +``` + +- [ ] **Step 3: Add SetSchedule** + +Append to `server/internal/services/workflows.go`: + +```go +// SetSchedule validates and stores a workflow's schedule, computing the first +// occurrence. next_run_at is persisted rather than held in memory: a leader +// handover between computing an occurrence and firing it would otherwise lose +// it or fire it twice. +// +// Passing s == nil, or a disabled schedule, clears next_run_at so the +// scheduler's query stops matching the document at all. +func SetSchedule(instanceID, workflowID string, s *models.Schedule) (*time.Time, error) { + ctx, cancel := wfCtx() + defer cancel() + + set := bson.M{"schedule": s, "updated_at": time.Now()} + unset := bson.M{} + + var next *time.Time + if s != nil && s.Enabled { + at, err := workflowsched.NextOccurrence(s.Cron, s.TZ, time.Now()) + if err != nil { + return nil, err + } + next = &at + set["next_run_at"] = at + } else { + unset["next_run_at"] = "" + } + + update := bson.M{"$set": set} + if len(unset) > 0 { + update["$unset"] = unset + } + + res, err := db.Col("workflows").UpdateOne(ctx, + bson.M{"workflow_id": workflowID, "instance_id": instanceID}, update) + if err != nil { + return nil, err + } + if res.MatchedCount == 0 { + return nil, mongo.ErrNoDocuments + } + return next, nil +} +``` + +Add `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"` to that file's imports. + +- [ ] **Step 4: Verify it builds and tests pass** + +Run: `cd server && go build ./... && go test ./...` +Expected: build silent, tests PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/models/workflow.go server/internal/services/workflows.go +git commit -m "feat: persist a workflow schedule and its next occurrence" +``` + +--- + +### Task 4: The scheduler loop + +**Files:** +- Create: `server/internal/workflowsched/sched.go` +- Modify: `server/cmd/main.go` — imports and the `RunAsLeader` block at line 173 + +**Interfaces:** +- Consumes: `Decide`, `NextOccurrence`, `models.Workflow`, `services.TriggerWorkflow`, `services.LogEvent`. +- Produces: `workflowsched.Start(ctx context.Context)`. + +- [ ] **Step 1: Write the loop** + +Create `server/internal/workflowsched/sched.go`: + +```go +package workflowsched + +import ( + "context" + "log" + "time" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/services" + "go.mongodb.org/mongo-driver/v2/bson" + "go.mongodb.org/mongo-driver/v2/mongo/options" +) + +const tickInterval = 30 * time.Second + +// Start runs the scheduler until ctx is cancelled. It is called inside +// bus.RunAsLeader("housekeeping", …) alongside monitorsched and the sweepers: +// one role, one lock. N replicas each running this loop would fire every +// scheduled workflow N times. +func Start(ctx context.Context) { + go func() { + ticker := time.NewTicker(tickInterval) + defer ticker.Stop() + for { + select { + case <-ctx.Done(): + return + case <-ticker.C: + tick(ctx) + } + } + }() +} + +func tick(ctx context.Context) { + now := time.Now() + + cur, err := db.Col("workflows").Find(ctx, bson.M{ + "schedule.enabled": true, + "next_run_at": bson.M{"$lte": now}, + }) + if err != nil { + log.Printf("workflowsched: find due: %v", err) + return + } + defer cur.Close(ctx) + + var due []models.Workflow + if err := cur.All(ctx, &due); err != nil { + log.Printf("workflowsched: decode due: %v", err) + return + } + + for _, wf := range due { + if ctx.Err() != nil { + return + } + process(ctx, wf, now) + } +} + +func process(ctx context.Context, wf models.Workflow, now time.Time) { + if wf.NextRunAt == nil || wf.Schedule == nil { + return + } + dueAt := *wf.NextRunAt + + next, err := NextOccurrence(wf.Schedule.Cron, wf.Schedule.TZ, now) + if err != nil { + // A schedule that no longer parses cannot be advanced, and leaving + // next_run_at in the past would spin this loop every 30 seconds + // forever. Disable it and say so. + log.Printf("workflowsched: workflow %s has an unusable schedule, disabling: %v", wf.WorkflowID, err) + disable(ctx, wf, err.Error()) + return + } + + // The claim. Matching on the current next_run_at as well as the id means a + // second process reaching this document after another has claimed it + // matches nothing and does nothing. This — not the leader lock — is what + // makes a double fire impossible; the lock only keeps it cheap. + res, err := db.Col("workflows").UpdateOne(ctx, + bson.M{"workflow_id": wf.WorkflowID, "next_run_at": dueAt}, + bson.M{"$set": bson.M{"next_run_at": next}}, + ) + if err != nil { + log.Printf("workflowsched: claim %s: %v", wf.WorkflowID, err) + return + } + if res.MatchedCount == 0 { + return // claimed elsewhere + } + + switch Decide(dueAt, now, hasActiveRun(ctx, wf.InstanceID, wf.WorkflowID)) { + case SkipMissed: + recordSkip(ctx, wf, string(SkipMissed), dueAt, now) + case SkipRunning: + recordSkip(ctx, wf, string(SkipRunning), dueAt, now) + case Fire: + if _, err := services.TriggerWorkflow(wf.InstanceID, wf.WorkflowID, "schedule"); err != nil { + log.Printf("workflowsched: trigger %s: %v", wf.WorkflowID, err) + recordSkip(ctx, wf, "error: "+err.Error(), dueAt, now) + return + } + _, _ = db.Col("workflows").UpdateOne(ctx, + bson.M{"workflow_id": wf.WorkflowID}, + bson.M{"$set": bson.M{"last_run_at": now}, "$unset": bson.M{"last_skipped": ""}}, + ) + services.LogEvent(wf.InstanceID, "workflow.scheduled_run", "schedule", "", "", + "workflow "+wf.Name+" started on schedule") + } +} + +func hasActiveRun(ctx context.Context, instanceID, workflowID string) bool { + err := db.Col("workflow_runs").FindOne(ctx, bson.M{ + "instance_id": instanceID, + "workflow_id": workflowID, + "status": "running", + }, options.FindOne().SetProjection(bson.M{"_id": 1})).Err() + return err == nil +} + +func recordSkip(ctx context.Context, wf models.Workflow, reason string, due, at time.Time) { + _, _ = db.Col("workflows").UpdateOne(ctx, + bson.M{"workflow_id": wf.WorkflowID}, + bson.M{"$set": bson.M{"last_skipped": models.Skip{Reason: reason, Due: due, At: at}}}, + ) + services.LogEvent(wf.InstanceID, "workflow.schedule_skipped", "schedule", "", "", + "workflow "+wf.Name+" skipped "+due.Format(time.RFC3339)+": "+reason) +} + +func disable(ctx context.Context, wf models.Workflow, reason string) { + _, _ = db.Col("workflows").UpdateOne(ctx, + bson.M{"workflow_id": wf.WorkflowID}, + bson.M{ + "$set": bson.M{"schedule.enabled": false}, + "$unset": bson.M{"next_run_at": ""}, + }, + ) + services.LogEvent(wf.InstanceID, "workflow.schedule_disabled", "schedule", "", "", + "workflow "+wf.Name+" schedule disabled: "+reason) +} +``` + +Note the import list above omits `mongo` — this file uses `bson` and `options` but no driver error values. + +- [ ] **Step 2: Embed the timezone database** + +In `server/cmd/main.go`, in the import block: + +```go + _ "time/tzdata" +``` + +**This is load-bearing.** `server/Dockerfile` builds on Alpine, which ships no zoneinfo, so without it `time.LoadLocation("Europe/London")` fails in production and every schedule silently falls back to UTC — an hour wrong for half the year, in the direction nobody notices until a maintenance window lands in business hours. It works on a developer machine either way, which is exactly why it gets forgotten. + +- [ ] **Step 3: Start it under the leader lock** + +In `server/cmd/main.go`, inside the existing `bus.RunAsLeader(ctx, "housekeeping", func(jobCtx context.Context) {` block, beside `monitorsched.Start(jobCtx)`: + +```go + workflowsched.Start(jobCtx) +``` + +Add the import: `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/workflowsched"`. + +- [ ] **Step 4: Verify it builds and tests pass** + +Run: `cd server && go build ./... && go vet ./... && go test ./...` +Expected: build silent, tests PASS. + +- [ ] **Step 5: Manual smoke test** + +Set a workflow's schedule to `* * * * *` (every minute) in `UTC` directly in Mongo, along with `next_run_at` set to now, and watch a run start within 30 seconds. Then, while it is running, confirm the next occurrence records `last_skipped.reason: "already_running"` rather than starting a second run. Finally set `next_run_at` to two days ago and confirm `"missed"`. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/workflowsched/sched.go server/cmd/main.go +git commit -m "feat: fire scheduled workflow runs from the housekeeping leader" +``` + +--- + +### Task 5: Schedule API + +**Files:** +- Modify: `server/internal/api/workflows.go` + +**Interfaces:** +- Consumes: `services.SetSchedule`, `workflowsched.NextOccurrence`, `workflowsched.ErrBadSchedule`. +- Produces: `PUT /api/workflows/:id/schedule`, `GET /api/workflows/:id/schedule/preview?cron=…&tz=…`. + +- [ ] **Step 1: Register the routes** + +In `registerWorkflowRoutes`, beside the existing `/workflows/:id/run`: + +```go + g.PUT("/workflows/:id/schedule", putWorkflowSchedule) + g.GET("/workflows/:id/schedule/preview", previewWorkflowSchedule) +``` + +- [ ] **Step 2: Add the handlers** + +Append to `server/internal/api/workflows.go`: + +```go +func putWorkflowSchedule(c *gin.Context) { + var body models.Schedule + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + + instanceID := auth.InstanceID(c) + next, err := services.SetSchedule(instanceID, c.Param("id"), &body) + if err != nil { + if errors.Is(err, workflowsched.ErrBadSchedule) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + if errors.Is(err, mongo.ErrNoDocuments) { + c.JSON(http.StatusNotFound, gin.H{"error": "workflow not found"}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + services.LogEvent(instanceID, "workflow.schedule_updated", auth.Email(c), "", "", + fmt.Sprintf("schedule %q %s enabled=%v", body.Cron, body.TZ, body.Enabled)) + c.JSON(http.StatusOK, gin.H{"schedule": body, "next_run_at": next}) +} + +// previewWorkflowSchedule exists so the browser and the scheduler agree on +// what a cron string means. A client-side cron parser that disagrees with the +// server by one field is a bug found in production, at night. +func previewWorkflowSchedule(c *gin.Context) { + expr := c.Query("cron") + tz := c.Query("tz") + + occurrences := make([]time.Time, 0, 3) + from := time.Now() + for i := 0; i < 3; i++ { + next, err := workflowsched.NextOccurrence(expr, tz, from) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + occurrences = append(occurrences, next) + from = next + } + c.JSON(http.StatusOK, gin.H{"occurrences": occurrences}) +} +``` + +Add whichever of `"errors"`, `"fmt"`, `"time"`, `"go.mongodb.org/mongo-driver/v2/mongo"`, the `models` package and the `workflowsched` package are not already imported. Use the same actor helper the neighbouring handlers in this file use for `LogEvent`. + +- [ ] **Step 3: Verify it builds** + +Run: `cd server && go build ./... && go vet ./internal/api/` +Expected: no output. + +- [ ] **Step 4: Manual smoke test** + +```bash +curl -b cookies.txt -X PUT localhost:8080/api/workflows//schedule \ + -H 'content-type: application/json' -d '{"enabled":true,"cron":"0 2 * * 0","tz":"Europe/London"}' +curl -b cookies.txt 'localhost:8080/api/workflows//schedule/preview?cron=0+2+*+*+0&tz=Europe/London' +curl -b cookies.txt -X PUT localhost:8080/api/workflows//schedule \ + -H 'content-type: application/json' -d '{"enabled":true,"cron":"nope","tz":"UTC"}' # expect 400 +``` + +Expected: the first returns a `next_run_at` matching the first preview occurrence, and the third is 400. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/api/workflows.go +git commit -m "feat: schedule and preview endpoints for workflows" +``` + +--- + +### Task 6: Frontend API client + +**Files:** +- Modify: `web/lib/api.ts` + +**Interfaces:** +- Consumes: the routes from Task 5. +- Produces: `Schedule`, `Skip`, `Workflow.schedule`/`.next_run_at`/`.last_run_at`/`.last_skipped`, `api.setWorkflowSchedule`, `api.previewSchedule`. + +- [ ] **Step 1: Add the types** + +```ts +export interface Schedule { + enabled: boolean; + cron: string; + tz: string; +} + +export interface Skip { + reason: string; + due: string; + at: string; +} +``` + +In the `Workflow` interface: + +```ts + schedule?: Schedule; + next_run_at?: string; + last_run_at?: string; + last_skipped?: Skip; +``` + +- [ ] **Step 2: Add the client methods** + +```ts + setWorkflowSchedule(workflowId: string, schedule: Schedule): Promise<{ schedule: Schedule; next_run_at: string | null }> { + return request(`/workflows/${workflowId}/schedule`, { method: "PUT", body: JSON.stringify(schedule) }); + }, + + previewSchedule(workflowId: string, cron: string, tz: string): Promise<{ occurrences: string[] }> { + return request(`/workflows/${workflowId}/schedule/preview?cron=${encodeURIComponent(cron)}&tz=${encodeURIComponent(tz)}`); + }, +``` + +- [ ] **Step 3: Verify it typechecks** + +Run: `cd web && npx tsc --noEmit -p tsconfig.json` +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +git add web/lib/api.ts +git commit -m "feat: schedule methods on the web api client" +``` + +--- + +### Task 7: Schedule card + +**Files:** +- Create: `web/components/workflows/ScheduleCard.tsx` +- Modify: `web/app/(app)/workflows/[id]/page.tsx` + +**Interfaces:** +- Consumes: `api.setWorkflowSchedule`, `api.previewSchedule`, `Workflow.schedule`, `Workflow.last_skipped`. +- Produces: ``. + +- [ ] **Step 1: Write the component** + +Create `web/components/workflows/ScheduleCard.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api, Workflow } from "@/lib/api"; +import { Button } from "@/components/ui"; + +/* + * Presets write cron underneath rather than being their own storage format: + * one representation, and the raw field is always the truth. The next three + * occurrences come from the server so the browser cannot disagree with the + * scheduler about what an expression means. + */ + +const PRESETS: { label: string; cron: string }[] = [ + { label: "Hourly", cron: "0 * * * *" }, + { label: "Nightly, 02:00", cron: "0 2 * * *" }, + { label: "Weekly, Sun 02:00", cron: "0 2 * * 0" }, + { label: "Monthly, 1st 02:00", cron: "0 2 1 * *" }, +]; + +const ZONES = ["UTC", "Europe/London", "Europe/Berlin", "America/New_York", "America/Los_Angeles", "Asia/Singapore", "Australia/Sydney"]; + +export function ScheduleCard({ workflow }: { workflow: Workflow }) { + const queryClient = useQueryClient(); + const [enabled, setEnabled] = useState(workflow.schedule?.enabled ?? false); + const [cron, setCron] = useState(workflow.schedule?.cron ?? "0 2 * * 0"); + const [tz, setTz] = useState(workflow.schedule?.tz ?? Intl.DateTimeFormat().resolvedOptions().timeZone ?? "UTC"); + const [error, setError] = useState(null); + + const { data: preview } = useQuery({ + queryKey: ["schedule-preview", workflow.workflow_id, cron, tz], + queryFn: () => api.previewSchedule(workflow.workflow_id, cron, tz), + retry: false, + }); + + const { mutate: save, isPending } = useMutation({ + mutationFn: () => api.setWorkflowSchedule(workflow.workflow_id, { enabled, cron, tz }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["workflows"] }); + setError(null); + }, + onError: (e: Error) => setError(e.message), + }); + + return ( +
+
+

Schedule

+ + {enabled ? "Active" : "Off"} + +
+ +
+ + +
+ {PRESETS.map((p) => ( + + ))} +
+ +
+ + + +
+ +
+

Next three runs

+ {preview ? ( +
    + {preview.occurrences.map((o) => ( +
  • {new Date(o).toLocaleString()}
  • + ))} +
+ ) : ( +

That expression is not valid.

+ )} +
+ + {workflow.last_skipped && ( +

+ Skipped {new Date(workflow.last_skipped.due).toLocaleString()} —{" "} + {workflow.last_skipped.reason === "already_running" + ? "previous run still active" + : workflow.last_skipped.reason === "missed" + ? "the control plane was not running at the time" + : workflow.last_skipped.reason} +

+ )} + + {error &&

{error}

} + +
+ +
+
+
+ ); +} +``` + +- [ ] **Step 2: Mount it** + +In `web/app/(app)/workflows/[id]/page.tsx`, render `` in the sidebar or below the step designer, wherever the page's existing panels sit. + +- [ ] **Step 3: Verify it builds** + +Run: `cd web && npx tsc --noEmit -p tsconfig.json && npx next build` +Expected: `✓ Compiled successfully`. + +- [ ] **Step 4: Manual check** + +Enable a weekly schedule, confirm the three previewed occurrences are Sundays at 02:00 local, save, reload, and confirm it persisted. Type nonsense into the cron field and confirm the preview panel says the expression is not valid rather than showing stale occurrences. + +- [ ] **Step 5: Commit** + +```bash +git add web/components/workflows/ScheduleCard.tsx "web/app/(app)/workflows/[id]/page.tsx" +git commit -m "feat: schedule editor on the workflow page" +``` + +--- + +### Task 8: Schedule on the workflows list + +**Files:** +- Modify: `web/app/(app)/workflows/page.tsx` + +- [ ] **Step 1: Add the chip and next-run column** + +For each workflow row, when `w.schedule?.enabled`, render a mono chip carrying the cron expression and the next run as relative time: + +```tsx +{w.schedule?.enabled && ( + + {w.schedule.cron} + +)} +{w.next_run_at && ( + + next {new Date(w.next_run_at).toLocaleString()} + +)} +``` + +- [ ] **Step 2: Verify it builds** + +Run: `cd web && npx tsc --noEmit -p tsconfig.json && npx next build` +Expected: `✓ Compiled successfully`. + +- [ ] **Step 3: Commit** + +```bash +git add "web/app/(app)/workflows/page.tsx" +git commit -m "feat: show workflow schedules in the list" +``` + +--- + +### Task 9: Documentation + +**Files:** +- Modify: `docsite/docs/vantage/workflows.md`, `CLAUDE.md` + +- [ ] **Step 1: Document schedules for users** + +Add a "Schedules" section to `docsite/docs/vantage/workflows.md`: the 5-field cron format with the four preset equivalents, that the timezone is stored by name so DST is handled, that an overlapping occurrence is skipped rather than queued, and that an occurrence missed by more than an hour is recorded and dropped rather than fired late. + +- [ ] **Step 2: Update the contributor map** + +In `CLAUDE.md`, under Workflows, add: schedules live on the workflow document with a persisted `next_run_at`; `workflowsched` runs inside the single `RunAsLeader("housekeeping", …)`; the atomic claim on `next_run_at` — not the lock — is what prevents a double fire; `import _ "time/tzdata"` is required because Alpine ships no zone database and its absence silently reverts every schedule to UTC. + +- [ ] **Step 3: Commit** + +```bash +git add docsite/docs/vantage/workflows.md CLAUDE.md +git commit -m "docs: scheduled workflows" +``` + +--- + +## Verification + +```bash +cd server && go build ./... && go vet ./... && go test ./... +cd ../web && npx tsc --noEmit -p tsconfig.json && npx next build +cd .. && graphify update . +``` + +All must pass. `go test ./internal/workflowsched/` must report the cron, DST and decision suites passing. + +Two things no unit test here covers, because both need a real database: + +- **The claim under contention.** The spec asks that two concurrent claims of the same due workflow start exactly one run. `Decide` is tested; the `findOneAndUpdate` guard is not. Verify it by hand with two server processes pointed at one Mongo and one workflow due immediately — exactly one run document should appear. +- **Persistence across a restart.** Set a schedule, restart the server, and confirm the workflow still fires at its stated time. `next_run_at` is persisted precisely so that a restart or a leader handover does not lose it, and a regression there is invisible until the night it matters. diff --git a/docs/superpowers/plans/2026-08-04-server-tags.md b/docs/superpowers/plans/2026-08-04-server-tags.md new file mode 100644 index 0000000..734ba6d --- /dev/null +++ b/docs/superpowers/plans/2026-08-04-server-tags.md @@ -0,0 +1,1308 @@ +# Server Tags 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:** Give every server a `key:value` tag map, let workflows target servers by tag selector unioned with their explicit list, and surface both in the UI. + +**Architecture:** Tags are a `map[string]string` field on the existing `servers` documents — no new collection. Validation and matching live as pure functions in `server/internal/services` so they can be unit tested without a database; the Mongo-backed wrappers around them stay thin. Workflow targeting gains `target_tags` beside the untouched `target_server_ids`, and one `ResolveTargets` function produces the distinct union that `TriggerWorkflow` already expects as a list of server IDs. + +**Tech Stack:** Go 1.26, gin, mongo-driver v2, Next.js 16 + TanStack Query, Tailwind 3. + +## Global Constraints + +- Tag keys and values are lowercase `[a-z0-9_-]` only. Key max 32 chars, value max 64 chars, max 20 tags per server. +- Reserved key prefix `sys:` — rejected on user writes, unused otherwise. +- Every mutating API path writes an audit event via `services.LogEvent`. +- Every service query is scoped by `instance_id`. No exceptions, no unscoped lookups. +- No component in `web/` may carry a hex colour; use the Tailwind token names only. +- `web/` is dark-only. Do not add a light theme. +- This repository has **no Go tests today**. Tests added here are the first; they must run under plain `go test ./...` from the repo root with no database, no network, and no build tags. +- Commit messages use the `feat:` / `fix:` / `docs:` prefixes already in `git log`. + +--- + +## File Structure + +**Create:** +- `server/internal/services/tags.go` — tag validation, normalisation, matching. Pure functions plus the Mongo-backed read/write. +- `server/internal/services/tags_test.go` — unit tests for the pure half. +- `server/internal/services/targets.go` — `ResolveTargets` and its pure core. +- `server/internal/services/targets_test.go` — unit tests for the pure core. +- `web/components/servers/TagChips.tsx` — display and inline edit of one server's tags. +- `web/components/servers/TagFilterBar.tsx` — the fleet list filter. + +**Modify:** +- `server/internal/models/server.go` — add `Tags`. +- `server/internal/models/workflow.go` — add `TargetTags`. +- `server/internal/services/servers.go` — `ListServers` gains a tag filter; add `EnsureServerIndexes`. +- `server/internal/services/workflow_runner.go:20-34` — `TriggerWorkflow` resolves targets. +- `server/internal/services/workflows.go` — validate `target_tags` on create/update. +- `server/internal/api/handlers.go` — three routes and their handlers. +- `server/cmd/main.go:111` — call `EnsureServerIndexes`. +- `web/lib/api.ts` — types and client methods. +- `web/app/(app)/servers/page.tsx` — filter bar and tag column. +- `web/app/(app)/servers/[id]/page.tsx` — tag chips in the header. +- `web/app/(app)/workflows/[id]/page.tsx` — target section. +- `docsite/docs/vantage/servers.md`, `docsite/docs/vantage/workflows.md` — document tags. +- `CLAUDE.md` — one line in the servers/workflows subsystem notes. + +--- + +### Task 1: Tag validation + +**Files:** +- Create: `server/internal/services/tags.go` +- Test: `server/internal/services/tags_test.go` +- Modify: `server/internal/models/server.go:46-67` + +**Interfaces:** +- Consumes: nothing. +- Produces: `services.ValidateTags(tags map[string]string) error`, `services.ErrInvalidTag` (a `error` value), and `models.Server.Tags map[string]string`. + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/services/tags_test.go`: + +```go +package services + +import ( + "strings" + "testing" +) + +func TestValidateTags(t *testing.T) { + long33 := strings.Repeat("a", 33) + long65 := strings.Repeat("b", 65) + + tooMany := map[string]string{} + for i := 0; i < 21; i++ { + tooMany[string(rune('a'+i))] = "x" + } + + cases := []struct { + name string + tags map[string]string + ok bool + }{ + {"empty is fine", map[string]string{}, true}, + {"nil is fine", nil, true}, + {"simple pair", map[string]string{"env": "prod"}, true}, + {"dash and underscore", map[string]string{"team_name": "core-infra"}, true}, + {"digits", map[string]string{"tier1": "web2"}, true}, + {"uppercase key", map[string]string{"Env": "prod"}, false}, + {"uppercase value", map[string]string{"env": "Prod"}, false}, + {"space in value", map[string]string{"env": "pro d"}, false}, + {"colon in key", map[string]string{"en:v": "prod"}, false}, + {"empty key", map[string]string{"": "prod"}, false}, + {"empty value", map[string]string{"env": ""}, false}, + {"key too long", map[string]string{long33: "prod"}, false}, + {"value too long", map[string]string{"env": long65}, false}, + {"reserved prefix", map[string]string{"sys:os": "linux"}, false}, + {"too many tags", tooMany, false}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := ValidateTags(tc.tags) + if tc.ok && err != nil { + t.Fatalf("expected valid, got %v", err) + } + if !tc.ok && err == nil { + t.Fatal("expected an error, got nil") + } + }) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/services/ -run TestValidateTags -v` +Expected: FAIL — `undefined: ValidateTags` + +- [ ] **Step 3: Write minimal implementation** + +Create `server/internal/services/tags.go`: + +```go +package services + +import ( + "errors" + "fmt" + "strings" +) + +// ErrInvalidTag is returned for any tag the rules below reject. Handlers map +// it to 400 — a malformed tag is the caller's mistake, not a server fault. +var ErrInvalidTag = errors.New("invalid tag") + +const ( + maxTagKeyLen = 32 + maxTagValueLen = 64 + maxTagsPerHost = 20 + // Reserved for tags the agent may derive from inventory later. Refusing + // it now means a user tag written today can never collide with a system + // tag invented tomorrow. + sysTagPrefix = "sys:" +) + +func validTagRunes(s string) bool { + for _, r := range s { + switch { + case r >= 'a' && r <= 'z': + case r >= '0' && r <= '9': + case r == '-' || r == '_': + default: + return false + } + } + return true +} + +// ValidateTags enforces the shape of a whole tag map. It lives in the service +// layer rather than a handler so that every write path — the tags endpoint, +// server create, anything added later — agrees on what a valid tag is. +func ValidateTags(tags map[string]string) error { + if len(tags) > maxTagsPerHost { + return fmt.Errorf("%w: at most %d tags per server", ErrInvalidTag, maxTagsPerHost) + } + for k, v := range tags { + if strings.HasPrefix(k, sysTagPrefix) { + return fmt.Errorf("%w: keys beginning %q are reserved", ErrInvalidTag, sysTagPrefix) + } + if k == "" || len(k) > maxTagKeyLen || !validTagRunes(k) { + return fmt.Errorf("%w: key %q must be 1-%d chars of a-z, 0-9, - or _", ErrInvalidTag, k, maxTagKeyLen) + } + if v == "" || len(v) > maxTagValueLen || !validTagRunes(v) { + return fmt.Errorf("%w: value for %q must be 1-%d chars of a-z, 0-9, - or _", ErrInvalidTag, k, maxTagValueLen) + } + } + return nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd server && go test ./internal/services/ -run TestValidateTags -v` +Expected: PASS, all 15 subtests. + +- [ ] **Step 5: Add the model field** + +In `server/internal/models/server.go`, inside `type Server struct`, after the `Inventory` line: + +```go + Tags map[string]string `bson:"tags,omitempty" json:"tags,omitempty"` +``` + +- [ ] **Step 6: Verify it builds** + +Run: `cd server && go build ./...` +Expected: no output. + +- [ ] **Step 7: Commit** + +```bash +git add server/internal/services/tags.go server/internal/services/tags_test.go server/internal/models/server.go +git commit -m "feat: validate server tags and add the model field" +``` + +--- + +### Task 2: Tag parsing for query strings + +**Files:** +- Modify: `server/internal/services/tags.go` +- Test: `server/internal/services/tags_test.go` + +**Interfaces:** +- Consumes: `ValidateTags`, `ErrInvalidTag` from Task 1. +- Produces: `services.ParseTagFilters(raw []string) (map[string]string, error)`. + +- [ ] **Step 1: Write the failing test** + +Append to `server/internal/services/tags_test.go`: + +```go +func TestParseTagFilters(t *testing.T) { + got, err := ParseTagFilters([]string{"env:prod", "role:web"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(got) != 2 || got["env"] != "prod" || got["role"] != "web" { + t.Fatalf("unexpected map: %#v", got) + } + + empty, err := ParseTagFilters(nil) + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if len(empty) != 0 { + t.Fatalf("expected empty map, got %#v", empty) + } + + for _, bad := range []string{"env", "env:", ":prod", "env:prod:extra", "ENV:prod", ""} { + if _, err := ParseTagFilters([]string{bad}); err == nil { + t.Fatalf("expected %q to be rejected", bad) + } + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/services/ -run TestParseTagFilters -v` +Expected: FAIL — `undefined: ParseTagFilters` + +- [ ] **Step 3: Write minimal implementation** + +Append to `server/internal/services/tags.go`: + +```go +// ParseTagFilters turns repeated ?tag=key:value query values into a map. +// +// A malformed filter is an error rather than a silently ignored value: a +// filter that matches nothing and a filter that is nonsense look identical in +// a list, and only one of them is the caller's fault. +func ParseTagFilters(raw []string) (map[string]string, error) { + out := make(map[string]string, len(raw)) + for _, r := range raw { + k, v, found := strings.Cut(r, ":") + if !found { + return nil, fmt.Errorf("%w: filter %q must be key:value", ErrInvalidTag, r) + } + if strings.Contains(v, ":") { + return nil, fmt.Errorf("%w: filter %q has more than one colon", ErrInvalidTag, r) + } + out[k] = v + } + if err := ValidateTags(out); err != nil { + return nil, err + } + return out, nil +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd server && go test ./internal/services/ -run TestParseTagFilters -v` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/services/tags.go server/internal/services/tags_test.go +git commit -m "feat: parse repeated tag query filters" +``` + +--- + +### Task 3: Target resolution core + +**Files:** +- Create: `server/internal/services/targets.go` +- Test: `server/internal/services/targets_test.go` + +**Interfaces:** +- Consumes: `models.Server` with `Tags` from Task 1. +- Produces: `services.MatchesTags(srv models.Server, sel map[string]string) bool` and `services.UnionTargets(all []models.Server, ids []string, sel map[string]string) []models.Server`, plus `services.ErrNoTargets`. + +- [ ] **Step 1: Write the failing test** + +Create `server/internal/services/targets_test.go`: + +```go +package services + +import ( + "testing" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +func fixtureServers() []models.Server { + return []models.Server{ + {ServerID: "a", Hostname: "web-1", Tags: map[string]string{"env": "prod", "role": "web"}}, + {ServerID: "b", Hostname: "web-2", Tags: map[string]string{"env": "prod", "role": "web"}}, + {ServerID: "c", Hostname: "db-1", Tags: map[string]string{"env": "prod", "role": "db"}}, + {ServerID: "d", Hostname: "web-3", Tags: map[string]string{"env": "staging", "role": "web"}}, + {ServerID: "e", Hostname: "untagged-1"}, + } +} + +func ids(servers []models.Server) []string { + out := make([]string, 0, len(servers)) + for _, s := range servers { + out = append(out, s.ServerID) + } + return out +} + +func equalIDs(t *testing.T, got []models.Server, want ...string) { + t.Helper() + gotIDs := ids(got) + if len(gotIDs) != len(want) { + t.Fatalf("got %v, want %v", gotIDs, want) + } + for i := range want { + if gotIDs[i] != want[i] { + t.Fatalf("got %v, want %v", gotIDs, want) + } + } +} + +func TestMatchesTags(t *testing.T) { + web1 := fixtureServers()[0] + + if !MatchesTags(web1, map[string]string{"env": "prod"}) { + t.Fatal("expected single-key match") + } + if !MatchesTags(web1, map[string]string{"env": "prod", "role": "web"}) { + t.Fatal("expected AND across keys to match") + } + if MatchesTags(web1, map[string]string{"env": "prod", "role": "db"}) { + t.Fatal("AND across keys must not match on one key alone") + } + if MatchesTags(web1, map[string]string{"team": "core"}) { + t.Fatal("absent key must not match") + } + if MatchesTags(web1, map[string]string{}) { + t.Fatal("an empty selector must match nothing, not everything") + } +} + +func TestUnionTargets(t *testing.T) { + all := fixtureServers() + + t.Run("ids only", func(t *testing.T) { + equalIDs(t, UnionTargets(all, []string{"a", "c"}, nil), "a", "c") + }) + + t.Run("selector only", func(t *testing.T) { + equalIDs(t, UnionTargets(all, nil, map[string]string{"env": "prod", "role": "web"}), "a", "b") + }) + + t.Run("union deduplicates", func(t *testing.T) { + // "a" is named explicitly AND matched by the selector; it appears once. + equalIDs(t, UnionTargets(all, []string{"a", "c"}, map[string]string{"role": "web"}), "a", "b", "c", "d") + }) + + t.Run("unknown id is dropped", func(t *testing.T) { + equalIDs(t, UnionTargets(all, []string{"a", "does-not-exist"}, nil), "a") + }) + + t.Run("both empty yields nothing", func(t *testing.T) { + equalIDs(t, UnionTargets(all, nil, nil)) + }) + + t.Run("order follows the fleet, not the arguments", func(t *testing.T) { + equalIDs(t, UnionTargets(all, []string{"c", "a"}, nil), "a", "c") + }) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `cd server && go test ./internal/services/ -run 'TestMatchesTags|TestUnionTargets' -v` +Expected: FAIL — `undefined: MatchesTags` + +- [ ] **Step 3: Write minimal implementation** + +Create `server/internal/services/targets.go`: + +```go +package services + +import ( + "errors" + + "gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models" +) + +// ErrNoTargets means a workflow named no servers and matched none. Handlers +// map it to 400: a workflow that matches nothing must say so rather than +// report success over zero servers. +var ErrNoTargets = errors.New("workflow has no target servers") + +// MatchesTags reports whether srv carries every pair in sel — AND across keys. +// An empty selector matches nothing. That is deliberate: the alternative, +// "matches everything", turns a cleared field in the workflow designer into a +// fleet-wide run. +func MatchesTags(srv models.Server, sel map[string]string) bool { + if len(sel) == 0 { + return false + } + for k, v := range sel { + if srv.Tags[k] != v { + return false + } + } + return true +} + +// UnionTargets returns the distinct union of the servers named by ids and +// those matching sel, in the order they appear in all. +// +// Order comes from the fleet rather than the arguments so that two workflows +// naming the same servers in a different order still run them in the same +// order, which makes two runs comparable line by line. +// +// Offline servers are NOT filtered out. The dispatcher already answers 503 per +// server, and a patch run that silently omits an unreachable machine is worse +// than one that visibly fails on it. +func UnionTargets(all []models.Server, ids []string, sel map[string]string) []models.Server { + named := make(map[string]bool, len(ids)) + for _, id := range ids { + named[id] = true + } + + out := make([]models.Server, 0, len(ids)+len(all)) + for _, s := range all { + if named[s.ServerID] || MatchesTags(s, sel) { + out = append(out, s) + } + } + return out +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `cd server && go test ./internal/services/ -run 'TestMatchesTags|TestUnionTargets' -v` +Expected: PASS, all subtests. + +- [ ] **Step 5: Commit** + +```bash +git add server/internal/services/targets.go server/internal/services/targets_test.go +git commit -m "feat: resolve workflow targets as the union of ids and a tag selector" +``` + +--- + +### Task 4: Database-backed tag reads and writes + +**Files:** +- Modify: `server/internal/services/tags.go`, `server/internal/services/servers.go`, `server/internal/services/targets.go` +- Modify: `server/cmd/main.go:111` + +**Interfaces:** +- Consumes: `ValidateTags`, `UnionTargets`, `ErrNoTargets`. +- Produces: + - `services.SetServerTags(instanceID, serverID string, tags map[string]string) error` + - `services.KnownTags(instanceID string) (map[string][]string, error)` + - `services.ListServersFiltered(instanceID string, sel map[string]string) ([]models.Server, error)` + - `services.ResolveTargets(instanceID string, ids []string, sel map[string]string) ([]models.Server, error)` + - `services.EnsureServerIndexes() error` + +- [ ] **Step 1: Add the Mongo-backed tag functions** + +Append to `server/internal/services/tags.go`: + +```go +// SetServerTags replaces a server's whole tag map. +// +// Replace rather than patch: a tag set is small enough that sending all of it +// is free, and last-write-wins over a whole map is easier to reason about than +// merge semantics between two people editing the same server. +func SetServerTags(instanceID, serverID string, tags map[string]string) error { + if err := ValidateTags(tags); err != nil { + return err + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + res, err := db.Col("servers").UpdateOne(ctx, + bson.M{"server_id": serverID, "instance_id": instanceID}, + bson.M{"$set": bson.M{"tags": tags}}, + ) + if err != nil { + return err + } + if res.MatchedCount == 0 { + return mongo.ErrNoDocuments + } + return nil +} + +// KnownTags returns every key in use in this instance with its distinct +// values, for the UI's pickers. This is an aggregation rather than a +// maintained registry: a tag is a property of a server, not an entity, and a +// registry would need reference counting to know when a tag stopped existing. +func KnownTags(instanceID string) (map[string][]string, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + cur, err := db.Col("servers").Find(ctx, + bson.M{"instance_id": instanceID, "tags": bson.M{"$exists": true}}, + options.Find().SetProjection(bson.M{"tags": 1}), + ) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + seen := map[string]map[string]bool{} + for cur.Next(ctx) { + var s models.Server + if err := cur.Decode(&s); err != nil { + return nil, err + } + for k, v := range s.Tags { + if seen[k] == nil { + seen[k] = map[string]bool{} + } + seen[k][v] = true + } + } + if err := cur.Err(); err != nil { + return nil, err + } + + out := make(map[string][]string, len(seen)) + for k, vals := range seen { + list := make([]string, 0, len(vals)) + for v := range vals { + list = append(list, v) + } + sort.Strings(list) + out[k] = list + } + return out, nil +} +``` + +Add to that file's imports: `"context"`, `"sort"`, `"time"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/db"`, `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"`, `"go.mongodb.org/mongo-driver/v2/bson"`, `"go.mongodb.org/mongo-driver/v2/mongo"`, `"go.mongodb.org/mongo-driver/v2/mongo/options"`. + +- [ ] **Step 2: Add the filtered list and the index builder** + +Append to `server/internal/services/servers.go`: + +```go +// ListServersFiltered is ListServers with an optional tag selector. An empty +// selector returns the whole fleet — unlike MatchesTags, where empty means +// "nothing", because here the caller is a list view whose default is +// "everything", not a run about to touch machines. +func ListServersFiltered(instanceID string, sel map[string]string) ([]models.Server, error) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + filter := bson.M{"instance_id": instanceID} + for k, v := range sel { + filter["tags."+k] = v + } + + cur, err := db.Col("servers").Find(ctx, filter, options.Find().SetSort(bson.D{{Key: "hostname", Value: 1}})) + if err != nil { + return nil, err + } + defer cur.Close(ctx) + + servers := []models.Server{} + if err := cur.All(ctx, &servers); err != nil { + return nil, err + } + return servers, nil +} + +// EnsureServerIndexes declares the wildcard index over the tag subdocument. +// It is wildcard because the queried key is chosen by the user at request time +// and cannot be named in advance. +// +// Non-fatal, following EnsureSecretIndexes: a missing index degrades tag +// filtering to a collection scan over a small collection, which is slower. +// A fatal error here would refuse to boot the fleet list over it. +func EnsureServerIndexes() error { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + _, err := db.Col("servers").Indexes().CreateOne(ctx, mongo.IndexModel{ + Keys: bson.D{{Key: "instance_id", Value: 1}, {Key: "tags.$**", Value: 1}}, + }) + return err +} +``` + +Add `"go.mongodb.org/mongo-driver/v2/mongo"` to that file's imports if it is not already there. + +- [ ] **Step 3: Add ResolveTargets** + +Append to `server/internal/services/targets.go`: + +```go +// ResolveTargets is the database-backed wrapper around UnionTargets. It is the +// single answer to "which servers does this workflow touch", used by the run +// path and by validation alike, so the two cannot disagree. +func ResolveTargets(instanceID string, ids []string, sel map[string]string) ([]models.Server, error) { + all, err := ListServers(instanceID) + if err != nil { + return nil, err + } + matched := UnionTargets(all, ids, sel) + if len(matched) == 0 { + return nil, ErrNoTargets + } + return matched, nil +} +``` + +Add `"gitea.hostxtra.co.uk/mrhid6/vantage/server/internal/models"` to that file's imports (already present from Task 3). + +- [ ] **Step 4: Wire the index builder into boot** + +In `server/cmd/main.go`, beside the existing `EnsureSecretIndexes` call around line 111: + +```go + if err := services.EnsureServerIndexes(); err != nil { + log.Printf("server index warning: %v", err) + } +``` + +- [ ] **Step 5: Verify it builds and existing tests still pass** + +Run: `cd server && go build ./... && go test ./...` +Expected: build silent, tests PASS. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/services/tags.go server/internal/services/servers.go server/internal/services/targets.go server/cmd/main.go +git commit -m "feat: read and write server tags, resolve targets from the database" +``` + +--- + +### Task 5: Tag API routes + +**Files:** +- Modify: `server/internal/api/handlers.go` — routes at line 53-61, handlers near `listServers` at line 121. + +**Interfaces:** +- Consumes: `SetServerTags`, `KnownTags`, `ListServersFiltered`, `ParseTagFilters`, `ErrInvalidTag`. +- Produces: `PUT /api/servers/:id/tags`, `GET /api/servers/tags`, and `?tag=` on `GET /api/servers`. + +- [ ] **Step 1: Register the routes** + +In `RegisterRoutes`, immediately after `apiGroup.GET("/servers", listServers)`: + +```go + apiGroup.GET("/servers/tags", listKnownTags) +``` + +and after `apiGroup.POST("/servers/:id/apply-updates", applyUpdates)`: + +```go + apiGroup.PUT("/servers/:id/tags", putServerTags) +``` + +`/servers/tags` must be registered **before** the `/servers/:id` routes are matched — gin resolves static segments ahead of wildcards, so this works, but keep it grouped with the other `/servers` routes for readability. + +- [ ] **Step 2: Replace the listServers handler** + +Replace `listServers` in `server/internal/api/handlers.go:121-128` with: + +```go +func listServers(c *gin.Context) { + sel, err := services.ParseTagFilters(c.QueryArray("tag")) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + servers, err := services.ListServersFiltered(auth.InstanceID(c), sel) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, servers) +} +``` + +- [ ] **Step 3: Add the two new handlers** + +Append near the other server handlers in the same file: + +```go +func listKnownTags(c *gin.Context) { + tags, err := services.KnownTags(auth.InstanceID(c)) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusOK, tags) +} + +func putServerTags(c *gin.Context) { + var body struct { + Tags map[string]string `json:"tags"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid body"}) + return + } + + instanceID := auth.InstanceID(c) + serverID := c.Param("id") + + before, err := services.GetServer(instanceID, serverID) + if err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "server not found"}) + return + } + + if err := services.SetServerTags(instanceID, serverID, body.Tags); err != nil { + if errors.Is(err, services.ErrInvalidTag) { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + services.LogEvent(instanceID, "server.tags_updated", auth.Email(c), serverID, "", + fmt.Sprintf("tags %v -> %v", before.Tags, body.Tags)) + c.JSON(http.StatusOK, gin.H{"tags": body.Tags}) +} +``` + +Add `"errors"` and `"fmt"` to the file's imports if absent. If the actor helper in this file is named something other than `auth.Email(c)`, use whatever the neighbouring handlers use — grep one existing `services.LogEvent(` call in this file and copy its actor argument exactly. + +- [ ] **Step 4: Verify it builds** + +Run: `cd server && go build ./... && go vet ./internal/api/` +Expected: no output. + +- [ ] **Step 5: Manual smoke test** + +Run the stack (`cd deploy && docker compose up -d`), sign in, then: + +```bash +curl -b cookies.txt -X PUT localhost:8080/api/servers//tags \ + -H 'content-type: application/json' -d '{"tags":{"env":"prod","role":"web"}}' +curl -b cookies.txt 'localhost:8080/api/servers?tag=env:prod' +curl -b cookies.txt localhost:8080/api/servers/tags +curl -b cookies.txt -X PUT localhost:8080/api/servers//tags \ + -H 'content-type: application/json' -d '{"tags":{"ENV":"prod"}}' # expect 400 +``` + +Expected: the first three succeed, the fourth is 400 with an `invalid tag` message, and `/api/audit` shows a `server.tags_updated` event. + +- [ ] **Step 6: Commit** + +```bash +git add server/internal/api/handlers.go +git commit -m "feat: tag endpoints for servers" +``` + +--- + +### Task 6: Workflow tag targeting + +**Files:** +- Modify: `server/internal/models/workflow.go:47-56` +- Modify: `server/internal/services/workflows.go:262-289` +- Modify: `server/internal/services/workflow_runner.go:20-69` + +**Interfaces:** +- Consumes: `ResolveTargets`, `ErrNoTargets`, `ValidateTags`. +- Produces: `models.Workflow.TargetTags map[string]string`. + +- [ ] **Step 1: Add the model field** + +In `server/internal/models/workflow.go`, inside `type Workflow struct`, after `TargetServerIDs`: + +```go + TargetTags map[string]string `bson:"target_tags,omitempty" json:"target_tags,omitempty"` +``` + +- [ ] **Step 2: Validate it on create and update** + +In `server/internal/services/workflows.go`, inside both `CreateWorkflow` and `UpdateWorkflow`, before the existing `validateTargetServers` call: + +```go + if err := ValidateTags(w.TargetTags); err != nil { + return err // CreateWorkflow returns (nil, err) + } +``` + +Make sure `UpdateWorkflow` also persists the field — if it builds an explicit `$set` document, add `"target_tags": w.TargetTags` to it. + +- [ ] **Step 3: Resolve targets in TriggerWorkflow** + +In `server/internal/services/workflow_runner.go`, replace lines 25-34 (the `len(wf.TargetServerIDs) == 0` guard and the `validateTargetServers` call) with: + +```go + targets, err := ResolveTargets(instanceID, wf.TargetServerIDs, wf.TargetTags) + if err != nil { + return "", err + } + if len(wf.Steps) == 0 { + return "", fmt.Errorf("workflow has no steps") + } +``` + +Then replace the `ServerRuns` construction at lines 57-69 with: + +```go + ServerRuns: make([]models.ServerRun, 0, len(targets)), + } + for _, srv := range targets { + sr := models.ServerRun{ServerID: srv.ServerID, Hostname: srv.Hostname, Status: "queued", RunEnv: map[string]string{}} + for _, rs := range resolved { + sr.Steps = append(sr.Steps, models.StepRun{Order: rs.Order, Name: rs.Name, Status: "queued", OutputEnv: map[string]string{}}) + } + run.ServerRuns = append(run.ServerRuns, sr) + } +``` + +This also removes the per-server `getServerByID` lookup — `ResolveTargets` already returned whole `models.Server` values, so the hostname is in hand. + +- [ ] **Step 4: Map ErrNoTargets to 400 in the API** + +In `server/internal/api/workflows.go`, in the handler for `POST /workflows/:id/run`, before the generic 500: + +```go + if errors.Is(err, services.ErrNoTargets) { + c.JSON(http.StatusBadRequest, gin.H{"error": "this workflow matches no servers"}) + return + } +``` + +Add `"errors"` to the imports if absent. + +- [ ] **Step 5: Verify it builds and tests pass** + +Run: `cd server && go build ./... && go test ./...` +Expected: build silent, tests PASS. If `validateTargetServers` is now unused, delete it rather than leaving dead code. + +- [ ] **Step 6: Manual smoke test** + +Tag two servers `env:test`, set a workflow's `target_tags` to `{"env":"test"}` with an empty server list, run it, and confirm the run's `server_runs` holds exactly those two. Then clear both the list and the tags and confirm the run endpoint answers 400. + +- [ ] **Step 7: Commit** + +```bash +git add server/internal/models/workflow.go server/internal/services/workflows.go server/internal/services/workflow_runner.go server/internal/api/workflows.go +git commit -m "feat: target workflow runs by tag selector" +``` + +--- + +### Task 7: Frontend API client + +**Files:** +- Modify: `web/lib/api.ts` — `Server` interface near line 24, `Workflow` interface, and the `api` object near line 542. + +**Interfaces:** +- Consumes: the routes from Tasks 5 and 6. +- Produces: `Server.tags`, `Workflow.target_tags`, `api.setServerTags`, `api.listKnownTags`, `api.listServers(tags?)`. + +- [ ] **Step 1: Add the types** + +In the `Server` interface add: + +```ts + tags?: Record; +``` + +In the `Workflow` and `WorkflowInput` interfaces add: + +```ts + target_tags?: Record; +``` + +- [ ] **Step 2: Add the client methods** + +Replace the existing `listServers` in the `api` object and add two methods beside it: + +```ts + listServers(tags?: Record): Promise { + const params = Object.entries(tags ?? {}).map(([k, v]) => `tag=${encodeURIComponent(`${k}:${v}`)}`); + return request(`/servers${params.length ? `?${params.join("&")}` : ""}`); + }, + + listKnownTags(): Promise> { + return request>("/servers/tags"); + }, + + setServerTags(serverId: string, tags: Record): Promise<{ tags: Record }> { + return request<{ tags: Record }>(`/servers/${serverId}/tags`, { + method: "PUT", + body: JSON.stringify({ tags }), + }); + }, +``` + +- [ ] **Step 3: Verify it typechecks** + +Run: `cd web && npx tsc --noEmit -p tsconfig.json` +Expected: no output. Any call site of `api.listServers()` still compiles because the argument is optional. + +- [ ] **Step 4: Commit** + +```bash +git add web/lib/api.ts +git commit -m "feat: tag methods on the web api client" +``` + +--- + +### Task 8: Tag chips and editor + +**Files:** +- Create: `web/components/servers/TagChips.tsx` +- Modify: `web/app/(app)/servers/[id]/page.tsx` — header block. + +**Interfaces:** +- Consumes: `api.setServerTags`, `api.listKnownTags`, `Server.tags`. +- Produces: `} editable={boolean} />`. + +- [ ] **Step 1: Write the component** + +Create `web/components/servers/TagChips.tsx`: + +```tsx +"use client"; + +import { useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { api } from "@/lib/api"; +import { Button } from "@/components/ui"; + +/* + * A tag is key:value, so the chip shows both halves with the key dimmed — the + * value is the part people scan for, the key is what disambiguates it. + */ + +export function TagChips({ serverId, tags, editable = false }: { serverId: string; tags?: Record; editable?: boolean }) { + const queryClient = useQueryClient(); + const [editing, setEditing] = useState(false); + const [draft, setDraft] = useState<[string, string][]>(Object.entries(tags ?? {})); + const [error, setError] = useState(null); + + const { data: known } = useQuery({ + queryKey: ["server-tags"], + queryFn: () => api.listKnownTags(), + enabled: editing, + staleTime: 60_000, + }); + + const { mutate: save, isPending } = useMutation({ + mutationFn: () => api.setServerTags(serverId, Object.fromEntries(draft.filter(([k, v]) => k && v))), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["servers"] }); + queryClient.invalidateQueries({ queryKey: ["server-tags"] }); + setEditing(false); + setError(null); + }, + onError: (e: Error) => setError(e.message), + }); + + const entries = Object.entries(tags ?? {}); + + if (!editing) { + return ( +
+ {entries.length === 0 && No tags} + {entries.map(([k, v]) => ( + + {k}: + {v} + + ))} + {editable && ( + + )} +
+ ); + } + + return ( +
+ {Object.keys(known ?? {}).map((k) => + +
+ {draft.map(([k, v], i) => ( +
+ setDraft((d) => d.map((row, j) => (j === i ? [e.target.value, row[1]] : row)))} + placeholder="env" + className="w-32 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-accent/50 focus:outline-none" + /> + : + setDraft((d) => d.map((row, j) => (j === i ? [row[0], e.target.value] : row)))} + placeholder="prod" + className="w-40 rounded-lg border border-border bg-surface-2 px-2 py-1 font-mono text-xs text-text-primary focus:border-accent/50 focus:outline-none" + /> + +
+ ))} +
+ + {draft.length < 20 && ( + + )} + + {error &&

{error}

} + +
+ + +
+
+ ); +} +``` + +- [ ] **Step 2: Mount it on the server detail header** + +In `web/app/(app)/servers/[id]/page.tsx`, import it and render it directly under the hostname heading: + +```tsx + +``` + +- [ ] **Step 3: Verify it builds** + +Run: `cd web && npx tsc --noEmit -p tsconfig.json && npx next build` +Expected: `✓ Compiled successfully`. + +- [ ] **Step 4: Manual check** + +Open a server, add `env:prod`, save, reload — the chip persists. Try `ENV:prod` and confirm the inline error names the rule rather than showing a raw 400. + +- [ ] **Step 5: Commit** + +```bash +git add web/components/servers/TagChips.tsx "web/app/(app)/servers/[id]/page.tsx" +git commit -m "feat: view and edit server tags" +``` + +--- + +### Task 9: Fleet filter bar + +**Files:** +- Create: `web/components/servers/TagFilterBar.tsx` +- Modify: `web/app/(app)/servers/page.tsx` + +**Interfaces:** +- Consumes: `api.listKnownTags`, `api.listServers(tags)`. +- Produces: `} onChange={(v) => void} />`. + +- [ ] **Step 1: Write the component** + +Create `web/components/servers/TagFilterBar.tsx`: + +```tsx +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { api } from "@/lib/api"; + +export function TagFilterBar({ value, onChange }: { value: Record; onChange: (v: Record) => void }) { + const { data: known } = useQuery({ queryKey: ["server-tags"], queryFn: () => api.listKnownTags(), staleTime: 60_000 }); + + const keys = Object.keys(known ?? {}).sort(); + if (keys.length === 0) return null; + + const active = Object.entries(value); + + return ( +
+ {keys.map((k) => ( + + ))} + {active.length > 0 && ( + + )} +
+ ); +} +``` + +- [ ] **Step 2: Drive the list from the URL** + +In `web/app/(app)/servers/page.tsx`, read the filter from `useSearchParams`, write it back with `router.replace`, and pass it to the query so a filtered fleet view is a shareable URL: + +```tsx +const searchParams = useSearchParams(); +const router = useRouter(); + +const selected = Object.fromEntries( + searchParams.getAll("tag").map((t) => t.split(":")).filter((p) => p.length === 2), +) as Record; + +function setSelected(next: Record) { + const qs = Object.entries(next).map(([k, v]) => `tag=${encodeURIComponent(`${k}:${v}`)}`).join("&"); + router.replace(qs ? `/servers?${qs}` : "/servers"); +} + +const { data: servers, isLoading } = useQuery({ + queryKey: ["servers", selected], + queryFn: () => api.listServers(selected), +}); +``` + +Render `` above the list, and add a tags cell to each row using the read-only form: ``. + +A page reading `useSearchParams` must be inside a `` boundary in the App Router. If the build complains, wrap the page body in one. + +- [ ] **Step 3: Verify it builds** + +Run: `cd web && npx tsc --noEmit -p tsconfig.json && npx next build` +Expected: `✓ Compiled successfully`. + +- [ ] **Step 4: Manual check** + +Filter to `env: prod`, copy the URL, open it in a new tab, and confirm the filter is still applied. + +- [ ] **Step 5: Commit** + +```bash +git add web/components/servers/TagFilterBar.tsx "web/app/(app)/servers/page.tsx" +git commit -m "feat: filter the fleet list by tag" +``` + +--- + +### Task 10: Workflow target section + +**Files:** +- Modify: `web/app/(app)/workflows/[id]/page.tsx` + +**Interfaces:** +- Consumes: `Workflow.target_tags`, `api.listServers()`, `api.listKnownTags()`. +- Produces: no new exports. + +- [ ] **Step 1: Add the tag selector beside the server picker** + +In the workflow's target editor, add a key/value row editor writing to `target_tags`, using the same two-input pattern as `TagChips` (repeat it locally rather than extracting — the editing model differs: this one has no save button of its own, it feeds the workflow's own save). + +- [ ] **Step 2: Add the resolved readout** + +Compute the union client-side from the server list already fetched — no new endpoint, because the browser holds the whole fleet already: + +```tsx +const matched = (servers ?? []).filter( + (s) => + targetServerIds.includes(s.server_id) || + (Object.keys(targetTags).length > 0 && Object.entries(targetTags).every(([k, v]) => s.tags?.[k] === v)), +); +``` + +Render it as the readout the design promised: + +```tsx +

s.hostname).join("\n")}> + Runs on {matched.length} {matched.length === 1 ? "server" : "servers"} +

+{matched.length === 0 && ( +

This workflow matches no servers and cannot run.

+)} +``` + +The filter above must stay identical in meaning to `UnionTargets` and `MatchesTags` in Go — empty selector matches nothing, AND across keys. If one changes, change both. + +- [ ] **Step 3: Verify it builds** + +Run: `cd web && npx tsc --noEmit -p tsconfig.json && npx next build` +Expected: `✓ Compiled successfully`. + +- [ ] **Step 4: Manual check** + +Set a tag selector with no explicit servers, confirm the readout counts correctly and that saving then running dispatches to exactly those machines. + +- [ ] **Step 5: Commit** + +```bash +git add "web/app/(app)/workflows/[id]/page.tsx" +git commit -m "feat: target workflows by tag in the designer" +``` + +--- + +### Task 11: Documentation + +**Files:** +- Modify: `docsite/docs/vantage/servers.md`, `docsite/docs/vantage/workflows.md`, `CLAUDE.md` + +- [ ] **Step 1: Document tags for users** + +Add a "Tags" section to `docsite/docs/vantage/servers.md` covering: what a tag is (`key:value`), the character and count rules stated plainly, how to filter the fleet, and that the filter is in the URL. Add a "Targeting" section to `docsite/docs/vantage/workflows.md` covering the union rule, that an empty selector matches nothing, and that offline servers are still targeted and will fail visibly rather than being skipped. + +- [ ] **Step 2: Update the contributor map** + +In `CLAUDE.md`, under the SSH keys / Workflows subsystem notes, add a short paragraph: tags are a map on `servers` with no registry collection, `ResolveTargets` is the single answer to which servers a workflow touches, the union deduplicates, an empty selector matches nothing on purpose, and the wildcard index exists because the queried key is user-chosen. + +- [ ] **Step 3: Commit** + +```bash +git add docsite/docs/vantage/servers.md docsite/docs/vantage/workflows.md CLAUDE.md +git commit -m "docs: server tags and workflow tag targeting" +``` + +--- + +## Verification + +Full check before calling this done: + +```bash +cd server && go build ./... && go vet ./... && go test ./... +cd ../web && npx tsc --noEmit -p tsconfig.json && npx next build +cd .. && graphify update . +``` + +All must pass. `go test ./...` should report the tag and target suites passing, and no package should report a build failure.