# 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/<id>/schedule \
  -H 'content-type: application/json' -d '{"enabled":true,"cron":"0 2 * * 0","tz":"Europe/London"}'
curl -b cookies.txt 'localhost:8080/api/workflows/<id>/schedule/preview?cron=0+2+*+*+0&tz=Europe/London'
curl -b cookies.txt -X PUT localhost:8080/api/workflows/<id>/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: `<ScheduleCard workflow={Workflow} />`.

- [ ] **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<string | null>(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 (
        <div className="rounded-lg border border-border bg-surface">
            <div className="flex items-baseline justify-between gap-3 border-b border-border-soft px-5 py-3.5">
                <h2 className="text-[15px] font-semibold text-text-primary">Schedule</h2>
                <span className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">
                    {enabled ? "Active" : "Off"}
                </span>
            </div>

            <div className="flex flex-col gap-4 p-5">
                <label className="flex items-start gap-3">
                    <input type="checkbox" checked={enabled} onChange={(e) => setEnabled(e.target.checked)} className="mt-0.5 h-4 w-4 accent-accent" />
                    <span>
                        <span className="block text-sm text-text-primary">Run on a schedule</span>
                        <span className="mt-0.5 block text-xs text-text-tertiary">
                            A scheduled run is skipped, not queued, while a previous run is still going.
                        </span>
                    </span>
                </label>

                <div className="flex flex-wrap gap-2">
                    {PRESETS.map((p) => (
                        <button
                            key={p.cron}
                            type="button"
                            onClick={() => setCron(p.cron)}
                            className={`rounded-lg border px-3 py-1.5 text-xs transition-colors focus:outline-none focus-visible:ring-2 focus-visible:ring-accent ${
                                cron === p.cron ? "border-accent bg-accent/10 text-accent" : "border-border bg-surface-2 text-text-secondary hover:border-accent/40"
                            }`}
                        >
                            {p.label}
                        </button>
                    ))}
                </div>

                <div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
                    <label className="block">
                        <span className="mb-1.5 block text-sm font-medium text-text-secondary">Cron expression</span>
                        <input
                            value={cron}
                            onChange={(e) => setCron(e.target.value)}
                            className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 font-mono text-sm text-text-primary focus:border-accent/50 focus:outline-none focus:ring-1 focus:ring-accent/30"
                        />
                        <span className="mt-1.5 block font-mono text-[11px] text-text-tertiary">minute hour day month weekday</span>
                    </label>

                    <label className="block">
                        <span className="mb-1.5 block text-sm font-medium text-text-secondary">Timezone</span>
                        <select
                            value={tz}
                            onChange={(e) => setTz(e.target.value)}
                            className="w-full rounded-lg border border-border bg-surface-2 px-3 py-2 text-sm text-text-primary focus:border-accent/50 focus:outline-none"
                        >
                            {[...new Set([tz, ...ZONES])].map((z) => (
                                <option key={z} value={z}>{z}</option>
                            ))}
                        </select>
                    </label>
                </div>

                <div className="rounded-lg bg-well px-4 py-3">
                    <p className="font-mono text-[10px] uppercase tracking-[0.16em] text-text-tertiary">Next three runs</p>
                    {preview ? (
                        <ul className="mt-1.5 flex flex-col gap-0.5 font-mono text-[11.5px] text-text-secondary">
                            {preview.occurrences.map((o) => (
                                <li key={o}>{new Date(o).toLocaleString()}</li>
                            ))}
                        </ul>
                    ) : (
                        <p className="mt-1.5 font-mono text-[11.5px] text-danger">That expression is not valid.</p>
                    )}
                </div>

                {workflow.last_skipped && (
                    <p className="rounded-lg border border-warning/30 bg-warning/10 px-4 py-3 text-xs text-warning">
                        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}
                    </p>
                )}

                {error && <p className="text-sm text-danger">{error}</p>}

                <div>
                    <Button variant="primary" loading={isPending} onClick={() => save()}>
                        Save schedule
                    </Button>
                </div>
            </div>
        </div>
    );
}
```

- [ ] **Step 2: Mount it**

In `web/app/(app)/workflows/[id]/page.tsx`, render `<ScheduleCard workflow={workflow} />` 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 && (
    <span className="rounded-sm border border-border px-1.5 py-0.5 font-mono text-[10px] text-text-secondary">
        {w.schedule.cron}
    </span>
)}
{w.next_run_at && (
    <span className="font-mono text-[11px] text-text-tertiary">
        next {new Date(w.next_run_at).toLocaleString()}
    </span>
)}
```

- [ ] **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.
