diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index acaeb61905..e728f30e23 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -1474,9 +1474,16 @@ func initOfficeServices( // Reconcile using the new infra package. reconciler := officeinfra.NewReconciler(repos.Office, log) - reconciler.ReconcileAll(ctx) + reconcileSignal := reconciler.ReconcileAll(ctx) log.Info("Office reconciliation complete") + // Startup scan (REQ-OFFICE-ROUTINE-ARMING-003): a read-only pass over + // every routine's schedule state, logged and counted for an unattended + // install. Launched after reconciliation so a trigger-less routine + // reconciliation would have fixed is not reported prematurely; does not + // block startup and cannot affect its result. + go officeroutines.RunStartupScan(ctx, reconcileSignal, repos.Office, log, time.Now().UTC()) + // System skill sync. Upserts every embedded SKILL.md (the ones written // to disk by EnsureBundledSkills above) into office_skills as // is_system = true rows for each known workspace, removing system diff --git a/apps/backend/internal/office/infra/reconcile.go b/apps/backend/internal/office/infra/reconcile.go index eef1bf9a14..5c53fb93e6 100644 --- a/apps/backend/internal/office/infra/reconcile.go +++ b/apps/backend/internal/office/infra/reconcile.go @@ -8,6 +8,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/office/models" "github.com/kandev/kandev/internal/office/repository/sqlite" + "github.com/kandev/kandev/internal/office/routines" "go.uber.org/zap" ) @@ -31,8 +32,11 @@ func NewReconciler(repo *sqlite.Repository, log *logger.Logger) *Reconciler { } // ReconcileAll runs every reconciliation step. Errors are logged, not returned, -// so that startup is never blocked by a single reconciliation failure. -func (r *Reconciler) ReconcileAll(ctx context.Context) { +// so that startup is never blocked by a single reconciliation failure. The +// returned signal proves to a caller such as the routine arming startup scan +// that this pass has returned, whether or not every step succeeded +// (AC-OFFICE-ROUTINE-ARMING-003.1). +func (r *Reconciler) ReconcileAll(ctx context.Context) routines.ReconcileSignal { if err := r.reconcileAgentWorkingStatus(ctx); err != nil { r.logger.Warn("reconcile agent working status", zap.Error(err)) } @@ -48,6 +52,7 @@ func (r *Reconciler) ReconcileAll(ctx context.Context) { if err := r.reconcileChannels(ctx); err != nil { r.logger.Warn("reconcile channels", zap.Error(err)) } + return routines.SignalReconcileComplete() } // reconcileAgentWorkingStatus clears the display projection for runs that no @@ -111,6 +116,17 @@ func (r *Reconciler) reconcileRoutineTriggers(ctx context.Context) error { return nil } +// createTriggersForNewRoutines backfills a "manual" trigger for any routine +// this reconciler finds with no trigger row at all. Known residual: the +// coordinator install (internal/office/repository/sqlite/coordinator_install.go) +// commits its routine and canonical cron trigger in two separate +// transactions. If a restart lands in that window — routine committed, +// trigger not yet created — this method observes a routine with no trigger +// and creates a spurious manual one. The coordinator's own next install call +// still detects and creates the canonical cron trigger correctly +// (hasAnyCronTrigger ignores manual-kind triggers), leaving the extra manual +// trigger in place as a harmless one-time crash-recovery artifact, not a +// misconfiguration to chase. func (r *Reconciler) createTriggersForNewRoutines( ctx context.Context, routines []*models.Routine, dbIDs map[string]struct{}, ) { diff --git a/apps/backend/internal/office/models/coordinator_install.go b/apps/backend/internal/office/models/coordinator_install.go new file mode 100644 index 0000000000..bb9985d84a --- /dev/null +++ b/apps/backend/internal/office/models/coordinator_install.go @@ -0,0 +1,37 @@ +package models + +import ( + "context" + "errors" +) + +// ErrCoordinatorInstallContention reports that a coordinator install's +// serialized section (internal/office/repository/sqlite.Repository. +// InstallCoordinatorRoutine) could not be entered: its acquisition bound +// was reached before the cross-process lock was won +// (AC-OFFICE-COORDINATOR-INSTALL-001.9/.13). Declared here, not in the +// sqlite package that raises it, so internal/office/routines can classify +// it via errors.Is without importing sqlite — the same reason +// CoordinatorInstallTx lives here. Distinguishable from a plain +// identity-lookup/trigger-read failure (AC-OFFICE-COORDINATOR-INSTALL-001.7) +// and from the caller's own context being cancelled while waiting, which +// surfaces as ctx.Err() (context.Canceled/context.DeadlineExceeded) +// instead of this sentinel. +var ErrCoordinatorInstallContention = errors.New("coordinator install: lock contention") + +// CoordinatorInstallTx is the transaction-scoped write surface the +// coordinator-install path (internal/office/routines) uses to read a +// matched routine's triggers and, when needed, create the routine and/or +// its canonical trigger — all inside the single transaction +// internal/office/repository/sqlite.Repository.InstallCoordinatorRoutine +// opens and locks for one (workspace, assignee, canonical name) identity. +// +// Declared here, rather than in internal/office/routines where the install +// logic lives, so the sqlite repository can implement it without importing +// routines, and routines can require it on its own Repository interface +// without importing sqlite. +type CoordinatorInstallTx interface { + ListTriggersByRoutineID(ctx context.Context, routineID string) ([]*RoutineTrigger, error) + CreateRoutine(ctx context.Context, routine *Routine) error + CreateRoutineTrigger(ctx context.Context, t *RoutineTrigger) error +} diff --git a/apps/backend/internal/office/repository/sqlite/coordinator_install.go b/apps/backend/internal/office/repository/sqlite/coordinator_install.go new file mode 100644 index 0000000000..41374259c6 --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/coordinator_install.go @@ -0,0 +1,280 @@ +package sqlite + +import ( + "context" + "database/sql" + "errors" + "strings" + "time" + + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/db/dialect" + "github.com/kandev/kandev/internal/office/models" +) + +// coordinatorInstallLockTimeout is the hard, non-configurable bound +// AC-OFFICE-COORDINATOR-INSTALL-001.9 requires on acquiring the +// cross-process coordinator-install lock. It is a compile-time constant on +// purpose: no environment variable, configuration key, or runtime flag may +// expose it, because both of the installer's callers run on a user-facing +// onboarding path where an unbounded wait is indistinguishable from a hang. +const coordinatorInstallLockTimeout = 5 * time.Second + +// coordinatorInstallLockPollInterval bounds how often the PostgreSQL path +// retries its non-blocking advisory-lock attempt while waiting out +// coordinatorInstallLockTimeout. Not exposed to operators for the same +// reason as the bound itself. +const coordinatorInstallLockPollInterval = 25 * time.Millisecond + +const coordinatorInstallLockNamespace = "office-coordinator-install:" + +// coordinatorInstallTriggerLockNamespace is deliberately distinct from +// coordinatorInstallLockNamespace so a routine id can never collide with an +// (workspace, assignee, canonical name) identity key. +const coordinatorInstallTriggerLockNamespace = "office-coordinator-install-trigger:" + +// CoordinatorInstallLockKey derives the shared lock key a coordinator +// install acquires before reading or writing a (workspace, assignee, +// canonical name) identity's routine, so two concurrent installs for the +// same identity never both observe "absent" +// (AC-OFFICE-COORDINATOR-INSTALL-001.8/.9). One exported function, not an +// exported namespace constant, so no caller reassembles the key itself — +// mirrors internal/workflow/repository.ParticipantRoleSeatLockKey's +// rationale. +func CoordinatorInstallLockKey(workspaceID, agentID, canonicalName string) string { + return strings.Join([]string{coordinatorInstallLockNamespace, workspaceID, agentID, canonicalName}, "|") +} + +// CoordinatorInstallTriggerLockKey derives the lock key EnsureCoordinatorTrigger +// acquires before reading or writing a specific routine's triggers, so two +// concurrent calls for the same routine never both create a canonical +// trigger (AC-OFFICE-COORDINATOR-INSTALL-001.8/.9). Kept separate from +// CoordinatorInstallLockKey because the trigger phase runs in its own +// transaction, after the routine's own transaction has already committed — +// see EnsureCoordinatorTrigger. +func CoordinatorInstallTriggerLockKey(routineID string) string { + return coordinatorInstallTriggerLockNamespace + routineID +} + +// InstallCoordinatorRoutine looks up every existing routine at the given +// (workspace, assignee, canonical name) identity and hands them to decide, +// all inside one transaction that is serialized — across every process +// sharing this database, not merely other goroutines in this one +// (AC-OFFICE-COORDINATOR-INSTALL-001.8/.9) — against every other install +// for the same identity. matches is supplied in office_routines.created_at, +// id order (empty when none exist). decide may use the given tx-scoped +// writer to create the routine; any error it returns aborts the +// transaction, leaving the database unchanged. +// +// Deliberately does not also create the routine's canonical trigger: a +// trigger-creation failure must never roll back an already-decided routine +// (AC-OFFICE-COORDINATOR-INSTALL-001.12), so trigger creation is +// EnsureCoordinatorTrigger's own, separately-committed transaction. +// +// See WithCoordinatorInstallLock for the locking mechanism and how +// ErrCoordinatorInstallContention and ctx cancellation are distinguished. +func (r *Repository) InstallCoordinatorRoutine( + ctx context.Context, + workspaceID, agentID, canonicalName string, + decide func(ctx context.Context, matches []*models.Routine, tx models.CoordinatorInstallTx) error, +) error { + return r.WithCoordinatorInstallLock(ctx, CoordinatorInstallLockKey(workspaceID, agentID, canonicalName), + func(txCtx context.Context, tx *sqlx.Tx) error { + matches, err := r.listMatchingCoordinatorRoutinesTx(txCtx, tx, workspaceID, agentID, canonicalName) + if err != nil { + return err + } + return decide(txCtx, matches, &coordinatorInstallTxWriter{r: r, tx: tx}) + }) +} + +// EnsureCoordinatorTrigger reads routine's existing triggers and hands them +// to decide, inside its own transaction serialized — across every process +// sharing this database — against every other call for the same routine id +// (AC-OFFICE-COORDINATOR-INSTALL-001.8/.9). Separate from +// InstallCoordinatorRoutine's transaction so a trigger-creation failure can +// never undo the routine InstallCoordinatorRoutine already committed +// (AC-OFFICE-COORDINATOR-INSTALL-001.4/.12); a caller runs this only after +// InstallCoordinatorRoutine has returned a routine successfully. decide may +// use the given tx-scoped writer to create the canonical trigger; any error +// it returns aborts this transaction only, leaving the routine (and any +// pre-existing triggers) unchanged. +func (r *Repository) EnsureCoordinatorTrigger( + ctx context.Context, + routineID string, + decide func(ctx context.Context, triggers []*models.RoutineTrigger, tx models.CoordinatorInstallTx) error, +) error { + return r.WithCoordinatorInstallLock(ctx, CoordinatorInstallTriggerLockKey(routineID), + func(txCtx context.Context, tx *sqlx.Tx) error { + writer := &coordinatorInstallTxWriter{r: r, tx: tx} + triggers, err := writer.ListTriggersByRoutineID(txCtx, routineID) + if err != nil { + return err + } + return decide(txCtx, triggers, writer) + }) +} + +func (r *Repository) listMatchingCoordinatorRoutinesTx( + ctx context.Context, tx *sqlx.Tx, workspaceID, agentID, canonicalName string, +) ([]*models.Routine, error) { + var routines []*models.Routine + err := tx.SelectContext(ctx, &routines, tx.Rebind(` + SELECT * FROM office_routines + WHERE workspace_id = ? AND assignee_agent_profile_id = ? AND name = ? + ORDER BY created_at, id + `), workspaceID, agentID, canonicalName) + if err != nil { + return nil, err + } + if routines == nil { + routines = []*models.Routine{} + } + return routines, nil +} + +// coordinatorInstallTxWriter adapts this transaction's queries to +// models.CoordinatorInstallTx. +type coordinatorInstallTxWriter struct { + r *Repository + tx *sqlx.Tx +} + +func (w *coordinatorInstallTxWriter) ListTriggersByRoutineID( + ctx context.Context, routineID string, +) ([]*models.RoutineTrigger, error) { + var triggers []*models.RoutineTrigger + err := w.tx.SelectContext(ctx, &triggers, w.tx.Rebind( + `SELECT * FROM office_routine_triggers WHERE routine_id = ? ORDER BY created_at, id`), routineID) + if err != nil { + return nil, err + } + if triggers == nil { + triggers = []*models.RoutineTrigger{} + } + return triggers, nil +} + +func (w *coordinatorInstallTxWriter) CreateRoutine(ctx context.Context, routine *models.Routine) error { + return w.r.CreateRoutineTx(ctx, w.tx, routine) +} + +func (w *coordinatorInstallTxWriter) CreateRoutineTrigger(ctx context.Context, t *models.RoutineTrigger) error { + return w.r.CreateRoutineTriggerTx(ctx, w.tx, t) +} + +// WithCoordinatorInstallLock runs fn once inside a single database +// transaction serialized, for the given lock key, against every other +// process sharing this database (AC-OFFICE-COORDINATOR-INSTALL-001.9). The +// key is opaque here — see CoordinatorInstallLockKey and +// CoordinatorInstallTriggerLockKey for the two keying schemes callers use. +// +// On SQLite this is exactly the pattern +// internal/workflow/repository.EnsureRoleSeat already establishes for the +// same class of problem: the writer connection is the only open connection +// (internal/db.OpenSQLite's db.SetMaxOpenConns(1)) and SQLite's own +// file-level lock (that same package's busy_timeout) already serializes a +// second process's write transaction, so opening the transaction is the +// whole mechanism — no advisory lock exists on SQLite, and none is needed. +// +// On PostgreSQL, a plain transaction does not by itself stop two callers +// both reading "absent" under READ COMMITTED, so fn's transaction +// additionally holds a transaction-scoped advisory lock keyed on identity +// (pg_try_advisory_xact_lock, released automatically at commit/rollback). +// It is acquired via a bounded poll rather than the blocking +// pg_advisory_xact_lock precedent uses, so the hard acquisition bound +// applies identically on both dialects rather than only on SQLite's +// already-bounded busy_timeout. +// +// The transaction commits when fn returns nil and rolls back otherwise. +// Returns models.ErrCoordinatorInstallContention when +// coordinatorInstallLockTimeout is reached before the lock is won. Returns +// ctx.Err() when the caller's +// own context was already done — distinguishable from contention because +// the bound is enforced on a context derived from, not identical to, the +// caller's context, so the two can be told apart by which one is actually +// expired. +func (r *Repository) WithCoordinatorInstallLock( + ctx context.Context, + lockKey string, + fn func(ctx context.Context, tx *sqlx.Tx) error, +) error { + boundCtx, cancel := context.WithTimeout(ctx, coordinatorInstallLockTimeout) + defer cancel() + + tx, err := r.db.BeginTxx(boundCtx, nil) + if err != nil { + return classifyCoordinatorInstallWaitErr(ctx, err) + } + committed := false + defer func() { + if !committed { + _ = tx.Rollback() + } + }() + + if dialect.IsPostgres(r.db.DriverName()) { + if lockErr := acquirePostgresAdvisoryLock(boundCtx, tx, r.db, lockKey); lockErr != nil { + return classifyCoordinatorInstallWaitErr(ctx, lockErr) + } + } + + if err := fn(boundCtx, tx); err != nil { + return classifyCoordinatorInstallWaitErr(ctx, err) + } + if err := tx.Commit(); err != nil { + return classifyCoordinatorInstallWaitErr(ctx, err) + } + committed = true + return nil +} + +// acquirePostgresAdvisoryLock polls a non-blocking transaction-scoped +// advisory lock until it is won or ctx is done. Using the non-blocking +// pg_try_advisory_xact_lock in a poll loop (rather than the blocking +// pg_advisory_xact_lock) is what lets the caller bound acquisition at an +// exact, dialect-independent duration. +func acquirePostgresAdvisoryLock(ctx context.Context, tx *sqlx.Tx, db *sqlx.DB, lockKey string) error { + for { + var acquired bool + if err := tx.QueryRowContext(ctx, db.Rebind( + "SELECT pg_try_advisory_xact_lock(hashtextextended(?, 0))"), lockKey, + ).Scan(&acquired); err != nil { + return err + } + if acquired { + return nil + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-time.After(coordinatorInstallLockPollInterval): + } + } +} + +// sqliteBusyErrText matches internal/db's own SQLITE_BUSY detection +// (internal/db/migration_errors.go), duplicated here rather than exported +// cross-package for one string check. +const sqliteBusyErrText = "database is locked" + +// classifyCoordinatorInstallWaitErr distinguishes the caller's own context +// having already ended from the bounded wait's own deadline being reached: +// the latter is contention (AC-OFFICE-COORDINATOR-INSTALL-001.13), the +// former is cancellation, reported distinguishably from both contention and +// the plain read/write failures of AC-OFFICE-COORDINATOR-INSTALL-001.7. +// sql.ErrTxDone is included in the contention bucket because it is what +// database/sql surfaces from Commit when boundCtx's own deadline already +// auto-rolled the transaction back underneath it — the same exhausted-wait +// outcome as the deadline case, just observed one call later. +func classifyCoordinatorInstallWaitErr(callerCtx context.Context, err error) error { + if callerCtx.Err() != nil { + return callerCtx.Err() + } + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, sql.ErrTxDone) || + strings.Contains(err.Error(), sqliteBusyErrText) { + return models.ErrCoordinatorInstallContention + } + return err +} diff --git a/apps/backend/internal/office/repository/sqlite/coordinator_install_test.go b/apps/backend/internal/office/repository/sqlite/coordinator_install_test.go new file mode 100644 index 0000000000..fe1e9ad4cf --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/coordinator_install_test.go @@ -0,0 +1,94 @@ +package sqlite + +import ( + "context" + "errors" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/office/models" +) + +// newTestRepositoryForCoordinatorInstall creates an in-memory SQLite repo, +// mirroring base_test.go's newTestRepo. That helper lives in package +// sqlite_test and cannot reach this package's unexported +// classifyCoordinatorInstallWaitErr, so this file stays in package sqlite +// with its own copy of the same setup. +func newTestRepositoryForCoordinatorInstall(t *testing.T) *Repository { + t.Helper() + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + + if _, _, err := settingsstore.Provide(db, db, nil); err != nil { + t.Fatalf("settings store init: %v", err) + } + + repo, err := NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + return repo +} + +func TestClassifyCoordinatorInstallWaitErr_CallerCancellationWins(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + err := classifyCoordinatorInstallWaitErr(ctx, context.DeadlineExceeded) + if !errors.Is(err, ctx.Err()) { + t.Fatalf("got %v, want the caller's own ctx.Err() (%v)", err, ctx.Err()) + } + if errors.Is(err, models.ErrCoordinatorInstallContention) { + t.Fatal("caller cancellation must not be reported as contention") + } +} + +func TestClassifyCoordinatorInstallWaitErr_DeadlineExceededIsContention(t *testing.T) { + err := classifyCoordinatorInstallWaitErr(context.Background(), context.DeadlineExceeded) + if !errors.Is(err, models.ErrCoordinatorInstallContention) { + t.Fatalf("got %v, want ErrCoordinatorInstallContention", err) + } +} + +func TestClassifyCoordinatorInstallWaitErr_SQLiteBusyTextIsContention(t *testing.T) { + err := classifyCoordinatorInstallWaitErr(context.Background(), errors.New("database is locked")) + if !errors.Is(err, models.ErrCoordinatorInstallContention) { + t.Fatalf("got %v, want ErrCoordinatorInstallContention", err) + } +} + +func TestClassifyCoordinatorInstallWaitErr_OtherErrorsPassThrough(t *testing.T) { + plain := errors.New("routine_create_failed") + err := classifyCoordinatorInstallWaitErr(context.Background(), plain) + if !errors.Is(err, plain) { + t.Fatalf("got %v, want the original error unclassified", err) + } + if errors.Is(err, models.ErrCoordinatorInstallContention) { + t.Fatal("a plain read/write failure must not be reported as contention") + } +} + +// TestWithCoordinatorInstallLock_FnContentionIsClassified guards +// AC-OFFICE-COORDINATOR-INSTALL-001.13's "the serialized section fails +// because a concurrent install held it" clause: fn's own error (a write +// racing another process's held lock) must be reported as contention, not +// as a plain create/lookup failure. +func TestWithCoordinatorInstallLock_FnContentionIsClassified(t *testing.T) { + r := newTestRepositoryForCoordinatorInstall(t) + + err := r.WithCoordinatorInstallLock(context.Background(), "test-lock-key", + func(ctx context.Context, tx *sqlx.Tx) error { + return errors.New("database is locked") + }) + + if !errors.Is(err, models.ErrCoordinatorInstallContention) { + t.Fatalf("got %v, want ErrCoordinatorInstallContention", err) + } +} diff --git a/apps/backend/internal/office/repository/sqlite/routine_arming_scan.go b/apps/backend/internal/office/repository/sqlite/routine_arming_scan.go new file mode 100644 index 0000000000..789cac8d44 --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/routine_arming_scan.go @@ -0,0 +1,37 @@ +package sqlite + +import ( + "context" + + "github.com/kandev/kandev/internal/office/models" +) + +// ListWorkspaceIDsOrdered returns every workspace ID ordered by created_at +// ascending, ties broken by id ascending, for the Office routine arming +// startup scan (AC-OFFICE-ROUTINE-ARMING-003.2). The workspaces table is +// owned by the task package's schema; this package already reads it +// directly elsewhere (e.g. HasOfficeAdoption). +func (r *Repository) ListWorkspaceIDsOrdered(ctx context.Context) ([]string, error) { + var ids []string + if err := r.ro.SelectContext(ctx, &ids, + `SELECT id FROM workspaces ORDER BY created_at, id`); err != nil { + return nil, err + } + return ids, nil +} + +// ListRoutinesOrdered returns a workspace's routines ordered by created_at +// ascending, ties broken by id ascending, for the Office routine arming +// startup scan (AC-OFFICE-ROUTINE-ARMING-003.2). ListRoutines orders by name +// instead, for the UI, so this is a separate method rather than a shared one. +func (r *Repository) ListRoutinesOrdered(ctx context.Context, workspaceID string) ([]*models.Routine, error) { + var routines []*models.Routine + if err := r.ro.SelectContext(ctx, &routines, r.ro.Rebind( + `SELECT * FROM office_routines WHERE workspace_id = ? ORDER BY created_at, id`), workspaceID); err != nil { + return nil, err + } + if routines == nil { + routines = []*models.Routine{} + } + return routines, nil +} diff --git a/apps/backend/internal/office/repository/sqlite/routine_arming_scan_test.go b/apps/backend/internal/office/repository/sqlite/routine_arming_scan_test.go new file mode 100644 index 0000000000..e59bfb1fcf --- /dev/null +++ b/apps/backend/internal/office/repository/sqlite/routine_arming_scan_test.go @@ -0,0 +1,177 @@ +package sqlite_test + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/office/repository/sqlite" +) + +// newArmingScanTestRepo adds the `workspaces` table (owned by the task +// package's schema, not office) with a created_at column, so the ordering +// under test has something to order by. +func newArmingScanTestRepo(t *testing.T) *sqlite.Repository { + t.Helper() + repo := newTestRepo(t) + if _, err := repo.ExecRaw(context.Background(), ` + CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + t.Fatalf("create workspaces table: %v", err) + } + return repo +} + +// TestListWorkspaceIDsOrdered_OrdersByCreatedAtThenID seeds workspace rows +// with created_at values out of insertion order and asserts the returned +// order follows created_at (ties on id), not insertion or primary-key order +// — AC-OFFICE-ROUTINE-ARMING-003.2's scan must walk workspaces in a stable, +// deterministic sequence. +func TestListWorkspaceIDsOrdered_OrdersByCreatedAtThenID(t *testing.T) { + repo := newArmingScanTestRepo(t) + ctx := context.Background() + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + // IDs deliberately sort alphabetically in the opposite order from + // created_at ("ws-mike" < "ws-yankee" < "ws-zulu"), so a query that + // silently fell back to ORDER BY id would produce a different result + // than one ordering by created_at and be caught here. + seed := []struct { + id string + createdAt time.Time + }{ + {"ws-mike", base.Add(2 * time.Hour)}, + {"ws-zulu", base}, + {"ws-yankee", base.Add(1 * time.Hour)}, + } + for _, s := range seed { + if _, err := repo.ExecRaw(ctx, + `INSERT INTO workspaces (id, created_at) VALUES (?, ?)`, s.id, s.createdAt); err != nil { + t.Fatalf("seed workspace %s: %v", s.id, err) + } + } + + ids, err := repo.ListWorkspaceIDsOrdered(ctx) + if err != nil { + t.Fatalf("ListWorkspaceIDsOrdered: %v", err) + } + + want := []string{"ws-zulu", "ws-yankee", "ws-mike"} + if len(ids) != len(want) { + t.Fatalf("got %v, want %v", ids, want) + } + for i, id := range want { + if ids[i] != id { + t.Errorf("position %d: got %q, want %q (full result: %v)", i, ids[i], id, ids) + } + } +} + +// TestListWorkspaceIDsOrdered_TiesBreakByID seeds two workspaces with the +// identical created_at and asserts the tie is broken by id ascending. +func TestListWorkspaceIDsOrdered_TiesBreakByID(t *testing.T) { + repo := newArmingScanTestRepo(t) + ctx := context.Background() + same := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + for _, id := range []string{"ws-b", "ws-a"} { + if _, err := repo.ExecRaw(ctx, + `INSERT INTO workspaces (id, created_at) VALUES (?, ?)`, id, same); err != nil { + t.Fatalf("seed workspace %s: %v", id, err) + } + } + + ids, err := repo.ListWorkspaceIDsOrdered(ctx) + if err != nil { + t.Fatalf("ListWorkspaceIDsOrdered: %v", err) + } + want := []string{"ws-a", "ws-b"} + if len(ids) != len(want) || ids[0] != want[0] || ids[1] != want[1] { + t.Fatalf("got %v, want %v", ids, want) + } +} + +// TestListRoutinesOrdered_OrdersByCreatedAtThenID mirrors the workspace +// ordering test for a single workspace's routines: seeded out of order, +// asserted back in created_at order (ties on id). +func TestListRoutinesOrdered_OrdersByCreatedAtThenID(t *testing.T) { + repo := newArmingScanTestRepo(t) + ctx := context.Background() + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + if _, err := repo.ExecRaw(ctx, + `INSERT INTO workspaces (id, created_at) VALUES (?, ?)`, "ws-1", base); err != nil { + t.Fatalf("seed workspace: %v", err) + } + + // IDs deliberately sort alphabetically in the opposite order from + // created_at, so a query that silently fell back to ORDER BY id would + // produce a different result than one ordering by created_at. + seed := []struct { + id string + createdAt time.Time + }{ + {"routine-mike", base.Add(2 * time.Hour)}, + {"routine-zulu", base}, + {"routine-yankee", base.Add(1 * time.Hour)}, + } + for _, s := range seed { + if _, err := repo.ExecRaw(ctx, + `INSERT INTO office_routines (id, workspace_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, + s.id, "ws-1", s.id, s.createdAt, s.createdAt); err != nil { + t.Fatalf("seed routine %s: %v", s.id, err) + } + } + + routines, err := repo.ListRoutinesOrdered(ctx, "ws-1") + if err != nil { + t.Fatalf("ListRoutinesOrdered: %v", err) + } + + want := []string{"routine-zulu", "routine-yankee", "routine-mike"} + if len(routines) != len(want) { + t.Fatalf("got %d routines, want %d", len(routines), len(want)) + } + for i, id := range want { + if routines[i].ID != id { + t.Errorf("position %d: got %q, want %q", i, routines[i].ID, id) + } + } +} + +// TestListRoutinesOrdered_ScopedToWorkspace guards against the query +// dropping the workspace_id filter: a routine seeded under a different +// workspace must not appear in the result. +func TestListRoutinesOrdered_ScopedToWorkspace(t *testing.T) { + repo := newArmingScanTestRepo(t) + ctx := context.Background() + now := time.Now().UTC() + + for _, id := range []string{"ws-1", "ws-2"} { + if _, err := repo.ExecRaw(ctx, + `INSERT INTO workspaces (id, created_at) VALUES (?, ?)`, id, now); err != nil { + t.Fatalf("seed workspace %s: %v", id, err) + } + } + if _, err := repo.ExecRaw(ctx, + `INSERT INTO office_routines (id, workspace_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, + "routine-ws1", "ws-1", "routine-ws1", now, now); err != nil { + t.Fatalf("seed routine: %v", err) + } + if _, err := repo.ExecRaw(ctx, + `INSERT INTO office_routines (id, workspace_id, name, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, + "routine-ws2", "ws-2", "routine-ws2", now, now); err != nil { + t.Fatalf("seed routine: %v", err) + } + + routines, err := repo.ListRoutinesOrdered(ctx, "ws-1") + if err != nil { + t.Fatalf("ListRoutinesOrdered: %v", err) + } + if len(routines) != 1 || routines[0].ID != "routine-ws1" { + t.Fatalf("got %v, want exactly [routine-ws1]", routines) + } +} diff --git a/apps/backend/internal/office/repository/sqlite/routines.go b/apps/backend/internal/office/repository/sqlite/routines.go index cc43560bc6..6e251fcde5 100644 --- a/apps/backend/internal/office/repository/sqlite/routines.go +++ b/apps/backend/internal/office/repository/sqlite/routines.go @@ -17,6 +17,19 @@ import ( // CreateRoutineTrigger creates a new routine trigger. func (r *Repository) CreateRoutineTrigger(ctx context.Context, t *models.RoutineTrigger) error { + return r.insertRoutineTrigger(ctx, r.db, t) +} + +// CreateRoutineTriggerTx is CreateRoutineTrigger scoped to a caller-owned +// transaction, used by the coordinator-install path +// (AC-OFFICE-COORDINATOR-INSTALL-001.4/.12) to create the canonical trigger +// in the same transaction as the identity lookup and, when applicable, the +// routine insert. +func (r *Repository) CreateRoutineTriggerTx(ctx context.Context, tx *sqlx.Tx, t *models.RoutineTrigger) error { + return r.insertRoutineTrigger(ctx, tx, t) +} + +func (r *Repository) insertRoutineTrigger(ctx context.Context, ext sqlx.ExtContext, t *models.RoutineTrigger) error { if t.ID == "" { t.ID = uuid.New().String() } @@ -24,7 +37,7 @@ func (r *Repository) CreateRoutineTrigger(ctx context.Context, t *models.Routine t.CreatedAt = now t.UpdatedAt = now - _, err := r.db.ExecContext(ctx, r.db.Rebind(` + _, err := ext.ExecContext(ctx, r.db.Rebind(` INSERT INTO office_routine_triggers ( id, routine_id, kind, cron_expression, timezone, public_id, signing_mode, secret, next_run_at, last_fired_at, @@ -36,11 +49,12 @@ func (r *Repository) CreateRoutineTrigger(ctx context.Context, t *models.Routine return err } -// ListTriggersByRoutineID returns all triggers for a routine. +// ListTriggersByRoutineID returns all triggers for a routine, in trigger +// order (created_at ascending, ties broken by id ascending). func (r *Repository) ListTriggersByRoutineID(ctx context.Context, routineID string) ([]*models.RoutineTrigger, error) { var triggers []*models.RoutineTrigger err := r.ro.SelectContext(ctx, &triggers, r.ro.Rebind( - `SELECT * FROM office_routine_triggers WHERE routine_id = ? ORDER BY created_at`), routineID) + `SELECT * FROM office_routine_triggers WHERE routine_id = ? ORDER BY created_at, id`), routineID) if err != nil { return nil, err } @@ -50,6 +64,37 @@ func (r *Repository) ListTriggersByRoutineID(ctx context.Context, routineID stri return triggers, nil } +// ListTriggersByRoutineIDs returns every named routine's triggers in one +// batch query, each list in trigger order (created_at ascending, ties +// broken by id ascending). A routine with no triggers is present in the +// returned map with an empty (non-nil) slice. +func (r *Repository) ListTriggersByRoutineIDs( + ctx context.Context, routineIDs []string, +) (map[string][]*models.RoutineTrigger, error) { + result := make(map[string][]*models.RoutineTrigger, len(routineIDs)) + for _, id := range routineIDs { + result[id] = []*models.RoutineTrigger{} + } + if len(routineIDs) == 0 { + return result, nil + } + + query, args, err := sqlx.In( + `SELECT * FROM office_routine_triggers WHERE routine_id IN (?) ORDER BY created_at, id`, routineIDs) + if err != nil { + return nil, err + } + query = r.ro.Rebind(query) + var triggers []*models.RoutineTrigger + if err := r.ro.SelectContext(ctx, &triggers, query, args...); err != nil { + return nil, err + } + for _, t := range triggers { + result[t.RoutineID] = append(result[t.RoutineID], t) + } + return result, nil +} + // GetTriggerByPublicID returns a trigger by its public ID (for webhook lookup). func (r *Repository) GetTriggerByPublicID(ctx context.Context, publicID string) (*models.RoutineTrigger, error) { var t models.RoutineTrigger diff --git a/apps/backend/internal/office/repository/sqlite/routines_test.go b/apps/backend/internal/office/repository/sqlite/routines_test.go index 4e0c3129c5..fa40ee2d34 100644 --- a/apps/backend/internal/office/repository/sqlite/routines_test.go +++ b/apps/backend/internal/office/repository/sqlite/routines_test.go @@ -5,7 +5,12 @@ import ( "testing" "time" + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" "github.com/kandev/kandev/internal/office/models" + "github.com/kandev/kandev/internal/office/repository/sqlite" ) func TestRoutine_CRUD(t *testing.T) { @@ -201,6 +206,93 @@ func TestRoutineRun_ActiveFingerprint(t *testing.T) { } +func TestListTriggersByRoutineIDs_Batch(t *testing.T) { + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + db.SetMaxOpenConns(1) + t.Cleanup(func() { _ = db.Close() }) + if _, _, err := settingsstore.Provide(db, db, nil); err != nil { + t.Fatalf("settings store init: %v", err) + } + repo, err := sqlite.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + ctx := context.Background() + + r1 := &models.Routine{ + WorkspaceID: "ws-1", Name: "R1", TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + } + r2 := &models.Routine{ + WorkspaceID: "ws-1", Name: "R2", TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + } + r3 := &models.Routine{ + WorkspaceID: "ws-1", Name: "R3 (no triggers)", TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + } + for _, r := range []*models.Routine{r1, r2, r3} { + if err := repo.CreateRoutine(ctx, r); err != nil { + t.Fatalf("create routine: %v", err) + } + } + + // t1 and t2 belong to r1 and get the same created_at (forced below), so + // the id tiebreak is the only thing that can order them. + t1 := &models.RoutineTrigger{ID: "trigger-b", RoutineID: r1.ID, Kind: "manual", Enabled: true} + t2 := &models.RoutineTrigger{ID: "trigger-a", RoutineID: r1.ID, Kind: "manual", Enabled: true} + t3 := &models.RoutineTrigger{RoutineID: r2.ID, Kind: "manual", Enabled: true} + for _, tr := range []*models.RoutineTrigger{t1, t2, t3} { + if err := repo.CreateRoutineTrigger(ctx, tr); err != nil { + t.Fatalf("create trigger: %v", err) + } + } + + tie := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + if _, err := db.ExecContext(ctx, + `UPDATE office_routine_triggers SET created_at = ? WHERE id IN (?, ?)`, + tie, t1.ID, t2.ID, + ); err != nil { + t.Fatalf("force created_at tie: %v", err) + } + + byRoutine, err := repo.ListTriggersByRoutineIDs(ctx, []string{r1.ID, r2.ID, r3.ID}) + if err != nil { + t.Fatalf("batch list: %v", err) + } + if len(byRoutine) != 3 { + t.Fatalf("map size = %d, want 3", len(byRoutine)) + } + + r1Triggers := byRoutine[r1.ID] + if len(r1Triggers) != 2 { + t.Fatalf("r1 triggers = %d, want 2", len(r1Triggers)) + } + if r1Triggers[0].ID != "trigger-a" || r1Triggers[1].ID != "trigger-b" { + t.Errorf("r1 trigger order = [%s, %s], want [trigger-a, trigger-b] (id tiebreak)", + r1Triggers[0].ID, r1Triggers[1].ID) + } + + if len(byRoutine[r2.ID]) != 1 { + t.Fatalf("r2 triggers = %d, want 1", len(byRoutine[r2.ID])) + } + + if r3Triggers, ok := byRoutine[r3.ID]; !ok || r3Triggers == nil || len(r3Triggers) != 0 { + t.Errorf("r3 triggers = %v, want present and empty", r3Triggers) + } + + empty, err := repo.ListTriggersByRoutineIDs(ctx, nil) + if err != nil { + t.Fatalf("empty batch list: %v", err) + } + if len(empty) != 0 { + t.Errorf("empty input map size = %d, want 0", len(empty)) + } +} + func TestRoutineRun_Create(t *testing.T) { repo := newTestRepo(t) ctx := context.Background() diff --git a/apps/backend/internal/office/routines/arming.go b/apps/backend/internal/office/routines/arming.go new file mode 100644 index 0000000000..c9789e435e --- /dev/null +++ b/apps/backend/internal/office/routines/arming.go @@ -0,0 +1,263 @@ +package routines + +import ( + "context" + "sort" + "time" + + "github.com/kandev/kandev/internal/office/shared" +) + +// ScheduleState reports whether a routine's triggers can currently fire it, +// independent of the routine's own status (intent). +type ScheduleState string + +const ( + ScheduleStateArmed ScheduleState = "armed" + ScheduleStateTriggerInvalid ScheduleState = "trigger_invalid" + ScheduleStateTriggerUnscheduled ScheduleState = "trigger_unscheduled" + ScheduleStateTriggerDisabled ScheduleState = "trigger_disabled" + ScheduleStateEventOnly ScheduleState = "event_only" + ScheduleStateUnscheduledManualOnly ScheduleState = "unscheduled_manual_only" + ScheduleStateUnscheduledNoTrigger ScheduleState = "unscheduled_no_trigger" + ScheduleStateUnknown ScheduleState = "unknown" +) + +// dispatchGrace is the single window, shared by classification and the +// startup scan, during which a cron trigger with a null next_run_at is +// still considered armed rather than stalled: the tick that fired it has +// not yet recomputed its next occurrence. +const dispatchGrace = 60 * time.Second + +// UnarmedReason names why a cron trigger cannot currently fire. When more +// than one applies to the same trigger, they are reported in this order. +type UnarmedReason string + +const ( + UnarmedReasonDisabled UnarmedReason = "disabled" + UnarmedReasonNotSchedulable UnarmedReason = "not_schedulable" + UnarmedReasonStalled UnarmedReason = "stalled" +) + +// UnarmedCronTrigger is one cron trigger that cannot currently fire, and why. +type UnarmedCronTrigger struct { + TriggerID string `json:"trigger_id"` + Reasons []UnarmedReason `json:"reasons"` +} + +const ( + triggerKindCron = "cron" + triggerKindWebhook = "webhook" + triggerKindManual = "manual" +) + +// ClassifyRoutine implements the nine-rule schedule-state table over a +// routine's own trigger rows, in rule order, stopping at the first match. +// It takes no routine status: schedule state is independent of intent. +func ClassifyRoutine(triggers []*RoutineTrigger, now time.Time) (ScheduleState, []UnarmedCronTrigger) { + ordered := sortTriggers(triggers) + cronTriggers, hasEnabledWebhook, hasNonCronTrigger := partitionTriggers(ordered) + states := cronTriggerStates(cronTriggers, now) + unarmed := unarmedCronTriggers(states, now) + + if state, ok := classifyCronTriggers(states, now); ok { + return state, unarmed + } + if hasEnabledWebhook { + return ScheduleStateEventOnly, nil + } + if hasNonCronTrigger { + return ScheduleStateUnscheduledManualOnly, nil + } + return ScheduleStateUnscheduledNoTrigger, nil +} + +// partitionTriggers splits a routine's ordered triggers by kind: every cron +// trigger (in order), whether any enabled webhook trigger exists, and +// whether any non-cron trigger exists at all (rule 7 fires on existence +// alone, regardless of kind or enabled state). +func partitionTriggers(triggers []*RoutineTrigger) (cron []*RoutineTrigger, hasEnabledWebhook, hasNonCronTrigger bool) { + for _, t := range triggers { + switch t.Kind { + case triggerKindCron: + cron = append(cron, t) + default: + hasNonCronTrigger = true + if t.Kind == triggerKindWebhook && t.Enabled { + hasEnabledWebhook = true + } + } + } + return cron, hasEnabledWebhook, hasNonCronTrigger +} + +// cronTriggerState pairs a cron trigger with its schedulability, computed +// once per trigger and shared across classification and unarmed-list +// membership rather than re-parsed by each. +type cronTriggerState struct { + trigger *RoutineTrigger + schedulable bool +} + +func cronTriggerStates(cronTriggers []*RoutineTrigger, now time.Time) []cronTriggerState { + states := make([]cronTriggerState, len(cronTriggers)) + for i, t := range cronTriggers { + states[i] = cronTriggerState{trigger: t, schedulable: isSchedulable(t, now)} + } + return states +} + +// classifyCronTriggers evaluates rules 1 through 5 (the ones decided by +// cron trigger state alone) and reports whether one of them matched. +func classifyCronTriggers(states []cronTriggerState, now time.Time) (ScheduleState, bool) { + for _, s := range states { + if isArmed(s.trigger, now) { + return ScheduleStateArmed, true + } + } + for _, s := range states { + if s.trigger.Enabled && !s.schedulable { + return ScheduleStateTriggerInvalid, true + } + } + for _, s := range states { + if s.trigger.Enabled && s.schedulable && s.trigger.NextRunAt == nil { + return ScheduleStateTriggerUnscheduled, true + } + } + if len(states) > 0 { + return ScheduleStateTriggerDisabled, true + } + return "", false +} + +// isArmed reports whether this single cron trigger can currently fire: +// either it has a next occurrence on the calendar (past-due or future), or +// it just fired and hasn't been recomputed yet (within dispatchGrace). +// Schedulability does not gate this: a trigger that already claimed a next +// occurrence still fires on it even if its expression or timezone can no +// longer be parsed (unarmedCronTriggers reports that separately). +func isArmed(t *RoutineTrigger, now time.Time) bool { + if !t.Enabled { + return false + } + if t.NextRunAt != nil { + return true + } + return withinDispatchGrace(t, now) +} + +func isSchedulable(t *RoutineTrigger, now time.Time) bool { + _, err := shared.NextCronTime(t.CronExpression, t.Timezone, now) + return err == nil +} + +func withinDispatchGrace(t *RoutineTrigger, now time.Time) bool { + if t.LastFiredAt == nil { + return false + } + elapsed := now.Sub(*t.LastFiredAt) + return elapsed >= 0 && elapsed <= dispatchGrace +} + +// unarmedCronTriggers reports every cron trigger, in trigger order, that +// matches AC-OFFICE-ROUTINE-ARMING-001.8's unarmed predicate — independent +// of whether the same trigger is also armed, so an armed-but-broken trigger +// appears here too. +func unarmedCronTriggers(states []cronTriggerState, now time.Time) []UnarmedCronTrigger { + out := []UnarmedCronTrigger{} + for _, s := range states { + if !isUnarmedCron(s, now) { + continue + } + out = append(out, UnarmedCronTrigger{TriggerID: s.trigger.ID, Reasons: unarmedReasons(s, now)}) + } + return out +} + +// isUnarmedCron implements AC-OFFICE-ROUTINE-ARMING-001.8's three-way OR: +// disabled, or not schedulable, or (enabled, schedulable, no next +// occurrence, and past the dispatch grace). +func isUnarmedCron(s cronTriggerState, now time.Time) bool { + if !s.trigger.Enabled { + return true + } + if !s.schedulable { + return true + } + return s.trigger.NextRunAt == nil && !withinDispatchGrace(s.trigger, now) +} + +func unarmedReasons(s cronTriggerState, now time.Time) []UnarmedReason { + var reasons []UnarmedReason + if !s.trigger.Enabled { + reasons = append(reasons, UnarmedReasonDisabled) + } + if !s.schedulable { + reasons = append(reasons, UnarmedReasonNotSchedulable) + } + if s.trigger.Enabled && s.schedulable && s.trigger.NextRunAt == nil && !withinDispatchGrace(s.trigger, now) { + reasons = append(reasons, UnarmedReasonStalled) + } + return reasons +} + +// sortTriggers returns a copy of triggers in trigger order: created_at +// ascending, ties broken by id ascending. +func sortTriggers(triggers []*RoutineTrigger) []*RoutineTrigger { + ordered := make([]*RoutineTrigger, len(triggers)) + copy(ordered, triggers) + sort.SliceStable(ordered, func(i, j int) bool { + if !ordered[i].CreatedAt.Equal(ordered[j].CreatedAt) { + return ordered[i].CreatedAt.Before(ordered[j].CreatedAt) + } + return ordered[i].ID < ordered[j].ID + }) + return ordered +} + +// RoutineClassification is one routine's schedule state plus its unarmed +// cron trigger list. +type RoutineClassification struct { + State ScheduleState + Unarmed []UnarmedCronTrigger +} + +// RoutineTriggerReader is the read surface ClassifyRoutines needs: a batch +// read across many routines, and a per-routine fallback read for when the +// batch read fails. +type RoutineTriggerReader interface { + ListTriggersByRoutineIDs(ctx context.Context, routineIDs []string) (map[string][]*RoutineTrigger, error) + ListTriggersByRoutineID(ctx context.Context, routineID string) ([]*RoutineTrigger, error) +} + +// ClassifyRoutines classifies every named routine against one batch trigger +// read and one captured instant. If the batch read fails, it falls back to +// re-reading each named routine's triggers individually, once each, +// reporting ScheduleStateUnknown only for a routine whose own re-read also +// fails. +func ClassifyRoutines( + ctx context.Context, reader RoutineTriggerReader, routineIDs []string, now time.Time, +) (map[string]RoutineClassification, error) { + results := make(map[string]RoutineClassification, len(routineIDs)) + + byRoutine, err := reader.ListTriggersByRoutineIDs(ctx, routineIDs) + if err == nil { + for _, id := range routineIDs { + state, unarmed := ClassifyRoutine(byRoutine[id], now) + results[id] = RoutineClassification{State: state, Unarmed: unarmed} + } + return results, nil + } + + for _, id := range routineIDs { + triggers, rerr := reader.ListTriggersByRoutineID(ctx, id) + if rerr != nil { + results[id] = RoutineClassification{State: ScheduleStateUnknown} + continue + } + state, unarmed := ClassifyRoutine(triggers, now) + results[id] = RoutineClassification{State: state, Unarmed: unarmed} + } + return results, nil +} diff --git a/apps/backend/internal/office/routines/arming_dialect_postgres_test.go b/apps/backend/internal/office/routines/arming_dialect_postgres_test.go new file mode 100644 index 0000000000..0ecde96486 --- /dev/null +++ b/apps/backend/internal/office/routines/arming_dialect_postgres_test.go @@ -0,0 +1,127 @@ +package routines_test + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + "github.com/kandev/kandev/internal/office/models" + "github.com/kandev/kandev/internal/office/repository/sqlite" + "github.com/kandev/kandev/internal/office/routines" + "github.com/kandev/kandev/internal/testutil" +) + +// dialectFixtureRepo is the subset of *sqlite.Repository this test needs: +// enough to seed identical rows and then run Task 01's batch classifier +// against them. +type dialectFixtureRepo interface { + routines.RoutineTriggerReader + CreateRoutine(ctx context.Context, r *models.Routine) error + CreateRoutineTrigger(ctx context.Context, t *models.RoutineTrigger) error +} + +// seedArmingDialectFixture creates one routine per schedule state this test +// cares about, using fixed IDs so the two dialects can be compared directly +// by routine ID. now anchors the grace-window and past/future trigger times. +func seedArmingDialectFixture(t *testing.T, repo dialectFixtureRepo, now time.Time) []string { + t.Helper() + ctx := context.Background() + + ids := []string{"armed-past-due", "trigger-invalid", "armed-in-grace", "trigger-disabled", "no-trigger"} + for _, id := range ids { + r := &models.Routine{ + ID: id, WorkspaceID: "ws-dialect", Name: id, TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + } + if err := repo.CreateRoutine(ctx, r); err != nil { + t.Fatalf("create routine %s: %v", id, err) + } + } + + past := now.Add(-time.Hour) + firedRecently := now.Add(-30 * time.Second) + triggers := []*models.RoutineTrigger{ + { + ID: "t-armed-past-due", RoutineID: "armed-past-due", Kind: "cron", + CronExpression: "*/5 * * * *", Timezone: "UTC", Enabled: true, NextRunAt: &past, + }, + { + ID: "t-invalid", RoutineID: "trigger-invalid", Kind: "cron", + CronExpression: "not a cron", Timezone: "UTC", Enabled: true, + }, + { + ID: "t-armed-in-grace", RoutineID: "armed-in-grace", Kind: "cron", + CronExpression: "*/5 * * * *", Timezone: "UTC", Enabled: true, LastFiredAt: &firedRecently, + }, + { + ID: "t-disabled", RoutineID: "trigger-disabled", Kind: "cron", + CronExpression: "*/5 * * * *", Timezone: "UTC", Enabled: false, + }, + } + for _, tr := range triggers { + if err := repo.CreateRoutineTrigger(ctx, tr); err != nil { + t.Fatalf("create trigger %s: %v", tr.ID, err) + } + } + return ids +} + +// TestClassifyRoutines_IdenticalOnSQLiteAndPostgres covers +// AC-OFFICE-ROUTINE-ARMING-001.6: the same trigger rows, read and +// classified through Task 01's batch entry point, produce the same +// schedule state and unarmed cron trigger list on SQLite and PostgreSQL. +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. +func TestClassifyRoutines_IdenticalOnSQLiteAndPostgres(t *testing.T) { + dsn := testutil.PostgresDSNFromEnv(t) + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + + sqliteDB, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = sqliteDB.Close() }) + if _, _, err := settingsstore.Provide(sqliteDB, sqliteDB, nil); err != nil { + t.Fatalf("sqlite settings store: %v", err) + } + sqliteRepo, err := sqlite.NewWithDB(sqliteDB, sqliteDB, nil) + if err != nil { + t.Fatalf("sqlite repo: %v", err) + } + + pgDB := testutil.OpenIsolatedPostgres(t, dsn) + if _, _, err := settingsstore.Provide(pgDB, pgDB, nil); err != nil { + t.Fatalf("postgres settings store: %v", err) + } + pgRepo, err := sqlite.NewWithDB(pgDB, pgDB, nil) + if err != nil { + t.Fatalf("postgres repo: %v", err) + } + + ctx := context.Background() + ids := seedArmingDialectFixture(t, sqliteRepo, now) + seedArmingDialectFixture(t, pgRepo, now) + + sqliteResults, err := routines.ClassifyRoutines(ctx, sqliteRepo, ids, now) + if err != nil { + t.Fatalf("classify sqlite: %v", err) + } + pgResults, err := routines.ClassifyRoutines(ctx, pgRepo, ids, now) + if err != nil { + t.Fatalf("classify postgres: %v", err) + } + + for _, id := range ids { + sqliteResult, pgResult := sqliteResults[id], pgResults[id] + if sqliteResult.State != pgResult.State { + t.Errorf("routine %s: sqlite state = %q, postgres state = %q", id, sqliteResult.State, pgResult.State) + } + if !reflect.DeepEqual(sqliteResult.Unarmed, pgResult.Unarmed) { + t.Errorf("routine %s: sqlite unarmed = %+v, postgres unarmed = %+v", id, sqliteResult.Unarmed, pgResult.Unarmed) + } + } +} diff --git a/apps/backend/internal/office/routines/arming_metrics.go b/apps/backend/internal/office/routines/arming_metrics.go new file mode 100644 index 0000000000..3aeee1bb81 --- /dev/null +++ b/apps/backend/internal/office/routines/arming_metrics.go @@ -0,0 +1,54 @@ +package routines + +import ( + "expvar" + "strings" +) + +// expvar counters for the routine arming startup scan, exposed via stdlib's +// /debug/vars handler. The label model is "key=value;key=value..." so a +// Prometheus translation layer can split on `;` and `=` later, mirroring +// internal/orchestrator/office_stall_metrics.go. +var ( + // armingScanObservationsTotal counts every routine the scan classified, + // labelled by workspace, intent, and schedule state + // (AC-OFFICE-ROUTINE-ARMING-003.6). + armingScanObservationsTotal = expvar.NewMap("office_routine_arming_scan_observations_total") + + // armingScanSkippedTotal counts a scan step abandoned because an input + // could not be read, labelled by reason, so a scan failing closed is + // visible rather than silent (AC-OFFICE-ROUTINE-ARMING-003.7). + armingScanSkippedTotal = expvar.NewMap("office_routine_arming_scan_skipped_total") +) + +// Skip reasons for armingScanSkippedTotal. +const ( + armingSkipNoSignal = "no_signal" + armingSkipWorkspaceEnumFailed = "workspace_enum_failed" + armingSkipRoutineEnumFailed = "routine_enum_failed" + armingSkipClassificationUnknown = "classification_unknown" +) + +// armingLabel builds a "k1=v1;k2=v2;..." expvar map key. Returns an empty +// label for an odd number of arguments rather than guessing. +func armingLabel(pairs ...string) string { + if len(pairs)%2 != 0 { + return "" + } + parts := make([]string, 0, len(pairs)/2) + for i := 0; i < len(pairs); i += 2 { + parts = append(parts, pairs[i]+"="+pairs[i+1]) + } + return strings.Join(parts, ";") +} + +// armingScanObserved records one routine the scan reached. +func armingScanObserved(workspaceID, intent string, state ScheduleState) { + armingScanObservationsTotal.Add( + armingLabel("workspace", workspaceID, "intent", intent, "schedule_state", string(state)), 1) +} + +// armingScanSkipped records a fail-closed skip in the startup scan. +func armingScanSkipped(reason string) { + armingScanSkippedTotal.Add(armingLabel("reason", reason), 1) +} diff --git a/apps/backend/internal/office/routines/arming_test.go b/apps/backend/internal/office/routines/arming_test.go new file mode 100644 index 0000000000..b6b3fa91ca --- /dev/null +++ b/apps/backend/internal/office/routines/arming_test.go @@ -0,0 +1,475 @@ +package routines_test + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/kandev/kandev/internal/office/models" + "github.com/kandev/kandev/internal/office/routines" +) + +func trig(mods ...func(*models.RoutineTrigger)) *models.RoutineTrigger { + t := &models.RoutineTrigger{ + ID: "t-default", + Kind: "cron", + CronExpression: "*/5 * * * *", + Timezone: "UTC", + Enabled: true, + CreatedAt: time.Unix(0, 0).UTC(), + } + for _, m := range mods { + m(t) + } + return t +} + +func withID(id string) func(*models.RoutineTrigger) { + return func(t *models.RoutineTrigger) { t.ID = id } +} + +func withCreatedAt(ts time.Time) func(*models.RoutineTrigger) { + return func(t *models.RoutineTrigger) { t.CreatedAt = ts } +} + +func withKind(kind string) func(*models.RoutineTrigger) { + return func(t *models.RoutineTrigger) { t.Kind = kind } +} + +func withEnabled(enabled bool) func(*models.RoutineTrigger) { + return func(t *models.RoutineTrigger) { t.Enabled = enabled } +} + +func withCron(expr, tz string) func(*models.RoutineTrigger) { + return func(t *models.RoutineTrigger) { t.CronExpression = expr; t.Timezone = tz } +} + +func withNextRunAt(ts *time.Time) func(*models.RoutineTrigger) { + return func(t *models.RoutineTrigger) { t.NextRunAt = ts } +} + +func withLastFiredAt(ts *time.Time) func(*models.RoutineTrigger) { + return func(t *models.RoutineTrigger) { t.LastFiredAt = ts } +} + +func timePtr(t time.Time) *time.Time { return &t } + +// TestClassifyRoutine_Rules covers the nine-rule table of +// AC-OFFICE-ROUTINE-ARMING-001.1 in isolation, one rule per case, plus the +// precedence and boundary criteria named alongside it. +func TestClassifyRoutine_Rules(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + triggers []*models.RoutineTrigger + want routines.ScheduleState + }{ + { + name: "rule 8: no triggers", + triggers: nil, + want: routines.ScheduleStateUnscheduledNoTrigger, + }, + { + name: "rule 7: manual trigger only", + triggers: []*models.RoutineTrigger{ + trig(withKind("manual")), + }, + want: routines.ScheduleStateUnscheduledManualOnly, + }, + { + name: "rule 7: disabled webhook trigger, no cron trigger", + triggers: []*models.RoutineTrigger{ + trig(withKind("webhook"), withEnabled(false)), + }, + want: routines.ScheduleStateUnscheduledManualOnly, + }, + { + name: "rule 7: disabled manual trigger only (rule 7 is enabled-independent)", + triggers: []*models.RoutineTrigger{ + trig(withKind("manual"), withEnabled(false)), + }, + want: routines.ScheduleStateUnscheduledManualOnly, + }, + { + name: "rule 6: enabled webhook, no cron trigger", + triggers: []*models.RoutineTrigger{ + trig(withKind("webhook"), withEnabled(true)), + }, + want: routines.ScheduleStateEventOnly, + }, + { + name: "rule 5: cron trigger exists, none enabled", + triggers: []*models.RoutineTrigger{ + trig(withEnabled(false)), + }, + want: routines.ScheduleStateTriggerDisabled, + }, + { + name: "rule 4: enabled cron, schedulable, never fired (AC-001.11 excludes rule 2)", + triggers: []*models.RoutineTrigger{ + trig(withNextRunAt(nil), withLastFiredAt(nil)), + }, + want: routines.ScheduleStateTriggerUnscheduled, + }, + { + name: "rule 4: enabled cron, schedulable, null next_run_at, last fired outside grace", + triggers: []*models.RoutineTrigger{ + trig(withNextRunAt(nil), withLastFiredAt(timePtr(now.Add(-2*time.Minute)))), + }, + want: routines.ScheduleStateTriggerUnscheduled, + }, + { + name: "rule 3: enabled cron, not schedulable (bad expression), precedes rule 4", + triggers: []*models.RoutineTrigger{ + trig(withCron("not a cron", "UTC"), withNextRunAt(nil)), + }, + want: routines.ScheduleStateTriggerInvalid, + }, + { + name: "rule 3: enabled cron, not schedulable (bad timezone)", + triggers: []*models.RoutineTrigger{ + trig(withCron("*/5 * * * *", "Not/AZone"), withNextRunAt(nil)), + }, + want: routines.ScheduleStateTriggerInvalid, + }, + { + name: "rule 3: enabled cron, syntactically valid but impossible occurrence", + triggers: []*models.RoutineTrigger{ + trig(withCron("0 0 30 2 *", "UTC"), withNextRunAt(nil)), + }, + want: routines.ScheduleStateTriggerInvalid, + }, + { + name: "rule 2: enabled cron, schedulable, null next_run_at, within dispatch grace", + triggers: []*models.RoutineTrigger{ + trig(withNextRunAt(nil), withLastFiredAt(timePtr(now.Add(-30*time.Second)))), + }, + want: routines.ScheduleStateArmed, + }, + { + name: "rule 2: dispatch grace boundary, exactly 60s elapsed is still armed", + triggers: []*models.RoutineTrigger{ + trig(withNextRunAt(nil), withLastFiredAt(timePtr(now.Add(-60*time.Second)))), + }, + want: routines.ScheduleStateArmed, + }, + { + name: "rule 4: dispatch grace boundary, 60s plus 1ns elapsed is unscheduled", + triggers: []*models.RoutineTrigger{ + trig(withNextRunAt(nil), withLastFiredAt(timePtr(now.Add(-60*time.Second-time.Nanosecond)))), + }, + want: routines.ScheduleStateTriggerUnscheduled, + }, + { + name: "rule 1: enabled cron, next_run_at in the past (past-due is due, not broken)", + triggers: []*models.RoutineTrigger{ + trig(withNextRunAt(timePtr(now.Add(-time.Hour)))), + }, + want: routines.ScheduleStateArmed, + }, + { + name: "rule 1: enabled cron, next_run_at in the future (scheduled, not yet due)", + triggers: []*models.RoutineTrigger{ + trig(withNextRunAt(timePtr(now.Add(time.Hour)))), + }, + want: routines.ScheduleStateArmed, + }, + { + name: "rule 1 (AC-001.4): next_run_at exactly the classification instant", + triggers: []*models.RoutineTrigger{ + trig(withNextRunAt(timePtr(now))), + }, + want: routines.ScheduleStateArmed, + }, + { + name: "rule 3 precedes rule 4 when both an invalid and a merely-unscheduled enabled cron trigger exist", + triggers: []*models.RoutineTrigger{ + trig(withID("t-unscheduled"), withNextRunAt(nil), withLastFiredAt(nil)), + trig(withID("t-invalid"), withCron("garbage", "UTC"), withNextRunAt(nil)), + }, + want: routines.ScheduleStateTriggerInvalid, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + state, _ := routines.ClassifyRoutine(tc.triggers, now) + if state != tc.want { + t.Errorf("state = %q, want %q", state, tc.want) + } + }) + } +} + +// TestClassifyRoutine_ArmedWithUnarmedSiblings covers AC-OFFICE-ROUTINE-ARMING-001.9: +// a routine may be armed on one trigger while another remains on the unarmed list. +func TestClassifyRoutine_ArmedWithUnarmedSiblings(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + armed := trig(withID("t-armed"), withNextRunAt(timePtr(now.Add(time.Hour)))) + disabled := trig(withID("t-disabled"), withEnabled(false)) + + state, unarmed := routines.ClassifyRoutine([]*models.RoutineTrigger{armed, disabled}, now) + + if state != routines.ScheduleStateArmed { + t.Fatalf("state = %q, want armed", state) + } + if len(unarmed) != 1 || unarmed[0].TriggerID != "t-disabled" { + t.Fatalf("unarmed = %+v, want exactly t-disabled", unarmed) + } +} + +// TestClassifyRoutine_UnarmedReasonsOrderAndCombination covers +// AC-OFFICE-ROUTINE-ARMING-001.8: every applicable reason, in the fixed order. +func TestClassifyRoutine_UnarmedReasonsOrderAndCombination(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + + tests := []struct { + name string + trigger *models.RoutineTrigger + want []routines.UnarmedReason + }{ + { + name: "disabled and not schedulable both apply, in order", + trigger: trig(withEnabled(false), withCron("garbage", "UTC")), + want: []routines.UnarmedReason{routines.UnarmedReasonDisabled, routines.UnarmedReasonNotSchedulable}, + }, + { + name: "disabled only", + trigger: trig(withEnabled(false)), + want: []routines.UnarmedReason{routines.UnarmedReasonDisabled}, + }, + { + name: "not schedulable only", + trigger: trig(withCron("garbage", "UTC")), + want: []routines.UnarmedReason{routines.UnarmedReasonNotSchedulable}, + }, + { + name: "stalled only: enabled, schedulable, null next_run_at, past grace", + trigger: trig(withNextRunAt(nil), withLastFiredAt(timePtr(now.Add(-5*time.Minute)))), + want: []routines.UnarmedReason{routines.UnarmedReasonStalled}, + }, + { + name: "stalled only: enabled, schedulable, null next_run_at, never fired", + trigger: trig(withNextRunAt(nil), withLastFiredAt(nil)), + want: []routines.UnarmedReason{routines.UnarmedReasonStalled}, + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + // Pair with a second armed trigger so the routine's overall state + // doesn't erase the unarmed sibling under test. + armed := trig(withID("t-armed-sibling"), withNextRunAt(timePtr(now.Add(time.Hour)))) + _, unarmed := routines.ClassifyRoutine([]*models.RoutineTrigger{tc.trigger, armed}, now) + if len(unarmed) != 1 { + t.Fatalf("unarmed entries = %d, want 1: %+v", len(unarmed), unarmed) + } + if unarmed[0].TriggerID != tc.trigger.ID { + t.Fatalf("unarmed trigger id = %q, want %q", unarmed[0].TriggerID, tc.trigger.ID) + } + if len(unarmed[0].Reasons) != len(tc.want) { + t.Fatalf("reasons = %v, want %v", unarmed[0].Reasons, tc.want) + } + for i, r := range tc.want { + if unarmed[0].Reasons[i] != r { + t.Errorf("reasons[%d] = %q, want %q", i, unarmed[0].Reasons[i], r) + } + } + }) + } +} + +// TestClassifyRoutine_ArmedTriggerNotInUnarmedList proves an armed trigger +// (next_run_at set, or within grace) never appears on the unarmed list. +func TestClassifyRoutine_ArmedTriggerNotInUnarmedList(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + only := trig(withNextRunAt(timePtr(now.Add(time.Hour)))) + + state, unarmed := routines.ClassifyRoutine([]*models.RoutineTrigger{only}, now) + if state != routines.ScheduleStateArmed { + t.Fatalf("state = %q, want armed", state) + } + if len(unarmed) != 0 { + t.Fatalf("unarmed = %+v, want empty", unarmed) + } + if unarmed == nil { + t.Fatal("unarmed is nil, want a non-nil empty slice so JSON marshals it as [] not null") + } + if got, err := json.Marshal(unarmed); err != nil { + t.Fatalf("json.Marshal: %v", err) + } else if string(got) != "[]" { + t.Errorf("json.Marshal(unarmed) = %s, want []", got) + } +} + +// TestClassifyRoutine_ArmedButNotSchedulableTriggerStillReported covers the +// AC-OFFICE-ROUTINE-ARMING-001.8/001.9 pairing: a trigger that already has a +// next occurrence stays armed even if its expression or timezone can no +// longer be parsed, but unarmed-list membership is independent of armed +// state, so the same trigger is still reported as needing a fix. +func TestClassifyRoutine_ArmedButNotSchedulableTriggerStillReported(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + broken := trig(withCron("garbage", "UTC"), withNextRunAt(timePtr(now.Add(time.Hour)))) + + state, unarmed := routines.ClassifyRoutine([]*models.RoutineTrigger{broken}, now) + + if state != routines.ScheduleStateArmed { + t.Fatalf("state = %q, want armed (a claimed next occurrence still fires)", state) + } + if len(unarmed) != 1 || unarmed[0].TriggerID != broken.ID { + t.Fatalf("unarmed = %+v, want exactly %q", unarmed, broken.ID) + } + want := []routines.UnarmedReason{routines.UnarmedReasonNotSchedulable} + if len(unarmed[0].Reasons) != len(want) || unarmed[0].Reasons[0] != want[0] { + t.Errorf("reasons = %v, want %v", unarmed[0].Reasons, want) + } +} + +// TestClassifyRoutine_TriggerOrder covers the "trigger order" terminology +// definition (created_at ascending, ties by id ascending) applied to the +// unarmed cron trigger list. +func TestClassifyRoutine_TriggerOrder(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + // Same created_at: tie broken by id ascending. + second := trig(withID("b"), withEnabled(false), withCreatedAt(base)) + first := trig(withID("a"), withEnabled(false), withCreatedAt(base)) + third := trig(withID("c"), withEnabled(false), withCreatedAt(base.Add(time.Minute))) + + // Deliberately out of order as input. + _, unarmed := routines.ClassifyRoutine([]*models.RoutineTrigger{third, second, first}, now) + + if len(unarmed) != 3 { + t.Fatalf("unarmed count = %d, want 3", len(unarmed)) + } + gotOrder := []string{unarmed[0].TriggerID, unarmed[1].TriggerID, unarmed[2].TriggerID} + wantOrder := []string{"a", "b", "c"} + for i := range wantOrder { + if gotOrder[i] != wantOrder[i] { + t.Errorf("order[%d] = %q, want %q (got %v)", i, gotOrder[i], wantOrder[i], gotOrder) + } + } +} + +// -- ClassifyRoutines (batch + per-routine fallback) -- + +type fakeTriggerReader struct { + byRoutine map[string][]*models.RoutineTrigger + batchErr error + singleErr map[string]error + batchCalls int + singleCalls map[string]int +} + +func newFakeTriggerReader() *fakeTriggerReader { + return &fakeTriggerReader{ + byRoutine: map[string][]*models.RoutineTrigger{}, + singleErr: map[string]error{}, + singleCalls: map[string]int{}, + } +} + +func (f *fakeTriggerReader) ListTriggersByRoutineIDs( + _ context.Context, routineIDs []string, +) (map[string][]*models.RoutineTrigger, error) { + f.batchCalls++ + if f.batchErr != nil { + return nil, f.batchErr + } + out := make(map[string][]*models.RoutineTrigger, len(routineIDs)) + for _, id := range routineIDs { + out[id] = f.byRoutine[id] + } + return out, nil +} + +func (f *fakeTriggerReader) ListTriggersByRoutineID( + _ context.Context, routineID string, +) ([]*models.RoutineTrigger, error) { + f.singleCalls[routineID]++ + if err, ok := f.singleErr[routineID]; ok { + return nil, err + } + return f.byRoutine[routineID], nil +} + +// TestClassifyRoutines_HappyPathIsOneBatchQuery covers +// AC-OFFICE-ROUTINE-ARMING-001.12: one batch read for the whole call. +func TestClassifyRoutines_HappyPathIsOneBatchQuery(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + reader := newFakeTriggerReader() + reader.byRoutine["r1"] = []*models.RoutineTrigger{trig(withNextRunAt(timePtr(now.Add(time.Hour))))} + reader.byRoutine["r2"] = nil + + results, err := routines.ClassifyRoutines(context.Background(), reader, []string{"r1", "r2"}, now) + if err != nil { + t.Fatalf("classify: %v", err) + } + if reader.batchCalls != 1 { + t.Fatalf("batch calls = %d, want 1", reader.batchCalls) + } + if len(reader.singleCalls) != 0 { + t.Fatalf("single calls = %v, want none on the happy path", reader.singleCalls) + } + if results["r1"].State != routines.ScheduleStateArmed { + t.Errorf("r1 state = %q, want armed", results["r1"].State) + } + if results["r2"].State != routines.ScheduleStateUnscheduledNoTrigger { + t.Errorf("r2 state = %q, want unscheduled_no_trigger", results["r2"].State) + } +} + +// TestClassifyRoutines_BatchFailureFallsBackPerRoutine covers +// AC-OFFICE-ROUTINE-ARMING-001.13: a batch failure re-reads each named +// routine individually, once each, and only the routines whose individual +// re-read also fails become unknown. +func TestClassifyRoutines_BatchFailureFallsBackPerRoutine(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + reader := newFakeTriggerReader() + reader.batchErr = errors.New("batch decode failure") + reader.byRoutine["r-ok"] = []*models.RoutineTrigger{trig(withNextRunAt(timePtr(now.Add(time.Hour))))} + reader.singleErr["r-bad"] = errors.New("row undecodable") + + results, err := routines.ClassifyRoutines(context.Background(), reader, []string{"r-ok", "r-bad"}, now) + if err != nil { + t.Fatalf("classify: %v", err) + } + if reader.singleCalls["r-ok"] != 1 { + t.Errorf("r-ok single-read calls = %d, want 1", reader.singleCalls["r-ok"]) + } + if reader.singleCalls["r-bad"] != 1 { + t.Errorf("r-bad single-read calls = %d, want 1", reader.singleCalls["r-bad"]) + } + if results["r-ok"].State != routines.ScheduleStateArmed { + t.Errorf("r-ok state = %q, want armed (its own re-read succeeded)", results["r-ok"].State) + } + if results["r-bad"].State != routines.ScheduleStateUnknown { + t.Errorf("r-bad state = %q, want unknown", results["r-bad"].State) + } +} + +// TestClassifyRoutines_SharesOneInstant covers the second half of +// AC-OFFICE-ROUTINE-ARMING-001.12: the per-routine fallback reuses the +// batch call's captured instant rather than recapturing it. +func TestClassifyRoutines_SharesOneInstant(t *testing.T) { + now := time.Date(2026, 9, 17, 12, 0, 0, 0, time.UTC) + reader := newFakeTriggerReader() + reader.batchErr = errors.New("batch failure") + // Within grace of `now`, but would be outside grace of any later instant + // a buggy re-capture might use. + reader.byRoutine["r1"] = []*models.RoutineTrigger{ + trig(withNextRunAt(nil), withLastFiredAt(timePtr(now.Add(-59*time.Second)))), + } + + results, err := routines.ClassifyRoutines(context.Background(), reader, []string{"r1"}, now) + if err != nil { + t.Fatalf("classify: %v", err) + } + if results["r1"].State != routines.ScheduleStateArmed { + t.Errorf("r1 state = %q, want armed (classified against the original instant)", results["r1"].State) + } +} diff --git a/apps/backend/internal/office/routines/coordinator_install_dialect_postgres_test.go b/apps/backend/internal/office/routines/coordinator_install_dialect_postgres_test.go new file mode 100644 index 0000000000..a9d27fb12c --- /dev/null +++ b/apps/backend/internal/office/routines/coordinator_install_dialect_postgres_test.go @@ -0,0 +1,153 @@ +package routines_test + +import ( + "context" + "testing" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/office/repository/sqlite" + "github.com/kandev/kandev/internal/office/routines" + "github.com/kandev/kandev/internal/testutil" +) + +// TestCreateDefaultCoordinatorRoutine_IdenticalOnSQLiteAndPostgres covers +// AC-OFFICE-COORDINATOR-INSTALL-001.11: a fresh install and its idempotent +// repeat produce the same routine/trigger shape on SQLite and PostgreSQL. +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. +func TestCreateDefaultCoordinatorRoutine_IdenticalOnSQLiteAndPostgres(t *testing.T) { + dsn := testutil.PostgresDSNFromEnv(t) + + sqliteDB, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = sqliteDB.Close() }) + sqliteDB.SetMaxOpenConns(1) + sqliteRepo, err := sqlite.NewWithDB(sqliteDB, sqliteDB, nil) + if err != nil { + t.Fatalf("sqlite repo: %v", err) + } + + pgDB := testutil.OpenIsolatedPostgres(t, dsn) + pgRepo, err := sqlite.NewWithDB(pgDB, pgDB, nil) + if err != nil { + t.Fatalf("postgres repo: %v", err) + } + + sqliteSvc := routines.NewRoutineService(sqliteRepo, logger.Default(), nil) + pgSvc := routines.NewRoutineService(pgRepo, logger.Default(), nil) + + ctx := context.Background() + for _, svc := range []*routines.RoutineService{sqliteSvc, pgSvc} { + first, err := svc.CreateDefaultCoordinatorRoutine(ctx, "ws-dialect", "agent-dialect") + if err != nil { + t.Fatalf("first install: %v", err) + } + if first.Status != "active" { + t.Errorf("status = %q, want active", first.Status) + } + second, err := svc.CreateDefaultCoordinatorRoutine(ctx, "ws-dialect", "agent-dialect") + if err != nil { + t.Fatalf("second install: %v", err) + } + if second.ID != first.ID { + t.Errorf("second install returned a different routine: %q vs %q", second.ID, first.ID) + } + } + + sqliteTriggers, err := sqliteRepo.ListTriggersByRoutineID(ctx, mustSoleRoutineID(t, ctx, sqliteRepo, "ws-dialect")) + if err != nil { + t.Fatalf("list sqlite triggers: %v", err) + } + pgTriggers, err := pgRepo.ListTriggersByRoutineID(ctx, mustSoleRoutineID(t, ctx, pgRepo, "ws-dialect")) + if err != nil { + t.Fatalf("list postgres triggers: %v", err) + } + if len(sqliteTriggers) != 1 || len(pgTriggers) != 1 { + t.Fatalf("trigger counts = sqlite:%d postgres:%d, want 1 each", len(sqliteTriggers), len(pgTriggers)) + } + if sqliteTriggers[0].CronExpression != pgTriggers[0].CronExpression || sqliteTriggers[0].Timezone != pgTriggers[0].Timezone { + t.Errorf("trigger schedule differs: sqlite=%+v postgres=%+v", sqliteTriggers[0], pgTriggers[0]) + } + if !sqliteTriggers[0].Enabled || !pgTriggers[0].Enabled { + t.Errorf("expected both canonical triggers enabled: sqlite=%v postgres=%v", sqliteTriggers[0].Enabled, pgTriggers[0].Enabled) + } +} + +// mustSoleRoutineID returns the single routine in workspaceID, failing the +// test if there is not exactly one. +func mustSoleRoutineID(t *testing.T, ctx context.Context, repo *sqlite.Repository, workspaceID string) string { + t.Helper() + list, err := repo.ListRoutines(ctx, workspaceID) + if err != nil { + t.Fatalf("list routines: %v", err) + } + if len(list) != 1 { + t.Fatalf("routines in %s = %d, want 1", workspaceID, len(list)) + } + return list[0].ID +} + +// TestCreateDefaultCoordinatorRoutine_ConcurrentOnPostgresCreatesAtMostOne +// covers AC-OFFICE-COORDINATOR-INSTALL-001.8/.9/.13 on the dialect where +// the advisory-lock poll actually runs: two concurrent installs for the +// same identity, against two separate connections to the same PostgreSQL +// database, create at most one routine and at most one canonical trigger. +// Skips unless KANDEV_TEST_POSTGRES_DSN is set. +func TestCreateDefaultCoordinatorRoutine_ConcurrentOnPostgresCreatesAtMostOne(t *testing.T) { + dsn := testutil.PostgresDSNFromEnv(t) + pgDB := testutil.OpenIsolatedPostgres(t, dsn) + repo, err := sqlite.NewWithDB(pgDB, pgDB, nil) + if err != nil { + t.Fatalf("postgres repo: %v", err) + } + svc := routines.NewRoutineService(repo, logger.Default(), nil) + + type result struct { + id string + err error + } + resultsCh := make(chan result, 2) + run := func() { + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-pg-concurrent", "agent-pg-concurrent") + if routine != nil { + resultsCh <- result{routine.ID, err} + return + } + resultsCh <- result{"", err} + } + go run() + go run() + + var results [2]result + for i := range results { + results[i] = <-resultsCh + } + for i, res := range results { + if res.err != nil { + t.Fatalf("call %d unexpected error: %v", i, res.err) + } + } + if results[0].id != results[1].id { + t.Errorf("concurrent installs returned different routines: %q vs %q", results[0].id, results[1].id) + } + + ctx := context.Background() + routineList, err := repo.ListRoutines(ctx, "ws-pg-concurrent") + if err != nil { + t.Fatalf("list routines: %v", err) + } + if len(routineList) != 1 { + t.Fatalf("routines created = %d, want 1", len(routineList)) + } + triggers, err := repo.ListTriggersByRoutineID(ctx, routineList[0].ID) + if err != nil { + t.Fatalf("list triggers: %v", err) + } + if len(triggers) != 1 { + t.Fatalf("triggers created = %d, want 1", len(triggers)) + } +} diff --git a/apps/backend/internal/office/routines/coordinator_install_metrics.go b/apps/backend/internal/office/routines/coordinator_install_metrics.go new file mode 100644 index 0000000000..6c6446a322 --- /dev/null +++ b/apps/backend/internal/office/routines/coordinator_install_metrics.go @@ -0,0 +1,55 @@ +package routines + +import ( + "expvar" + "strings" +) + +// coordinatorInstallConditionsTotal counts every reportable condition +// CreateDefaultCoordinatorRoutine observes, keyed by workspace, assignee, +// and condition (AC-OFFICE-COORDINATOR-INSTALL-001.10). These count calls +// in which a condition was *observed*, not calls that completed +// successfully — a call that is ultimately rejected still counts the +// conditions it had already detected before rejecting. +var coordinatorInstallConditionsTotal = expvar.NewMap("office_coordinator_install_conditions_total") + +// Condition labels for coordinatorInstallConditionsTotal. Each names a +// distinct reportable outcome the acceptance criteria require to be told +// apart from every other one. +const ( + coordinatorInstallConditionEmptyIdentity = "empty_identity" + coordinatorInstallConditionLookupFailed = "lookup_failed" + coordinatorInstallConditionTriggerReadFailed = "trigger_read_failed" + coordinatorInstallConditionDuplicateMatches = "duplicate_matches" + coordinatorInstallConditionScheduleState = "schedule_state" + coordinatorInstallConditionTriggerCompleted = "trigger_completed" + coordinatorInstallConditionRoutineCreated = "routine_created" + coordinatorInstallConditionNextOccurrenceFailed = "next_occurrence_failed" + coordinatorInstallConditionTriggerCreateFailed = "trigger_create_failed" + coordinatorInstallConditionRoutineCreateFailed = "routine_create_failed" + coordinatorInstallConditionContention = "contention" + coordinatorInstallConditionCancelled = "cancelled" + coordinatorInstallConditionCommitFailed = "commit_failed" +) + +// coordinatorInstallLabel builds the "k1=v1;k2=v2" label string the office +// stall metrics (internal/orchestrator/office_stall_metrics.go) and the +// routine-arming-scan metrics (arming_metrics.go) already use. +func coordinatorInstallLabel(pairs ...string) string { + if len(pairs)%2 != 0 { + return "" + } + parts := make([]string, 0, len(pairs)/2) + for i := 0; i < len(pairs); i += 2 { + parts = append(parts, pairs[i]+"="+pairs[i+1]) + } + return strings.Join(parts, ";") +} + +// coordinatorInstallObserved increments the counter for one reportable +// condition on one install call, naming the workspace and assignee agent +// per AC-OFFICE-COORDINATOR-INSTALL-001.10. +func coordinatorInstallObserved(condition, workspaceID, agentID string) { + coordinatorInstallConditionsTotal.Add( + coordinatorInstallLabel("condition", condition, "workspace", workspaceID, "assignee", agentID), 1) +} diff --git a/apps/backend/internal/office/routines/coordinator_install_test.go b/apps/backend/internal/office/routines/coordinator_install_test.go new file mode 100644 index 0000000000..c9852579f3 --- /dev/null +++ b/apps/backend/internal/office/routines/coordinator_install_test.go @@ -0,0 +1,853 @@ +package routines + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + "go.uber.org/zap/zapcore" + + "github.com/kandev/kandev/internal/office/models" + "github.com/kandev/kandev/internal/office/repository/sqlite" +) + +func newCoordinatorInstallRepo(t *testing.T) *sqlite.Repository { + t.Helper() + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + // A single physical connection, matching internal/db.OpenSQLite's + // production writer pool: an in-memory database is per-connection, so + // without this, a second concurrent BeginTxx can be handed a fresh + // connection to an empty database instead of blocking on the one + // initSchema() already populated. + db.SetMaxOpenConns(1) + repo, err := sqlite.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + return repo +} + +func newCoordinatorInstallService(t *testing.T, repo Repository) *RoutineService { + t.Helper() + log, _ := newObservedLogger(t, zapcore.InfoLevel) + return NewRoutineService(repo, log, nil) +} + +func patchRoutineCreatedAt(t *testing.T, repo *sqlite.Repository, id string, createdAt time.Time) { + t.Helper() + if _, err := repo.ExecRaw(context.Background(), + `UPDATE office_routines SET created_at = ? WHERE id = ?`, createdAt, id); err != nil { + t.Fatalf("patch created_at for %s: %v", id, err) + } +} + +// coordinatorInstallRepoFailures wraps a real repository, letting a test +// force InstallCoordinatorRoutine to fail outright (before decide ever +// runs) or wrap the tx-scoped writer decide is given so a specific write +// inside it fails instead. +type coordinatorInstallRepoFailures struct { + *sqlite.Repository + failLookup error + // createRoutineErr, when set, is what CreateRoutine returns instead of + // the generic "forced routine create failure" text — lets a test + // control decide's own error text (e.g. to collide with the SQLite + // busy-text heuristic). + createRoutineErr error + failTriggerRead bool + failCreateRoutine bool + failCreateTrigger bool + // failCommitAfterDecide, when set, is returned instead of nil once the + // wrapped InstallCoordinatorRoutine call has already succeeded — + // simulating WithCoordinatorInstallLock's own tx.Commit() failing after + // decide returned nil, a failure point CoordinatorInstallTx has no way + // to trigger directly since it never exposes Commit to decide. + failCommitAfterDecide error + installDecideCalls int + triggerDecideCalls int +} + +func (f *coordinatorInstallRepoFailures) InstallCoordinatorRoutine( + ctx context.Context, + workspaceID, agentID, canonicalName string, + decide func(ctx context.Context, matches []*Routine, tx models.CoordinatorInstallTx) error, +) error { + if f.failLookup != nil { + return f.failLookup + } + err := f.Repository.InstallCoordinatorRoutine(ctx, workspaceID, agentID, canonicalName, + func(ctx context.Context, matches []*Routine, tx models.CoordinatorInstallTx) error { + f.installDecideCalls++ + return decide(ctx, matches, &coordinatorInstallTxFailures{ + CoordinatorInstallTx: tx, + failCreateRoutine: f.failCreateRoutine, + createRoutineErr: f.createRoutineErr, + }) + }) + if err == nil && f.failCommitAfterDecide != nil { + return f.failCommitAfterDecide + } + return err +} + +func (f *coordinatorInstallRepoFailures) EnsureCoordinatorTrigger( + ctx context.Context, + routineID string, + decide func(ctx context.Context, triggers []*RoutineTrigger, tx models.CoordinatorInstallTx) error, +) error { + if f.failTriggerRead { + return errors.New("forced trigger read failure") + } + return f.Repository.EnsureCoordinatorTrigger(ctx, routineID, + func(ctx context.Context, triggers []*RoutineTrigger, tx models.CoordinatorInstallTx) error { + f.triggerDecideCalls++ + return decide(ctx, triggers, &coordinatorInstallTxFailures{ + CoordinatorInstallTx: tx, + failCreateTrigger: f.failCreateTrigger, + }) + }) +} + +type coordinatorInstallTxFailures struct { + models.CoordinatorInstallTx + failCreateRoutine bool + createRoutineErr error + failCreateTrigger bool +} + +func (w *coordinatorInstallTxFailures) CreateRoutine(ctx context.Context, routine *models.Routine) error { + if w.createRoutineErr != nil { + return w.createRoutineErr + } + if w.failCreateRoutine { + return errors.New("forced routine create failure") + } + return w.CoordinatorInstallTx.CreateRoutine(ctx, routine) +} + +func (w *coordinatorInstallTxFailures) CreateRoutineTrigger(ctx context.Context, t *models.RoutineTrigger) error { + if w.failCreateTrigger { + return errors.New("forced trigger create failure") + } + return w.CoordinatorInstallTx.CreateRoutineTrigger(ctx, t) +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.14: empty workspace or assignee is +// rejected before any identity lookup, distinguishable from AC-001.7's +// read failure. A nil Repository proves no repo call happens: any call +// panics with a nil-interface dereference and fails the test loudly. +func TestCreateDefaultCoordinatorRoutine_EmptyIdentityRejected(t *testing.T) { + log, logs := newObservedLogger(t, zapcore.InfoLevel) + svc := NewRoutineService(nil, log, nil) + before := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionEmptyIdentity, "workspace", "", "assignee", "agent-1"))) + + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "", "agent-1") + + if err == nil { + t.Fatal("expected error for empty workspace_id") + } + if routine != nil { + t.Errorf("expected nil routine, got %+v", routine) + } + after := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionEmptyIdentity, "workspace", "", "assignee", "agent-1"))) + if after != before+1 { + t.Errorf("empty_identity counter = %d, want %d", after, before+1) + } + if logs.Len() != 1 || logs.All()[0].Level != zapcore.WarnLevel { + t.Fatalf("log entries = %+v, want exactly one warn", logs.All()) + } + + if _, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", ""); err == nil { + t.Fatal("expected error for empty agent_id") + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.12: no match creates a routine +// (status=active) plus its canonical trigger, enabled, with the canonical +// cron/timezone and a next_run_at strictly after the instant the install +// captured at its start. +func TestCreateDefaultCoordinatorRoutine_NoMatchCreatesRoutineAndTrigger(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + svc := newCoordinatorInstallService(t, repo) + + before := time.Now().UTC() + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if routine == nil { + t.Fatal("expected a routine") + } + if routine.Status != "active" { + t.Errorf("status = %q, want active", routine.Status) + } + if routine.WorkspaceID != "ws-1" || routine.AssigneeAgentProfileID != "agent-1" || routine.Name != CoordinatorRoutineName { + t.Errorf("routine identity = %+v", routine) + } + + triggers, err := repo.ListTriggersByRoutineID(context.Background(), routine.ID) + if err != nil { + t.Fatalf("list triggers: %v", err) + } + if len(triggers) != 1 { + t.Fatalf("trigger count = %d, want 1", len(triggers)) + } + trigger := triggers[0] + if !trigger.Enabled { + t.Error("canonical trigger should be enabled") + } + if trigger.CronExpression != CoordinatorRoutineCron || trigger.Timezone != "UTC" { + t.Errorf("trigger schedule = %q/%q", trigger.CronExpression, trigger.Timezone) + } + if trigger.NextRunAt == nil || !trigger.NextRunAt.After(before) { + t.Errorf("next_run_at = %v, want non-nil and after %v", trigger.NextRunAt, before) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.2/.5: a single match with any cron +// trigger (canonical or not, enabled or disabled) is returned unchanged; +// no second trigger is created, and the schedule-state condition fires. +func TestCreateDefaultCoordinatorRoutine_SingleMatchWithCronTriggerUnchanged(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + existing := &Routine{ + ID: "r-existing", WorkspaceID: "ws-1", Name: CoordinatorRoutineName, TaskTemplate: "", + AssigneeAgentProfileID: "agent-1", Status: "active", ConcurrencyPolicy: "coalesce_if_active", Variables: "{}", + } + if err := repo.CreateRoutine(context.Background(), existing); err != nil { + t.Fatalf("seed routine: %v", err) + } + if err := repo.CreateRoutineTrigger(context.Background(), &RoutineTrigger{ + ID: "t-existing", RoutineID: existing.ID, Kind: "cron", + CronExpression: "0 * * * *", Timezone: "America/New_York", Enabled: false, + }); err != nil { + t.Fatalf("seed trigger: %v", err) + } + + svc := newCoordinatorInstallService(t, repo) + before := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionScheduleState, "workspace", "ws-1", "assignee", "agent-1"))) + + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if routine.ID != existing.ID { + t.Errorf("returned routine id = %q, want %q", routine.ID, existing.ID) + } + if routine.Status != "active" { + t.Errorf("status changed to %q", routine.Status) + } + + triggers, err := repo.ListTriggersByRoutineID(context.Background(), existing.ID) + if err != nil { + t.Fatalf("list triggers: %v", err) + } + if len(triggers) != 1 { + t.Fatalf("trigger count = %d, want 1 (no second trigger created)", len(triggers)) + } + if triggers[0].Enabled || triggers[0].CronExpression != "0 * * * *" { + t.Errorf("existing trigger mutated: %+v", triggers[0]) + } + + after := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionScheduleState, "workspace", "ws-1", "assignee", "agent-1"))) + if after != before+1 { + t.Errorf("schedule_state counter = %d, want %d", after, before+1) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.4: a matched routine with no cron +// trigger at all gets the canonical trigger completed. Re-running the +// install afterwards is then a no-op via the .5 branch (re-entrancy). +func TestCreateDefaultCoordinatorRoutine_MatchWithNoTriggerCompletesInstall(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + existing := &Routine{ + ID: "r-half", WorkspaceID: "ws-1", Name: CoordinatorRoutineName, TaskTemplate: "", + AssigneeAgentProfileID: "agent-1", Status: "active", ConcurrencyPolicy: "coalesce_if_active", Variables: "{}", + } + if err := repo.CreateRoutine(context.Background(), existing); err != nil { + t.Fatalf("seed routine: %v", err) + } + + svc := newCoordinatorInstallService(t, repo) + before := time.Now().UTC() + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if routine.ID != existing.ID { + t.Errorf("returned routine id = %q, want %q", routine.ID, existing.ID) + } + + triggers, err := repo.ListTriggersByRoutineID(context.Background(), existing.ID) + if err != nil { + t.Fatalf("list triggers: %v", err) + } + if len(triggers) != 1 { + t.Fatalf("trigger count = %d, want 1", len(triggers)) + } + if !triggers[0].Enabled || triggers[0].NextRunAt == nil || !triggers[0].NextRunAt.After(before) { + t.Errorf("completed trigger = %+v", triggers[0]) + } + + // Re-entrancy: calling again must not create a second trigger. + if _, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1"); err != nil { + t.Fatalf("second call unexpected error: %v", err) + } + triggers, err = repo.ListTriggersByRoutineID(context.Background(), existing.ID) + if err != nil { + t.Fatalf("list triggers after second call: %v", err) + } + if len(triggers) != 1 { + t.Fatalf("trigger count after second call = %d, want 1", len(triggers)) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.3: two or more matches select the +// earliest by created_at (ties by id), report the duplicate, and leave the +// non-selected routine and its triggers entirely untouched while the +// selected routine still runs through .4. +func TestCreateDefaultCoordinatorRoutine_DuplicateMatchesSelectsEarliestAndCompletes(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + earlier := &Routine{ + ID: "r-earlier", WorkspaceID: "ws-1", Name: CoordinatorRoutineName, TaskTemplate: "", + AssigneeAgentProfileID: "agent-1", Status: "active", ConcurrencyPolicy: "coalesce_if_active", Variables: "{}", + } + if err := repo.CreateRoutine(context.Background(), earlier); err != nil { + t.Fatalf("seed earlier routine: %v", err) + } + patchRoutineCreatedAt(t, repo, earlier.ID, base) + + later := &Routine{ + ID: "r-later", WorkspaceID: "ws-1", Name: CoordinatorRoutineName, TaskTemplate: "", + AssigneeAgentProfileID: "agent-1", Status: "active", ConcurrencyPolicy: "coalesce_if_active", Variables: "{}", + } + if err := repo.CreateRoutine(context.Background(), later); err != nil { + t.Fatalf("seed later routine: %v", err) + } + patchRoutineCreatedAt(t, repo, later.ID, base.Add(time.Minute)) + if err := repo.CreateRoutineTrigger(context.Background(), &RoutineTrigger{ + ID: "t-later", RoutineID: later.ID, Kind: "cron", + CronExpression: "0 * * * *", Timezone: "UTC", Enabled: true, + }); err != nil { + t.Fatalf("seed later trigger: %v", err) + } + + svc := newCoordinatorInstallService(t, repo) + before := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionDuplicateMatches, "workspace", "ws-1", "assignee", "agent-1"))) + + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if routine.ID != earlier.ID { + t.Errorf("selected routine id = %q, want earliest %q", routine.ID, earlier.ID) + } + + after := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionDuplicateMatches, "workspace", "ws-1", "assignee", "agent-1"))) + if after != before+1 { + t.Errorf("duplicate_matches counter = %d, want %d", after, before+1) + } + + earlierTriggers, err := repo.ListTriggersByRoutineID(context.Background(), earlier.ID) + if err != nil { + t.Fatalf("list earlier triggers: %v", err) + } + if len(earlierTriggers) != 1 { + t.Fatalf("earlier trigger count = %d, want 1 (completed)", len(earlierTriggers)) + } + + laterTriggers, err := repo.ListTriggersByRoutineID(context.Background(), later.ID) + if err != nil { + t.Fatalf("list later triggers: %v", err) + } + if len(laterTriggers) != 1 || laterTriggers[0].ID != "t-later" { + t.Errorf("later routine's trigger was touched: %+v", laterTriggers) + } + + laterRoutine, err := repo.GetRoutine(context.Background(), later.ID) + if err != nil { + t.Fatalf("get later routine: %v", err) + } + if laterRoutine.Status != "active" { + t.Errorf("non-selected routine status changed to %q", laterRoutine.Status) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.3: when two duplicate matches share the +// identical created_at, the tie is broken by id ascending — this is the +// case TestCreateDefaultCoordinatorRoutine_DuplicateMatchesSelectsEarliestAndCompletes +// above cannot exercise, since its two routines differ by created_at alone. +func TestCreateDefaultCoordinatorRoutine_DuplicateMatchesTiesBreakByIDAscending(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + tied := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + + lowerID := &Routine{ + ID: "r-aaa-lower", WorkspaceID: "ws-1", Name: CoordinatorRoutineName, TaskTemplate: "", + AssigneeAgentProfileID: "agent-1", Status: "active", ConcurrencyPolicy: "coalesce_if_active", Variables: "{}", + } + if err := repo.CreateRoutine(context.Background(), lowerID); err != nil { + t.Fatalf("seed lower-id routine: %v", err) + } + patchRoutineCreatedAt(t, repo, lowerID.ID, tied) + + higherID := &Routine{ + ID: "r-zzz-higher", WorkspaceID: "ws-1", Name: CoordinatorRoutineName, TaskTemplate: "", + AssigneeAgentProfileID: "agent-1", Status: "active", ConcurrencyPolicy: "coalesce_if_active", Variables: "{}", + } + if err := repo.CreateRoutine(context.Background(), higherID); err != nil { + t.Fatalf("seed higher-id routine: %v", err) + } + patchRoutineCreatedAt(t, repo, higherID.ID, tied) + if err := repo.CreateRoutineTrigger(context.Background(), &RoutineTrigger{ + ID: "t-higher", RoutineID: higherID.ID, Kind: "cron", + CronExpression: "0 * * * *", Timezone: "UTC", Enabled: true, + }); err != nil { + t.Fatalf("seed higher-id trigger: %v", err) + } + + svc := newCoordinatorInstallService(t, repo) + + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if routine.ID != lowerID.ID { + t.Errorf("selected routine id = %q, want the lower id %q for the created_at tie", routine.ID, lowerID.ID) + } + + lowerTriggers, err := repo.ListTriggersByRoutineID(context.Background(), lowerID.ID) + if err != nil { + t.Fatalf("list lower-id triggers: %v", err) + } + if len(lowerTriggers) != 1 { + t.Fatalf("lower-id trigger count = %d, want 1 (completed)", len(lowerTriggers)) + } + + higherTriggers, err := repo.ListTriggersByRoutineID(context.Background(), higherID.ID) + if err != nil { + t.Fatalf("list higher-id triggers: %v", err) + } + if len(higherTriggers) != 1 || higherTriggers[0].ID != "t-higher" { + t.Errorf("non-selected (higher id) routine's trigger was touched: %+v", higherTriggers) + } + + higherRoutine, err := repo.GetRoutine(context.Background(), higherID.ID) + if err != nil { + t.Fatalf("get higher-id routine: %v", err) + } + if higherRoutine.Status != "active" { + t.Errorf("non-selected (higher id) routine status changed to %q", higherRoutine.Status) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.6: an existing match's status is never +// changed by either the .4 (repair) or .5 (unchanged) branch, even when +// the routine's status is not "active". +func TestCreateDefaultCoordinatorRoutine_NeverChangesStatus(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + paused := &Routine{ + ID: "r-paused", WorkspaceID: "ws-1", Name: CoordinatorRoutineName, TaskTemplate: "", + AssigneeAgentProfileID: "agent-1", Status: "paused", ConcurrencyPolicy: "coalesce_if_active", Variables: "{}", + } + if err := repo.CreateRoutine(context.Background(), paused); err != nil { + t.Fatalf("seed routine: %v", err) + } + + svc := newCoordinatorInstallService(t, repo) + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if routine.Status != "paused" { + t.Errorf("status changed to %q on repair branch, want paused", routine.Status) + } + + // Second call takes the .5 unchanged branch (a cron trigger now exists). + routine, err = svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err != nil { + t.Fatalf("unexpected error on second call: %v", err) + } + if routine.Status != "paused" { + t.Errorf("status changed to %q on unchanged branch, want paused", routine.Status) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.7: an identity-lookup failure rejects +// the call, creates nothing, and is reported distinctly (lookup_failed), +// with decide never invoked (also demonstrating AC-001.15's no-retry rule: +// exactly one attempt). +func TestCreateDefaultCoordinatorRoutine_IdentityLookupFailureRejectsAndCreatesNothing(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + failing := &coordinatorInstallRepoFailures{Repository: repo, failLookup: errors.New("boom")} + svc := newCoordinatorInstallService(t, failing) + before := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionLookupFailed, "workspace", "ws-1", "assignee", "agent-1"))) + + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err == nil { + t.Fatal("expected error") + } + if routine != nil { + t.Errorf("expected nil routine, got %+v", routine) + } + if failing.installDecideCalls != 0 { + t.Errorf("install decide called %d times, want 0", failing.installDecideCalls) + } + after := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionLookupFailed, "workspace", "ws-1", "assignee", "agent-1"))) + if after != before+1 { + t.Errorf("lookup_failed counter = %d, want %d", after, before+1) + } + + routines, err := repo.ListRoutines(context.Background(), "ws-1") + if err != nil { + t.Fatalf("list routines: %v", err) + } + if len(routines) != 0 { + t.Errorf("routines created = %d, want 0", len(routines)) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.7: a trigger-read failure on a matched +// routine rejects the call and creates nothing, reported distinctly +// (trigger_read_failed) from an identity-lookup failure. +func TestCreateDefaultCoordinatorRoutine_TriggerReadFailureRejectsAndCreatesNothing(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + existing := &Routine{ + ID: "r-existing", WorkspaceID: "ws-1", Name: CoordinatorRoutineName, TaskTemplate: "", + AssigneeAgentProfileID: "agent-1", Status: "active", ConcurrencyPolicy: "coalesce_if_active", Variables: "{}", + } + if err := repo.CreateRoutine(context.Background(), existing); err != nil { + t.Fatalf("seed routine: %v", err) + } + + failing := &coordinatorInstallRepoFailures{Repository: repo, failTriggerRead: true} + svc := newCoordinatorInstallService(t, failing) + before := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionTriggerReadFailed, "workspace", "ws-1", "assignee", "agent-1"))) + + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err == nil { + t.Fatal("expected error") + } + if routine == nil || routine.ID != existing.ID { + t.Errorf("expected selected routine returned alongside error, got %+v", routine) + } + if failing.installDecideCalls != 1 { + t.Errorf("install decide called %d times, want 1", failing.installDecideCalls) + } + if failing.triggerDecideCalls != 0 { + t.Errorf("trigger decide called %d times, want 0", failing.triggerDecideCalls) + } + after := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionTriggerReadFailed, "workspace", "ws-1", "assignee", "agent-1"))) + if after != before+1 { + t.Errorf("trigger_read_failed counter = %d, want %d", after, before+1) + } + + triggers, err := repo.ListTriggersByRoutineID(context.Background(), existing.ID) + if err != nil { + t.Fatalf("list triggers: %v", err) + } + if len(triggers) != 0 { + t.Errorf("triggers created = %d, want 0", len(triggers)) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.12: a routine-insert failure creates no +// trigger. +func TestCreateDefaultCoordinatorRoutine_RoutineInsertFailureCreatesNoTrigger(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + failing := &coordinatorInstallRepoFailures{Repository: repo, failCreateRoutine: true} + svc := newCoordinatorInstallService(t, failing) + before := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionRoutineCreateFailed, "workspace", "ws-1", "assignee", "agent-1"))) + + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err == nil { + t.Fatal("expected error") + } + if routine != nil { + t.Errorf("expected nil routine, got %+v", routine) + } + after := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionRoutineCreateFailed, "workspace", "ws-1", "assignee", "agent-1"))) + if after != before+1 { + t.Errorf("routine_create_failed counter = %d, want %d", after, before+1) + } + + routines, err := repo.ListRoutines(context.Background(), "ws-1") + if err != nil { + t.Fatalf("list routines: %v", err) + } + if len(routines) != 0 { + t.Errorf("routines created = %d, want 0", len(routines)) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.12: a trigger-creation failure after a +// successful routine insert leaves the routine in place — a half-finished +// install a follow-up call then completes (AC-001.4 re-entrancy). +func TestCreateDefaultCoordinatorRoutine_TriggerCreateFailureLeavesRoutineInPlace(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + failing := &coordinatorInstallRepoFailures{Repository: repo, failCreateTrigger: true} + svc := newCoordinatorInstallService(t, failing) + + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err == nil { + t.Fatal("expected error") + } + if routine == nil { + t.Fatal("expected routine returned alongside error") + } + + routines, err := repo.ListRoutines(context.Background(), "ws-1") + if err != nil { + t.Fatalf("list routines: %v", err) + } + if len(routines) != 1 { + t.Fatalf("routines created = %d, want 1 (half-finished install preserved)", len(routines)) + } + triggers, err := repo.ListTriggersByRoutineID(context.Background(), routine.ID) + if err != nil { + t.Fatalf("list triggers: %v", err) + } + if len(triggers) != 0 { + t.Fatalf("triggers created = %d, want 0", len(triggers)) + } + + // A follow-up install (no failures injected) completes it. + plainSvc := newCoordinatorInstallService(t, repo) + completed, err := plainSvc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err != nil { + t.Fatalf("follow-up install unexpected error: %v", err) + } + if completed.ID != routine.ID { + t.Errorf("follow-up returned a different routine: %q vs %q", completed.ID, routine.ID) + } + triggers, err = repo.ListTriggersByRoutineID(context.Background(), routine.ID) + if err != nil { + t.Fatalf("list triggers after follow-up: %v", err) + } + if len(triggers) != 1 { + t.Fatalf("triggers after follow-up = %d, want 1", len(triggers)) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.13: contention (the lock's acquisition +// bound was reached) is reported distinctly from an identity-lookup +// failure and from context cancellation. +func TestCreateDefaultCoordinatorRoutine_ContentionReportedDistinctFromOtherFailures(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + failing := &coordinatorInstallRepoFailures{Repository: repo, failLookup: models.ErrCoordinatorInstallContention} + svc := newCoordinatorInstallService(t, failing) + + beforeContention := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionContention, "workspace", "ws-1", "assignee", "agent-1"))) + beforeLookup := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionLookupFailed, "workspace", "ws-1", "assignee", "agent-1"))) + + _, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if !errors.Is(err, models.ErrCoordinatorInstallContention) { + t.Fatalf("expected contention error, got %v", err) + } + + afterContention := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionContention, "workspace", "ws-1", "assignee", "agent-1"))) + afterLookup := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionLookupFailed, "workspace", "ws-1", "assignee", "agent-1"))) + if afterContention != beforeContention+1 { + t.Errorf("contention counter = %d, want %d", afterContention, beforeContention+1) + } + if afterLookup != beforeLookup { + t.Errorf("lookup_failed counter changed to %d, want unchanged %d", afterLookup, beforeLookup) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.13: the caller's own context ending +// while waiting is reported distinctly from both contention and a plain +// lookup failure. +func TestCreateDefaultCoordinatorRoutine_ContextCancelledReportedDistinctFromContention(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + failing := &coordinatorInstallRepoFailures{Repository: repo, failLookup: context.Canceled} + svc := newCoordinatorInstallService(t, failing) + + beforeCancelled := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionCancelled, "workspace", "ws-1", "assignee", "agent-1"))) + beforeContention := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionContention, "workspace", "ws-1", "assignee", "agent-1"))) + + _, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected context.Canceled, got %v", err) + } + + afterCancelled := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionCancelled, "workspace", "ws-1", "assignee", "agent-1"))) + afterContention := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionContention, "workspace", "ws-1", "assignee", "agent-1"))) + if afterCancelled != beforeCancelled+1 { + t.Errorf("cancelled counter = %d, want %d", afterCancelled, beforeCancelled+1) + } + if afterContention != beforeContention { + t.Errorf("contention counter changed to %d, want unchanged %d", afterContention, beforeContention) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.13: decision.errored must gate before +// the Contention case in reportCoordinatorLockFailure. decide's own +// CreateRoutine failure is reported once, at its own failure site, as +// routine_create_failed; classifyCoordinatorInstallWaitErr's busy-text +// heuristic reclassifies the returned error as ErrCoordinatorInstallContention +// purely because the error text happens to contain "database is locked" — +// but since decide already reported, that must not also increment the +// contention counter a second time. +func TestCreateDefaultCoordinatorRoutine_DecideOwnErrorMatchingBusyTextNotDoubleReported(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + failing := &coordinatorInstallRepoFailures{ + Repository: repo, + createRoutineErr: errors.New("routine insert failed: database is locked"), + } + svc := newCoordinatorInstallService(t, failing) + + beforeContention := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionContention, "workspace", "ws-1", "assignee", "agent-1"))) + beforeRoutineCreateFailed := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionRoutineCreateFailed, "workspace", "ws-1", "assignee", "agent-1"))) + + _, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err == nil { + t.Fatal("expected error") + } + + afterContention := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionContention, "workspace", "ws-1", "assignee", "agent-1"))) + afterRoutineCreateFailed := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionRoutineCreateFailed, "workspace", "ws-1", "assignee", "agent-1"))) + if afterContention != beforeContention { + t.Errorf("contention counter = %d, want unchanged at %d (decide already reported its own failure; no double report)", afterContention, beforeContention) + } + if afterRoutineCreateFailed != beforeRoutineCreateFailed+1 { + t.Errorf("routine_create_failed counter = %d, want %d", afterRoutineCreateFailed, beforeRoutineCreateFailed+1) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.12/.13: a failure surfacing after decide +// itself already ran and returned nil (the enclosing transaction failing to +// commit) is reported as commit_failed, not silently dropped and not folded +// into contention or cancelled. failCommitAfterDecide lets the real decide +// callback run to completion (a real commit happens underneath), then +// substitutes a synthetic, unclassified error for InstallCoordinatorRoutine's +// return value — reproducing the exact decision shape +// (ran=true, errored=false, created=true) a genuine post-decide commit +// failure leaves behind, which is what reportCoordinatorLockFailure's +// default case must classify. It does not exercise the real +// WithCoordinatorInstallLock rollback path; that is covered separately at +// the repository layer (coordinator_install_test.go's +// TestWithCoordinatorInstallLock_FnContentionIsClassified and friends). +func TestCreateDefaultCoordinatorRoutine_CommitFailureAfterDecideSucceedsReportsCommitFailed(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + failing := &coordinatorInstallRepoFailures{ + Repository: repo, + failCommitAfterDecide: errors.New("forced commit failure: disk I/O error"), + } + svc := newCoordinatorInstallService(t, failing) + + beforeCommitFailed := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionCommitFailed, "workspace", "ws-1", "assignee", "agent-1"))) + beforeContention := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionContention, "workspace", "ws-1", "assignee", "agent-1"))) + beforeCreated := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionRoutineCreated, "workspace", "ws-1", "assignee", "agent-1"))) + + _, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + if err == nil { + t.Fatal("expected error") + } + if errors.Is(err, models.ErrCoordinatorInstallContention) { + t.Fatalf("a plain synthetic commit error must not be misclassified as contention, got %v", err) + } + + afterCommitFailed := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionCommitFailed, "workspace", "ws-1", "assignee", "agent-1"))) + afterContention := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionContention, "workspace", "ws-1", "assignee", "agent-1"))) + afterCreated := counterValue(coordinatorInstallConditionsTotal.Get( + coordinatorInstallLabel("condition", coordinatorInstallConditionRoutineCreated, "workspace", "ws-1", "assignee", "agent-1"))) + if afterCommitFailed != beforeCommitFailed+1 { + t.Errorf("commit_failed counter = %d, want %d", afterCommitFailed, beforeCommitFailed+1) + } + if afterContention != beforeContention { + t.Errorf("contention counter changed to %d, want unchanged %d", afterContention, beforeContention) + } + if afterCreated != beforeCreated { + t.Errorf("routine_created counter changed to %d, want unchanged %d (err != nil, so the created report must not fire)", afterCreated, beforeCreated) + } +} + +// AC-OFFICE-COORDINATOR-INSTALL-001.8/.9/.11: two concurrent installs for +// the same identity, sharing one writer connection (the real deployment +// shape: one Kandev backend process, one SQLite writer pool of size 1 — +// see internal/db.OpenSQLite), create at most one routine and at most one +// canonical trigger between them. Go's database/sql pool hands out that +// one physical connection to only one BeginTxx caller at a time, which is +// exactly the mechanism WithCoordinatorInstallLock's SQLite doc comment +// describes. +func TestCreateDefaultCoordinatorRoutine_ConcurrentInstallsCreateAtMostOne(t *testing.T) { + repo := newCoordinatorInstallRepo(t) + svc := newCoordinatorInstallService(t, repo) + + type result struct { + routine *Routine + err error + } + resultsCh := make(chan result, 2) + run := func() { + routine, err := svc.CreateDefaultCoordinatorRoutine(context.Background(), "ws-1", "agent-1") + resultsCh <- result{routine, err} + } + go run() + go run() + + var results [2]result + for i := range results { + results[i] = <-resultsCh + } + for i, res := range results { + if res.err != nil { + t.Fatalf("call %d unexpected error: %v", i, res.err) + } + } + if results[0].routine.ID != results[1].routine.ID { + t.Errorf("concurrent installs returned different routines: %q vs %q", + results[0].routine.ID, results[1].routine.ID) + } + + routines, err := repo.ListRoutines(context.Background(), "ws-1") + if err != nil { + t.Fatalf("list routines: %v", err) + } + if len(routines) != 1 { + t.Fatalf("routines created = %d, want 1", len(routines)) + } + triggers, err := repo.ListTriggersByRoutineID(context.Background(), routines[0].ID) + if err != nil { + t.Fatalf("list triggers: %v", err) + } + if len(triggers) != 1 { + t.Fatalf("triggers created = %d, want 1", len(triggers)) + } +} diff --git a/apps/backend/internal/office/routines/dto.go b/apps/backend/internal/office/routines/dto.go index 16bc84ec50..25f4b8affe 100644 --- a/apps/backend/internal/office/routines/dto.go +++ b/apps/backend/internal/office/routines/dto.go @@ -30,14 +30,23 @@ type RunRoutineRequest struct { Variables map[string]string `json:"variables"` } +// RoutineWithSchedule pairs a routine's intent (Status, unchanged) with +// Task 01's schedule-state classification, so a caller never has to +// correlate two separate reads to see both (AC-OFFICE-ROUTINE-ARMING-002.1). +type RoutineWithSchedule struct { + *Routine + ScheduleState ScheduleState `json:"schedule_state"` + UnarmedCronTriggers []UnarmedCronTrigger `json:"unarmed_cron_triggers"` +} + // RoutineResponse wraps a single routine. type RoutineResponse struct { - Routine *Routine `json:"routine"` + Routine *RoutineWithSchedule `json:"routine"` } // RoutineListResponse wraps a list of routines. type RoutineListResponse struct { - Routines []*Routine `json:"routines"` + Routines []*RoutineWithSchedule `json:"routines"` } // CreateTriggerRequest is the request body for creating a routine trigger. diff --git a/apps/backend/internal/office/routines/handler.go b/apps/backend/internal/office/routines/handler.go index 1832bf5ede..19f52df8d8 100644 --- a/apps/backend/internal/office/routines/handler.go +++ b/apps/backend/internal/office/routines/handler.go @@ -81,12 +81,36 @@ func RegisterRoutes(api *gin.RouterGroup, h *Handler) { } func (h *Handler) listRoutines(c *gin.Context) { - routines, err := h.svc.ListRoutinesFromConfig(c.Request.Context(), c.Param("wsId")) + ctx := c.Request.Context() + routines, err := h.svc.ListRoutinesFromConfig(ctx, c.Param("wsId")) if err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + respondInternalError(c, err) return } - c.JSON(http.StatusOK, RoutineListResponse{Routines: routines}) + withSchedule, err := h.svc.AttachScheduleState(ctx, routines) + if err != nil { + respondInternalError(c, err) + return + } + c.JSON(http.StatusOK, RoutineListResponse{Routines: withSchedule}) +} + +// withScheduleState classifies a single routine's schedule state, for the +// single-routine response shapes (create/get/update). +func (h *Handler) withScheduleState(c *gin.Context, routine *Routine) (*RoutineWithSchedule, error) { + withSchedule, err := h.svc.AttachScheduleState(c.Request.Context(), []*Routine{routine}) + if err != nil { + return nil, err + } + return withSchedule[0], nil +} + +// jsonErrorKey is the JSON body key used for error responses in this package. +const jsonErrorKey = "error" + +// respondInternalError writes a 500 response carrying err's message. +func respondInternalError(c *gin.Context, err error) { + c.JSON(http.StatusInternalServerError, gin.H{jsonErrorKey: err.Error()}) } func (h *Handler) createRoutine(c *gin.Context) { @@ -127,10 +151,15 @@ func (h *Handler) createRoutine(c *gin.Context) { Variables: req.Variables, } if err := h.svc.CreateRoutine(c.Request.Context(), routine); err != nil { - c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + respondInternalError(c, err) return } - c.JSON(http.StatusCreated, RoutineResponse{Routine: routine}) + withSchedule, err := h.withScheduleState(c, routine) + if err != nil { + respondInternalError(c, err) + return + } + c.JSON(http.StatusCreated, RoutineResponse{Routine: withSchedule}) } func (h *Handler) getRoutine(c *gin.Context) { @@ -139,7 +168,12 @@ func (h *Handler) getRoutine(c *gin.Context) { c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, RoutineResponse{Routine: routine}) + withSchedule, err := h.withScheduleState(c, routine) + if err != nil { + respondInternalError(c, err) + return + } + c.JSON(http.StatusOK, RoutineResponse{Routine: withSchedule}) } func (h *Handler) updateRoutine(c *gin.Context) { @@ -148,7 +182,12 @@ func (h *Handler) updateRoutine(c *gin.Context) { c.JSON(statusCode, gin.H{"error": err.Error()}) return } - c.JSON(http.StatusOK, RoutineResponse{Routine: routine}) + withSchedule, err := h.withScheduleState(c, routine) + if err != nil { + respondInternalError(c, err) + return + } + c.JSON(http.StatusOK, RoutineResponse{Routine: withSchedule}) } func (h *Handler) doUpdateRoutine(c *gin.Context) (*Routine, int, error) { diff --git a/apps/backend/internal/office/routines/schedule_response_test.go b/apps/backend/internal/office/routines/schedule_response_test.go new file mode 100644 index 0000000000..962ef9f0a6 --- /dev/null +++ b/apps/backend/internal/office/routines/schedule_response_test.go @@ -0,0 +1,261 @@ +package routines_test + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/office/models" + "github.com/kandev/kandev/internal/office/repository/sqlite" + "github.com/kandev/kandev/internal/office/routines" +) + +// countingRepo wraps a real repository, counting calls to the two trigger +// read paths ClassifyRoutines uses, and can force either to fail so the +// AC-OFFICE-ROUTINE-ARMING-002.7 query-count bound and the AC-001.13 +// fallback path can both be exercised against a real database. +type countingRepo struct { + routines.Repository + batchCalls int + singleCalls int + forceBatchErr bool + forceSingleErrOn map[string]bool +} + +func (c *countingRepo) ListTriggersByRoutineIDs( + ctx context.Context, routineIDs []string, +) (map[string][]*models.RoutineTrigger, error) { + c.batchCalls++ + if c.forceBatchErr { + return nil, errors.New("forced batch failure") + } + return c.Repository.ListTriggersByRoutineIDs(ctx, routineIDs) +} + +func (c *countingRepo) ListTriggersByRoutineID(ctx context.Context, routineID string) ([]*models.RoutineTrigger, error) { + c.singleCalls++ + if c.forceSingleErrOn[routineID] { + return nil, errors.New("forced single-read failure") + } + return c.Repository.ListTriggersByRoutineID(ctx, routineID) +} + +func newCountingRoutineService(t *testing.T) (*routines.RoutineService, *countingRepo) { + t.Helper() + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + repo, err := sqlite.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + counting := &countingRepo{Repository: repo} + svc := routines.NewRoutineService(counting, logger.Default(), &noopActivity{}) + return svc, counting +} + +func mustCreateRoutine(t *testing.T, svc *routines.RoutineService, id, name string) *models.Routine { + t.Helper() + r := &models.Routine{ + ID: id, WorkspaceID: "ws-1", Name: name, TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + } + if err := svc.CreateRoutine(context.Background(), r); err != nil { + t.Fatalf("create routine %s: %v", id, err) + } + return r +} + +func mustCreateTrigger(t *testing.T, svc *routines.RoutineService, tr *models.RoutineTrigger) { + t.Helper() + if err := svc.CreateRoutineTrigger(context.Background(), tr); err != nil { + t.Fatalf("create trigger %s: %v", tr.ID, err) + } +} + +// TestAttachScheduleState_AllStatesFixture covers the routine list response +// carrying the right schedule state and unarmed list per routine +// (AC-OFFICE-ROUTINE-ARMING-002.1) across a fixture spanning several +// schedule states. +func TestAttachScheduleState_AllStatesFixture(t *testing.T) { + svc, counting := newCountingRoutineService(t) + ctx := context.Background() + + armed := mustCreateRoutine(t, svc, "r-armed", "Armed") + invalid := mustCreateRoutine(t, svc, "r-invalid", "Invalid") + disabled := mustCreateRoutine(t, svc, "r-disabled", "Disabled") + manualOnly := mustCreateRoutine(t, svc, "r-manual", "ManualOnly") + noTrigger := mustCreateRoutine(t, svc, "r-none", "NoTrigger") + + future := time.Now().UTC().Add(time.Hour) + mustCreateTrigger(t, svc, &models.RoutineTrigger{ + ID: "t-armed", RoutineID: armed.ID, Kind: "cron", + CronExpression: "*/5 * * * *", Timezone: "UTC", Enabled: true, NextRunAt: &future, + }) + // CreateRoutineTrigger validates the cron expression up front, so an + // already-invalid trigger (e.g. a timezone later removed from tzdata) is + // seeded straight through the repository instead. + if err := counting.CreateRoutineTrigger(ctx, &models.RoutineTrigger{ + ID: "t-invalid", RoutineID: invalid.ID, Kind: "cron", + CronExpression: "garbage", Timezone: "UTC", Enabled: true, + }); err != nil { + t.Fatalf("seed invalid trigger: %v", err) + } + mustCreateTrigger(t, svc, &models.RoutineTrigger{ + ID: "t-disabled", RoutineID: disabled.ID, Kind: "cron", + CronExpression: "*/5 * * * *", Timezone: "UTC", Enabled: false, + }) + mustCreateTrigger(t, svc, &models.RoutineTrigger{ + ID: "t-manual", RoutineID: manualOnly.ID, Kind: "manual", Enabled: true, + }) + + list, err := svc.ListRoutinesFromConfig(ctx, "ws-1") + if err != nil { + t.Fatalf("list routines: %v", err) + } + withSchedule, err := svc.AttachScheduleState(ctx, list) + if err != nil { + t.Fatalf("attach schedule state: %v", err) + } + + want := map[string]routines.ScheduleState{ + armed.ID: routines.ScheduleStateArmed, + invalid.ID: routines.ScheduleStateTriggerInvalid, + disabled.ID: routines.ScheduleStateTriggerDisabled, + manualOnly.ID: routines.ScheduleStateUnscheduledManualOnly, + noTrigger.ID: routines.ScheduleStateUnscheduledNoTrigger, + } + if len(withSchedule) != len(want) { + t.Fatalf("result count = %d, want %d", len(withSchedule), len(want)) + } + for _, rw := range withSchedule { + wantState, ok := want[rw.ID] + if !ok { + t.Fatalf("unexpected routine %s in result", rw.ID) + } + if rw.ScheduleState != wantState { + t.Errorf("routine %s: state = %q, want %q", rw.ID, rw.ScheduleState, wantState) + } + if rw.Status != "active" { + t.Errorf("routine %s: intent (Status) = %q, want active", rw.ID, rw.Status) + } + } + + disabledResult := findByID(t, withSchedule, disabled.ID) + if len(disabledResult.UnarmedCronTriggers) != 1 || disabledResult.UnarmedCronTriggers[0].TriggerID != "t-disabled" { + t.Errorf("disabled routine unarmed list = %+v, want exactly t-disabled", disabledResult.UnarmedCronTriggers) + } +} + +func findByID(t *testing.T, list []*routines.RoutineWithSchedule, id string) *routines.RoutineWithSchedule { + t.Helper() + for _, rw := range list { + if rw.ID == id { + return rw + } + } + t.Fatalf("routine %s not found in result", id) + return nil +} + +// TestAttachScheduleState_QueryCountBound covers +// AC-OFFICE-ROUTINE-ARMING-002.7: producing the list costs one query for +// routines (asserted by the caller, not here) plus exactly one batch query +// for triggers on the all-succeeds path, regardless of routine count. +func TestAttachScheduleState_QueryCountBound(t *testing.T) { + svc, counting := newCountingRoutineService(t) + ctx := context.Background() + + for i := 0; i < 5; i++ { + mustCreateRoutine(t, svc, "r-"+string(rune('a'+i)), "Routine") + } + + list, err := svc.ListRoutinesFromConfig(ctx, "ws-1") + if err != nil { + t.Fatalf("list routines: %v", err) + } + if _, err := svc.AttachScheduleState(ctx, list); err != nil { + t.Fatalf("attach schedule state: %v", err) + } + + if counting.batchCalls != 1 { + t.Errorf("batch calls = %d, want 1", counting.batchCalls) + } + if counting.singleCalls != 0 { + t.Errorf("single calls = %d, want 0 on the all-succeeds path", counting.singleCalls) + } +} + +// TestAttachScheduleState_BatchFailureFallsBackPerRoutine covers the +// AC-OFFICE-ROUTINE-ARMING-002.7 documented exception: a batch failure +// re-reads every named routine individually, once each, rather than +// silently growing beyond the bounded path. +func TestAttachScheduleState_BatchFailureFallsBackPerRoutine(t *testing.T) { + svc, counting := newCountingRoutineService(t) + ctx := context.Background() + + for i := 0; i < 3; i++ { + mustCreateRoutine(t, svc, "r-"+string(rune('a'+i)), "Routine") + } + list, err := svc.ListRoutinesFromConfig(ctx, "ws-1") + if err != nil { + t.Fatalf("list routines: %v", err) + } + + counting.forceBatchErr = true + if _, err := svc.AttachScheduleState(ctx, list); err != nil { + t.Fatalf("attach schedule state: %v", err) + } + + if counting.batchCalls != 1 { + t.Errorf("batch calls = %d, want 1", counting.batchCalls) + } + if counting.singleCalls != len(list) { + t.Errorf("single calls = %d, want %d (once per routine)", counting.singleCalls, len(list)) + } +} + +// TestAttachScheduleState_TriggerReadFailureKeepsIntent covers +// AC-OFFICE-ROUTINE-ARMING-002.12: a routine whose triggers could not be +// read still carries its real intent (Status) alongside schedule state +// unknown, rather than being dropped or blanked. +func TestAttachScheduleState_TriggerReadFailureKeepsIntent(t *testing.T) { + svc, counting := newCountingRoutineService(t) + ctx := context.Background() + + ok := mustCreateRoutine(t, svc, "r-ok", "OK") + bad := mustCreateRoutine(t, svc, "r-bad", "Bad") + + list, err := svc.ListRoutinesFromConfig(ctx, "ws-1") + if err != nil { + t.Fatalf("list routines: %v", err) + } + + counting.forceBatchErr = true + counting.forceSingleErrOn = map[string]bool{bad.ID: true} + + withSchedule, err := svc.AttachScheduleState(ctx, list) + if err != nil { + t.Fatalf("attach schedule state: %v", err) + } + + okResult := findByID(t, withSchedule, ok.ID) + if okResult.ScheduleState != routines.ScheduleStateUnscheduledNoTrigger { + t.Errorf("ok routine state = %q, want unscheduled_no_trigger", okResult.ScheduleState) + } + + badResult := findByID(t, withSchedule, bad.ID) + if badResult.ScheduleState != routines.ScheduleStateUnknown { + t.Errorf("bad routine state = %q, want unknown", badResult.ScheduleState) + } + if badResult.Status != "active" { + t.Errorf("bad routine intent (Status) = %q, want active (preserved despite trigger read failure)", badResult.Status) + } +} diff --git a/apps/backend/internal/office/routines/service.go b/apps/backend/internal/office/routines/service.go index 5f95a92ac0..b3b303c372 100644 --- a/apps/backend/internal/office/routines/service.go +++ b/apps/backend/internal/office/routines/service.go @@ -50,6 +50,7 @@ type Repository interface { CreateRoutineTrigger(ctx context.Context, t *RoutineTrigger) error ListTriggersByRoutineID(ctx context.Context, routineID string) ([]*RoutineTrigger, error) + ListTriggersByRoutineIDs(ctx context.Context, routineIDs []string) (map[string][]*RoutineTrigger, error) GetTriggerByPublicID(ctx context.Context, publicID string) (*RoutineTrigger, error) GetDueTriggers(ctx context.Context, now time.Time) ([]*RoutineTrigger, error) ClaimTrigger(ctx context.Context, triggerID string, oldNextRunAt time.Time) (bool, error) @@ -84,6 +85,40 @@ type Repository interface { // pause as a skipped run, keyed on (routine_id, pause_id) so a second // blocked fire under the same pause writes no duplicate row. CreatePauseSkippedRoutineRun(ctx context.Context, routineID, triggerID, source, pauseID string) (bool, error) + + // InstallCoordinatorRoutine looks up every existing routine at the + // given (workspace, assignee, canonical name) identity and hands them + // to decide, all inside one transaction serialized — across every + // process sharing the database, not merely other goroutines in this + // one — against every other install for the same identity, bounded to + // a hard, non-configurable acquisition timeout + // (AC-OFFICE-COORDINATOR-INSTALL-001.8/.9). matches is supplied in + // office_routines.created_at, id order. decide may use the given + // tx-scoped writer to create the routine; any error it returns aborts + // the transaction unchanged. Deliberately does not also create the + // canonical trigger — see EnsureCoordinatorTrigger. Returns + // models.ErrCoordinatorInstallContention when the acquisition bound is + // reached, or ctx.Err() when the caller's own context ended first + // (AC-OFFICE-COORDINATOR-INSTALL-001.13). + InstallCoordinatorRoutine( + ctx context.Context, + workspaceID, agentID, canonicalName string, + decide func(ctx context.Context, matches []*Routine, tx models.CoordinatorInstallTx) error, + ) error + + // EnsureCoordinatorTrigger reads routine's existing triggers and hands + // them to decide, inside its own transaction serialized against every + // other call for the same routine id, separately from + // InstallCoordinatorRoutine's transaction so a trigger-creation failure + // can never roll back an already-committed routine + // (AC-OFFICE-COORDINATOR-INSTALL-001.4/.8/.9/.12). decide may use the + // given tx-scoped writer to create the canonical trigger; any error it + // returns aborts this transaction only. + EnsureCoordinatorTrigger( + ctx context.Context, + routineID string, + decide func(ctx context.Context, triggers []*RoutineTrigger, tx models.CoordinatorInstallTx) error, + ) error } // WakeupEnqueuer is the slim surface routines need to enqueue + dispatch @@ -198,18 +233,148 @@ const CoordinatorRoutineCron = "*/5 * * * *" // with a max of 25, status=active, with a single cron trigger firing // every five minutes (UTC). // -// Idempotent: if the assignee agent already has a routine with the -// canonical name + cron expression the existing routine is returned -// instead of inserting a duplicate. +// Identity is the (workspace, assignee, canonical name) triple only — never +// a trigger's enabled value, next_run_at, cron expression, or presence +// (AC-OFFICE-COORDINATOR-INSTALL-001.1), so a routine whose trigger +// creation previously failed, or whose trigger was later edited, still +// matches itself instead of being duplicated. +// +// Runs in two separately-locked, separately-committed phases: ensuring the +// routine exists (AC-OFFICE-COORDINATOR-INSTALL-001.2/.3/.12's routine +// half) and, only once that has committed, ensuring its canonical trigger +// exists (AC-OFFICE-COORDINATOR-INSTALL-001.4/.5/.12's trigger half). Kept +// separate so a trigger-creation failure can never roll back an +// already-committed routine. func (s *RoutineService) CreateDefaultCoordinatorRoutine( ctx context.Context, workspaceID, agentID string, ) (*Routine, error) { if workspaceID == "" || agentID == "" { + coordinatorInstallObserved(coordinatorInstallConditionEmptyIdentity, workspaceID, agentID) + s.logger.Warn("coordinator install rejected: empty identity", + zap.String("workspace_id", workspaceID), zap.String("agent_id", agentID)) return nil, fmt.Errorf("create default coordinator routine: workspace_id and agent_id are required") } - if existing, ok := s.findCoordinatorRoutine(ctx, workspaceID, agentID); ok { - return existing, nil + + now := time.Now().UTC() + + routine, decision, err := s.ensureCoordinatorRoutine(ctx, workspaceID, agentID) + if err != nil { + return routine, s.reportCoordinatorLockFailure(workspaceID, agentID, decision, err, + coordinatorInstallConditionLookupFailed, "coordinator install: identity lookup failed") } + if decision.created { + coordinatorInstallObserved(coordinatorInstallConditionRoutineCreated, workspaceID, agentID) + s.logger.Info("coordinator-heartbeat routine created", + zap.String("workspace_id", workspaceID), + zap.String("agent_id", agentID), + zap.String("routine_id", routine.ID)) + } + + var triggerDecision coordinatorInstallDecision + triggerErr := s.repo.EnsureCoordinatorTrigger(ctx, routine.ID, + func(ctx context.Context, triggers []*RoutineTrigger, tx models.CoordinatorInstallTx) error { + triggerDecision.ran = true + created, derr := s.decideCoordinatorTrigger(ctx, workspaceID, agentID, routine, triggers, tx, now) + triggerDecision.created = created + triggerDecision.errored = derr != nil + return derr + }) + if triggerErr != nil { + return routine, s.reportCoordinatorLockFailure(workspaceID, agentID, triggerDecision, triggerErr, + coordinatorInstallConditionTriggerReadFailed, "coordinator install: read existing routine triggers failed") + } + if triggerDecision.created { + coordinatorInstallObserved(coordinatorInstallConditionTriggerCompleted, workspaceID, agentID) + s.logger.Info("coordinator install: canonical trigger created", + zap.String("workspace_id", workspaceID), + zap.String("agent_id", agentID), + zap.String("routine_id", routine.ID)) + } + return routine, nil +} + +// coordinatorInstallDecision captures what decide did inside one serialized +// section, independent of whether that section's transaction goes on to +// commit. Reused for both the routine phase (created means a new routine +// row) and the trigger phase (created means a new canonical trigger row), +// which share the same shape. +type coordinatorInstallDecision struct { + ran bool + errored bool + created bool +} + +// ensureCoordinatorRoutine implements +// AC-OFFICE-COORDINATOR-INSTALL-001.2/.3/.12's routine half inside +// InstallCoordinatorRoutine's cross-process lock: no match creates a +// routine; one or more matches selects the earliest (already guaranteed by +// the repository's created_at, id ordering), reporting the duplicate +// before continuing exactly as a single match would. decision.ran is false +// only when InstallCoordinatorRoutine's own identity lookup failed before +// this ever ran; decision.created is only ever true once InstallCoordinatorRoutine +// has returned nil, i.e. once the routine it names has actually committed. +func (s *RoutineService) ensureCoordinatorRoutine( + ctx context.Context, workspaceID, agentID string, +) (*Routine, coordinatorInstallDecision, error) { + var result *Routine + var decision coordinatorInstallDecision + err := s.repo.InstallCoordinatorRoutine(ctx, workspaceID, agentID, CoordinatorRoutineName, + func(ctx context.Context, matches []*Routine, tx models.CoordinatorInstallTx) error { + decision.ran = true + routine, created, err := s.selectOrCreateCoordinatorRoutine(ctx, workspaceID, agentID, matches, tx) + result = routine + decision.created = created + decision.errored = err != nil + return err + }) + return result, decision, err +} + +// selectOrCreateCoordinatorRoutine picks the earliest matched routine, or +// creates one when none matched (AC-OFFICE-COORDINATOR-INSTALL-001.2/.3/.12). +// The returned bool reports whether a new routine was created; the caller +// reports that fact only once the enclosing transaction has committed. +func (s *RoutineService) selectOrCreateCoordinatorRoutine( + ctx context.Context, workspaceID, agentID string, matches []*Routine, tx models.CoordinatorInstallTx, +) (*Routine, bool, error) { + if len(matches) == 0 { + routine, err := s.createCoordinatorRoutine(ctx, workspaceID, agentID, tx) + return routine, err == nil, err + } + selected := matches[0] + if len(matches) > 1 { + coordinatorInstallObserved(coordinatorInstallConditionDuplicateMatches, workspaceID, agentID) + s.logger.Warn("coordinator install: more than one routine matched identity", + zap.String("workspace_id", workspaceID), + zap.String("agent_id", agentID), + zap.String("routine_id", selected.ID), + zap.Int("match_count", len(matches))) + } + return selected, false, nil +} + +// hasAnyCronTrigger reports whether the routine owns a cron trigger of any +// kind — enabled or not, canonical expression or not. Identity depends on +// this fact alone, never on the trigger's own mutable state +// (AC-OFFICE-COORDINATOR-INSTALL-001.1/.5). +func hasAnyCronTrigger(triggers []*RoutineTrigger) bool { + for _, t := range triggers { + if t.Kind == triggerKindCron { + return true + } + } + return false +} + +// createCoordinatorRoutine implements +// AC-OFFICE-COORDINATOR-INSTALL-001.12's routine half: no routine matched +// the identity, so create one. A routine-insert failure creates nothing; +// its canonical trigger is created separately by decideCoordinatorTrigger +// once this commits. The success report belongs to the caller, once the +// enclosing transaction has actually committed. +func (s *RoutineService) createCoordinatorRoutine( + ctx context.Context, workspaceID, agentID string, tx models.CoordinatorInstallTx, +) (*Routine, error) { routine := &Routine{ ID: uuid.New().String(), WorkspaceID: workspaceID, @@ -223,74 +388,125 @@ func (s *RoutineService) CreateDefaultCoordinatorRoutine( CatchUpMax: 25, Variables: "{}", } - if err := s.repo.CreateRoutine(ctx, routine); err != nil { + if err := tx.CreateRoutine(ctx, routine); err != nil { + coordinatorInstallObserved(coordinatorInstallConditionRoutineCreateFailed, workspaceID, agentID) + s.logger.Warn("coordinator install: create routine failed", + zap.String("workspace_id", workspaceID), zap.String("agent_id", agentID), zap.Error(err)) return nil, fmt.Errorf("create coordinator routine: %w", err) } + return routine, nil +} + +// decideCoordinatorTrigger implements +// AC-OFFICE-COORDINATOR-INSTALL-001.4/.5/.12's trigger half inside +// EnsureCoordinatorTrigger's cross-process lock: a routine with any cron +// trigger (canonical or not, enabled or disabled) is left untouched and its +// schedule state reported; a routine with no cron trigger at all — whether +// freshly created or a previous install's half-finished routine — gets the +// canonical trigger created. The returned bool reports whether a trigger was +// newly created; the caller reports that fact only once the enclosing +// transaction has committed. +func (s *RoutineService) decideCoordinatorTrigger( + ctx context.Context, workspaceID, agentID string, routine *Routine, + triggers []*RoutineTrigger, tx models.CoordinatorInstallTx, now time.Time, +) (bool, error) { + if hasAnyCronTrigger(triggers) { + state, _ := ClassifyRoutine(triggers, now) + coordinatorInstallObserved(coordinatorInstallConditionScheduleState, workspaceID, agentID) + s.logger.Info("coordinator install: routine already scheduled", + zap.String("workspace_id", workspaceID), + zap.String("agent_id", agentID), + zap.String("routine_id", routine.ID), + zap.String("schedule_state", string(state))) + return false, nil + } + if err := s.createCanonicalTrigger(ctx, workspaceID, agentID, routine, tx, now); err != nil { + return false, err + } + return true, nil +} + +// createCanonicalTrigger inserts the canonical cron trigger for routine: +// enabled, the canonical expression and timezone, and a next_run_at equal +// to the first canonical occurrence strictly after now — the single instant +// CreateDefaultCoordinatorRoutine captured once at the start of the install +// (AC-OFFICE-COORDINATOR-INSTALL-001.4). Shared by the .4 (repair) and .12 +// (fresh install) branches, which both require the identical trigger shape. +func (s *RoutineService) createCanonicalTrigger( + ctx context.Context, workspaceID, agentID string, routine *Routine, tx models.CoordinatorInstallTx, now time.Time, +) error { + next, err := shared.NextCronTime(CoordinatorRoutineCron, "UTC", now) + if err != nil { + coordinatorInstallObserved(coordinatorInstallConditionNextOccurrenceFailed, workspaceID, agentID) + s.logger.Warn("coordinator install: compute canonical trigger occurrence failed", + zap.String("workspace_id", workspaceID), + zap.String("agent_id", agentID), + zap.String("routine_id", routine.ID), + zap.Error(err)) + return fmt.Errorf("compute canonical trigger next occurrence: %w", err) + } trigger := &RoutineTrigger{ ID: uuid.New().String(), RoutineID: routine.ID, - Kind: "cron", + Kind: triggerKindCron, CronExpression: CoordinatorRoutineCron, Timezone: "UTC", Enabled: true, + NextRunAt: &next, } - if err := s.CreateRoutineTrigger(ctx, trigger); err != nil { - // The routine row is in place — leave it; the user can wire a - // trigger from the UI. Returning the routine + the error lets - // callers log a warn without losing the install. - return routine, fmt.Errorf("create coordinator trigger: %w", err) + if err := tx.CreateRoutineTrigger(ctx, trigger); err != nil { + coordinatorInstallObserved(coordinatorInstallConditionTriggerCreateFailed, workspaceID, agentID) + s.logger.Warn("coordinator install: create canonical trigger failed", + zap.String("workspace_id", workspaceID), + zap.String("agent_id", agentID), + zap.String("routine_id", routine.ID), + zap.Error(err)) + return fmt.Errorf("create coordinator trigger: %w", err) } - s.logger.Info("coordinator-heartbeat routine installed", - zap.String("workspace_id", workspaceID), - zap.String("agent_id", agentID), - zap.String("routine_id", routine.ID), - zap.String("cron", CoordinatorRoutineCron)) - return routine, nil + return nil } -// findCoordinatorRoutine returns the existing coordinator-heartbeat -// routine for the (workspace, agent) pair when one already exists with -// the canonical name and a cron trigger matching CoordinatorRoutineCron. -// The boolean is the "found" flag — errors fall through as not-found -// so the caller proceeds to create a fresh row. -func (s *RoutineService) findCoordinatorRoutine( - ctx context.Context, workspaceID, agentID string, -) (*Routine, bool) { - routines, err := s.repo.ListRoutines(ctx, workspaceID) - if err != nil { - return nil, false +// reportCoordinatorLockFailure classifies the error one of the two +// cross-process locks (InstallCoordinatorRoutine or EnsureCoordinatorTrigger) +// returned and reports whichever condition decide had no chance to report +// itself: lock contention (AC-OFFICE-COORDINATOR-INSTALL-001.13), the +// caller's own context ending while waiting (also .13, distinguishable +// from contention), the read that precedes decide failing before decide +// ever ran (AC-OFFICE-COORDINATOR-INSTALL-001.7, via +// fallbackCondition/fallbackMsg), or the serialized transaction failing to +// commit after decide itself succeeded. decision.errored is checked first +// and gates every other case: when decide's own failure path already +// reported its specific condition at its own failure site, nothing here +// reports again, even though classifyCoordinatorInstallWaitErr's +// busy-text/deadline heuristic can independently match decide's own error +// text and would otherwise also match the contention case below. +func (s *RoutineService) reportCoordinatorLockFailure( + workspaceID, agentID string, decision coordinatorInstallDecision, err error, fallbackCondition, fallbackMsg string, +) error { + switch { + case decision.errored: + // decide already reported at its own failure site; nothing to add. + case errors.Is(err, models.ErrCoordinatorInstallContention): + coordinatorInstallObserved(coordinatorInstallConditionContention, workspaceID, agentID) + s.logger.Warn("coordinator install: lock contention", + zap.String("workspace_id", workspaceID), zap.String("agent_id", agentID)) + case errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded): + coordinatorInstallObserved(coordinatorInstallConditionCancelled, workspaceID, agentID) + s.logger.Warn("coordinator install: context cancelled while waiting", + zap.String("workspace_id", workspaceID), zap.String("agent_id", agentID)) + case !decision.ran: + coordinatorInstallObserved(fallbackCondition, workspaceID, agentID) + s.logger.Warn(fallbackMsg, + zap.String("workspace_id", workspaceID), zap.String("agent_id", agentID), zap.Error(err)) + default: + // decide ran and returned nil, so the commit that failed is the + // only thing that went wrong (AC-OFFICE-COORDINATOR-INSTALL-001.12's + // "insert fails" branch: the work never became durable). + coordinatorInstallObserved(coordinatorInstallConditionCommitFailed, workspaceID, agentID) + s.logger.Warn("coordinator install: commit failed after decide succeeded", + zap.String("workspace_id", workspaceID), zap.String("agent_id", agentID), zap.Error(err)) } - for _, r := range routines { - if r.AssigneeAgentProfileID != agentID { - continue - } - if r.Name != CoordinatorRoutineName { - continue - } - if s.hasCronTrigger(ctx, r.ID, CoordinatorRoutineCron) { - return r, true - } - } - return nil, false -} - -// hasCronTrigger returns true when the routine owns a cron trigger -// with the given expression. Triggers are listed via the routines repo; -// errors degrade to false so the caller treats the routine as missing -// its expected trigger and fixes it up via a fresh install. -func (s *RoutineService) hasCronTrigger( - ctx context.Context, routineID, cronExpr string, -) bool { - triggers, err := s.repo.ListTriggersByRoutineID(ctx, routineID) - if err != nil { - return false - } - for _, t := range triggers { - if t.Kind == "cron" && t.CronExpression == cronExpr { - return true - } - } - return false + return fmt.Errorf("create default coordinator routine: %w", err) } // -- Routine CRUD -- @@ -358,6 +574,29 @@ func (s *RoutineService) ListRoutinesFromConfig(ctx context.Context, workspaceID return s.repo.ListRoutines(ctx, workspaceID) } +// AttachScheduleState pairs each routine with Task 01's schedule-state +// classification, via one batch trigger read for the whole call so the +// query count does not grow with the number of routines +// (AC-OFFICE-ROUTINE-ARMING-002.7). A routine whose own trigger read fails +// still carries its intent alongside schedule state ScheduleStateUnknown +// (AC-OFFICE-ROUTINE-ARMING-002.12). +func (s *RoutineService) AttachScheduleState(ctx context.Context, list []*Routine) ([]*RoutineWithSchedule, error) { + ids := make([]string, len(list)) + for i, r := range list { + ids[i] = r.ID + } + classifications, err := ClassifyRoutines(ctx, s.repo, ids, time.Now().UTC()) + if err != nil { + return nil, err + } + out := make([]*RoutineWithSchedule, len(list)) + for i, r := range list { + c := classifications[r.ID] + out[i] = &RoutineWithSchedule{Routine: r, ScheduleState: c.State, UnarmedCronTriggers: c.Unarmed} + } + return out, nil +} + // -- Trigger management -- // CreateRoutineTrigger creates a trigger and computes next_run_at for cron. @@ -417,8 +656,7 @@ func (s *RoutineService) ListAllRoutineRuns(ctx context.Context, wsID string, li // pair, not in the time argument: re-arming to the original due time // instead would make the trigger due again on the very next 30-second // scheduler tick, retrying (and failing) forever with no backoff. 24 hours -// caps the trigger at one dispatch a day until it is deleted and -// recreated, matching shared.findNextMatch's own give-up interval. +// caps the trigger at one dispatch a day until it is deleted and recreated. const catchUpFallbackInterval = 24 * time.Hour // catchUpReclaimAfter is how stale a claimed-but-never-armed trigger's diff --git a/apps/backend/internal/office/routines/startup_scan.go b/apps/backend/internal/office/routines/startup_scan.go new file mode 100644 index 0000000000..60176a87e1 --- /dev/null +++ b/apps/backend/internal/office/routines/startup_scan.go @@ -0,0 +1,146 @@ +package routines + +import ( + "context" + "time" + + "github.com/kandev/kandev/internal/common/logger" + + "go.uber.org/zap" +) + +// ReconcileSignal proves Office startup reconciliation has returned. Only +// SignalReconcileComplete, called after infra.Reconciler.ReconcileAll +// returns, can construct a received signal — a zero-value ReconcileSignal +// (as an unauthorized caller, or a test proving the refusal path, would +// hold) carries no completion. RunStartupScan uses this instead of a timer +// or an inspection of trigger rows to order itself after reconciliation +// (AC-OFFICE-ROUTINE-ARMING-003.1). +type ReconcileSignal struct{ received bool } + +// SignalReconcileComplete constructs a received ReconcileSignal. Call this +// only after ReconcileAll has returned. +func SignalReconcileComplete() ReconcileSignal { + return ReconcileSignal{received: true} +} + +// StartupScanReader is the read surface RunStartupScan needs: workspace and +// routine enumeration, plus Task 01's trigger reads for classification. +type StartupScanReader interface { + RoutineTriggerReader + ListWorkspaceIDsOrdered(ctx context.Context) ([]string, error) + ListRoutinesOrdered(ctx context.Context, workspaceID string) ([]*Routine, error) +} + +// RunStartupScan performs one read-only pass over every enumerable routine +// in every enumerable workspace, classifying each via Task 01 and emitting a +// structured log record plus expvar counters per REQ-OFFICE-ROUTINE-ARMING-003. +// It writes nothing, returns nothing, and never blocks its caller past its +// own execution: no outcome here can fail or change Office startup +// (AC-OFFICE-ROUTINE-ARMING-003.10), which is why this is meant to be +// launched with `go` after the signal is available. +func RunStartupScan(ctx context.Context, signal ReconcileSignal, reader StartupScanReader, log *logger.Logger, now time.Time) { + if !signal.received { + armingScanSkipped(armingSkipNoSignal) + log.Warn("office routine arming scan: no reconciliation completion signal") + return + } + + workspaceIDs, err := reader.ListWorkspaceIDsOrdered(ctx) + if err != nil { + armingScanSkipped(armingSkipWorkspaceEnumFailed) + log.Warn("office routine arming scan: failed to enumerate workspaces", zap.Error(err)) + return + } + + for _, workspaceID := range workspaceIDs { + scanWorkspace(ctx, reader, log, now, workspaceID) + } +} + +// scanWorkspace classifies and records every routine in one workspace. A +// failure to list the workspace's routines is recorded and skipped without +// retry or abandoning the scan (AC-OFFICE-ROUTINE-ARMING-003.13). +func scanWorkspace(ctx context.Context, reader StartupScanReader, log *logger.Logger, now time.Time, workspaceID string) { + routineList, err := reader.ListRoutinesOrdered(ctx, workspaceID) + if err != nil { + armingScanSkipped(armingSkipRoutineEnumFailed) + log.Warn("office routine arming scan: failed to enumerate workspace routines", + zap.String("workspace_id", workspaceID), zap.Error(err)) + return + } + if len(routineList) == 0 { + return + } + + ids := make([]string, len(routineList)) + for i, r := range routineList { + ids[i] = r.ID + } + // ClassifyRoutines never returns a top-level error: a batch read failure + // falls back to a per-routine re-read (Task 01), and a routine whose own + // re-read also fails carries ScheduleStateUnknown in the result map + // instead (AC-OFFICE-ROUTINE-ARMING-003.9), so there is nothing to check + // here beyond the map it returns. + classifications, _ := ClassifyRoutines(ctx, reader, ids, now) + + for _, routine := range routineList { + recordRoutine(log, workspaceID, routine, classifications[routine.ID]) + } +} + +// cannotFireStates are the AC-OFFICE-ROUTINE-ARMING-003.3 schedule states: an +// active routine that owns a cron trigger which cannot currently fire it. +var cannotFireStates = map[ScheduleState]bool{ + ScheduleStateTriggerDisabled: true, + ScheduleStateTriggerUnscheduled: true, + ScheduleStateTriggerInvalid: true, +} + +// noScheduleStates are the AC-OFFICE-ROUTINE-ARMING-003.14 schedule states: +// an active routine that owns no cron trigger at all. +var noScheduleStates = map[ScheduleState]bool{ + ScheduleStateUnscheduledManualOnly: true, + ScheduleStateUnscheduledNoTrigger: true, +} + +const routineIntentActive = "active" + +// recordRoutine emits exactly one log record for one routine reached by the +// scan, and the AC-OFFICE-ROUTINE-ARMING-003.6 observation counter. +func recordRoutine(log *logger.Logger, workspaceID string, routine *Routine, c RoutineClassification) { + armingScanObserved(workspaceID, routine.Status, c.State) + + fields := []zap.Field{ + zap.String("workspace_id", workspaceID), + zap.String("routine_id", routine.ID), + zap.String("schedule_state", string(c.State)), + } + + if c.State == ScheduleStateUnknown { + armingScanSkipped(armingSkipClassificationUnknown) + log.Info("office routine arming scan: schedule state unknown", fields...) + return + } + + if routine.Status != routineIntentActive { + log.Info("office routine arming scan: routine intent is not active", fields...) + return + } + + recordActiveRoutine(log, fields, c) +} + +// recordActiveRoutine emits the AC-OFFICE-ROUTINE-ARMING-003.3/.5/.14 record +// for a routine whose intent is active. +func recordActiveRoutine(log *logger.Logger, fields []zap.Field, c RoutineClassification) { + switch { + case cannotFireStates[c.State]: + fields = append(fields, zap.Any("unarmed_cron_triggers", c.Unarmed)) + log.Warn("office routine arming scan: routine cannot fire", fields...) + case noScheduleStates[c.State]: + log.Warn("office routine arming scan: routine has no schedule", fields...) + default: + log.Info("office routine arming scan: routine observed", fields...) + } +} diff --git a/apps/backend/internal/office/routines/startup_scan_test.go b/apps/backend/internal/office/routines/startup_scan_test.go new file mode 100644 index 0000000000..60709ab6d2 --- /dev/null +++ b/apps/backend/internal/office/routines/startup_scan_test.go @@ -0,0 +1,400 @@ +package routines + +import ( + "context" + "errors" + "expvar" + "reflect" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/office/repository/sqlite" +) + +func newArmingScanRepo(t *testing.T) *sqlite.Repository { + t.Helper() + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatalf("open sqlite: %v", err) + } + t.Cleanup(func() { _ = db.Close() }) + repo, err := sqlite.NewWithDB(db, db, nil) + if err != nil { + t.Fatalf("new repo: %v", err) + } + if _, err := repo.ExecRaw(context.Background(), ` + CREATE TABLE IF NOT EXISTS workspaces ( + id TEXT PRIMARY KEY, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP + ) + `); err != nil { + t.Fatalf("create workspaces table: %v", err) + } + return repo +} + +func mustCreateArmingWorkspace(t *testing.T, repo *sqlite.Repository, id string, createdAt time.Time) { + t.Helper() + if _, err := repo.ExecRaw(context.Background(), + `INSERT INTO workspaces (id, created_at) VALUES (?, ?)`, id, createdAt); err != nil { + t.Fatalf("create workspace %s: %v", id, err) + } +} + +func mustCreateArmingRoutine(t *testing.T, repo *sqlite.Repository, r *Routine, createdAt time.Time) { + t.Helper() + if err := repo.CreateRoutine(context.Background(), r); err != nil { + t.Fatalf("create routine %s: %v", r.ID, err) + } + if _, err := repo.ExecRaw(context.Background(), + `UPDATE office_routines SET created_at = ? WHERE id = ?`, createdAt, r.ID); err != nil { + t.Fatalf("set created_at for %s: %v", r.ID, err) + } +} + +func mustCreateArmingTrigger(t *testing.T, repo *sqlite.Repository, tr *RoutineTrigger) { + t.Helper() + if err := repo.CreateRoutineTrigger(context.Background(), tr); err != nil { + t.Fatalf("create trigger %s: %v", tr.ID, err) + } +} + +func newObservedLogger(t *testing.T, level zapcore.Level) (*logger.Logger, *observer.ObservedLogs) { + t.Helper() + core, logs := observer.New(level) + log, err := logger.NewFromZap(zap.New(core)) + if err != nil { + t.Fatalf("create observer logger: %v", err) + } + return log, logs +} + +func counterValue(v expvar.Var) int64 { + if v == nil { + return 0 + } + iv, ok := v.(*expvar.Int) + if !ok { + return 0 + } + return iv.Value() +} + +// panicIfCalledReader proves RunStartupScan makes no reader call at all when +// invoked without a completion signal (AC-OFFICE-ROUTINE-ARMING-003.1). +type panicIfCalledReader struct{} + +func (panicIfCalledReader) ListWorkspaceIDsOrdered(context.Context) ([]string, error) { + panic("ListWorkspaceIDsOrdered called despite missing completion signal") +} + +func (panicIfCalledReader) ListRoutinesOrdered(context.Context, string) ([]*Routine, error) { + panic("ListRoutinesOrdered called despite missing completion signal") +} + +func (panicIfCalledReader) ListTriggersByRoutineIDs( + context.Context, []string, +) (map[string][]*RoutineTrigger, error) { + panic("ListTriggersByRoutineIDs called despite missing completion signal") +} + +func (panicIfCalledReader) ListTriggersByRoutineID(context.Context, string) ([]*RoutineTrigger, error) { + panic("ListTriggersByRoutineID called despite missing completion signal") +} + +// armingScanFailures wraps a real repository, letting a test force a +// workspace-enumeration failure, a per-workspace routine-enumeration +// failure, or a classification failure for named routines. +type armingScanFailures struct { + *sqlite.Repository + failWorkspaceEnum bool + failRoutineEnumFor map[string]bool + forceBatchErr bool + forceSingleErrOn map[string]bool +} + +func (f *armingScanFailures) ListWorkspaceIDsOrdered(ctx context.Context) ([]string, error) { + if f.failWorkspaceEnum { + return nil, errors.New("forced workspace enumeration failure") + } + return f.Repository.ListWorkspaceIDsOrdered(ctx) +} + +func (f *armingScanFailures) ListRoutinesOrdered(ctx context.Context, workspaceID string) ([]*Routine, error) { + if f.failRoutineEnumFor[workspaceID] { + return nil, errors.New("forced routine enumeration failure") + } + return f.Repository.ListRoutinesOrdered(ctx, workspaceID) +} + +func (f *armingScanFailures) ListTriggersByRoutineIDs( + ctx context.Context, routineIDs []string, +) (map[string][]*RoutineTrigger, error) { + if f.forceBatchErr { + return nil, errors.New("forced batch trigger read failure") + } + return f.Repository.ListTriggersByRoutineIDs(ctx, routineIDs) +} + +func (f *armingScanFailures) ListTriggersByRoutineID(ctx context.Context, routineID string) ([]*RoutineTrigger, error) { + if f.forceSingleErrOn[routineID] { + return nil, errors.New("forced single trigger read failure") + } + return f.Repository.ListTriggersByRoutineID(ctx, routineID) +} + +func TestRunStartupScan_NoSignalClassifiesNothing(t *testing.T) { + log, logs := newObservedLogger(t, zapcore.InfoLevel) + before := counterValue(armingScanSkippedTotal.Get(armingLabel("reason", armingSkipNoSignal))) + + RunStartupScan(context.Background(), ReconcileSignal{}, panicIfCalledReader{}, log, time.Now().UTC()) + + after := counterValue(armingScanSkippedTotal.Get(armingLabel("reason", armingSkipNoSignal))) + if after != before+1 { + t.Errorf("no_signal skip counter = %d, want %d", after, before+1) + } + if logs.Len() != 1 { + t.Fatalf("log entries = %d, want 1: %+v", logs.Len(), logs.All()) + } + if logs.All()[0].Level != zapcore.WarnLevel { + t.Errorf("level = %v, want warn", logs.All()[0].Level) + } +} + +func TestRunStartupScan_WorkspaceEnumerationFailure(t *testing.T) { + repo := newArmingScanRepo(t) + reader := &armingScanFailures{Repository: repo, failWorkspaceEnum: true} + log, logs := newObservedLogger(t, zapcore.InfoLevel) + before := counterValue(armingScanSkippedTotal.Get(armingLabel("reason", armingSkipWorkspaceEnumFailed))) + + RunStartupScan(context.Background(), SignalReconcileComplete(), reader, log, time.Now().UTC()) + + after := counterValue(armingScanSkippedTotal.Get(armingLabel("reason", armingSkipWorkspaceEnumFailed))) + if after != before+1 { + t.Errorf("workspace_enum_failed skip counter = %d, want %d", after, before+1) + } + if logs.Len() != 1 { + t.Fatalf("log entries = %d, want 1: %+v", logs.Len(), logs.All()) + } + if logs.All()[0].Level != zapcore.WarnLevel { + t.Errorf("level = %v, want warn", logs.All()[0].Level) + } +} + +func TestRunStartupScan_PerWorkspaceRoutineEnumerationFailureContinues(t *testing.T) { + repo := newArmingScanRepo(t) + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + mustCreateArmingWorkspace(t, repo, "ws-a", base) + mustCreateArmingWorkspace(t, repo, "ws-b", base.Add(time.Minute)) + mustCreateArmingWorkspace(t, repo, "ws-c", base.Add(2*time.Minute)) + + mustCreateArmingRoutine(t, repo, &Routine{ + ID: "r-a", WorkspaceID: "ws-a", Name: "A", TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + }, base) + mustCreateArmingRoutine(t, repo, &Routine{ + ID: "r-c", WorkspaceID: "ws-c", Name: "C", TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + }, base) + + reader := &armingScanFailures{Repository: repo, failRoutineEnumFor: map[string]bool{"ws-b": true}} + log, logs := newObservedLogger(t, zapcore.InfoLevel) + before := counterValue(armingScanSkippedTotal.Get(armingLabel("reason", armingSkipRoutineEnumFailed))) + + RunStartupScan(context.Background(), SignalReconcileComplete(), reader, log, time.Now().UTC()) + + after := counterValue(armingScanSkippedTotal.Get(armingLabel("reason", armingSkipRoutineEnumFailed))) + if after != before+1 { + t.Errorf("routine_enum_failed skip counter = %d, want %d", after, before+1) + } + + var sawA, sawC, warnedB bool + for _, entry := range logs.All() { + ctx := entry.ContextMap() + ws, _ := ctx["workspace_id"].(string) + routineID, _ := ctx["routine_id"].(string) + switch { + case routineID == "r-a": + sawA = true + case routineID == "r-c": + sawC = true + case ws == "ws-b" && entry.Level == zapcore.WarnLevel: + warnedB = true + } + } + if !sawA { + t.Error("ws-a's routine record was not preserved") + } + if !sawC { + t.Error("scan did not continue to ws-c after ws-b's enumeration failed") + } + if !warnedB { + t.Error("ws-b's enumeration failure was not recorded as a warning") + } +} + +func TestRunStartupScan_AllStatesFixtureRecordMatrix(t *testing.T) { + repo := newArmingScanRepo(t) + now := time.Now().UTC() + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + mustCreateArmingWorkspace(t, repo, "ws-1", base) + + future := now.Add(time.Hour) + mustCreateArmingRoutine(t, repo, &Routine{ + ID: "r-cannot-fire", WorkspaceID: "ws-1", Name: "CannotFire", TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + }, base) + mustCreateArmingTrigger(t, repo, &RoutineTrigger{ + ID: "t-cannot-fire", RoutineID: "r-cannot-fire", Kind: "cron", + CronExpression: "*/5 * * * *", Timezone: "UTC", Enabled: false, + }) + + mustCreateArmingRoutine(t, repo, &Routine{ + ID: "r-no-schedule", WorkspaceID: "ws-1", Name: "NoSchedule", TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + }, base.Add(time.Minute)) + + mustCreateArmingRoutine(t, repo, &Routine{ + ID: "r-observed", WorkspaceID: "ws-1", Name: "Observed", TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + }, base.Add(2*time.Minute)) + mustCreateArmingTrigger(t, repo, &RoutineTrigger{ + ID: "t-observed", RoutineID: "r-observed", Kind: "cron", + CronExpression: "*/5 * * * *", Timezone: "UTC", Enabled: true, NextRunAt: &future, + }) + + mustCreateArmingRoutine(t, repo, &Routine{ + ID: "r-inactive", WorkspaceID: "ws-1", Name: "Inactive", TaskTemplate: "{}", + Status: "paused", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + }, base.Add(3*time.Minute)) + mustCreateArmingTrigger(t, repo, &RoutineTrigger{ + ID: "t-inactive", RoutineID: "r-inactive", Kind: "cron", + CronExpression: "*/5 * * * *", Timezone: "UTC", Enabled: false, + }) + + mustCreateArmingRoutine(t, repo, &Routine{ + ID: "r-unknown", WorkspaceID: "ws-1", Name: "Unknown", TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + }, base.Add(4*time.Minute)) + + reader := &armingScanFailures{ + Repository: repo, + forceBatchErr: true, + forceSingleErrOn: map[string]bool{"r-unknown": true}, + } + log, logs := newObservedLogger(t, zapcore.InfoLevel) + obsBefore := counterValue(armingScanObservationsTotal.Get( + armingLabel("workspace", "ws-1", "intent", "active", "schedule_state", "unknown"))) + skipBefore := counterValue(armingScanSkippedTotal.Get(armingLabel("reason", armingSkipClassificationUnknown))) + + RunStartupScan(context.Background(), SignalReconcileComplete(), reader, log, now) + + if logs.Len() != 5 { + t.Fatalf("log entries = %d, want 5: %+v", logs.Len(), logs.All()) + } + + byRoutine := map[string]observer.LoggedEntry{} + for _, entry := range logs.All() { + if id, ok := entry.ContextMap()["routine_id"].(string); ok { + byRoutine[id] = entry + } + } + + cannotFire := byRoutine["r-cannot-fire"] + if cannotFire.Level != zapcore.WarnLevel || cannotFire.Message != "office routine arming scan: routine cannot fire" { + t.Errorf("r-cannot-fire record = %+v", cannotFire) + } + if _, ok := cannotFire.ContextMap()["unarmed_cron_triggers"]; !ok { + t.Error("r-cannot-fire record missing unarmed_cron_triggers") + } + + noSchedule := byRoutine["r-no-schedule"] + if noSchedule.Level != zapcore.WarnLevel || noSchedule.Message != "office routine arming scan: routine has no schedule" { + t.Errorf("r-no-schedule record = %+v", noSchedule) + } + if _, ok := noSchedule.ContextMap()["unarmed_cron_triggers"]; ok { + t.Error("r-no-schedule record should carry no unarmed_cron_triggers") + } + + observed := byRoutine["r-observed"] + if observed.Level != zapcore.InfoLevel || observed.Message != "office routine arming scan: routine observed" { + t.Errorf("r-observed record = %+v", observed) + } + + inactive := byRoutine["r-inactive"] + if inactive.Level != zapcore.InfoLevel || inactive.Message != "office routine arming scan: routine intent is not active" { + t.Errorf("r-inactive record = %+v", inactive) + } + + unknown := byRoutine["r-unknown"] + if unknown.Level != zapcore.InfoLevel || unknown.Message != "office routine arming scan: schedule state unknown" { + t.Errorf("r-unknown record = %+v", unknown) + } + + obsAfter := counterValue(armingScanObservationsTotal.Get( + armingLabel("workspace", "ws-1", "intent", "active", "schedule_state", "unknown"))) + if obsAfter != obsBefore+1 { + t.Errorf("unknown observation counter = %d, want %d", obsAfter, obsBefore+1) + } + skipAfter := counterValue(armingScanSkippedTotal.Get(armingLabel("reason", armingSkipClassificationUnknown))) + if skipAfter != skipBefore+1 { + t.Errorf("classification_unknown skip counter = %d, want %d", skipAfter, skipBefore+1) + } +} + +// summarizeRecords strips wall-clock/logger-added fields so two scans over +// unchanged data can be compared per AC-OFFICE-ROUTINE-ARMING-003.12. +func summarizeRecords(entries []observer.LoggedEntry) []map[string]interface{} { + out := make([]map[string]interface{}, len(entries)) + for i, e := range entries { + ctx := e.ContextMap() + ctx["__level"] = e.Level.String() + ctx["__message"] = e.Message + out[i] = ctx + } + return out +} + +func TestRunStartupScan_IdempotentOverUnchangedData(t *testing.T) { + repo := newArmingScanRepo(t) + now := time.Now().UTC() + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + mustCreateArmingWorkspace(t, repo, "ws-1", base) + + future := now.Add(time.Hour) + mustCreateArmingRoutine(t, repo, &Routine{ + ID: "r-armed", WorkspaceID: "ws-1", Name: "Armed", TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + }, base) + mustCreateArmingTrigger(t, repo, &RoutineTrigger{ + ID: "t-armed", RoutineID: "r-armed", Kind: "cron", + CronExpression: "*/5 * * * *", Timezone: "UTC", Enabled: true, NextRunAt: &future, + }) + mustCreateArmingRoutine(t, repo, &Routine{ + ID: "r-disabled", WorkspaceID: "ws-1", Name: "Disabled", TaskTemplate: "{}", + Status: "active", ConcurrencyPolicy: "skip_if_active", Variables: "{}", + }, base.Add(time.Minute)) + mustCreateArmingTrigger(t, repo, &RoutineTrigger{ + ID: "t-disabled", RoutineID: "r-disabled", Kind: "cron", + CronExpression: "*/5 * * * *", Timezone: "UTC", Enabled: false, + }) + + log1, logs1 := newObservedLogger(t, zapcore.InfoLevel) + RunStartupScan(context.Background(), SignalReconcileComplete(), repo, log1, now) + + log2, logs2 := newObservedLogger(t, zapcore.InfoLevel) + RunStartupScan(context.Background(), SignalReconcileComplete(), repo, log2, now) + + first := summarizeRecords(logs1.All()) + second := summarizeRecords(logs2.All()) + if !reflect.DeepEqual(first, second) { + t.Errorf("scan not idempotent over unchanged data:\nfirst: %+v\nsecond: %+v", first, second) + } +} diff --git a/apps/backend/internal/office/shared/cron.go b/apps/backend/internal/office/shared/cron.go index 9cb0d08317..c8ec2d25cd 100644 --- a/apps/backend/internal/office/shared/cron.go +++ b/apps/backend/internal/office/shared/cron.go @@ -36,30 +36,17 @@ func NextCronTime(expression, timezone string, after time.Time) (time.Time, erro if err != nil { return time.Time{}, err } - trimmed := strings.TrimSpace(expression) - if len(strings.Fields(trimmed)) != 5 { - return time.Time{}, fmt.Errorf("parse cron expression: %q: must be exactly 5 whitespace-separated fields (minute hour day-of-month month day-of-week); descriptors and TZ/CRON_TZ prefixes are not supported", expression) - } - schedule, err := cronParser.Parse(trimmed) + specSchedule, err := parseCronExpression(expression) if err != nil { - return time.Time{}, fmt.Errorf("parse cron expression: %w", err) - } - specSchedule, ok := schedule.(*cron.SpecSchedule) - if !ok { - // cronParser is configured with only the 5 standard fields (no - // descriptors), so Parse always returns *SpecSchedule for a - // well-formed 5-field expression. This would only trip if - // robfig/cron's internal type changed — fail loudly rather than - // silently degrading matchesWallClock to "everything matches". - return time.Time{}, fmt.Errorf("internal: unexpected schedule type %T", schedule) + return time.Time{}, err } start := after.In(loc) - candidate := schedule.Next(start) + candidate := specSchedule.Next(start) if candidate.IsZero() { return time.Time{}, fmt.Errorf("%w: %q", ErrUnsatisfiableCron, expression) } for isAmbiguousFallBack(candidate) || !matchesWallClock(specSchedule, candidate) { - candidate = schedule.Next(candidate) + candidate = specSchedule.Next(candidate) if candidate.IsZero() { return time.Time{}, fmt.Errorf("%w: %q", ErrUnsatisfiableCron, expression) } @@ -70,6 +57,38 @@ func NextCronTime(expression, timezone string, after time.Time) (time.Time, erro return candidate.UTC(), nil } +// parseCronExpression parses a 5-field cron expression into a +// *cron.SpecSchedule without computing an occurrence. +func parseCronExpression(expression string) (*cron.SpecSchedule, error) { + trimmed := strings.TrimSpace(expression) + if len(strings.Fields(trimmed)) != 5 { + return nil, fmt.Errorf("parse cron expression: %q: must be exactly 5 whitespace-separated fields (minute hour day-of-month month day-of-week); descriptors and TZ/CRON_TZ prefixes are not supported", expression) + } + schedule, err := cronParser.Parse(trimmed) + if err != nil { + return nil, fmt.Errorf("parse cron expression: %w", err) + } + specSchedule, ok := schedule.(*cron.SpecSchedule) + if !ok { + // cronParser is configured with only the 5 standard fields (no + // descriptors), so Parse always returns *SpecSchedule for a + // well-formed 5-field expression. This would only trip if + // robfig/cron's internal type changed — fail loudly rather than + // silently degrading matchesWallClock to "everything matches". + return nil, fmt.Errorf("internal: unexpected schedule type %T", schedule) + } + return specSchedule, nil +} + +// ValidateCronSchedule reports whether the scheduler can compute a next +// occurrence for a cron expression in its timezone. It uses the same parser +// and occurrence rules as NextCronTime, including ErrUnsatisfiableCron for a +// syntactically valid expression that can never match a date. +func ValidateCronSchedule(expression, timezone string) error { + _, err := NextCronTime(expression, timezone, time.Now().UTC()) + return err +} + // findEarlierMatchAcrossSubHourTransition recovers a fire that // schedule.Next skipped over because its hour-advancing loop steps by an // absolute 1h and desynchronizes from a zone transition whose offset delta diff --git a/apps/backend/internal/office/shared/cron_test.go b/apps/backend/internal/office/shared/cron_test.go index 74aa756e42..336ad676bb 100644 --- a/apps/backend/internal/office/shared/cron_test.go +++ b/apps/backend/internal/office/shared/cron_test.go @@ -539,6 +539,24 @@ func TestNextCronTime_InvalidTimezone(t *testing.T) { } } +func TestValidateCronSchedule_ValidExpression(t *testing.T) { + if err := ValidateCronSchedule("0 9 * * *", "America/New_York"); err != nil { + t.Fatalf("unexpected error: %v", err) + } +} + +func TestValidateCronSchedule_InvalidExpression(t *testing.T) { + if err := ValidateCronSchedule("invalid", ""); err == nil { + t.Fatal("expected error for invalid expression") + } +} + +func TestValidateCronSchedule_InvalidTimezone(t *testing.T) { + if err := ValidateCronSchedule("0 9 * * *", "Not/A_Zone"); err == nil { + t.Fatal("expected error for invalid timezone") + } +} + // TestNextCronTime_RejectsCronTZPrefix verifies that a caller-supplied // TZ=/CRON_TZ= prefix is rejected rather than silently accepted. robfig/cron // strips the prefix in Parse() before any field-mask check, so without this @@ -560,3 +578,16 @@ func TestNextCronTime_RejectsCronTZPrefix(t *testing.T) { }) } } + +// TestValidateCronSchedule_RejectsUnsatisfiable verifies that validation uses +// the scheduler's occurrence rules instead of accepting an impossible date. +func TestValidateCronSchedule_RejectsUnsatisfiable(t *testing.T) { + start := time.Now() + err := ValidateCronSchedule("0 0 30 2 *", "UTC") + if !errors.Is(err, ErrUnsatisfiableCron) { + t.Fatalf("expected ErrUnsatisfiableCron, got %v", err) + } + if elapsed := time.Since(start); elapsed > 50*time.Millisecond { + t.Errorf("ValidateCronSchedule took %v, want a bounded scheduler check", elapsed) + } +} diff --git a/apps/backend/internal/office/testharness/routes.go b/apps/backend/internal/office/testharness/routes.go index e1d7e2c8a4..6f4232e701 100644 --- a/apps/backend/internal/office/testharness/routes.go +++ b/apps/backend/internal/office/testharness/routes.go @@ -125,6 +125,7 @@ func RegisterRoutes( g.POST("/run-skills", seedRunSkillSnapshotHandler(officeRepo, log)) g.POST("/cost-events", seedCostEventHandler(officeRepo, log)) g.POST("/activity", seedActivityHandler(officeRepo, log)) + g.POST("/routine-triggers", seedRoutineTriggerHandler(officeRepo, log)) } if agentSvc != nil { g.POST("/runtime-token", mintRuntimeTokenHandler(agentSvc, log)) diff --git a/apps/backend/internal/office/testharness/routine_triggers.go b/apps/backend/internal/office/testharness/routine_triggers.go new file mode 100644 index 0000000000..3f37c15f96 --- /dev/null +++ b/apps/backend/internal/office/testharness/routine_triggers.go @@ -0,0 +1,108 @@ +package testharness + +import ( + "net/http" + "time" + + "github.com/gin-gonic/gin" + "github.com/google/uuid" + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + officemodels "github.com/kandev/kandev/internal/office/models" + officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" +) + +// seedRoutineTriggerRequest seeds an office_routine_triggers row directly, +// bypassing RoutineService.CreateRoutineTrigger's cron validation. The public +// create endpoint rejects an unparseable cron expression outright and always +// computes a non-null NextRunAt, so it cannot produce trigger_invalid, +// trigger_unscheduled, or trigger_disabled — this route is the only way an +// E2E fixture can reach every schedule state in the nine-rule table +// (REQ-OFFICE-ROUTINE-ARMING-001). +type seedRoutineTriggerRequest struct { + RoutineID string `json:"routine_id"` + Kind string `json:"kind"` + CronExpression string `json:"cron_expression,omitempty"` + Timezone string `json:"timezone,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + NextRunAt *string `json:"next_run_at,omitempty"` + LastFiredAt *string `json:"last_fired_at,omitempty"` +} + +// seedRoutineTriggerHandler inserts the requested trigger row as-is (no +// service-layer validation) so tests can construct otherwise-unreachable +// schedule states. +func seedRoutineTriggerHandler(repo *officesqlite.Repository, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + req, ok := decodeSeedRoutineTriggerRequest(c) + if !ok { + return + } + nextRunAt, err := parseOptionalRFC3339(req.NextRunAt) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{respKeyError: "next_run_at must be RFC3339"}) + return + } + lastFiredAt, err := parseOptionalRFC3339(req.LastFiredAt) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{respKeyError: "last_fired_at must be RFC3339"}) + return + } + trigger := buildSeededTrigger(req, nextRunAt, lastFiredAt) + if err := repo.CreateRoutineTrigger(c.Request.Context(), trigger); err != nil { + log.Error("test harness: create routine trigger failed", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{respKeyError: err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"trigger_id": trigger.ID}) + } +} + +func decodeSeedRoutineTriggerRequest(c *gin.Context) (seedRoutineTriggerRequest, bool) { + var req seedRoutineTriggerRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{respKeyError: errInvalidJSONPrefix + err.Error()}) + return seedRoutineTriggerRequest{}, false + } + if req.RoutineID == "" || req.Kind == "" { + c.JSON(http.StatusBadRequest, gin.H{respKeyError: "routine_id and kind are required"}) + return seedRoutineTriggerRequest{}, false + } + return req, true +} + +func buildSeededTrigger( + req seedRoutineTriggerRequest, nextRunAt, lastFiredAt *time.Time, +) *officemodels.RoutineTrigger { + enabled := true + if req.Enabled != nil { + enabled = *req.Enabled + } + timezone := req.Timezone + if timezone == "" { + timezone = "UTC" + } + return &officemodels.RoutineTrigger{ + RoutineID: req.RoutineID, + Kind: req.Kind, + CronExpression: req.CronExpression, + Timezone: timezone, + PublicID: uuid.New().String(), + Enabled: enabled, + NextRunAt: nextRunAt, + LastFiredAt: lastFiredAt, + } +} + +func parseOptionalRFC3339(raw *string) (*time.Time, error) { + if raw == nil || *raw == "" { + return nil, nil + } + t, err := time.Parse(time.RFC3339, *raw) + if err != nil { + return nil, err + } + t = t.UTC() + return &t, nil +} diff --git a/apps/backend/internal/office/testharness/routine_triggers_test.go b/apps/backend/internal/office/testharness/routine_triggers_test.go new file mode 100644 index 0000000000..db40edbc43 --- /dev/null +++ b/apps/backend/internal/office/testharness/routine_triggers_test.go @@ -0,0 +1,166 @@ +package testharness + +import ( + "bytes" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/gin-gonic/gin" + + "github.com/kandev/kandev/internal/common/logger" + officemodels "github.com/kandev/kandev/internal/office/models" + officesqlite "github.com/kandev/kandev/internal/office/repository/sqlite" +) + +// newRoutineTriggerTestRouter wires the office repo (with a seeded routine) +// and a router mounting only the routine-triggers route. +func newRoutineTriggerTestRouter(t *testing.T) (*gin.Engine, *officesqlite.Repository, string) { + t.Helper() + taskRepo, sqlxDB := newTestRepo(t) + officeRepo, err := officesqlite.NewWithDB(sqlxDB, sqlxDB, logger.Default()) + if err != nil { + t.Fatalf("new office repo: %v", err) + } + routine := &officemodels.Routine{ + WorkspaceID: "ws-1", + Name: "Seeded routine", + TaskTemplate: "{}", + Status: "active", + ConcurrencyPolicy: "skip_if_active", + Variables: "{}", + } + if err := officeRepo.CreateRoutine(t.Context(), routine); err != nil { + t.Fatalf("create routine: %v", err) + } + router := gin.New() + RegisterRoutes(router, taskRepo, officeRepo, nil, nil, nil, logger.Default(), nil, nil) + return router, officeRepo, routine.ID +} + +func postSeedRoutineTrigger(t *testing.T, router *gin.Engine, body map[string]any) *httptest.ResponseRecorder { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/api/v1/_test/routine-triggers", bytes.NewReader(mustJSON(t, body))) + req.Header.Set("Content-Type", "application/json") + res := httptest.NewRecorder() + router.ServeHTTP(res, req) + return res +} + +func TestSeedRoutineTriggerDefaultsEnabledAndTimezone(t *testing.T) { + router, officeRepo, routineID := newRoutineTriggerTestRouter(t) + + res := postSeedRoutineTrigger(t, router, map[string]any{ + "routine_id": routineID, + "kind": "cron", + "cron_expression": "0 9 * * *", + }) + if res.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", res.Code, res.Body.String()) + } + + triggers, err := officeRepo.ListTriggersByRoutineID(t.Context(), routineID) + if err != nil { + t.Fatalf("list triggers: %v", err) + } + if len(triggers) != 1 { + t.Fatalf("trigger count = %d, want 1", len(triggers)) + } + if !triggers[0].Enabled { + t.Fatalf("enabled defaulted to false, want true") + } + if triggers[0].Timezone != "UTC" { + t.Fatalf("timezone defaulted to %q, want UTC", triggers[0].Timezone) + } +} + +// TestSeedRoutineTriggerBypassesCronValidation proves this route can produce +// the schedule states the public create-trigger endpoint cannot: an enabled +// cron trigger with an unparseable expression (trigger_invalid) and a +// disabled trigger (trigger_disabled). +func TestSeedRoutineTriggerBypassesCronValidation(t *testing.T) { + router, officeRepo, routineID := newRoutineTriggerTestRouter(t) + + disabled := false + res := postSeedRoutineTrigger(t, router, map[string]any{ + "routine_id": routineID, + "kind": "cron", + "cron_expression": "not a cron expression", + "enabled": disabled, + }) + if res.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", res.Code, res.Body.String()) + } + + triggers, err := officeRepo.ListTriggersByRoutineID(t.Context(), routineID) + if err != nil { + t.Fatalf("list triggers: %v", err) + } + if len(triggers) != 1 { + t.Fatalf("trigger count = %d, want 1", len(triggers)) + } + got := triggers[0] + if got.Enabled { + t.Fatalf("enabled = true, want false") + } + if got.CronExpression != "not a cron expression" { + t.Fatalf("cron_expression = %q, want the invalid literal preserved", got.CronExpression) + } +} + +func TestSeedRoutineTriggerPersistsNextRunAtAndLastFiredAt(t *testing.T) { + router, officeRepo, routineID := newRoutineTriggerTestRouter(t) + + nextRunAt := time.Now().UTC().Add(24 * time.Hour).Truncate(time.Second) + lastFiredAt := time.Now().UTC().Add(-1 * time.Hour).Truncate(time.Second) + res := postSeedRoutineTrigger(t, router, map[string]any{ + "routine_id": routineID, + "kind": "cron", + "cron_expression": "0 9 * * *", + "next_run_at": nextRunAt.Format(time.RFC3339), + "last_fired_at": lastFiredAt.Format(time.RFC3339), + }) + if res.Code != http.StatusOK { + t.Fatalf("status=%d body=%s", res.Code, res.Body.String()) + } + + triggers, err := officeRepo.ListTriggersByRoutineID(t.Context(), routineID) + if err != nil { + t.Fatalf("list triggers: %v", err) + } + got := triggers[0] + if got.NextRunAt == nil || !got.NextRunAt.Equal(nextRunAt) { + t.Fatalf("next_run_at = %v, want %v", got.NextRunAt, nextRunAt) + } + if got.LastFiredAt == nil || !got.LastFiredAt.Equal(lastFiredAt) { + t.Fatalf("last_fired_at = %v, want %v", got.LastFiredAt, lastFiredAt) + } +} + +func TestSeedRoutineTriggerRejectsMissingFields(t *testing.T) { + router, _, routineID := newRoutineTriggerTestRouter(t) + + res := postSeedRoutineTrigger(t, router, map[string]any{"routine_id": routineID}) + if res.Code != http.StatusBadRequest { + t.Fatalf("status=%d, want 400 when kind is missing", res.Code) + } + + res = postSeedRoutineTrigger(t, router, map[string]any{"kind": "cron"}) + if res.Code != http.StatusBadRequest { + t.Fatalf("status=%d, want 400 when routine_id is missing", res.Code) + } +} + +func TestSeedRoutineTriggerRejectsBadTimestamp(t *testing.T) { + router, _, routineID := newRoutineTriggerTestRouter(t) + + res := postSeedRoutineTrigger(t, router, map[string]any{ + "routine_id": routineID, + "kind": "cron", + "next_run_at": "not-a-timestamp", + }) + if res.Code != http.StatusBadRequest { + t.Fatalf("status=%d, want 400 for malformed next_run_at", res.Code) + } +} diff --git a/apps/web/app/office/routines/[id]/routine-detail-view.test.tsx b/apps/web/app/office/routines/[id]/routine-detail-view.test.tsx index e96c94ed3f..6b035e31c4 100644 --- a/apps/web/app/office/routines/[id]/routine-detail-view.test.tsx +++ b/apps/web/app/office/routines/[id]/routine-detail-view.test.tsx @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import { StateProvider } from "@/components/state-provider"; +import { TooltipProvider } from "@kandev/ui/tooltip"; import { defaultOfficeState } from "@/lib/state/slices/office/office-slice"; import type { Routine, RoutineTrigger } from "@/lib/state/slices/office/types"; import { RoutineDetailView } from "./routine-detail-view"; @@ -103,10 +104,12 @@ function renderDetailView(routine: Routine, triggers: RoutineTrigger[]) { office: { ...defaultOfficeState.office }, }} > - - - - + + + + + + , ); } diff --git a/apps/web/app/office/routines/[id]/routine-detail-view.tsx b/apps/web/app/office/routines/[id]/routine-detail-view.tsx index a67c7f34e9..ffcdd3b3b8 100644 --- a/apps/web/app/office/routines/[id]/routine-detail-view.tsx +++ b/apps/web/app/office/routines/[id]/routine-detail-view.tsx @@ -23,6 +23,7 @@ import { timeAgo } from "@/lib/utils/time"; import { useOfficeTopbar } from "../../components/office-topbar-context"; import { isRoutineFiring } from "../../lib/routine-status"; import { routineNotFiringMessage } from "../../lib/routine-not-firing"; +import { ScheduleStateBadge, UnarmedScheduleHint } from "../schedule-state-badge"; import { coerceCatchUpMax } from "../../lib/catch-up-max"; import { useTranslation } from "react-i18next"; import type { TFunction } from "i18next"; @@ -212,6 +213,7 @@ export function RoutineDetailView({ initialRoutine, initialTriggers }: RoutineDe {t("office:schedule")} - + +
+ + +
{/* `{{when}}` carries a formatted timestamp, not a translated label. */}
{t("office:lastFired", { when: lastFiredAt ? timeAgo(lastFiredAt) : t("office:never") })} diff --git a/apps/web/app/office/routines/routine-row.tsx b/apps/web/app/office/routines/routine-row.tsx index bdca4904b1..eb10c46990 100644 --- a/apps/web/app/office/routines/routine-row.tsx +++ b/apps/web/app/office/routines/routine-row.tsx @@ -24,6 +24,7 @@ import type { TFunction } from "i18next"; import { useTranslation } from "react-i18next"; import { CONCURRENCY_POLICY_LABEL_KEYS } from "../lib/label-keys"; import { isRoutineFiring } from "../lib/routine-status"; +import { ScheduleStateBadge, UnarmedScheduleHint } from "./schedule-state-badge"; /** * A routine's concurrency policy is a wire value; only its label is copy. @@ -108,21 +109,21 @@ export function RoutineRow({
onClick(routine.id)} > -
+
e.stopPropagation()} > {routine.name} -
+
{assignee && {assignee.name}} {cronTrigger?.cronExpression && ( {cronTrigger.cronExpression} @@ -131,38 +132,44 @@ export function RoutineRow({ {routine.lastRunAt ? timeAgo(routine.lastRunAt) : t("office:neverRun")} {concurrencyLabel(t, routine.concurrencyPolicy)}
+
+ + {isActive ? t("office:on") : t("office:off")} + + + +
+
+
+ { + onToggle(routine.id, checked); + }} + onClick={(e) => e.stopPropagation()} + className="cursor-pointer" + /> + + + + + {t("office:editRoutine")} + + onRunNow(routine.id)} + onDelete={() => onDelete(routine.id)} + />
- - {isActive ? t("office:on") : t("office:off")} - - { - onToggle(routine.id, checked); - }} - onClick={(e) => e.stopPropagation()} - className="cursor-pointer" - /> - - - - - {t("office:editRoutine")} - - onRunNow(routine.id)} - onDelete={() => onDelete(routine.id)} - />
{expanded && ( diff --git a/apps/web/app/office/routines/schedule-state-badge.test.tsx b/apps/web/app/office/routines/schedule-state-badge.test.tsx new file mode 100644 index 0000000000..2619885e9a --- /dev/null +++ b/apps/web/app/office/routines/schedule-state-badge.test.tsx @@ -0,0 +1,106 @@ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import type { Routine, ScheduleState } from "@/lib/state/slices/office/types"; +import { ScheduleStateBadge, UnarmedScheduleHint } from "./schedule-state-badge"; + +afterEach(cleanup); + +function renderBadge(ui: React.ReactElement) { + return render({ui}); +} + +function routine(overrides: Record = {}): Routine { + return { + id: "routine-1", + workspaceId: "ws-1", + name: "Nightly digest", + taskTemplate: {}, + status: "active", + concurrencyPolicy: "skip_if_active", + createdAt: "2026-01-01T00:00:00Z", + updatedAt: "2026-01-01T00:00:00Z", + ...overrides, + } as unknown as Routine; +} + +// AC-OFFICE-ROUTINE-ARMING-002.10: every group renders a distinct label, and +// two states from different groups never render the same text. +describe("ScheduleStateBadge", () => { + const cases: Array<[ScheduleState, string]> = [ + ["armed", "Armed"], + ["trigger_invalid", "Schedule broken"], + ["trigger_unscheduled", "Schedule broken"], + ["trigger_disabled", "Schedule broken"], + ["event_only", "Event-triggered"], + ["unscheduled_manual_only", "No schedule"], + ["unscheduled_no_trigger", "No schedule"], + ["unknown", "Schedule unknown"], + ]; + + it.each(cases)("renders the group label for schedule state %s", (state, label) => { + renderBadge(); + expect(screen.getByText(label)).toBeTruthy(); + }); + + it("never renders event_only as having no schedule (AC-002.11)", () => { + renderBadge(); + expect(screen.queryByText("No schedule")).toBeNull(); + }); + + it("collapses every state in the same group onto the same label", () => { + const labels = new Set(); + for (const [state, label] of cases) { + cleanup(); + renderBadge(); + labels.add(label); + } + // Five distinct groups, not nine distinct per-state labels. + expect(labels.size).toBe(5); + }); +}); + +describe("UnarmedScheduleHint", () => { + it("renders nothing when the unarmed cron trigger list is empty (AC-002.4)", () => { + const { container } = render( + , + ); + expect(container.firstChild).toBeNull(); + }); + + it("distinguishes an unarmed entry that only needs re-arming", () => { + render( + , + ); + expect(screen.getByText("Needs re-arming")).toBeTruthy(); + }); + + it("distinguishes an unarmed entry that needs its expression or timezone edited", () => { + render( + , + ); + expect(screen.getByText("Needs schedule fix")).toBeTruthy(); + }); + + it("reports 're-arming' once any entry in a mixed list is still schedulable", () => { + render( + , + ); + expect(screen.getByText("Needs re-arming")).toBeTruthy(); + }); +}); diff --git a/apps/web/app/office/routines/schedule-state-badge.tsx b/apps/web/app/office/routines/schedule-state-badge.tsx new file mode 100644 index 0000000000..adcc35dae0 --- /dev/null +++ b/apps/web/app/office/routines/schedule-state-badge.tsx @@ -0,0 +1,123 @@ +"use client"; + +import { Badge } from "@kandev/ui/badge"; +import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { + IconAlertTriangle, + IconCalendarOff, + IconCircleCheck, + IconHelpCircle, + IconWebhook, +} from "@tabler/icons-react"; +import type { Icon } from "@tabler/icons-react"; +import { useTranslation } from "react-i18next"; +import type { TFunction } from "i18next"; +import type { Routine } from "@/lib/state/slices/office/types"; +import { + hasSchedulableUnarmedEntry, + readScheduleState, + readUnarmedCronTriggers, + scheduleStateGroup, + type ScheduleStateGroup, +} from "./schedule-state"; + +const GROUP_LABEL_KEYS: Record = { + armed: "office:scheduleStateArmed", + broken: "office:scheduleStateBroken", + event_only: "office:scheduleStateEventOnly", + no_schedule: "office:scheduleStateNoSchedule", + unknown: "office:scheduleStateUnknown", +}; + +const GROUP_DESCRIPTION_KEYS: Record = { + armed: "office:scheduleStateArmedDescription", + broken: "office:scheduleStateBrokenDescription", + event_only: "office:scheduleStateEventOnlyDescription", + no_schedule: "office:scheduleStateNoScheduleDescription", + unknown: "office:scheduleStateUnknownDescription", +}; + +const GROUP_ICONS: Record = { + armed: IconCircleCheck, + broken: IconAlertTriangle, + event_only: IconWebhook, + no_schedule: IconCalendarOff, + unknown: IconHelpCircle, +}; + +const GROUP_VARIANTS: Record< + ScheduleStateGroup, + "default" | "secondary" | "destructive" | "outline" +> = { + armed: "default", + broken: "destructive", + event_only: "outline", + no_schedule: "secondary", + unknown: "outline", +}; + +/** + * ScheduleStateBadge renders the second, independent switch + * (REQ-OFFICE-ROUTINE-ARMING-001's schedule state) alongside a routine's + * existing intent badge. Each of the five label groups carries its own text + * and icon (AC-002.9, AC-002.10) so it never collapses to color alone. + */ +export function ScheduleStateBadge({ + routine, + className, +}: { + routine: Routine; + className?: string; +}) { + const { t } = useTranslation(); + const group = scheduleStateGroup(readScheduleState(routine)); + const GroupIcon = GROUP_ICONS[group]; + const label = t(GROUP_LABEL_KEYS[group]); + + return ( + + + + + + {t(GROUP_DESCRIPTION_KEYS[group])} + + ); +} + +// unarmedHintLabel resolves AC-OFFICE-ROUTINE-ARMING-002.4's schedulable vs. +// non-schedulable distinction to copy. Only called once the unarmed list is +// confirmed non-empty, so "schedulable" and "not schedulable" are always a +// real two-way split, never a vacuous one. +function unarmedHintLabel(t: TFunction, schedulable: boolean): string { + return schedulable ? t("office:unarmedNeedsRearm") : t("office:unarmedNeedsEdit"); +} + +/** + * UnarmedScheduleHint renders nothing when a routine's unarmed cron trigger + * list is empty (AC-002.4's suppression rule), and otherwise distinguishes a + * routine that only needs its existing triggers re-armed from one whose cron + * expression or timezone needs editing first. + */ +export function UnarmedScheduleHint({ + routine, + className, +}: { + routine: Routine; + className?: string; +}) { + const { t } = useTranslation(); + const unarmed = readUnarmedCronTriggers(routine); + if (unarmed.length === 0) return null; + + const schedulable = hasSchedulableUnarmedEntry(unarmed); + const label = unarmedHintLabel(t, schedulable); + + return ( + + {label} + + ); +} diff --git a/apps/web/app/office/routines/schedule-state.test.ts b/apps/web/app/office/routines/schedule-state.test.ts new file mode 100644 index 0000000000..9548339868 --- /dev/null +++ b/apps/web/app/office/routines/schedule-state.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, it } from "vitest"; +import type { Routine, ScheduleState, UnarmedCronTrigger } from "@/lib/state/slices/office/types"; +import { + hasSchedulableUnarmedEntry, + readScheduleState, + readUnarmedCronTriggers, + scheduleStateGroup, +} from "./schedule-state"; + +// AC-OFFICE-ROUTINE-ARMING-002.10: every one of the nine wire values maps to +// exactly one of the five label groups, and two states in different groups +// never collapse to the same group. +describe("scheduleStateGroup", () => { + it.each<[ScheduleState, string]>([ + ["armed", "armed"], + ["trigger_invalid", "broken"], + ["trigger_unscheduled", "broken"], + ["trigger_disabled", "broken"], + ["event_only", "event_only"], + ["unscheduled_manual_only", "no_schedule"], + ["unscheduled_no_trigger", "no_schedule"], + ["unknown", "unknown"], + ])("maps %s to group %s", (state, group) => { + expect(scheduleStateGroup(state)).toBe(group); + }); + + it("treats a missing state as unknown rather than throwing", () => { + expect(scheduleStateGroup(undefined)).toBe("unknown"); + }); +}); + +function routineWithRaw(raw: Record): Routine { + return raw as unknown as Routine; +} + +describe("readScheduleState / readUnarmedCronTriggers", () => { + it("reads the camelCase field when a mapping layer already provided it", () => { + const routine = routineWithRaw({ scheduleState: "armed" }); + expect(readScheduleState(routine)).toBe("armed"); + }); + + it("returns undefined when the mapping layer has no schedule state", () => { + expect(readScheduleState(routineWithRaw({}))).toBeUndefined(); + }); + + it("reads normalized unarmed cron triggers", () => { + const routine = routineWithRaw({ + unarmedCronTriggers: [{ triggerId: "trig-1", reasons: ["disabled"] }], + }); + expect(readUnarmedCronTriggers(routine)).toEqual([ + { triggerId: "trig-1", reasons: ["disabled"] }, + ]); + }); + + it("returns an empty list when the field is absent", () => { + expect(readUnarmedCronTriggers(routineWithRaw({}))).toEqual([]); + }); +}); + +describe("hasSchedulableUnarmedEntry", () => { + it("is true when at least one unarmed entry only needs re-arming", () => { + const unarmed: UnarmedCronTrigger[] = [ + { triggerId: "t1", reasons: ["not_schedulable"] }, + { triggerId: "t2", reasons: ["disabled"] }, + ]; + expect(hasSchedulableUnarmedEntry(unarmed)).toBe(true); + }); + + it("is false when every unarmed entry needs its expression or timezone edited", () => { + const unarmed: UnarmedCronTrigger[] = [{ triggerId: "t1", reasons: ["not_schedulable"] }]; + expect(hasSchedulableUnarmedEntry(unarmed)).toBe(false); + }); + + it("is false over an empty list", () => { + expect(hasSchedulableUnarmedEntry([])).toBe(false); + }); +}); diff --git a/apps/web/app/office/routines/schedule-state.ts b/apps/web/app/office/routines/schedule-state.ts new file mode 100644 index 0000000000..a919da032b --- /dev/null +++ b/apps/web/app/office/routines/schedule-state.ts @@ -0,0 +1,50 @@ +import type { Routine, ScheduleState, UnarmedCronTrigger } from "@/lib/state/slices/office/types"; + +/** + * ScheduleStateGroup collapses the nine wire values of `ScheduleState` into + * the five label groups AC-OFFICE-ROUTINE-ARMING-002.10 requires: two + * routines in different groups never render the same label. + */ +export type ScheduleStateGroup = "armed" | "broken" | "event_only" | "no_schedule" | "unknown"; + +const GROUP_BY_STATE: Record = { + armed: "armed", + trigger_invalid: "broken", + trigger_unscheduled: "broken", + trigger_disabled: "broken", + event_only: "event_only", + unscheduled_manual_only: "no_schedule", + unscheduled_no_trigger: "no_schedule", + unknown: "unknown", +}; + +export function scheduleStateGroup(state: ScheduleState | undefined): ScheduleStateGroup { + return state ? (GROUP_BY_STATE[state] ?? "unknown") : "unknown"; +} + +// The API normalizer translates the wire fields before a routine reaches the +// UI. These readers keep the component code independent of that wire shape. +export function readScheduleState(routine: Routine): ScheduleState | undefined { + return routine.scheduleState; +} + +export function readUnarmedCronTriggers(routine: Routine): UnarmedCronTrigger[] { + return routine.unarmedCronTriggers ?? []; +} + +// isSchedulableEntry reports whether this unarmed cron trigger could still +// fire once re-armed (enabled and/or its next occurrence recomputed), as +// opposed to one whose cron expression or timezone needs editing first. +function isSchedulableEntry(entry: UnarmedCronTrigger): boolean { + return !entry.reasons.includes("not_schedulable"); +} + +/** + * hasSchedulableUnarmedEntry implements AC-OFFICE-ROUTINE-ARMING-002.4's + * distinction: at least one entry that only needs re-arming, versus none. + * Meaningless over an empty list; the caller suppresses rendering entirely + * in that case rather than asking the question. + */ +export function hasSchedulableUnarmedEntry(unarmed: UnarmedCronTrigger[]): boolean { + return unarmed.some(isSchedulableEntry); +} diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index b9d9471269..4903ad7fbf 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1670,6 +1670,35 @@ export class ApiClient { return this.request("POST", "/api/v1/_test/comments", payload); } + /** + * Inserts an office_routine_triggers row directly, bypassing the public + * create-trigger endpoint's cron validation. The public endpoint always + * persists a schedulable, enabled trigger with a computed next_run_at, so + * it cannot produce trigger_invalid, trigger_unscheduled, or + * trigger_disabled; this seed is the only way an E2E fixture reaches + * every REQ-OFFICE-ROUTINE-ARMING-001 schedule state. + */ + async seedRoutineTrigger(opts: { + routineId: string; + kind: "cron" | "webhook" | "manual"; + cronExpression?: string; + timezone?: string; + enabled?: boolean; + nextRunAt?: string; + lastFiredAt?: string; + }): Promise<{ trigger_id: string }> { + const payload: Record = { + routine_id: opts.routineId, + kind: opts.kind, + }; + if (opts.cronExpression !== undefined) payload.cron_expression = opts.cronExpression; + if (opts.timezone !== undefined) payload.timezone = opts.timezone; + if (opts.enabled !== undefined) payload.enabled = opts.enabled; + if (opts.nextRunAt !== undefined) payload.next_run_at = opts.nextRunAt; + if (opts.lastFiredAt !== undefined) payload.last_fired_at = opts.lastFiredAt; + return this.request("POST", "/api/v1/_test/routine-triggers", payload); + } + // --- GitHub Mock Control --- async mockGitHubReset(): Promise { diff --git a/apps/web/e2e/tests/office/mobile-routine-schedule-state-layout.spec.ts b/apps/web/e2e/tests/office/mobile-routine-schedule-state-layout.spec.ts new file mode 100644 index 0000000000..05bd0bf687 --- /dev/null +++ b/apps/web/e2e/tests/office/mobile-routine-schedule-state-layout.spec.ts @@ -0,0 +1,39 @@ +import { expect, test } from "../../fixtures/office-fixture"; +import { assertNoDocumentHorizontalOverflow } from "../../helpers/layout-assertions"; + +test.describe("Mobile routine schedule-state row", () => { + test("wraps schedule metadata and keeps row actions reachable", async ({ + testPage, + officeApi, + officeSeed, + apiClient, + }) => { + const routine = (await officeApi.createRoutine(officeSeed.workspaceId, { + name: "E2E Mobile Routine With A Long Name For Schedule Status", + })) as { id: string }; + await apiClient.seedRoutineTrigger({ + routineId: routine.id, + kind: "cron", + cronExpression: "0 0 30 2 *", + enabled: true, + }); + + await testPage.goto("/office/routines"); + const row = testPage.getByTestId(`routine-row-${routine.id}`); + await expect(row).toBeVisible({ timeout: 10_000 }); + await expect( + row + .getByText(/^(Armed|Schedule broken|Event-triggered|No schedule|Schedule unknown)$/) + .first(), + ).toBeVisible(); + + await assertNoDocumentHorizontalOverflow(testPage, "mobile routine schedule-state row"); + + const viewport = testPage.viewportSize(); + const action = row.getByRole("button").last(); + const actionBox = await action.boundingBox(); + expect(viewport).toBeTruthy(); + expect(actionBox).toBeTruthy(); + expect(actionBox!.x + actionBox!.width).toBeLessThanOrEqual(viewport!.width); + }); +}); diff --git a/apps/web/e2e/tests/office/routine-schedule-state.spec.ts b/apps/web/e2e/tests/office/routine-schedule-state.spec.ts new file mode 100644 index 0000000000..e816b1c6a8 --- /dev/null +++ b/apps/web/e2e/tests/office/routine-schedule-state.spec.ts @@ -0,0 +1,139 @@ +import type { Page } from "@playwright/test"; +import { test, expect } from "../../fixtures/office-fixture"; + +// REQ-OFFICE-ROUTINE-ARMING-002: schedule state is a second switch, +// independent of a routine's intent (status). This spec seeds one routine +// per reachable schedule state and asserts the routines list renders the +// correct AC-002.10 label group for each, with no two groups ever +// rendering the same label. +// +// office_routine_triggers has no PATCH route, and the public create-trigger +// endpoint validates the cron expression and always computes a next_run_at, +// so it cannot produce `trigger_invalid`, `trigger_unscheduled`, or +// `trigger_disabled`. Those three are seeded directly through the +// KANDEV_E2E_MOCK-gated /_test/routine-triggers route +// (ApiClient.seedRoutineTrigger) instead. `unknown` requires a live +// database read failure and has no fixture-reachable trigger shape; it is +// covered by the backend unit tests in arming_test.go instead. + +const futureCronRun = () => new Date(Date.now() + 24 * 60 * 60 * 1000).toISOString(); + +function rowForRoutine(page: Page, name: string) { + return page.getByRole("link", { name, exact: true }).locator("xpath=../.."); +} + +test.describe("Routine schedule state rendering", () => { + test("routines list renders a distinct label per schedule-state group", async ({ + testPage, + officeApi, + officeSeed, + apiClient, + }) => { + const cases: Array<{ + name: string; + group: string; + expectedLabel: string; + seed?: (routineId: string) => Promise; + }> = [ + { + name: "E2E Armed Routine", + group: "armed", + expectedLabel: "Armed", + seed: (routineId) => + apiClient.seedRoutineTrigger({ + routineId, + kind: "cron", + cronExpression: "0 9 * * *", + enabled: true, + nextRunAt: futureCronRun(), + }), + }, + { + name: "E2E Invalid Cron Routine", + group: "broken", + expectedLabel: "Schedule broken", + seed: (routineId) => + apiClient.seedRoutineTrigger({ + routineId, + kind: "cron", + cronExpression: "not a cron expression", + enabled: true, + }), + }, + { + name: "E2E Unscheduled Cron Routine", + group: "broken", + expectedLabel: "Schedule broken", + seed: (routineId) => + apiClient.seedRoutineTrigger({ + routineId, + kind: "cron", + cronExpression: "0 9 * * *", + enabled: true, + }), + }, + { + name: "E2E Disabled Cron Routine", + group: "broken", + expectedLabel: "Schedule broken", + seed: (routineId) => + apiClient.seedRoutineTrigger({ + routineId, + kind: "cron", + cronExpression: "0 9 * * *", + enabled: false, + nextRunAt: futureCronRun(), + }), + }, + { + name: "E2E Webhook Routine", + group: "event_only", + expectedLabel: "Event-triggered", + seed: (routineId) => + apiClient.seedRoutineTrigger({ routineId, kind: "webhook", enabled: true }), + }, + { + name: "E2E Manual Only Routine", + group: "no_schedule", + expectedLabel: "No schedule", + seed: (routineId) => + apiClient.seedRoutineTrigger({ routineId, kind: "manual", enabled: true }), + }, + { + name: "E2E No Trigger Routine", + group: "no_schedule", + expectedLabel: "No schedule", + }, + ]; + + for (const testCase of cases) { + const routine = await officeApi.createRoutine(officeSeed.workspaceId, { + name: testCase.name, + }); + if (testCase.seed) { + await testCase.seed(routine.id as string); + } + } + + await testPage.goto("/office/routines"); + + for (const testCase of cases) { + const row = rowForRoutine(testPage, testCase.name); + await expect(row.getByText(testCase.expectedLabel, { exact: true })).toBeVisible({ + timeout: 10_000, + }); + } + + // AC-002.11: event_only never renders as having no schedule, even + // though it also fires without a cron trigger. + const webhookRow = rowForRoutine(testPage, "E2E Webhook Routine"); + await expect(webhookRow.getByText("No schedule", { exact: true })).toHaveCount(0); + + // AC-002.10: every case sharing a group above already asserted the same + // literal label text; the broken group additionally proves it across + // three different underlying raw states (trigger_invalid, + // trigger_unscheduled, trigger_disabled), not just two. + const brokenRoutineNames = cases.filter((c) => c.group === "broken").map((c) => c.name); + expect(brokenRoutineNames).toHaveLength(3); + }); +}); diff --git a/apps/web/lib/api/domains/office-routine-normalize.test.ts b/apps/web/lib/api/domains/office-routine-normalize.test.ts index f5c362a8ff..17c086e729 100644 --- a/apps/web/lib/api/domains/office-routine-normalize.test.ts +++ b/apps/web/lib/api/domains/office-routine-normalize.test.ts @@ -67,6 +67,20 @@ describe("normalizeRoutine", () => { expect(routine.lastRunAt).toBeUndefined(); }); + it("normalizes schedule state metadata from the list response", () => { + expect( + normalizeRoutine({ + schedule_state: "trigger_invalid", + unarmed_cron_triggers: [ + { trigger_id: "trigger-1", reasons: ["not_schedulable", "unknown"] }, + ], + }), + ).toMatchObject({ + scheduleState: "trigger_invalid", + unarmedCronTriggers: [{ triggerId: "trigger-1", reasons: ["not_schedulable"] }], + }); + }); + it("produces an empty object for task_template when absent, null, or malformed", () => { expect(normalizeRoutine({}).taskTemplate).toEqual({}); expect(normalizeRoutine({ task_template: null }).taskTemplate).toEqual({}); diff --git a/apps/web/lib/api/domains/office-routine-normalize.ts b/apps/web/lib/api/domains/office-routine-normalize.ts index 318c16795e..a9d60cc828 100644 --- a/apps/web/lib/api/domains/office-routine-normalize.ts +++ b/apps/web/lib/api/domains/office-routine-normalize.ts @@ -4,7 +4,10 @@ import type { Routine, RoutineRun, RoutineTrigger, + ScheduleState, UpdateRoutinePatch, + UnarmedCronTrigger, + UnarmedReason, } from "@/lib/state/slices/office/types"; type WireRecord = Record; @@ -33,6 +36,38 @@ function optBool(record: WireRecord, key: string): boolean | undefined { return typeof value === "boolean" ? value : undefined; } +const SCHEDULE_STATES = new Set([ + "armed", + "trigger_invalid", + "trigger_unscheduled", + "trigger_disabled", + "event_only", + "unscheduled_manual_only", + "unscheduled_no_trigger", + "unknown", +]); + +const UNARMED_REASONS = new Set(["disabled", "not_schedulable", "stalled"]); + +function normalizeScheduleState(value: unknown): ScheduleState | undefined { + if (typeof value !== "string") return undefined; + return SCHEDULE_STATES.has(value as ScheduleState) ? (value as ScheduleState) : undefined; +} + +function normalizeUnarmedCronTriggers(value: unknown): UnarmedCronTrigger[] { + if (!Array.isArray(value)) return []; + return value.filter(isJSONObject).map((entry) => { + const trigger = asRecord(entry); + const reasons = Array.isArray(trigger.reasons) + ? trigger.reasons.filter( + (reason): reason is UnarmedReason => + typeof reason === "string" && UNARMED_REASONS.has(reason as UnarmedReason), + ) + : []; + return { triggerId: str(trigger, "trigger_id"), reasons }; + }); +} + function isJSONObject(value: unknown): value is WireRecord { return value !== null && typeof value === "object" && !Array.isArray(value); } @@ -68,7 +103,7 @@ function assignIfDefined(body: WireRecord, key: string, value: unknown): void { export function normalizeRoutine(raw: unknown): Routine { const r = asRecord(raw); - return { + const routine: Routine = { id: str(r, "id"), workspaceId: str(r, "workspace_id"), name: str(r, "name"), @@ -84,6 +119,12 @@ export function normalizeRoutine(raw: unknown): Routine { createdAt: str(r, "created_at"), updatedAt: str(r, "updated_at"), }; + const scheduleState = normalizeScheduleState(r.schedule_state); + if (scheduleState !== undefined) routine.scheduleState = scheduleState; + if (Array.isArray(r.unarmed_cron_triggers)) { + routine.unarmedCronTriggers = normalizeUnarmedCronTriggers(r.unarmed_cron_triggers); + } + return routine; } export function normalizeRoutineTrigger(raw: unknown): RoutineTrigger { diff --git a/apps/web/lib/state/slices/office/types.ts b/apps/web/lib/state/slices/office/types.ts index 430ac22baf..9dc44f6734 100644 --- a/apps/web/lib/state/slices/office/types.ts +++ b/apps/web/lib/state/slices/office/types.ts @@ -150,6 +150,25 @@ export type BudgetPolicy = { export type RoutineStatus = "active" | "paused" | "archived"; +// ScheduleState is REQ-OFFICE-ROUTINE-ARMING-001's classification of whether a +// routine's triggers can currently fire it, independent of `status` (intent). +export type ScheduleState = + | "armed" + | "trigger_invalid" + | "trigger_unscheduled" + | "trigger_disabled" + | "event_only" + | "unscheduled_manual_only" + | "unscheduled_no_trigger" + | "unknown"; + +export type UnarmedReason = "disabled" | "not_schedulable" | "stalled"; + +export type UnarmedCronTrigger = { + triggerId: string; + reasons: UnarmedReason[]; +}; + export type Routine = { id: string; workspaceId: string; @@ -167,6 +186,8 @@ export type Routine = { lastRunAt?: string; createdAt: string; updatedAt: string; + scheduleState?: ScheduleState; + unarmedCronTriggers?: UnarmedCronTrigger[]; }; export type RoutineTriggerKind = "cron" | "webhook"; diff --git a/apps/web/src/locales/en/office.json b/apps/web/src/locales/en/office.json index ed8bae9f61..828ca50a88 100644 --- a/apps/web/src/locales/en/office.json +++ b/apps/web/src/locales/en/office.json @@ -780,6 +780,16 @@ "saving": "Saving...", "savingEllipsis": "Saving…", "schedule": "Schedule", + "scheduleStateArmed": "Armed", + "scheduleStateArmedDescription": "A cron trigger can fire this routine on schedule.", + "scheduleStateBroken": "Schedule broken", + "scheduleStateBrokenDescription": "This routine has a cron trigger, but it cannot currently fire.", + "scheduleStateEventOnly": "Event-triggered", + "scheduleStateEventOnlyDescription": "Fires when its webhook event arrives, not on a schedule.", + "scheduleStateNoSchedule": "No schedule", + "scheduleStateNoScheduleDescription": "This routine has no schedule and will not fire on its own.", + "scheduleStateUnknown": "Schedule unknown", + "scheduleStateUnknownDescription": "The routine's triggers could not be read, so its schedule state could not be determined.", "scopeId": "Scope ID", "scopeType": "Scope Type", "search": "Search...", @@ -948,6 +958,8 @@ "type": "Type", "typeNameToConfirm": "Type {{name}} to confirm", "typicalMappingAtLaunchTimeThe": "Typical mapping. At launch time the router walks the provider order and picks the first healthy provider, so a degraded primary may cause this run to use a different provider/model than shown here.", + "unarmedNeedsEdit": "Needs schedule fix", + "unarmedNeedsRearm": "Needs re-arming", "unassigned": "Unassigned", "unassignedParen": "(unassigned)", "unknownError": "Unknown error", diff --git a/apps/web/src/locales/pseudo/office.json b/apps/web/src/locales/pseudo/office.json index d11ba3fb31..f35fe94dd9 100644 --- a/apps/web/src/locales/pseudo/office.json +++ b/apps/web/src/locales/pseudo/office.json @@ -780,6 +780,16 @@ "saving": "Śàvĩńĝ...", "savingEllipsis": "Śàvĩńĝ…", "schedule": "Śćĥēďũĺē", + "scheduleStateArmed": "Àŕḿēď", + "scheduleStateArmedDescription": "À ćŕōń ţŕĩĝĝēŕ ćàń ƒĩŕē ţĥĩś ŕōũţĩńē ōń śćĥēďũĺē.", + "scheduleStateBroken": "Śćĥēďũĺē ƀŕōķēń", + "scheduleStateBrokenDescription": "Ţĥĩś ŕōũţĩńē ĥàś à ćŕōń ţŕĩĝĝēŕ, ƀũţ ĩţ ćàńńōţ ćũŕŕēńţĺŷ ƒĩŕē.", + "scheduleStateEventOnly": "Ēvēńţ-ţŕĩĝĝēŕēď", + "scheduleStateEventOnlyDescription": "Ƒĩŕēś ŵĥēń ĩţś ŵēƀĥōōķ ēvēńţ àŕŕĩvēś, ńōţ ōń à śćĥēďũĺē.", + "scheduleStateNoSchedule": "Ńō śćĥēďũĺē", + "scheduleStateNoScheduleDescription": "Ţĥĩś ŕōũţĩńē ĥàś ńō śćĥēďũĺē àńď ŵĩĺĺ ńōţ ƒĩŕē ōń ĩţś ōŵń.", + "scheduleStateUnknown": "Śćĥēďũĺē ũńķńōŵń", + "scheduleStateUnknownDescription": "Ţĥē ŕōũţĩńē'ś ţŕĩĝĝēŕś ćōũĺď ńōţ ƀē ŕēàď, śō ĩţś śćĥēďũĺē śţàţē ćōũĺď ńōţ ƀē ďēţēŕḿĩńēď.", "scopeId": "Śćōƥē ĨĎ", "scopeType": "Śćōƥē Ţŷƥē", "search": "Śēàŕćĥ...", @@ -948,6 +958,8 @@ "type": "Ţŷƥē", "typeNameToConfirm": "Ţŷƥē {{name}} ţō ćōńƒĩŕḿ", "typicalMappingAtLaunchTimeThe": "Ţŷƥĩćàĺ ḿàƥƥĩńĝ. Àţ ĺàũńćĥ ţĩḿē ţĥē ŕōũţēŕ ŵàĺķś ţĥē ƥŕōvĩďēŕ ōŕďēŕ àńď ƥĩćķś ţĥē ƒĩŕśţ ĥēàĺţĥŷ ƥŕōvĩďēŕ, śō à ďēĝŕàďēď ƥŕĩḿàŕŷ ḿàŷ ćàũśē ţĥĩś ŕũń ţō ũśē à ďĩƒƒēŕēńţ ƥŕōvĩďēŕ/ḿōďēĺ ţĥàń śĥōŵń ĥēŕē.", + "unarmedNeedsEdit": "Ńēēďś śćĥēďũĺē ƒĩx", + "unarmedNeedsRearm": "Ńēēďś ŕē-àŕḿĩńĝ", "unassigned": "Ũńàśśĩĝńēď", "unassignedParen": "(ũńàśśĩĝńēď)", "unknownError": "Ũńķńōŵń ēŕŕōŕ", diff --git a/apps/web/src/locales/pt-pt/office.json b/apps/web/src/locales/pt-pt/office.json index 8093c47ebe..ef584c1139 100644 --- a/apps/web/src/locales/pt-pt/office.json +++ b/apps/web/src/locales/pt-pt/office.json @@ -780,6 +780,16 @@ "saving": "A guardar...", "savingEllipsis": "A guardar…", "schedule": "Agendamento", + "scheduleStateArmed": "Armado", + "scheduleStateArmedDescription": "Existe um acionador cron que pode disparar esta rotina de acordo com o agendamento.", + "scheduleStateBroken": "Agendamento com falha", + "scheduleStateBrokenDescription": "Esta rotina tem um acionador cron, mas este não consegue disparar atualmente.", + "scheduleStateEventOnly": "Acionada por evento", + "scheduleStateEventOnlyDescription": "Dispara quando chega o respetivo evento de webhook, não segundo um agendamento.", + "scheduleStateNoSchedule": "Sem agendamento", + "scheduleStateNoScheduleDescription": "Esta rotina não tem agendamento e não dispara por si própria.", + "scheduleStateUnknown": "Agendamento desconhecido", + "scheduleStateUnknownDescription": "Não foi possível ler os acionadores desta rotina, pelo que não foi possível determinar o seu estado de agendamento.", "scopeId": "ID do âmbito", "scopeType": "Tipo de âmbito", "search": "Pesquisar...", @@ -948,6 +958,8 @@ "type": "Tipo", "typeNameToConfirm": "Escreva {{name}} para confirmar", "typicalMappingAtLaunchTimeThe": "Mapeamento típico. No momento do lançamento, o encaminhador percorre a ordem dos fornecedores e escolhe o primeiro que estiver saudável, pelo que um fornecedor principal degradado pode fazer com que esta execução use um fornecedor/modelo diferente do aqui apresentado.", + "unarmedNeedsEdit": "Necessita de correção do agendamento", + "unarmedNeedsRearm": "Necessita de ser reativado", "unassigned": "Sem responsável", "unassignedParen": "(sem responsável)", "unknownError": "Erro desconhecido", diff --git a/apps/web/src/locales/zh-cn/office.json b/apps/web/src/locales/zh-cn/office.json index 4098eb9ed2..07334ffad5 100644 --- a/apps/web/src/locales/zh-cn/office.json +++ b/apps/web/src/locales/zh-cn/office.json @@ -780,6 +780,16 @@ "saving": "正在保存…", "savingEllipsis": "正在保存…", "schedule": "计划", + "scheduleStateArmed": "已就绪", + "scheduleStateArmedDescription": "存在可按计划触发此例程的定时触发器。", + "scheduleStateBroken": "计划异常", + "scheduleStateBrokenDescription": "此例程存在定时触发器,但目前无法触发。", + "scheduleStateEventOnly": "事件触发", + "scheduleStateEventOnlyDescription": "当其 Webhook 事件到达时触发,而非按计划触发。", + "scheduleStateNoSchedule": "无计划", + "scheduleStateNoScheduleDescription": "此例程没有计划,不会自行触发。", + "scheduleStateUnknown": "计划状态未知", + "scheduleStateUnknownDescription": "无法读取此例程的触发器,因此无法确定其计划状态。", "scopeId": "范围 ID", "scopeType": "范围类型", "search": "搜索…", @@ -948,6 +958,8 @@ "type": "类型", "typeNameToConfirm": "输入 {{name}} 以确认", "typicalMappingAtLaunchTimeThe": "典型映射。启动时路由会按提供商顺序遍历并选择第一个健康提供商,因此主提供商降级时,此运行可能使用与此处显示不同的提供商/模型。", + "unarmedNeedsEdit": "需要修正计划", + "unarmedNeedsRearm": "需要重新启用", "unassigned": "未分配", "unassignedParen": "(未分配)", "unknownError": "未知错误", diff --git a/apps/web/src/locales/zh-hk/office.json b/apps/web/src/locales/zh-hk/office.json index b7b0a497ad..b8dfec3547 100644 --- a/apps/web/src/locales/zh-hk/office.json +++ b/apps/web/src/locales/zh-hk/office.json @@ -780,6 +780,16 @@ "saving": "正在儲存…", "savingEllipsis": "正在儲存…", "schedule": "計劃", + "scheduleStateArmed": "已就緒", + "scheduleStateArmedDescription": "存在可按計劃觸發此例程的定時觸發器。", + "scheduleStateBroken": "計劃異常", + "scheduleStateBrokenDescription": "此例程存在定時觸發器,但目前無法觸發。", + "scheduleStateEventOnly": "事件觸發", + "scheduleStateEventOnlyDescription": "當其 Webhook 事件到達時觸發,而非按計劃觸發。", + "scheduleStateNoSchedule": "無計劃", + "scheduleStateNoScheduleDescription": "此例程沒有計劃,不會自行觸發。", + "scheduleStateUnknown": "計劃狀態未知", + "scheduleStateUnknownDescription": "無法讀取此例程的觸發器,因此無法確定其計劃狀態。", "scopeId": "範圍 ID", "scopeType": "範圍類型", "search": "搜尋…", @@ -948,6 +958,8 @@ "type": "類型", "typeNameToConfirm": "輸入 {{name}} 以確認", "typicalMappingAtLaunchTimeThe": "典型映射。啓動時路由會按提供者順序遍歷並選擇第一個健康提供者,因此主提供者降級時,此執行可能使用與此處顯示不同的提供者/模型。", + "unarmedNeedsEdit": "需要修正計劃", + "unarmedNeedsRearm": "需要重新啓用", "unassigned": "未分配", "unassignedParen": "(未分配)", "unknownError": "未知錯誤", diff --git a/apps/web/src/locales/zh-tw/office.json b/apps/web/src/locales/zh-tw/office.json index 9a95ac78c6..ac216f500b 100644 --- a/apps/web/src/locales/zh-tw/office.json +++ b/apps/web/src/locales/zh-tw/office.json @@ -780,6 +780,16 @@ "saving": "正在儲存…", "savingEllipsis": "正在儲存…", "schedule": "計劃", + "scheduleStateArmed": "已就緒", + "scheduleStateArmedDescription": "存在可按計劃觸發此例程的定時觸發器。", + "scheduleStateBroken": "計劃異常", + "scheduleStateBrokenDescription": "此例程存在定時觸發器,但目前無法觸發。", + "scheduleStateEventOnly": "事件觸發", + "scheduleStateEventOnlyDescription": "當其 Webhook 事件到達時觸發,而非按計劃觸發。", + "scheduleStateNoSchedule": "無計劃", + "scheduleStateNoScheduleDescription": "此例程沒有計劃,不會自行觸發。", + "scheduleStateUnknown": "計劃狀態未知", + "scheduleStateUnknownDescription": "無法讀取此例程的觸發器,因此無法確定其計劃狀態。", "scopeId": "範圍 ID", "scopeType": "範圍型別", "search": "搜尋…", @@ -948,6 +958,8 @@ "type": "型別", "typeNameToConfirm": "輸入 {{name}} 以確認", "typicalMappingAtLaunchTimeThe": "典型對映。啟動時路由會按提供者順序遍歷並選擇第一個健康提供者,因此主提供者降級時,此執行可能使用與此處顯示不同的提供者/模型。", + "unarmedNeedsEdit": "需要修正計劃", + "unarmedNeedsRearm": "需要重新啟用", "unassigned": "未分配", "unassignedParen": "(未分配)", "unknownError": "未知錯誤", diff --git a/apps/web/src/office-routine-client-routes.test.tsx b/apps/web/src/office-routine-client-routes.test.tsx index 1b6192f4de..9c03e34c8c 100644 --- a/apps/web/src/office-routine-client-routes.test.tsx +++ b/apps/web/src/office-routine-client-routes.test.tsx @@ -1,6 +1,7 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { cleanup, render, screen, waitFor } from "@testing-library/react"; import { StateProvider } from "@/components/state-provider"; +import { TooltipProvider } from "@kandev/ui/tooltip"; import { defaultOfficeState } from "@/lib/state/slices/office/office-slice"; import type { Routine, RoutineTrigger } from "@/lib/state/slices/office/types"; import { RoutineDetailRoute } from "./office-routine-client-routes"; @@ -63,7 +64,9 @@ function renderRoute() { office: { ...defaultOfficeState.office }, }} > - + + + , ); } diff --git a/docs/plans/office-routine-arming/plan.md b/docs/plans/office-routine-arming/plan.md new file mode 100644 index 0000000000..2f8dbb3ea7 --- /dev/null +++ b/docs/plans/office-routine-arming/plan.md @@ -0,0 +1,180 @@ +--- +requirements: + - REQ-OFFICE-ROUTINE-ARMING-001 + - REQ-OFFICE-ROUTINE-ARMING-002 + - REQ-OFFICE-ROUTINE-ARMING-003 + - REQ-OFFICE-COORDINATOR-INSTALL-001 +system_design: + - ../../specs/office/system-design/routine-schedule-state.md + - ../../specs/office/system-design/coordinator-install-idempotency.md +created: 2026-09-17 +status: done +--- + +# Implementation Plan: Office Routine Arming Visibility and Coordinator Install Idempotency + +## Overview + +An Office routine has two independent switches — intent (`office_routines.status`) +and schedule state (whether its triggers can actually fire it) — and the product +today reports only intent. This plan makes schedule state visible everywhere +intent already is (routine reads, an unattended-install startup scan) and closes +the one identity defect that lets onboarding duplicate the pre-installed +coordinator routine. It repairs nothing: no criterion in any of the three +requirement documents enables, disables, creates, or deletes a trigger, and none +changes the existing routine-status dispatch gates. The scheduler already checks +status before it claims a cron slot, and the manual and webhook paths reject +non-firing statuses; this plan only makes the independent schedule state visible. + +Two system-design documents back this plan: +[Routine Schedule State](../../specs/office/system-design/routine-schedule-state.md) +covers REQ-OFFICE-ROUTINE-ARMING-001/002/003 (classification, the read-path +report, and the startup scan — one vertical feature contract split across two +requirement documents by scope), and +[Coordinator Install Idempotency](../../specs/office/system-design/coordinator-install-idempotency.md) +covers REQ-OFFICE-COORDINATOR-INSTALL-001. Both were written after Build, +grounded directly in the merged implementation, rather than before it — the +three requirement documents already pinned every table, column, function, and +endpoint this plan touches at file-and-line precision, so drafting the designs +speculatively ahead of code would have either duplicated that text or drifted +from it. This plan itself still carries the remaining architecture decisions — +package placement, response shape, verification boundaries — that the +requirements deliberately left to Build. + +Work lands in three waves: classification is the foundation everything else +reads (Wave 1); the read-path report, the startup scan, and the coordinator +install fix are three independent consumers of it and can proceed in parallel +(Wave 2); the frontend surface depends on the backend response shape Wave 2 +defines (Wave 3). + +## Code facts confirmed on this branch (2026-09-17) + +All paths relative to `apps/backend`. + +- **Schema.** `office_routines` and `office_routine_triggers` are defined in + `internal/office/repository/sqlite/base.go`, `createRoutineTables` + (L414-467) — one dialect-agnostic file for both SQLite and PostgreSQL via + `internal/db/dialect`. There is no separate PostgreSQL schema file. +- **Trigger reads today.** `internal/office/repository/sqlite/routines.go`: + `GetDueTriggers` (L65-78) filters `kind='cron' AND enabled=1 AND + next_run_at IS NOT NULL AND next_run_at <= ?` with no `ORDER BY` and does not + read `office_routines.status`. `RoutineService.processCronTrigger` reads the + routine and applies its status gate before `ClaimTrigger`; the query is only + the first stage of that path. `ClaimTrigger` and `UpdateTriggerNextRun` remain + the trigger-state writers. +- **Coordinator install today.** `internal/office/routines/service.go`: + `CreateDefaultCoordinatorRoutine` (L145-196) calls `findCoordinatorRoutine` + (L197-217), which matches workspace + assignee + canonical name + + `hasCronTrigger` (L222-239, ignores `enabled`, matches on kind + exact + expression only). Its two production callers — + `internal/office/agents/service.go:572` and + `internal/office/onboarding/service.go:767` — both discard the returned + routine and only warn-log the error (`if _, err := ...`), which is why + AC-OFFICE-COORDINATOR-INSTALL-001.10 forbids reporting through a return + value. +- **Cron evaluation.** `internal/office/shared/cron.go`: `NextCronTime` + validates the timezone, requires exactly 5 fields, follows the shared + day-of-month/day-of-week OR and DST rules, and returns `ErrUnsatisfiableCron` + when an otherwise valid expression has no possible occurrence. Schedule + classification uses the same occurrence path. +- **Startup sequencing.** `internal/office/infra/reconcile.go`: + `Reconciler.ReconcileAll` (L34-54) is synchronous with a void return; + `createTriggersForNewRoutines` (L113-132) gives every trigger-less routine + a `manual` trigger. Called from `internal/backendapp/main.go:1378-1380`, + immediately followed by `log.Info("Office reconciliation complete")` and, + a few lines later (~L1393), `services.Office.RegisterEventSubscribers`. + That gap is the wiring point for the startup-scan completion signal + required by AC-OFFICE-ROUTINE-ARMING-003.1 — nothing today observes + `ReconcileAll` returning. +- **The actual routine list/detail response.** + `internal/office/routines/handler.go`'s `listRoutines` (L45-52) and + `getRoutine` (L98-105) back `GET /workspaces/:wsId/routines` and + `GET /routines/:id`, returning `RoutineListResponse`/`RoutineResponse` + (`internal/office/routines/dto.go:34-41`) — today just `*Routine` + (intent only). This, not the dashboard, is Task 02's target. + Separately, `internal/office/dashboard/service_agents.go`'s + `ListRoutines` block inside `runSoftQueries` (~L551-561) only counts + `status == "active"` into the workspace KPI tile's `RoutineCount` and is + not in scope. +- **Trigger HTTP routes**, `internal/office/routines/handler.go`, + `RegisterRoutes` (L30-42): `GET`/`POST /routines/:id/triggers`, + `DELETE /routine-triggers/:triggerId`, + `POST /routine-triggers/:publicId/fire`. No route edits, enables, or + disables a trigger — confirms `## Out of scope` → *Disabling a trigger from + the UI* in the visibility document. +- **Metrics prior art.** `internal/orchestrator/office_stall_metrics.go` + (`expvar.NewMap` + label builder, L20-46) and + `internal/workflow/signalmetrics/` are the two labelled-counter + conventions already in the codebase; this plan's new counters follow the + same shape rather than inventing a third. +- **Persistence conformance.** `internal/persistence/requiredstores` already + registers office's schema owner (`catalog.go:52`) and + `internal/persistence/storeconformance` already has a fixed office + adapter (`adapters.go:224-229`). No new table is required by any + criterion in these three documents; Task 05's serialization mechanism must + be chosen so that stays true (see its risks). + +## Waves + +### Wave 1 — classify + +- [x] [Task 01: Classify a routine's schedule state](task-01-classify-schedule-state.md) + +### Wave 2 — three independent consumers of classification + +- [x] [Task 02: Report intent and schedule state on routine reads (API)](task-02-report-schedule-state-api.md) +- [x] [Task 04: Surface unarmed routines at startup](task-04-startup-scan.md) +- [x] [Task 05: Match the coordinator routine on identity, not mutable trigger state](task-05-coordinator-install-idempotency.md) + +### Wave 3 — frontend + +- [x] [Task 03: Render the intent/schedule-state distinction in the routines UI](task-03-report-schedule-state-ui.md) + +Tasks 02, 04, and 05 touch disjoint files and have independent verification +boundaries; once Task 01 lands they may proceed in parallel. Task 03 depends +only on Task 02's response shape, not on Task 04 or Task 05. + +## Required workflow verification + +After all task-level checks pass: + +1. `cd apps/backend && gofmt -l $(git diff --name-only main -- '*.go')` — must + be empty. +2. `make -C apps/backend test` (full suite; the office and persistence + packages are the ones this plan can regress). +3. `make -C apps/backend lint`. +4. If Task 05 adds a schema owner (see its risks): + `go run ./cmd/sqlguard ./internal` and + `go test -race ./internal/persistence/storeconformance -count=1` from + `apps/backend`. +5. `cd apps/web && pnpm run typecheck && pnpm run lint`. +6. `cd apps/web && pnpm run i18n:check`. +7. Playwright coverage per Task 03 (`cd apps/web && pnpm e2e:run`, scoped to + the affected spec — see that task). +8. Commit the explicit changed paths with a Conventional Commit message. + +## Risks and non-goals + +- **Keep repairs separate from visibility.** The operator-initiated re-arm + action remains outside this plan. A work order that starts writing `enabled` + or `next_run_at` outside Task 05's coordinator-install path has drifted from + scope. Schedule classification must continue to report an impossible cron + expression as `trigger_invalid`, matching `NextCronTime` and the scheduler's + permanent-disarm behavior. +- **Dispatch grace is one constant, not two.** AC-OFFICE-ROUTINE-ARMING-001.10 + requires classification (Task 01) and the startup scan (Task 04) to share + the exact same 60-second compile-time constant. Define it once in Task 01 + and import it in Task 04; do not redeclare it. +- **Task 05's serialization mechanism is Build's choice, but three + candidates are pre-ruled-out in the requirement's own `## Out of scope`:** + a bare default-isolation transaction (both callers can still both read + "absent"), a PostgreSQL advisory lock (no SQLite equivalent), and an + in-process-only guard (fails the documented cross-process domain). See + Task 05 for the constraint set the chosen mechanism must satisfy. +- **No coverage percentage is a project gate.** New tests must assert the + specific acceptance criteria named in each task, not a line-coverage + target. +- **Translation completeness.** Task 03's copy must exist in all five + shipped locales (`pt-pt`, `zh-cn`, `zh-hk`, `zh-tw`, plus source English) + before `pnpm run i18n:check` passes; use `pnpm run i18n:zh-hant` for the + Traditional Chinese pair rather than hand-translating. diff --git a/docs/plans/office-routine-arming/task-01-classify-schedule-state.md b/docs/plans/office-routine-arming/task-01-classify-schedule-state.md new file mode 100644 index 0000000000..6c02beb34a --- /dev/null +++ b/docs/plans/office-routine-arming/task-01-classify-schedule-state.md @@ -0,0 +1,125 @@ +--- +id: "01-classify-schedule-state" +title: "Classify a routine's schedule state" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-OFFICE-ROUTINE-ARMING-001 +acceptance_criteria: + - AC-OFFICE-ROUTINE-ARMING-001.1 + - AC-OFFICE-ROUTINE-ARMING-001.2 + - AC-OFFICE-ROUTINE-ARMING-001.3 + - AC-OFFICE-ROUTINE-ARMING-001.4 + - AC-OFFICE-ROUTINE-ARMING-001.5 + - AC-OFFICE-ROUTINE-ARMING-001.6 + - AC-OFFICE-ROUTINE-ARMING-001.7 + - AC-OFFICE-ROUTINE-ARMING-001.8 + - AC-OFFICE-ROUTINE-ARMING-001.9 + - AC-OFFICE-ROUTINE-ARMING-001.10 + - AC-OFFICE-ROUTINE-ARMING-001.11 + - AC-OFFICE-ROUTINE-ARMING-001.12 + - AC-OFFICE-ROUTINE-ARMING-001.13 +system_design: + - ../../specs/office/system-design/routine-schedule-state.md +--- + +# Task 01: Classify a routine's schedule state + +Satisfies REQ-OFFICE-ROUTINE-ARMING-001 (AC-OFFICE-ROUTINE-ARMING-001.1 through +.13). + +## Scope + +A pure, total classification of one routine's schedule state from its trigger +rows alone, plus the batched-read-with-per-routine-fallback semantics that +AC-001.12 and AC-001.13 require, plus the unarmed-cron-trigger list AC-001.8 +requires alongside it. This task defines the dispatch-grace constant +(AC-001.10) that Task 04 (startup scan) also consumes — define it once here. + +## Exclusions + +- No HTTP response shape, no dashboard change, no UI (Task 02/03). +- No startup scan (Task 04). +- No coordinator-install change (Task 05) — it consumes this task's output but + this task does not touch `routines/service.go`'s coordinator-install path. +- No repair: this task's function set never writes `office_routine_triggers` + or `office_routines`. + +## Acceptance conditions + +1. `ClassifyRoutine(triggers []models.RoutineTrigger, now time.Time) (ScheduleState, []UnarmedCronTrigger)` + (exact name/package left to Build; suggested home: + `internal/office/routines`, since that package already owns trigger + dispatch logic and is already imported by `office/agents` and + `office/onboarding`) implements the nine-rule table in rule order, + stopping at first match (AC-001.1), never reads `office_routines.status` + (AC-001.2), and treats a past-due or exactly-at-`now` `next_run_at` as + rule 1 (AC-001.3, AC-001.4). It returns every applicable unarmed-cron + reason per trigger, in the fixed order (`enabled=false`, then + "not schedulable", then "stalled past dispatch grace"), in trigger order + (`created_at` ascending, `id` ascending) (AC-001.8, AC-001.9, AC-001.11). + A `dispatchGrace` constant of exactly `60 * time.Second`, unexported and + with no environment/config/flag override, backs rule 2 and the unarmed + list's third reason (AC-001.10). +2. A batch classification entry point reads every named routine's trigger + rows in one query, captures one `now` for the whole call, and on a batch + read failure (or one undecodable row) re-reads exactly the affected + routines individually — once each — reporting `unknown` only for those + whose individual re-read also fails, reusing the batch call's captured + `now` rather than recapturing it per routine (AC-001.5, AC-001.12, + AC-001.13). A routine whose reads never fail counts as one query + contribution regardless of how many other routines are classified in the + same call (feeds Task 02's AC-002.7 query-count bound). +3. Table-driven tests cover: every one of the 9 rules in isolation; the + rule-1/rule-9 interaction of AC-001.9 (armed on one trigger, unarmed list + non-empty from a sibling); the never-fired exclusion of AC-001.11; the + batch-then-per-routine-fallback path of AC-001.13 with a fixture that + forces one row in a multi-routine batch to fail decode; and identical + output on SQLite and PostgreSQL for the same rows (AC-001.6, via the + `storeconformance`/`KANDEV_TEST_POSTGRES_DSN` pattern already used + elsewhere in this package's tests). + +## Verification + +- `cd apps/backend && go test -tags fts5 ./internal/office/routines/... ./internal/office/repository/sqlite/...` +- New tests must first fail against the current tree, where no classifier + exists. +- `KANDEV_TEST_POSTGRES_DSN= go test -tags fts5 ./internal/office/repository/sqlite/... -run TestClassify` (or + equivalent name) for the PostgreSQL leg of AC-001.6. + +## Files likely touched + +- new: `apps/backend/internal/office/routines/arming.go` +- new: `apps/backend/internal/office/routines/arming_test.go` +- `apps/backend/internal/office/repository/sqlite/routines.go` — add the + batch trigger-read query and the single-routine fallback read. +- `apps/backend/internal/office/repository/sqlite/routines_test.go` + +## Dependencies + +None. This is the foundation for Tasks 02, 04, and 05. + +## Parallelism + +Must land before Wave 2. Nothing in Wave 1 can run in parallel with it. + +## Result + +Implemented in `internal/office/routines/arming.go` (unexported `dispatchGrace`, +`ClassifyRoutine`, `ClassifyRoutines`, `RoutineTriggerReader`) plus +`ListTriggersByRoutineIDs` and an `, id` trigger-order tiebreak on +`ListTriggersByRoutineID` in `internal/office/repository/sqlite/routines.go`. +Tests: `arming_test.go` (all 9 rules, precedence, unarmed-reason ordering, the +armed/unarmed-sibling interaction, trigger order, batch-then-fallback), +`arming_dialect_postgres_test.go` (AC-001.6, gated on +`KANDEV_TEST_POSTGRES_DSN`, not run in this environment), and a new +`TestListTriggersByRoutineIDs_Batch` in `routines_test.go` covering the id +tiebreak and the empty-routine/empty-input cases. + +Verification: `gofmt -l` clean; `go test -tags fts5 +./internal/office/routines/... ./internal/office/repository/sqlite/...` +passes except the pre-existing, unrelated `TestMigrate_PriorityIdempotent` +failure (reproduced on the unmodified tree before this task's changes, via +`git stash`); `golangci-lint run` clean on both packages. diff --git a/docs/plans/office-routine-arming/task-02-report-schedule-state-api.md b/docs/plans/office-routine-arming/task-02-report-schedule-state-api.md new file mode 100644 index 0000000000..1f14f6bdf6 --- /dev/null +++ b/docs/plans/office-routine-arming/task-02-report-schedule-state-api.md @@ -0,0 +1,176 @@ +--- +id: "02-report-schedule-state-api" +title: "Report intent and schedule state on routine reads (API)" +status: done +wave: 2 +depends_on: ["01-classify-schedule-state"] +plan: "plan.md" +requirements: + - REQ-OFFICE-ROUTINE-ARMING-002 +acceptance_criteria: + - AC-OFFICE-ROUTINE-ARMING-002.1 + - AC-OFFICE-ROUTINE-ARMING-002.4 + - AC-OFFICE-ROUTINE-ARMING-002.5 + - AC-OFFICE-ROUTINE-ARMING-002.6 + - AC-OFFICE-ROUTINE-ARMING-002.7 + - AC-OFFICE-ROUTINE-ARMING-002.11 + - AC-OFFICE-ROUTINE-ARMING-002.12 +system_design: + - ../../specs/office/system-design/routine-schedule-state.md +--- + +# Task 02: Report intent and schedule state on routine reads (API) + +Satisfies REQ-OFFICE-ROUTINE-ARMING-002's data-shape criteria: +AC-OFFICE-ROUTINE-ARMING-002.1, .4 (data only — rendering is Task 03), .5, +.6, .7, .11, .12. The rendering-only criteria (.2, .3, .8, .9, .10) belong +to Task 03, which depends on this task's response shape. + +## Scope + +Extend the routine list response and the single-routine response to carry, +per routine: intent (`office_routines.status`, unchanged), schedule state, +and the unarmed cron trigger list, both from Task 01's classifier — without +growing the query count with the number of routines in the workspace. + +## Exclusions + +- No new classification logic — call Task 01's batch entry point. +- No UI change (Task 03). +- No startup scan (Task 04). +- No coordinator-install change (Task 05). + +## Acceptance conditions + +1. The routine list response and the single-routine response each carry + intent, schedule state, and the unarmed cron trigger list per routine, in + one call each (AC-002.1). The pair is not required to be a transactional + snapshot: intent comes from the routine row, schedule state from the + trigger rows, and neither is omitted because the other read failed — a + routine whose triggers could not be read still carries its intent + alongside schedule state `unknown` (AC-002.12). +2. Producing the list costs a bounded number of queries that does not grow + with routine count on the all-succeeds path: one query for routines, one + batch query for triggers (Task 01's batch entry point), each isolated + Task-001.13 re-read counted as the documented exception rather than a + regression (AC-002.7). +3. Response shape distinguishes, without prescribing rendering: a schedule + state of `unscheduled_manual_only` or `unscheduled_no_trigger` is + reportable as "no schedule" (AC-002.5); `unknown` is reportable as + undetermined, distinct from armed or broken (AC-002.6); `event_only` is + never bundled with "no schedule" and never asks for a trigger to be + created (AC-002.11); and for a non-empty unarmed list, whether at least + one entry is schedulable is derivable by the caller without it having to + inspect the dispatch-grace math itself — an empty list carries neither + side of that distinction (AC-002.4). + +## Verification + +- `cd apps/backend && go test -tags fts5 ./internal/office/dashboard/... ./internal/office/routines/...` +- Test: routine list response for a fixture with all nine schedule states + present carries the right state and unarmed list per routine. +- Test: query-count assertion (via a counting DB wrapper or an existing + budget-test pattern in this package) proves AC-002.7 on an all-succeeds + fixture of N routines, and separately on a fixture that forces one + Task-001.13 re-read. +- Test: a routine whose trigger read fails carries its real intent value + alongside `unknown` (AC-002.12). + +## Files likely touched + +- `apps/backend/internal/office/routines/handler.go` — `listRoutines` + (L45-52) and `getRoutine` (L98-105) are the actual routine list/detail + HTTP handlers (`GET /workspaces/:wsId/routines`, `GET /routines/:id`); + call Task 01's `ClassifyRoutines`/`ClassifyRoutine` here and populate the + extended response. +- `apps/backend/internal/office/routines/dto.go` — `RoutineResponse` (L34) + and `RoutineListResponse` (L39) currently carry only `*Routine` + (`{routine}` / `{routines}` — intent only). Extend them to also carry + schedule state and the unarmed cron trigger list per routine (e.g. a + wrapper type embedding `*Routine` plus the two new fields, so intent and + schedule state travel in the same JSON object per AC-002.1). +- Note: `apps/backend/internal/office/dashboard/service_agents.go`'s + `ListRoutines` block (~L551-561) is a separate, pre-existing aggregate — + it only counts `status == "active"` into the workspace dashboard's KPI + tile and is not this requirement's target; leave it alone unless it also + needs the distinction (out of scope unless the dashboard tile itself is + asked to change). +- Corresponding `*_test.go` files (`internal/office/routines/handler_test.go` + or new). + +## Dependencies + +Task 01. + +## Parallelism + +Independent of Task 04 and Task 05 once Task 01 lands — no shared files. + +## Result + +Implemented: + +- `apps/backend/internal/office/routines/dto.go` — added `RoutineWithSchedule` + (embeds `*Routine`, adds `schedule_state` and `unarmed_cron_triggers` JSON + fields). `RoutineResponse.Routine` and `RoutineListResponse.Routines` now + carry `*RoutineWithSchedule` / `[]*RoutineWithSchedule` instead of the bare + `*Routine`. +- `apps/backend/internal/office/routines/service.go` — added + `ListTriggersByRoutineIDs` to the `Repository` interface (structurally + required so `s.repo` satisfies Task 01's `RoutineTriggerReader`), and added + `AttachScheduleState(ctx, []*Routine) ([]*RoutineWithSchedule, error)`, + which calls `ClassifyRoutines` once per call (one batch trigger query, + independent of routine count) and pairs each routine's intent with its + schedule state and unarmed list. +- `apps/backend/internal/office/routines/handler.go` — `listRoutines` and a + new `withScheduleState` helper (used by `createRoutine`, `getRoutine`, + `updateRoutine`) call `AttachScheduleState` before building the response. + No other file in `internal/` constructs `RoutineResponse`/ + `RoutineListResponse` (confirmed via grep), so no other call sites needed + updates. + +AC-002.1: `RoutineWithSchedule` carries intent (`Status`, from the embedded +`*Routine`), `ScheduleState`, and `UnarmedCronTriggers` in one JSON object. +AC-002.12: covered by `TestAttachScheduleState_TriggerReadFailureKeepsIntent` +— a routine whose trigger reads fail (batch and single) keeps its real +`Status` alongside `ScheduleStateUnknown`. +AC-002.7: covered by `TestAttachScheduleState_QueryCountBound` (one batch +call, zero single calls, for 5 routines) and +`TestAttachScheduleState_BatchFailureFallsBackPerRoutine` (one batch call, +exactly one single call per routine on the documented fallback path), both +via a `countingRepo` decorator wrapping a real in-memory-SQLite-backed +repository. +AC-002.5/.6/.11/.4: satisfied structurally — `ScheduleState` is a plain +string enum a caller switches on directly (no additional derivation logic +needed in this task), and `UnarmedCronTrigger.Reasons` already carries +`not_schedulable` distinctly from `stalled`/`disabled`, so an empty list +carries neither and a non-empty list is inspectable without dispatch-grace +math. Verified in `TestAttachScheduleState_AllStatesFixture`, which covers +armed, trigger_invalid, trigger_disabled, unscheduled_manual_only, and +unscheduled_no_trigger routines in one list response. + +New test file: +`apps/backend/internal/office/routines/schedule_response_test.go` — 4 tests +(`TestAttachScheduleState_AllStatesFixture`, +`TestAttachScheduleState_QueryCountBound`, +`TestAttachScheduleState_BatchFailureFallsBackPerRoutine`, +`TestAttachScheduleState_TriggerReadFailureKeepsIntent`), all backed by a +real `*routines.RoutineService` over an in-memory SQLite repository (via a +`countingRepo` decorator), not just `ClassifyRoutines` in isolation (already +covered by Task 01's tests). + +Verification: + +- `go build -tags fts5 ./...` — clean. +- `go test -tags fts5 ./internal/office/routines/...` — `ok`. +- `go test -tags fts5 ./internal/office/dashboard/...` — `ok`. +- `go test -tags fts5 ./internal/office/repository/sqlite/...` — one + pre-existing, unrelated failure (`TestMigrate_PriorityIdempotent`, + confirmed via `git stash` against the clean tree during Task 01; not + touched by this task). +- `gofmt -l` on the new/changed files — clean. +- `golangci-lint run ./internal/office/routines/... --new-from-rev=` + — `0 issues` (fixed a goconst hit on the repeated `gin.H{"error": ...}` + literal by extracting `respondInternalError` + a `jsonErrorKey` constant in + `handler.go`; fixed staticcheck QF1008 redundant-embedded-selector hints in + the new test file). diff --git a/docs/plans/office-routine-arming/task-03-report-schedule-state-ui.md b/docs/plans/office-routine-arming/task-03-report-schedule-state-ui.md new file mode 100644 index 0000000000..586f37a239 --- /dev/null +++ b/docs/plans/office-routine-arming/task-03-report-schedule-state-ui.md @@ -0,0 +1,151 @@ +--- +id: "03-report-schedule-state-ui" +title: "Render the intent/schedule-state distinction in the routines UI" +status: done +wave: 3 +depends_on: ["02-report-schedule-state-api"] +plan: "plan.md" +requirements: + - REQ-OFFICE-ROUTINE-ARMING-002 +acceptance_criteria: + - AC-OFFICE-ROUTINE-ARMING-002.2 + - AC-OFFICE-ROUTINE-ARMING-002.3 + - AC-OFFICE-ROUTINE-ARMING-002.4 + - AC-OFFICE-ROUTINE-ARMING-002.5 + - AC-OFFICE-ROUTINE-ARMING-002.6 + - AC-OFFICE-ROUTINE-ARMING-002.8 + - AC-OFFICE-ROUTINE-ARMING-002.9 + - AC-OFFICE-ROUTINE-ARMING-002.10 + - AC-OFFICE-ROUTINE-ARMING-002.11 +system_design: + - ../../specs/office/system-design/routine-schedule-state.md +--- + +# Task 03: Render the intent/schedule-state distinction in the routines UI + +Satisfies REQ-OFFICE-ROUTINE-ARMING-002's rendering criteria: +AC-OFFICE-ROUTINE-ARMING-002.2, .3, .8, .9, .10, and the rendering half of +.4, .5, .6, .11 (data shape is Task 02). + +## Scope + +Routines list and detail view render both switches. This is a labels-and- +distinctions change only — the operator-initiated re-arm action was cut from +scope (see the requirement's `## Out of scope`), so there is no mutating flow +to build or test here. + +## Exclusions + +- No action, button, or endpoint that repairs a trigger or changes intent. +- No new backend response fields (Task 02). + +## Acceptance conditions + +1. The routines list visually and semantically distinguishes: `active` + + `armed` from `active` + anything else (AC-002.2); not-`active` from + `active`-but-broken, so deliberate and broken never render the same + (AC-002.3); and renders a distinct label for each of the five groups — + `armed`; `{trigger_invalid, trigger_unscheduled, trigger_disabled}`; + `event_only`; `{unscheduled_manual_only, unscheduled_no_trigger}`; + `unknown` — such that two routines in different groups never share a + label (AC-002.10). None of this distinction is carried by colour alone; + each has accompanying text, an icon, or an accessible label (AC-002.9). +2. A routine whose unarmed list has at least one schedulable entry is + visually distinguished from one where none are (needs re-arming vs. needs + editing), and this rendering is entirely suppressed when the unarmed list + is empty rather than defaulting to either message (AC-002.4). + `unscheduled_manual_only`/`unscheduled_no_trigger` render as "no + schedule" (AC-002.5); `unknown` renders as undetermined (AC-002.6); + `event_only` never renders as "no schedule" and is never directed to + create a trigger (AC-002.11). No rendering here offers a repair action. +3. All copy added by this task is routed through `t()`/`` and present + in English plus the four other shipped locales (`pt-pt`, `zh-cn`, + `zh-hk`, `zh-tw`); `pnpm run i18n:check` and `pnpm run i18n:ratchet` both + pass (AC-002.8). + +## Verification + +- `cd apps/web && pnpm run typecheck` +- `cd apps/web && pnpm run lint` +- `cd apps/web && pnpm run i18n:check` +- Component test(s) covering the five-group label mapping and the + schedulable/non-schedulable unarmed-list distinction. +- Playwright: extend or add a spec under `apps/web/e2e/` covering the + routines list/detail rendering for a fixture spanning all nine schedule + states (read-only assertions only — no mutating interaction to drive). + Confirm the e2e profile enables `KANDEV_FEATURES_OFFICE` before writing + the spec. +- `cd apps/web && pnpm e2e:run` scoped to the new/changed spec (respect the + one-worker-per-shard and memory-aware shard budget in + `apps/web/e2e/README.md`). + +## Files likely touched + +- Routines list and detail components under `apps/web` (Office routines + surface — exact path to be located during implementation; search for the + existing routine-status badge component). +- `apps/web/src/locales/en/*.json` plus `pt-pt`, `zh-cn`, `zh-hk`, `zh-tw` + (use `pnpm run i18n:zh-hant` for the Traditional Chinese pair). +- New or extended Playwright spec under `apps/web/e2e/`. + +## Dependencies + +Task 02. + +## Parallelism + +Independent of Task 04 and Task 05. + +## Result + +- `apps/web/app/office/routines/schedule-state.ts`: pure read/classify + helpers (`readScheduleState`, `readUnarmedCronTriggers`, + `scheduleStateGroup`, `hasSchedulableUnarmedEntry`) following the repo's + established camelCase-typed/snake_case-at-runtime fallback pattern for the + two new wire fields from Task 02. +- `apps/web/app/office/routines/schedule-state-badge.tsx`: `ScheduleStateBadge` + (five-group label + icon + tooltip, AC-002.9/.10) and + `UnarmedScheduleHint` (schedulable-vs-not distinction, suppressed when the + unarmed list is empty, AC-002.4/.5/.6/.11). Both render regardless of + `routine.status`, so intent and schedule state stay visually independent + (AC-002.1/.2/.3). +- Wired into `routine-row.tsx` (list) and `routine-detail-view.tsx`'s + `DetailReadOnlyCard` (detail view). +- 12 new `office:` copy keys added to `en` plus `pt-pt`, `zh-cn` (hand + translated) and `zh-hk`/`zh-tw` (via `pnpm run i18n:zh-hant`); pseudo-locale + regenerated and scoped to only these keys. +- Unit tests: `schedule-state.test.ts` (pure logic, all 8 reachable states to + 5 groups) and `schedule-state-badge.test.tsx` (rendering, using + `TooltipProvider` per the repo's Radix tooltip test convention). +- E2E: `apps/web/e2e/tests/office/routine-schedule-state.spec.ts` seeds one + routine per reachable schedule state and asserts the routines list renders + the correct AC-002.10 label group for each, plus AC-002.11 (`event_only` + never shows "No schedule"). + - Confirmed `KANDEV_FEATURES_OFFICE` is already `e2e: "true"` in + `profiles.yaml`; no profile change needed. + - **Backend API gap found and fixed**: the public routine-trigger API + cannot produce `trigger_invalid`, `trigger_unscheduled`, or + `trigger_disabled` — `RoutineService.CreateRoutineTrigger` validates the + cron expression and always computes a non-null `NextRunAt`, and there is + no PATCH-trigger route to disable one after creation. Added a + `KANDEV_E2E_MOCK`-gated test-harness route, + `POST /api/v1/_test/routine-triggers` + (`apps/backend/internal/office/testharness/routine_triggers.go` + + `routine_triggers_test.go`), that inserts a trigger row directly via the + repository, bypassing service-layer validation, following the existing + `seedRun`/`seedComment` harness pattern. Exposed as + `ApiClient.seedRoutineTrigger` in `apps/web/e2e/helpers/api-client.ts`. + - `unknown` (a live trigger-read failure) has no fixture-reachable trigger + shape at all — it requires simulating a database read error — so it is + intentionally not covered by this spec. It has backend unit coverage in + `arming_test.go` (Task 01) instead. + - The fixture therefore spans 7 of the 9 raw states, covering all 5 render + groups (`armed`, `broken` via 3 distinct raw states, `event_only`, + `no_schedule` via 2 distinct raw states); `unknown` is the one documented + gap. +- Verification run: `pnpm run typecheck`, `pnpm run lint`, `pnpm run + i18n:check`, `pnpm run i18n:ratchet`, `npx vitest run` on both new test + files, `go build ./...`, `gofmt -l`, `golangci-lint run + ./internal/office/testharness/...`, `go test + ./internal/office/testharness/...`, and `pnpm e2e:run + tests/office/routine-schedule-state.spec.ts` — all pass. diff --git a/docs/plans/office-routine-arming/task-04-startup-scan.md b/docs/plans/office-routine-arming/task-04-startup-scan.md new file mode 100644 index 0000000000..c1d02923fc --- /dev/null +++ b/docs/plans/office-routine-arming/task-04-startup-scan.md @@ -0,0 +1,210 @@ +--- +id: "04-startup-scan" +title: "Surface unarmed routines at startup" +status: done +wave: 2 +depends_on: ["01-classify-schedule-state"] +plan: "plan.md" +requirements: + - REQ-OFFICE-ROUTINE-ARMING-003 +acceptance_criteria: + - AC-OFFICE-ROUTINE-ARMING-003.1 + - AC-OFFICE-ROUTINE-ARMING-003.2 + - AC-OFFICE-ROUTINE-ARMING-003.3 + - AC-OFFICE-ROUTINE-ARMING-003.4 + - AC-OFFICE-ROUTINE-ARMING-003.5 + - AC-OFFICE-ROUTINE-ARMING-003.6 + - AC-OFFICE-ROUTINE-ARMING-003.7 + - AC-OFFICE-ROUTINE-ARMING-003.8 + - AC-OFFICE-ROUTINE-ARMING-003.9 + - AC-OFFICE-ROUTINE-ARMING-003.10 + - AC-OFFICE-ROUTINE-ARMING-003.11 + - AC-OFFICE-ROUTINE-ARMING-003.12 + - AC-OFFICE-ROUTINE-ARMING-003.13 + - AC-OFFICE-ROUTINE-ARMING-003.14 +system_design: + - ../../specs/office/system-design/routine-schedule-state.md +--- + +# Task 04: Surface unarmed routines at startup + +Satisfies REQ-OFFICE-ROUTINE-ARMING-003 (AC-OFFICE-ROUTINE-ARMING-003.1 +through .14). + +## Scope + +A read-only pass at Office startup, run once reconciliation has returned, +that classifies every enumerable routine in every enumerable workspace +(via Task 01) and emits structured logs plus `expvar` counters. No +production behavior changes if the scan is skipped or fails. + +## Exclusions + +- No classification logic of its own — consumes Task 01. +- No repair, no retry, no periodic re-scan, no API. +- No coordinator-install change (Task 05). + +## Acceptance conditions + +1. The scan runs after `infra.Reconciler.ReconcileAll` (main.go:1378-1380) + has returned, ordered by an explicit completion signal passed from the + startup sequence — not a timer, not an inspection of trigger rows. + Invoked without that signal (exercised only in a test), it classifies + nothing, emits one warning-level record, and increments the skipped + counter with a reason distinct from enumeration and per-routine + classification failure (AC-003.1). It iterates workspaces by + `workspaces.created_at` then `workspaces.id`, and within each, routines + by `office_routines.created_at` then `office_routines.id` (AC-003.2). + Office startup does not wait for the scan and no scan outcome, including + a database error, changes startup's result (AC-003.10). It writes + nothing and never enables, disables, creates, or deletes a trigger + (AC-003.11). +2. Per routine reached, exactly one record: warning for `active` + + {`trigger_disabled`, `trigger_unscheduled`, `trigger_invalid`} naming + workspace, routine, state, and unarmed cron triggers in trigger order + (AC-003.3); warning for `active` + {`unscheduled_manual_only`, + `unscheduled_no_trigger`} naming workspace, routine, and state with no + trigger list, since the routine owns no cron trigger (AC-003.14); + informational, no warning, for not-`active` (AC-003.4); informational, + no warning, for `active` + any other non-`unknown` state (AC-003.5, + scoped to exclude the .3 and .14 cases); and for `unknown`, one + informational record naming workspace/routine/state regardless of + intent, which both counts once under the observation counter (AC-003.6) + and once under the skipped counter (AC-003.7) — intentional double + counting, not a bug (AC-003.9). +3. Enumeration failures fail closed and are counted distinguishably: failing + to enumerate the workspace list at all increments the skipped counter + with its own reason, emits one warning, and returns without classifying + anything (AC-003.8); failing to enumerate one workspace's routines keeps + every record already emitted for earlier workspaces, increments the + skipped counter once for that workspace, emits one warning naming it, + does not retry it, and continues to the next workspace in order + (AC-003.13). +4. Running the scan twice over unchanged data emits the same records in the + same order with no database change, where "same" excludes wall-clock + fields, counters, and a trigger that crossed the dispatch-grace boundary + between the two scans, and where the comparison only applies between two + fully-successful scans (AC-003.12). + +## Verification + +- `cd apps/backend && go test -tags fts5 ./internal/office/routines/... ./internal/office/infra/... ./internal/backendapp/...` +- Test: scan invoked without the completion signal classifies nothing and + increments the "no signal" skipped reason (AC-003.1's test branch). +- Test: fixture spanning all schedule states produces exactly the record + level/content matrix of AC-003.3/.4/.5/.9/.14, one record per routine. +- Test: workspace-enumeration failure and mid-scan per-workspace + enumeration failure each fail closed per AC-003.8/.13, and startup itself + never fails or blocks on any scan outcome (AC-003.10). +- Test: two scans over unchanged fixture data produce identical records + under the AC-003.12 equality (excluding wall-clock/counters and any + grace-boundary-straddling trigger). + +## Files likely touched + +- new: `apps/backend/internal/office/routines/startup_scan.go` +- new: `apps/backend/internal/office/routines/startup_scan_test.go` +- new: `apps/backend/internal/office/routines/arming_metrics.go` (expvar + counters, following `internal/orchestrator/office_stall_metrics.go`'s + `expvar.NewMap` + label-builder shape) +- `apps/backend/internal/office/infra/reconcile.go` — emit or return the + completion signal `ReconcileAll` currently has no way to expose. +- `apps/backend/internal/backendapp/main.go` (~L1378-1393) — wire the + signal from `ReconcileAll` into the new scan, launched so it does not + block startup. + +## Dependencies + +Task 01 (classification, and the shared `dispatchGrace` constant it +defines). + +## Parallelism + +Independent of Task 02 and Task 05 once Task 01 lands — no shared files. + +## Result + +Implemented: + +- `apps/backend/internal/office/repository/sqlite/routine_arming_scan.go` + (new) — `ListWorkspaceIDsOrdered` (`SELECT id FROM workspaces ORDER BY + created_at, id`) and `ListRoutinesOrdered` (per-workspace, same ordering), + separate from the UI's name-ordered `ListRoutines`/workspace listing. +- `apps/backend/internal/office/routines/arming_metrics.go` (new) — two + `expvar.Map` counters (`office_routine_arming_scan_observations_total`, + `office_routine_arming_scan_skipped_total`), following + `internal/orchestrator/office_stall_metrics.go`'s label-string shape, with + the four skip reasons (`no_signal`, `workspace_enum_failed`, + `routine_enum_failed`, `classification_unknown`). +- `apps/backend/internal/office/routines/startup_scan.go` (new) — + `ReconcileSignal` (only constructible via `SignalReconcileComplete`, so an + unauthorized/test caller holds the refused zero value), the + `StartupScanReader` interface (Task 01's `RoutineTriggerReader` plus the + two new ordered listers), and `RunStartupScan`, which classifies every + workspace's routines via Task 01's `ClassifyRoutines` (one batch trigger + read per workspace) and emits exactly one structured log record per + routine reached, per the AC-003.3/.4/.5/.9/.14 matrix. +- `apps/backend/internal/office/infra/reconcile.go` — `ReconcileAll` now + returns `routines.ReconcileSignal` (via `SignalReconcileComplete()`) + instead of nothing, so a caller has an explicit, non-timer, non-inspection + way to know reconciliation returned. Existing callers that ignore the + return value keep compiling unchanged. +- `apps/backend/internal/backendapp/main.go` (~L1377-1384) — after + `reconciler.ReconcileAll(ctx)` returns, launches + `go officeroutines.RunStartupScan(ctx, reconcileSignal, repos.Office, log, + time.Now().UTC())`. `repos.Office` (`*sqlite.Repository`) satisfies + `StartupScanReader` structurally (confirmed by `go build` succeeding with + no adapter needed). The scan runs in its own goroutine so office startup + does not wait for it (AC-003.10). + +AC-003.1: `TestRunStartupScan_NoSignalClassifiesNothing` uses a +`panicIfCalledReader` (panics on any method call) to prove the no-signal +path makes zero reader calls, emits exactly one warning, and increments +`no_signal`. +AC-003.2: enumeration order comes from the two new `ORDER BY created_at, id` +repository methods; not independently retested here since Task 01/02 already +cover the tiebreak mechanics this reuses. +AC-003.3/.4/.5/.9/.14: `TestRunStartupScan_AllStatesFixtureRecordMatrix` +covers all five branches in one workspace (cannot-fire with a trigger list, +no-schedule without one, an active+armed "observed" info record, a +not-active routine regardless of its schedule state, and a forced-`unknown` +routine), and asserts both the `unknown` observation-counter increment and +the `classification_unknown` skip-counter increment fire together +(intentional double counting per the spec). +AC-003.6/.7: covered throughout via `armingScanObservationsTotal`/ +`armingScanSkippedTotal` counter-delta assertions. +AC-003.8: `TestRunStartupScan_WorkspaceEnumerationFailure` — one warning, +`workspace_enum_failed` incremented, no routine ever reached. +AC-003.10: the scan never returns an error or a value main.go could act on; +launched via `go`, it cannot block or fail startup by construction. +AC-003.11: the scan only calls the two new read-only listers and Task 01's +read-only trigger reader; no write path exists on `StartupScanReader`. +AC-003.12: `TestRunStartupScan_IdempotentOverUnchangedData` runs the scan +twice with the same fixed `now` and compares each run's log records +(level, message, context fields) via `reflect.DeepEqual`, excluding +wall-clock fields by never logging any. +AC-003.13: `TestRunStartupScan_PerWorkspaceRoutineEnumerationFailureContinues` +— 3 workspaces, the middle one's routine enumeration forced to fail; asserts +the earlier and later workspaces' records are preserved, the scan continues +past the failure, and `routine_enum_failed` increments exactly once. + +New files: +`apps/backend/internal/office/repository/sqlite/routine_arming_scan.go`, +`apps/backend/internal/office/routines/arming_metrics.go`, +`apps/backend/internal/office/routines/startup_scan.go`, +`apps/backend/internal/office/routines/startup_scan_test.go` (in-package +`routines` test file, needed to assert directly on the unexported expvar +counters, following `internal/orchestrator/office_stall_visibility_test.go`'s +precedent for the same reason). + +Verification: + +- `go build -tags fts5 ./...` — clean. +- `go test -tags fts5 ./internal/office/routines/... ./internal/office/infra/... ./internal/backendapp/...` + — all `ok`. +- `go test -tags fts5 ./internal/office/repository/sqlite/...` — one + pre-existing, unrelated failure (`TestMigrate_PriorityIdempotent`, + confirmed during Task 01; not touched by this task). +- `gofmt -l` on the new/changed files — clean. +- `golangci-lint run ./internal/office/infra/... ./internal/backendapp/... ./internal/office/routines/... ./internal/office/repository/sqlite/... --new-from-rev=` + — `0 issues`. diff --git a/docs/plans/office-routine-arming/task-05-coordinator-install-idempotency.md b/docs/plans/office-routine-arming/task-05-coordinator-install-idempotency.md new file mode 100644 index 0000000000..086ba9913b --- /dev/null +++ b/docs/plans/office-routine-arming/task-05-coordinator-install-idempotency.md @@ -0,0 +1,272 @@ +--- +id: "05-coordinator-install-idempotency" +title: "Match the coordinator routine on identity, not mutable trigger state" +status: done +wave: 2 +depends_on: ["01-classify-schedule-state"] +plan: "plan.md" +requirements: + - REQ-OFFICE-COORDINATOR-INSTALL-001 +acceptance_criteria: + - AC-OFFICE-COORDINATOR-INSTALL-001.1 + - AC-OFFICE-COORDINATOR-INSTALL-001.2 + - AC-OFFICE-COORDINATOR-INSTALL-001.3 + - AC-OFFICE-COORDINATOR-INSTALL-001.4 + - AC-OFFICE-COORDINATOR-INSTALL-001.5 + - AC-OFFICE-COORDINATOR-INSTALL-001.6 + - AC-OFFICE-COORDINATOR-INSTALL-001.7 + - AC-OFFICE-COORDINATOR-INSTALL-001.8 + - AC-OFFICE-COORDINATOR-INSTALL-001.9 + - AC-OFFICE-COORDINATOR-INSTALL-001.10 + - AC-OFFICE-COORDINATOR-INSTALL-001.11 + - AC-OFFICE-COORDINATOR-INSTALL-001.12 + - AC-OFFICE-COORDINATOR-INSTALL-001.13 + - AC-OFFICE-COORDINATOR-INSTALL-001.14 + - AC-OFFICE-COORDINATOR-INSTALL-001.15 +system_design: + - ../../specs/office/system-design/coordinator-install-idempotency.md +--- + +# Task 05: Match the coordinator routine on identity, not mutable trigger state + +Satisfies REQ-OFFICE-COORDINATOR-INSTALL-001 (AC-OFFICE-COORDINATOR-INSTALL-001.1 +through .15). + +## Scope + +Redefine coordinator-routine identity as workspace + assignee agent + +canonical routine name only, complete a half-finished install (routine with +no cron trigger) instead of duplicating it, handle the case where more than +one existing routine already matches that identity, and serialize +check-and-create against concurrent installs across processes sharing one +database. + +## Exclusions + +- Does not change the schedule-state definition (Task 01) — only reports it + (AC-OFFICE-COORDINATOR-INSTALL-001.5). +- Does not merge, delete, or otherwise touch duplicate routines created by a + prior buggy install beyond selecting one to complete + (AC-OFFICE-COORDINATOR-INSTALL-001.3). +- Does not add `enabled` to the identity match — forbidden by + AC-OFFICE-COORDINATOR-INSTALL-001.1, and explicitly the trap the source + card's framing invites. +- Does not add a database uniqueness constraint over the identity columns + (AC-OFFICE-COORDINATOR-INSTALL-001.9 forbids it; upgraded installs already + violate it). + +## Acceptance conditions + +1. `findCoordinatorRoutine` matches on workspace + assignee + canonical name + only — no trigger presence, expression, `enabled`, or `next_run_at` term + (AC-001.1). A single match: return it, create nothing + (AC-001.2). No cron trigger on the match: create the canonical cron + trigger (canonical expression, canonical timezone, `enabled = true`, + `next_run_at` = first canonical occurrence strictly after one instant + captured once at install start) and return the existing routine; if that + next-occurrence computation fails, reject and report without touching + the routine (it is a defect in the installer's own constants, unreachable + by any input); if trigger creation itself fails, reject, report, and + leave the routine in place so a later install reaches this same branch + (AC-001.4). Any cron trigger already present, canonical or not, enabled + or not: leave every trigger unchanged, create no second one, report + schedule state (AC-001.5). Never change `status` on an existing match + (AC-001.6). Empty workspace, assignee, or canonical name: reject before + the identity lookup, reported distinguishably from a read failure + (AC-001.14). No install-call ever retries a failure internally — each + rejection returns to the caller as an error, optionally alongside the + routine found or created, matching what both callers already do today + (AC-001.15). +2. More than one existing routine matches the identity: select the one with + the earliest `office_routines.created_at`, ties by `id` ascending; + delete none; report that more than one was found; then run the selected + routine through the same .4/.5 branch a single match would take + (AC-001.3). No match at all: create one routine with that identity and + `status = 'active'` plus its canonical trigger; a routine-insert failure + rejects and creates no trigger; a trigger-creation failure after a + successful routine insert leaves the routine in place and reports the + failure so a later install completes it via AC-001.4 (AC-001.12). An + identity-lookup failure, or a failure reading the existing routine's + triggers (the read that decides AC-001.4 vs. AC-001.5), rejects and + creates nothing — never treated as "absent" (AC-001.7). +3. Two concurrent installs for the same workspace and assignee create at + most one new coordinator routine and at most one new canonical trigger + between them, on both SQLite and PostgreSQL, via a serialization + mechanism whose domain is every process sharing the database — not an + in-process guard alone (AC-001.8, AC-001.9, AC-001.11). Acquisition never + blocks indefinitely: it acquires or fails within a single named + compile-time 5-second bound, not configurable by environment, config, or + flag (AC-001.9). Failure to acquire, or losing a serialized section to a + concurrent install, rejects, creates nothing, and reports contention + distinguishably from AC-001.7's failures and is not retried within the + call (AC-001.13). Context cancellation while waiting likewise rejects and + creates nothing, reported distinguishably from both contention and + AC-001.7 (AC-001.13). +4. Every condition above that must be reported (AC-001.3's duplicate count + and selection, AC-001.5's schedule state) is a structured log record plus + a counter per condition — never an additional return value, since both + production callers discard everything but the error today (AC-001.10). + Each record names workspace, assignee, and the routine it concerns. + Counters count calls in which a condition was *observed*, including + calls that are ultimately rejected. The whole requirement behaves + identically on SQLite and PostgreSQL (AC-001.11). + +## Design constraint for the serialization mechanism (AC-001.9, AC-001.13) + +Pick a mechanism satisfying: (a) blocks/serializes across processes sharing +one database, not merely goroutines in one process; (b) works identically on +SQLite and PostgreSQL; (c) bounds acquisition at a hard 5s; (d) distinguishes +"could not acquire" (contention) from "acquired, then lost the race inside +the critical section" from context cancellation. The requirement's own +`## Out of scope` names three mechanisms that individually fail one of +these and explicitly forbids treating them as interchangeable: a bare +default-isolation-level transaction (does not stop two callers both reading +"absent"), a PostgreSQL advisory lock (no SQLite equivalent), an in-process +mutex (fails the cross-process domain). A row-level lock strategy +(`SELECT ... FOR UPDATE`-equivalent per dialect, or a small dedicated lock +row keyed by workspace+assignee with a bounded wait) is a plausible +candidate Build should evaluate against all four constraints before +building it. + +**If the chosen mechanism adds a new table or columns**, add it to +`internal/persistence/requiredstores` and +`internal/persistence/storeconformance` per the backend schema-owner rule, +and add the fresh/replay conformance tests that requires — this is a risk to +flag during implementation, not a decision to make in this work order. + +## Verification + +- `cd apps/backend && go test -tags fts5 ./internal/office/routines/... ./internal/office/agents/... ./internal/office/onboarding/...` +- Test: identity match ignores an edited or absent trigger (regression for + the original card's bug — a routine with a non-canonical or deleted + trigger still matches). +- Test: install against a triggerless existing match creates exactly the + canonical trigger and returns the existing routine (AC-001.4), and running + it again is a no-op via AC-001.5. +- Test: two or more matching routines — selects earliest `created_at`/`id`, + deletes none, reports the count, and still completes the selected one's + schedule (AC-001.3). +- Test: concurrent-install race — two goroutines (or two DB connections + simulating two processes) racing `CreateDefaultCoordinatorRoutine` for the + same identity produce exactly one routine and one trigger; run against + both SQLite and `KANDEV_TEST_POSTGRES_DSN` PostgreSQL. +- Test: empty workspace/assignee/canonical-name is rejected before any read + (AC-001.14). +- Test: contention and context-cancellation rejections are distinguishable + from each other and from AC-001.7's failures in the returned error/report. +- If a new schema owner was added: `go run ./cmd/sqlguard ./internal` and + `go test -race ./internal/persistence/storeconformance -count=1`. + +## Files likely touched + +- `apps/backend/internal/office/routines/service.go` — + `CreateDefaultCoordinatorRoutine`, `findCoordinatorRoutine`, + `hasCronTrigger` (L145-239). +- `apps/backend/internal/office/repository/sqlite/routines.go` — new + identity-lookup query and whatever serialization primitive is chosen. +- new: `apps/backend/internal/office/routines/coordinator_install_metrics.go` + (structured records + counters, following the same `expvar.NewMap` + convention as Task 04). +- `apps/backend/internal/office/agents/service.go`, + `apps/backend/internal/office/onboarding/service.go` — no behavior change + expected (both already discard the return value except the error), but + their tests should assert the discard contract still holds. +- Corresponding `*_test.go` files, plus a PostgreSQL-gated concurrency test. + +## Dependencies + +Task 01 (this task reports Task 01's schedule state via AC-001.5; the +requirement document states it "cannot ship before that one"). + +## Parallelism + +Independent of Task 02 and Task 04 once Task 01 lands — no shared files +with either. + +## Result + +Implemented as two separately-locked, separately-committed phases rather +than one transaction, after TDD caught a real defect in the first cut: a +single all-or-nothing transaction covering both routine-creation and +trigger-creation would roll back an already-decided routine on a +trigger-creation failure, violating AC-001.12's "leaves the routine in +place" requirement. Splitting into two phases (each its own lock + tx) +fixes this — a trigger-creation failure can now only abort the trigger's +own transaction, never the routine's already-committed one. + +**Serialization mechanism** (resolves the work order's open design +question): a real database transaction on both dialects, holding, on +PostgreSQL only, a `pg_try_advisory_xact_lock` polled every 25ms inside a +context bounded to a hard 5s (`coordinatorInstallLockTimeout`, not +configurable by env/config/flag). On SQLite the existing single +writer-connection pool (`internal/db.OpenSQLite`'s `SetMaxOpenConns(1)`) +already serializes writers process-locally; PostgreSQL needs the explicit +advisory lock because a plain transaction under READ COMMITTED does not +stop two callers from both observing "absent". This combines (rather than +picks one of) the three mechanisms the requirement's Out-of-scope section +individually rules out. + +Files: +- `apps/backend/internal/office/models/coordinator_install.go` (new) — + `models.ErrCoordinatorInstallContention` sentinel and the + `models.CoordinatorInstallTx` interface, placed in the shared `models` + package (rather than in `sqlite` or `routines`) so neither package has to + import the other. +- `apps/backend/internal/office/repository/sqlite/coordinator_install.go` + (new) — `WithCoordinatorInstallLock` (generic lock-key + tx runner), + `CoordinatorInstallLockKey` (workspace+assignee+name), the separate + `CoordinatorInstallTriggerLockKey` (routine id), `InstallCoordinatorRoutine` + (routine-existence phase), `EnsureCoordinatorTrigger` (trigger-existence + phase, its own lock/tx), `acquirePostgresAdvisoryLock`, + `classifyCoordinatorInstallWaitErr`. +- `apps/backend/internal/office/repository/sqlite/routines.go` — added + `CreateRoutineTriggerTx`, refactored `CreateRoutineTrigger` to share an + `insertRoutineTrigger` helper. +- `apps/backend/internal/office/routines/coordinator_install_metrics.go` + (new) — `coordinatorInstallConditionsTotal` expvar map, 12 condition + constants, `coordinatorInstallObserved`. +- `apps/backend/internal/office/routines/service.go` — `Repository` + interface gained `InstallCoordinatorRoutine` and + `EnsureCoordinatorTrigger`. `CreateDefaultCoordinatorRoutine` now runs + `ensureCoordinatorRoutine` (AC-001.2/.3/.12's routine half) then + `EnsureCoordinatorTrigger`'s decide callback (AC-001.4/.5/.12's trigger + half, via `decideCoordinatorTrigger`), each reported through the shared + `reportCoordinatorLockFailure` (contention / cancelled / a + caller-supplied fallback condition for the read-before-decide failing). + +Tests (all new): +- `apps/backend/internal/office/routines/coordinator_install_test.go` + (internal `package routines`, for `coordinatorInstallConditionsTotal` + access) — empty identity (AC-001.14), no-match create (AC-001.12), + single match with any cron trigger unchanged (AC-001.2/.5), no-trigger + match completes install (AC-001.4) plus re-entrancy, duplicate matches + select earliest and leave the rest untouched (AC-001.3), status never + changes (AC-001.6), identity-lookup and trigger-read failures rejected + and distinguishable (AC-001.7), routine-insert failure creates no + trigger, trigger-create failure leaves the routine in place and a + follow-up call completes it (AC-001.12), contention vs. cancellation vs. + lookup-failure are counted on distinct counters (AC-001.13), and a real + two-goroutine race against one shared SQLite writer connection creates at + most one routine and one trigger (AC-001.8/.9). +- `apps/backend/internal/office/routines/coordinator_install_dialect_postgres_test.go` + (external `package routines_test`, `testutil.PostgresDSNFromEnv` gated, + skips without `KANDEV_TEST_POSTGRES_DSN`) — fresh install + idempotent + repeat produce the same shape on SQLite and PostgreSQL (AC-001.11), and a + genuine concurrent-install race against PostgreSQL (where the advisory + lock actually runs) creates at most one routine and trigger (AC-001.8/.9). + +No new database table or column was introduced, so +`internal/persistence/requiredstores`/`storeconformance` and `sqlguard` +were not needed. + +Verification: `go build -tags fts5 ./...` clean; `gofmt -l` clean on every +touched/new file; `go vet -tags fts5 ./internal/office/...` clean; +`golangci-lint run ./internal/office/models/... ./internal/office/repository/sqlite/... ./internal/office/routines/... --new-from-rev= --timeout=5m` +→ 0 issues; `go test -tags fts5 -race -count=1 ./internal/office/routines/... ./internal/office/agents/... ./internal/office/onboarding/... ./internal/office/wakeup/...` +all pass, including the two pre-existing coordinator-install tests +(`TestCreateDefaultCoordinatorRoutine_Idempotent`, +`TestRoutine_EndToEnd_CoordinatorHeartbeatFire`) unchanged. The new +PostgreSQL-gated tests skip locally (`KANDEV_TEST_POSTGRES_DSN` unset). The +one pre-existing, unrelated failure (`TestMigrate_PriorityIdempotent` in +`internal/office/repository/sqlite`) is confirmed present before this +task's changes and out of scope. diff --git a/docs/specs/office/requirements/coordinator-install-idempotency.md b/docs/specs/office/requirements/coordinator-install-idempotency.md new file mode 100644 index 0000000000..aea6c91a45 --- /dev/null +++ b/docs/specs/office/requirements/coordinator-install-idempotency.md @@ -0,0 +1,259 @@ +--- +status: draft +system: office +created: 2026-09-17 +owners: + - kandev +--- + +# Office Coordinator Install Idempotency Requirements + +## Overview + +Onboarding pre-installs a `Coordinator heartbeat` routine. The installer decides +whether that routine is already present by matching the workspace, the assignee +agent, the canonical routine name, **and** the presence of a cron trigger whose +expression equals the canonical one. That last clause is the problem: trigger +presence and trigger expression are mutable, so a routine that was installed +successfully can stop matching itself later. + +Two paths reach that state on an install running today. A half-finished install +leaves a routine row with no trigger at all, because trigger creation is a +second write that can fail after the routine is created and whose error is +warn-logged by both callers rather than rolled back. And a coordinator trigger +whose expression stops equalling the canonical one leaves a routine that no +longer matches itself. No endpoint edits a trigger in place — the trigger routes +are list, create, and delete — so that state is reached by deleting the +coordinator's trigger and creating a replacement carrying a different expression, +or by editing the row directly. Which path was taken matters less than the +result, because identity must not depend on a value that any of them can change. +Either way, re-running onboarding sees no match and creates a second coordinator +routine, so the workspace ends up with two heartbeats or, more often, two +routines neither of which fires. + +The match is *not* sensitive to `enabled` today — the installer never reads that +column. This document's job is to keep it that way while removing the two +mutable clauses it does match on. Making identity depend on `enabled`, which a +reader coming from the card's "disabled trigger" framing might reasonably assume +is already the case or is the fix, would **add a third duplication path** rather +than close one, and is forbidden by AC-OFFICE-COORDINATOR-INSTALL-001.1. + +This document does not define or repair a schedule. It consumes the +classification of +[Office Routine Arming Visibility](routine-arming-visibility.md) — schedule state +is defined there, and AC-OFFICE-COORDINATOR-INSTALL-001.5 requires the install to +report it — so this document cannot ship before that one. + +## Terminology + +This document uses the terms defined in +[Office Routine Arming Visibility § Terminology](routine-arming-visibility.md): +*fire*, *armed*, *intent*, and *schedule state*. It adds one: + +- **Canonical:** the routine name, cron expression, and timezone that onboarding + itself supplies for the coordinator routine. Canonical values are the + installer's own constants, not operator input. + +## Requirements + +### REQ-OFFICE-COORDINATOR-INSTALL-001: Match the coordinator routine on identity, not on mutable trigger state + +**Intent:** The pre-installed coordinator routine is matched on name plus the +presence of a cron trigger carrying an exact expression. A routine whose trigger +creation failed, or whose expression was edited, therefore stops matching itself +and the installer creates a second coordinator routine. Identity must not depend +on mutable trigger state. + +**User story:** As an operator re-running onboarding on an upgraded install, I +want one coordinator routine, with its schedule completed rather than duplicated. + +#### Acceptance criteria + +- **AC-OFFICE-COORDINATOR-INSTALL-001.1:** The coordinator routine's identity shall be + the workspace, the assignee agent, and the canonical routine name, and shall + not depend on any trigger's `enabled` value, `next_run_at`, cron expression, or + presence. +- **AC-OFFICE-COORDINATOR-INSTALL-001.2:** Installing the coordinator routine when a + routine with that identity already exists shall return the existing routine and + shall not create a second one, whatever its schedule state. +- **AC-OFFICE-COORDINATOR-INSTALL-001.3:** When more than one existing routine + satisfies that identity, the install shall return the one with the earliest + `office_routines.created_at`, breaking ties by `office_routines.id` ascending. + It shall delete none of them and shall report that more than one was found. The + routine it selects shall then be subject to + AC-OFFICE-COORDINATOR-INSTALL-001.4 and AC-OFFICE-COORDINATOR-INSTALL-001.5 + exactly as a single match would be, so a duplicated half-finished install is + completed rather than left permanently unable to fire. The routines it did not + select shall be left entirely untouched, including their triggers. +- **AC-OFFICE-COORDINATOR-INSTALL-001.4:** Installing the coordinator routine when a + routine with that identity exists with no cron trigger shall create the + canonical cron trigger for it and return the existing routine, completing an + install whose trigger creation previously failed. The created trigger shall + carry the canonical cron expression, the canonical timezone, `enabled = true`, + and a `next_run_at` equal to the first occurrence of the canonical expression + evaluated in the canonical timezone strictly after a single instant captured + once at the start of the install. A repaired trigger whose `next_run_at` is + null is not a completed install: it classifies `trigger_unscheduled` and still + cannot fire, which is the state this criterion exists to leave behind. When + that next occurrence cannot be computed, the install shall be rejected and + shall report the failure, leaving the existing routine in place; because the + expression and timezone are the installer's own constants, that failure is a + defect in the constants and no input can reach it. When that trigger creation fails, the install shall report + the failure and shall leave the existing routine in place rather than deleting + it, so that the next install reaches this same criterion and completes the + schedule. This criterion is therefore re-entrant: running it again against a + routine it already repaired takes the + AC-OFFICE-COORDINATOR-INSTALL-001.5 branch and changes nothing. +- **AC-OFFICE-COORDINATOR-INSTALL-001.5:** Installing the coordinator routine when a + routine with that identity exists with any cron trigger, enabled or disabled, + canonical expression or not, shall leave every trigger unchanged, shall create + no second trigger, and shall report the routine's schedule state. +- **AC-OFFICE-COORDINATOR-INSTALL-001.6:** Installing the coordinator routine shall + never change an existing routine's `status`. +- **AC-OFFICE-COORDINATOR-INSTALL-001.7:** When the identity lookup itself fails, or + when the read of the existing routine's triggers that decides between + AC-OFFICE-COORDINATOR-INSTALL-001.4 and AC-OFFICE-COORDINATOR-INSTALL-001.5 + fails, the install shall be rejected and shall create nothing. An indeterminate + read shall never be treated as "absent": neither as an absent routine, which + duplicates the routine, nor as an absent trigger, which duplicates the + trigger. +- **AC-OFFICE-COORDINATOR-INSTALL-001.8:** Two concurrent installs for the same + workspace and assignee shall between them create at most one new coordinator + routine and at most one new canonical cron trigger, whichever order the two + callers interleave. This bounds what the install *creates*; it is not a + uniqueness guarantee over the table. Duplicate routines a previous install + already created remain, and are handled by + AC-OFFICE-COORDINATOR-INSTALL-001.3; triggers that already exist remain, and + are handled by AC-OFFICE-COORDINATOR-INSTALL-001.5. +- **AC-OFFICE-COORDINATOR-INSTALL-001.9:** No database uniqueness constraint shall + be added to enforce + AC-OFFICE-COORDINATOR-INSTALL-001.8. Upgraded installs already hold duplicate + coordinator routines, so a unique index over the identity columns would fail to + apply on exactly the installs this document exists to fix, and the alternative + of deleting rows to make it apply is out of scope below. The guarantee shall + instead be met by serializing the check-and-create against concurrent installs + for the same workspace and assignee. The domain of that serialization shall be + every installer call that can reach the same database, not merely every call + within one process: the guarantee shall hold when two processes share a + database, which rules out a purely in-process guard as the sole mechanism. It + shall hold on both dialects named in + AC-OFFICE-COORDINATOR-INSTALL-001.11, which rules out any mechanism available + on only one of them. Acquisition shall not block indefinitely: it shall either + acquire or fail within a single named compile-time bound of 5 seconds, and that + bound shall not be operator-configurable — no environment variable, + configuration key, or runtime flag shall expose it — because both of the + installer's callers run on a user-facing onboarding path, where an unbounded + wait is indistinguishable from a hang. +- **AC-OFFICE-COORDINATOR-INSTALL-001.10:** The reports required by + AC-OFFICE-COORDINATOR-INSTALL-001.3 and + AC-OFFICE-COORDINATOR-INSTALL-001.5 shall be a structured record plus a counter + per condition, not an additional value returned from the install call. The + routine the call already returns under + AC-OFFICE-COORDINATOR-INSTALL-001.2 and + AC-OFFICE-COORDINATOR-INSTALL-001.3 is unchanged; what this criterion forbids + is carrying the *reports* out that way. Both of the installer's callers today + discard everything the call returns except the error, and neither is being + changed to inspect a richer return, so a report delivered as a return value + would report to nobody. Each record shall name the workspace, the assignee + agent, and the routine it concerns — for + AC-OFFICE-COORDINATOR-INSTALL-001.3, the routine it selected, and the number of + routines that matched. Each condition that applies shall be reported exactly + once per install call. A call that is rejected shall still report every + condition it had already detected before rejecting, in particular the + duplicate-routines condition of AC-OFFICE-COORDINATOR-INSTALL-001.3, which + describes state the call found in the database rather than work the call + performed. These counters therefore count the calls in which a condition was + observed, not the installs that completed, so a duplicated coordinator is + reported on the runs that fail as well as the runs that succeed. +- **AC-OFFICE-COORDINATOR-INSTALL-001.11:** The install shall behave identically on + SQLite and on PostgreSQL. +- **AC-OFFICE-COORDINATOR-INSTALL-001.12:** Installing the coordinator routine when + no routine with that identity exists shall create one routine carrying that + identity, with `status = 'active'`, together with its canonical cron trigger + carrying every field required by AC-OFFICE-COORDINATOR-INSTALL-001.4, and shall + return the created routine. When the routine insert fails, the install shall be + rejected, shall create no trigger, and shall report the failure. When the + routine insert succeeds and the trigger creation fails, the install shall leave + the created routine in place and report the failure, so that the next install + reaches AC-OFFICE-COORDINATOR-INSTALL-001.4 and completes the schedule; this is + the half-finished install this document exists to make recoverable, and it is + reached by the same path whether the first install failed here or before this + document existed. +- **AC-OFFICE-COORDINATOR-INSTALL-001.13:** When the serialization required by + AC-OFFICE-COORDINATOR-INSTALL-001.9 cannot be acquired, or the serialized + section fails because a concurrent install held it, the install shall be + rejected, shall create nothing, and shall report contention distinguishably + from the failures of AC-OFFICE-COORDINATOR-INSTALL-001.7. It shall not retry + within the same call. A rejected install leaves the routine the winning caller + created, so the next install returns it under + AC-OFFICE-COORDINATOR-INSTALL-001.2; contention therefore costs a warning, not + a duplicate and not a lost coordinator. Reaching the acquisition bound of + AC-OFFICE-COORDINATOR-INSTALL-001.9 without acquiring is contention and shall be + reported as contention. Cancellation of the caller's context while waiting shall + likewise reject the install and create nothing, and shall be reported + distinguishably from both contention and the failures of + AC-OFFICE-COORDINATOR-INSTALL-001.7, so that a shutdown part-way through + onboarding is not recorded as a duplicate-install race. + +- **AC-OFFICE-COORDINATOR-INSTALL-001.14:** The install shall reject a call whose + workspace, assignee agent, or canonical routine name is empty, before performing + the identity lookup, and shall create nothing. An empty value is not an + identity: accepting one lets a malformed call match, or create, a routine that + no real workspace or agent owns, and AC-OFFICE-COORDINATOR-INSTALL-001.1 makes + those three values the whole of identity. This keeps the guard the installer + applies today and extends it to the third component. The rejection shall be + reported distinguishably from the read failures of + AC-OFFICE-COORDINATOR-INSTALL-001.7, because nothing was read. +- **AC-OFFICE-COORDINATOR-INSTALL-001.15:** No failure in this requirement shall + be retried inside the same install call: not the identity lookup or the trigger + read of AC-OFFICE-COORDINATOR-INSTALL-001.7, not the trigger creation of + AC-OFFICE-COORDINATOR-INSTALL-001.4, not the routine insert or trigger creation + of AC-OFFICE-COORDINATOR-INSTALL-001.12, and not the serialization of + AC-OFFICE-COORDINATOR-INSTALL-001.9 and + AC-OFFICE-COORDINATOR-INSTALL-001.13. Each rejection returns to its caller, and + the next install is what reaches the criterion that completes the work; that is + the recovery model the rest of this requirement is built on, and an in-call + retry would only lengthen a failure on an onboarding path. A rejected install + shall surface its failure as an error, because the error is the only channel + either caller reads. It may also return the routine it found or created + alongside that error, as the installer does today: no criterion here depends on + that value, so Build is free to keep or drop it. + +## Out of scope + +- **Defining or repairing a schedule.** The classification belongs to + [Office Routine Arming Visibility](routine-arming-visibility.md). This document + *consumes* it — AC-OFFICE-COORDINATOR-INSTALL-001.5 reports schedule state, so + reporting is in scope and only the definition is not. It creates a canonical + trigger where none exists and otherwise touches no trigger. +- **Repairing a coordinator routine that is present but unarmed.** An install + reports the schedule state and stops. It does not enable a disabled trigger and + does not re-schedule a trigger whose `next_run_at` is null, even its own + canonical one, because it cannot tell an operator's deliberate choice from a + defect. The operator-initiated action that would repair it is deferred; see + that document's `## Out of scope`, which cites the successor card. +- **Merging or deleting duplicate coordinator routines** created by an earlier + install. The install reports that more than one was found and deletes none; + deciding which to keep needs an operator. This is what makes the uniqueness + constraint of AC-OFFICE-COORDINATOR-INSTALL-001.9 unavailable, and the two + decisions stand or fall together. +- **Correcting a coordinator trigger whose cron expression an operator edited.** + Identity stops depending on the expression; the edited value is left alone. +- **Rolling back a half-finished install.** This document completes one on the + next run rather than unwinding it, and does not change either caller's existing + choice to warn-log a failed install and continue. +- **Which serialization mechanism is used.** + AC-OFFICE-COORDINATOR-INSTALL-001.9 states the property, its domain, and the + two constraints that narrow the choice; AC-OFFICE-COORDINATOR-INSTALL-001.13 + states what contention does. Which conforming mechanism Build picks is its + decision. The three candidates are not interchangeable and this document does + not offer them as though they were: a transaction at the default isolation + level of either dialect does not by itself stop two callers both reading + "absent" and both inserting, a PostgreSQL advisory lock has no SQLite + equivalent, and an in-process guard fails the cross-process domain. Whatever is + chosen must satisfy .9 and .13 as written. +- **Other pre-installed routines.** Onboarding installs one routine today. A + second would need this contract restated for it, not inherited silently. +- **Whether the coordinator routine should exist at all,** and what its canonical + cron expression should be. This document keeps the install idempotent; it does + not revisit the values being installed. diff --git a/docs/specs/office/requirements/routine-arming-startup-scan.md b/docs/specs/office/requirements/routine-arming-startup-scan.md new file mode 100644 index 0000000000..adfde6d224 --- /dev/null +++ b/docs/specs/office/requirements/routine-arming-startup-scan.md @@ -0,0 +1,174 @@ +--- +status: draft +system: office +created: 2026-09-17 +owners: + - kandev +--- + +# Office Routine Arming Startup Scan Requirements + +## Overview + +The install this capability exists for is unattended: nobody opens the routines +page for weeks, so the read-path surface of +[Office Routine Arming Visibility](routine-arming-visibility.md) leaves a dead +loop undetected exactly where detection matters most. This document owns the +read-only scan that runs once at Office startup and reports what the classifier +found, to logs and to counters. + +It classifies nothing of its own: schedule state, the unarmed cron trigger list, +and the dispatch grace are all defined by that document, and this one consumes +them. It was authored inside it and moved here unchanged in identity to stay +inside the requirement-file size limit: the requirement is still +`REQ-OFFICE-ROUTINE-ARMING-003` and its criteria are still numbered +`AC-OFFICE-ROUTINE-ARMING-003.1` onward, so every reference to them elsewhere +still resolves. + +## Terminology + +This document uses the terms defined in +[Office Routine Arming Visibility § Terminology](routine-arming-visibility.md), +unchanged: *fire*, *intent*, *schedule state*, *armed*, *schedulable*, *dispatch +grace*, *trigger order*, *unarmed cron trigger*, and *surface*. + +## Requirements + +### REQ-OFFICE-ROUTINE-ARMING-003: Surface unarmed routines at startup + +**Intent:** The install this capability exists for is unattended. Nobody opens +the routines page for weeks, so a UI-only surface leaves the dead loop undetected +exactly where detection matters most. + +**User story:** As an operator reading logs or metrics after an upgrade, I want a +routine that is switched on and cannot fire to have left a trace. + +#### Acceptance criteria + +- **AC-OFFICE-ROUTINE-ARMING-003.1:** During Office startup the system shall + classify every routine it can enumerate, in every workspace it can enumerate, + and report the result. The scan shall read only after Office startup + reconciliation has returned — the pass that gives every trigger-less routine a + `manual` trigger — whether or not that pass succeeded, so that a routine the + scan reports as `unscheduled_no_trigger` is one reconciliation left without a + trigger rather than one the scan happened to read first. The ordering shall be + established by an explicit completion signal handed to the scan by the startup + sequence that ran reconciliation, and by nothing else: not a timer, not a retry, + and not an inspection of trigger rows. The scan is either invoked with that + signal or invoked without it. Invoked without it, the scan shall classify + nothing, emit a warning-level record, and increment the skipped counter of + AC-OFFICE-ROUTINE-ARMING-003.7 with a reason distinguishing this cause both from + enumeration failure and from per-routine classification failure. The startup + path that exists today always supplies the signal, so that branch is not reached + in production; it exists so that a caller which cannot establish the ordering is + refused rather than reporting a routine as `unscheduled_no_trigger` before + reconciliation has given it a trigger, and it is reached in a test by invoking + the scan without the signal. +- **AC-OFFICE-ROUTINE-ARMING-003.2:** The scan shall iterate workspaces ordered + by `workspaces.created_at` ascending, breaking ties by `workspaces.id` + ascending, and within each workspace shall iterate routines ordered by + `office_routines.created_at` ascending, breaking ties by `office_routines.id` + ascending, so that two scans over unchanged data report in the same order. +- **AC-OFFICE-ROUTINE-ARMING-003.3:** For a routine whose intent is `active` and + whose schedule state is `trigger_disabled`, `trigger_unscheduled` or + `trigger_invalid`, the system shall emit a warning-level record naming the + workspace, the routine, the schedule state, and every unarmed cron trigger in + trigger order. +- **AC-OFFICE-ROUTINE-ARMING-003.4:** For a routine whose intent is not `active`, + the system shall emit an informational record stating that the routine is + deliberately off, and shall not emit a warning. +- **AC-OFFICE-ROUTINE-ARMING-003.5:** For a routine whose intent is `active` and + whose schedule state is neither one of those in + AC-OFFICE-ROUTINE-ARMING-003.3 nor one of those in + AC-OFFICE-ROUTINE-ARMING-003.14 nor `unknown`, the system shall emit an + informational record and no warning. +- **AC-OFFICE-ROUTINE-ARMING-003.6:** The system shall publish a counter of scan + observations labelled by workspace, intent, and schedule state. +- **AC-OFFICE-ROUTINE-ARMING-003.7:** When the scan cannot classify a routine it + enumerated, the system shall increment a skipped counter labelled by reason, so + that a scan failing closed is visible rather than silent. +- **AC-OFFICE-ROUTINE-ARMING-003.8:** When the scan cannot enumerate the + workspace list at all, it shall increment that skipped counter with a reason + distinguishing enumeration failure from per-routine classification failure, + emit a warning-level record, and return without classifying anything. +- **AC-OFFICE-ROUTINE-ARMING-003.9:** A routine whose schedule state is `unknown` + shall emit an informational record naming the workspace, the routine, and the + `unknown` state, whatever its intent, and shall not emit a warning. An unreadable + row is not evidence of a broken routine. That record is the only one emitted for + such a routine: it stands in place of the record + AC-OFFICE-ROUTINE-ARMING-003.4 would otherwise require when the intent is not + `active`, so every routine the scan reaches produces exactly one record. The + routine shall increment the skipped counter of AC-OFFICE-ROUTINE-ARMING-003.7 + once, and shall also be counted once by the observation counter of + AC-OFFICE-ROUTINE-ARMING-003.6 under the `unknown` schedule state. Incrementing + both is intended, not double counting: the two answer different questions — how + many routines were observed in each state, and how many the scan could not + decide. +- **AC-OFFICE-ROUTINE-ARMING-003.10:** Office startup shall complete without + waiting for the scan to finish, and no scan outcome, including a database + error, shall fail startup or change its result. +- **AC-OFFICE-ROUTINE-ARMING-003.11:** The scan shall write nothing, per + AC-OFFICE-ROUTINE-ARMING-001.7, and shall not enable, disable, create, or + delete a trigger. +- **AC-OFFICE-ROUTINE-ARMING-003.12:** Running the scan twice over unchanged data + shall emit the same records, in the same order, and shall leave the database + unchanged. Two records are the same when they carry the same level, the same + workspace, the same routine, the same schedule state, and the same unarmed cron + triggers in the same order; wall-clock timestamps, durations, and fields the + logging layer adds are excluded from the comparison, as are counters, which + accumulate. The equality is asserted only where classification does not depend + on where the classification instant falls relative to the dispatch grace: a + routine is comparable between two scans when each of its cron triggers is on + the same side of the grace at both classification instants of + AC-OFFICE-ROUTINE-ARMING-001.12, where a null `last_fired_at` counts as outside + the grace at every instant. A trigger inside the grace at one scan and + outside it at the other changes schedule state with no database change, by the + design of rule 2, and is excluded from the comparison rather than counted as a + violation. The equality is likewise asserted only between two scans that each + enumerated the workspace list and every workspace's routines successfully and + classified every routine they enumerated: a scan in which + AC-OFFICE-ROUTINE-ARMING-003.8, AC-OFFICE-ROUTINE-ARMING-003.13, or a + per-routine classification failure occurred emits a different set of records with + no database change, by the design of those criteria, and is excluded from the + comparison. This criterion constrains classification, not the database's + willingness to answer. +- **AC-OFFICE-ROUTINE-ARMING-003.14:** For a routine whose intent is `active` and + whose schedule state is `unscheduled_manual_only` or `unscheduled_no_trigger`, + the system shall emit a warning-level record naming the workspace, the routine, + and the schedule state, and stating that the routine has no schedule. The record + shall carry no unarmed cron trigger list, because a routine in either state owns + no cron trigger. This is the state + [Office Routine Arming Visibility](routine-arming-visibility.md) names as the + sharper failure — switched on, presenting as on, incapable of firing — which is + why it warns rather than informs. The scan cannot tell a routine an operator + fires by hand from one whose schedule was never created, and this criterion does + not ask it to: either way the routine will not fire on a schedule, the operator + is the only one who can say which was meant, and the cost of saying so is one + warning per such routine per startup. +- **AC-OFFICE-ROUTINE-ARMING-003.13:** When the scan enumerates the workspace + list but then fails to enumerate the routines of one workspace, it shall retain + every record already emitted for earlier workspaces, increment the skipped + counter of AC-OFFICE-ROUTINE-ARMING-003.7 once for that workspace with the + enumeration-failure reason, emit a warning-level record naming it, and continue + to the next workspace in the order of + AC-OFFICE-ROUTINE-ARMING-003.2. It shall not retry that workspace within the + same scan and shall not abandon the scan. + +## Out of scope + +- **Classifying a routine.** The classification table, the unarmed cron trigger + list, and the dispatch grace belong to + [Office Routine Arming Visibility](routine-arming-visibility.md). This document + reports what that classifier returns and defines no state of its own. +- **Repair of any kind.** The scan writes nothing, per + AC-OFFICE-ROUTINE-ARMING-003.11. It does not re-arm, and the operator-initiated + re-arm action is deferred entirely — see that document's `## Out of scope`. +- **Running the scan again.** This is one pass at startup. A periodic re-scan, an + operator-invoked re-scan, and an API that returns the scan's findings are each + separate work; the read-path response of + REQ-OFFICE-ROUTINE-ARMING-002 already reports the same values on demand. +- **Operator notification.** Inbox items, mail, and paging are separate work. The + scan emits records and counters and stops. +- **What startup reconciliation itself does.** AC-OFFICE-ROUTINE-ARMING-003.1 + orders the scan after it and reports the result; it does not change which + triggers reconciliation creates. diff --git a/docs/specs/office/requirements/routine-arming-visibility.md b/docs/specs/office/requirements/routine-arming-visibility.md new file mode 100644 index 0000000000..5f4bf9f0c3 --- /dev/null +++ b/docs/specs/office/requirements/routine-arming-visibility.md @@ -0,0 +1,318 @@ +--- +status: draft +system: office +created: 2026-09-17 +owners: + - kandev +--- + +# Office Routine Arming Visibility Requirements + +## Overview + +An Office routine fires on its own only when a cron trigger reaches the +scheduler. Several independent states stop that happening, and none is reported +anywhere: the operator sees a routine badged "On" and cannot tell a running loop +from one somebody deliberately turned off, or from one silently dead. + +This capability makes the distinction **visible** and repairs nothing. +Operator-initiated repair was specified alongside it and deferred to a separate +card; see `## Out of scope`. Until that lands, an operator shown a broken +schedule still repairs it by hand. + +The live install at spec time has zero armed cron triggers anywhere: one routine +is `paused` with a disabled `cron` trigger and renders "Off", and one is `active` +with only a `manual` trigger, so it renders **"On"**, has no schedule at all, and +does not fire. The second is the sharper failure: switched on, presenting as on, +incapable of firing. + +Every term in this contract is an Office primitive: `office_routines.status`, +`office_routine_triggers`, and the dispatch tick in `internal/office/routines`. + +This document owns the classification and the read path. The startup scan that +reports the same classification to logs and counters on an unattended install is +`REQ-OFFICE-ROUTINE-ARMING-003`, which lives in +[Office Routine Arming Startup Scan](routine-arming-startup-scan.md) to stay +inside the requirement-file size limit. The two ship together. + +## Prior art + +No external knowledge base was reachable, so the prior art below is in-repo. + +[Office Stall Visibility](stall-visibility.md) is the same shape: a state no +detector reports, made visible without granting anything permission to recover +it. This document reuses its `Surface` definition, its detection/recovery split, +and its structured-log-plus-`expvar` convention, and differs in one way: stall +recovery is forbidden on principle, whereas here recovery is legitimate and +merely deferred, which is why `## Out of scope` names a successor card rather +than closing the question. + +## Terminology + +- **Fire:** the scheduler tick claims a trigger and dispatches a routine run. A + routine that fires and finds no work has fired. +- **Intent:** the operator's on/off choice, held in `office_routines.status`. + `active` means run this routine; any other value means deliberately off. +- **Schedule state:** whether the routine's triggers can fire it on a schedule, + computed from the trigger rows alone, without reference to intent. +- **Armed:** schedule state `armed` — some cron trigger can fire the routine on + a schedule with no further operator action. +- **Schedulable:** a cron trigger for which the shared scheduler can compute a + next occurrence. The expression and timezone must be valid, and the + expression must name at least one real calendar slot. The scheduler's + day-of-month/day-of-week OR rule and DST handling apply here too. A trigger + that fails these checks is not schedulable, and no amount of re-arming makes + it fire. +- **Dispatch grace:** a named duration of 60 seconds, separating a trigger + mid-dispatch from one permanently stuck. See + AC-OFFICE-ROUTINE-ARMING-001.10. +- **Trigger order:** `created_at` ascending, ties broken by `id` ascending. Every + ordered output over triggers in this document uses it. +- **Unarmed cron trigger:** a cron trigger of the routine that cannot currently + fire, carrying the reasons why. See AC-OFFICE-ROUTINE-ARMING-001.8. +- **Surface:** report a state to an operator without changing routine, trigger, + or run state. + +Intent and schedule state are two switches, and this document owns the +classification and reporting of the second one. Classification reads trigger +rows only. Dispatch uses the routine status separately: the cron path checks +the status before it claims a slot, and the manual and webhook paths reject a +non-firing status. This document reports both values and does not change those +dispatch gates. + +## Requirements + +### REQ-OFFICE-ROUTINE-ARMING-001: Classify a routine's schedule state + +**Intent:** Every unarmed routine is unarmed for exactly one reason, and the +reason determines what the operator must do next. A single boolean cannot carry +that, and a state computed from intent cannot separate deliberate from broken. + +**User story:** As an operator, I want each routine to report why it will or will +not fire, so I can tell a paused loop from a dead one. + +Schedule state is a total function of the routine's trigger rows alone. The first +matching rule wins; the ordering is the contract, not an implementation detail. + +| # | Condition on the routine's triggers | State | +|---|---|---| +| 0 | The trigger rows could not be read | `unknown` | +| 1 | Some `cron` trigger has `enabled = true` and `next_run_at` non-null | `armed` | +| 2 | Some `cron` trigger has `enabled = true`, `next_run_at` null, and `last_fired_at` no older than the dispatch grace | `armed` | +| 3 | Some `cron` trigger has `enabled = true` and is not schedulable | `trigger_invalid` | +| 4 | Some `cron` trigger has `enabled = true` | `trigger_unscheduled` | +| 5 | At least one `cron` trigger exists | `trigger_disabled` | +| 6 | Some `webhook` trigger has `enabled = true`, and no `cron` trigger exists | `event_only` | +| 7 | At least one trigger exists, none of kind `cron` | `unscheduled_manual_only` | +| 8 | No trigger rows exist | `unscheduled_no_trigger` | + +Rule 2 exists because the scheduler clears `next_run_at` when it claims a trigger +and restores it a few statements later; without it a healthy routine that happens +to be mid-dispatch classifies as broken. Rules 3 and 4 therefore describe an +enabled cron trigger whose `next_run_at` has been null for longer than that, +which is a permanent stall. Rule 3 precedes rule 4 so the more actionable +diagnosis wins when a routine owns an unschedulable enabled cron trigger +alongside a merely unscheduled one. Rule 5 is reached only when no cron trigger is +enabled, because rules 1 to 4 between them match every routine that has one. + +#### Acceptance criteria + +- **AC-OFFICE-ROUTINE-ARMING-001.1:** The system shall assign every routine + exactly one schedule state from the table above, applying the rules in the + order given and stopping at the first match. +- **AC-OFFICE-ROUTINE-ARMING-001.2:** The system shall compute schedule state + without reading `office_routines.status`, so that a routine's schedule state is + identical whether its intent is `active` or not. +- **AC-OFFICE-ROUTINE-ARMING-001.3:** A cron trigger with `enabled = true` whose + `next_run_at` is in the past shall match rule 1. A past-due trigger is due, not + broken. This says how rule 1 reads a past instant and does not override the + table: the same trigger with `enabled = false` reaches rule 5. +- **AC-OFFICE-ROUTINE-ARMING-001.4:** A cron trigger with `enabled = true` whose + `next_run_at` is exactly the classification instant of + AC-OFFICE-ROUTINE-ARMING-001.12 shall match rule 1, on the same terms. +- **AC-OFFICE-ROUTINE-ARMING-001.5:** When reading the routine's trigger rows + fails, the system shall report `unknown` and shall not report `armed`. A row + the system cannot decode is a row it could not read. +- **AC-OFFICE-ROUTINE-ARMING-001.6:** The system shall produce the same schedule + state for the same trigger rows on SQLite and on PostgreSQL. +- **AC-OFFICE-ROUTINE-ARMING-001.7:** Classification shall write no routine, + trigger, or run state. +- **AC-OFFICE-ROUTINE-ARMING-001.8:** Alongside the schedule state, the system + shall report the routine's unarmed cron triggers: every cron trigger that is + `enabled = false`, or is not schedulable, or is `enabled = true` and + schedulable with a null `next_run_at` and a `last_fired_at` either null or + older than the dispatch grace. Each entry shall carry the trigger's `id` and + **every** one of those three reasons that applies to it, ordered as the reasons + are listed in this criterion; a single entry may carry more than one. The list + shall be in trigger order. +- **AC-OFFICE-ROUTINE-ARMING-001.9:** A routine may be `armed` and still report a + non-empty unarmed cron trigger list: rule 1 matches on one trigger while + another remains broken. +- **AC-OFFICE-ROUTINE-ARMING-001.10:** The dispatch grace shall be a single named + compile-time constant of 60 seconds, used identically by classification and by + the startup scan. It shall not be operator-configurable: no environment + variable, configuration key, or runtime flag shall expose it. +- **AC-OFFICE-ROUTINE-ARMING-001.11:** A cron trigger with `enabled = true`, a + null `next_run_at`, and a null `last_fired_at` shall not match rule 2. A + trigger that has never fired is not mid-dispatch. Schedule state is the + routine's and is decided by first match across all its triggers, so such a + trigger sets the routine's state under rule 3 or rule 4 only when no sibling + matched an earlier rule; a sibling matching rule 1 leaves the routine `armed` + and puts this one on the unarmed cron trigger list, per + AC-OFFICE-ROUTINE-ARMING-001.9. +- **AC-OFFICE-ROUTINE-ARMING-001.12:** One classification of one routine shall + read that routine's trigger rows once and derive both the schedule state and + the unarmed cron trigger list from that single read, and shall compare every + `last_fired_at` against one instant captured once for that classification, so + that no two triggers of a routine are judged against different clocks. The + bounded read is the one the classification derives its answer from: when a batch + read fails and the per-routine re-read of AC-OFFICE-ROUTINE-ARMING-001.13 + supplies the rows, that re-read is the single read, and the instant stays the one + captured for the classification the re-read belongs to rather than being + re-captured per routine. A failed read derives nothing and does not count against + this criterion. +- **AC-OFFICE-ROUTINE-ARMING-001.13:** A trigger-read failure shall make + `unknown` exactly those routines whose own trigger rows were not read. When one + query reads the trigger rows of several routines and that query fails, or one + row in its result cannot be decoded, the system shall re-read each routine of + that batch individually and report `unknown` only for those whose individual + read also fails, so one undecodable row makes at most its own routine + `unknown`, never every routine in the workspace. That re-read shall be + attempted at most once per routine per classification. + +### REQ-OFFICE-ROUTINE-ARMING-002: Report intent and schedule state on routine reads + +**Intent:** The operator's only view of a routine today is a badge driven by +intent alone, which is why a routine with no schedule renders as "On". Both +values must arrive in one response, so the surface cannot show one without the +other. + +**User story:** As an operator opening the routines list, I want to see both +whether a routine is switched on and whether it can actually fire. + +#### Acceptance criteria + +- **AC-OFFICE-ROUTINE-ARMING-002.1:** The routine list response and the single + routine response shall each carry, per routine, the intent value, the schedule + state from REQ-OFFICE-ROUTINE-ARMING-001, and the unarmed cron trigger list + from AC-OFFICE-ROUTINE-ARMING-001.8. +- **AC-OFFICE-ROUTINE-ARMING-002.2:** The routines list shall distinguish a + routine whose intent is `active` and whose schedule state is `armed` from one + whose intent is `active` and whose schedule state is anything else. +- **AC-OFFICE-ROUTINE-ARMING-002.3:** The routines list shall distinguish a + routine whose intent is not `active` from a routine whose intent is `active` + but which cannot fire, so that deliberate and broken never render the same. +- **AC-OFFICE-ROUTINE-ARMING-002.4:** The surface shall distinguish a routine + whose unarmed cron trigger list holds at least one schedulable entry from one + whose unarmed entries are none of them schedulable, whatever its schedule + state: the first needs only its triggers re-armed, the second needs its cron + expression or timezone edited. A routine `armed` on one trigger and broken on + another still carries the distinction. The distinction applies only where the + unarmed cron trigger list is non-empty: over an empty list "none of them + schedulable" holds vacuously, so a routine with an empty list shall render + neither side of it rather than be told to edit an expression it has no problem + with. Neither rendering shall offer an action that performs the repair; + see `## Out of scope`. +- **AC-OFFICE-ROUTINE-ARMING-002.5:** For a routine whose schedule state is + `unscheduled_manual_only` or `unscheduled_no_trigger`, the surface shall report + that the routine has no schedule. +- **AC-OFFICE-ROUTINE-ARMING-002.6:** A routine whose schedule state is `unknown` + shall render as undetermined rather than as armed or as broken. +- **AC-OFFICE-ROUTINE-ARMING-002.7:** The routine list response shall be produced + with a number of database queries that does not grow with the number of + routines in the workspace. This bounds the path where every read succeeds; the + per-routine re-read AC-OFFICE-ROUTINE-ARMING-001.13 requires after a failed + batch read is exempt, at most one extra read per routine of that batch, because + isolating the bad routine is worth more than the query count on a path that is + already failing. +- **AC-OFFICE-ROUTINE-ARMING-002.8:** All copy added by this capability shall be + routed through the translation layer and present in every shipped locale. +- **AC-OFFICE-ROUTINE-ARMING-002.9:** Every distinction this requirement demands + shall be carried by text, an icon, or an accessible label, never by colour + alone. +- **AC-OFFICE-ROUTINE-ARMING-002.10:** The surface shall render a distinct label + for each of these groups: `armed`; `trigger_invalid`, `trigger_unscheduled` or + `trigger_disabled`; `event_only`; `unscheduled_manual_only` or + `unscheduled_no_trigger`; and `unknown`. Two routines in different groups shall + never render the same label. +- **AC-OFFICE-ROUTINE-ARMING-002.11:** A routine whose schedule state is + `event_only` shall not be reported as having no schedule and shall not be + directed to create a trigger. It fires when its event arrives. +- **AC-OFFICE-ROUTINE-ARMING-002.12:** Intent and schedule state shall be carried + in the same response for the same routine, and neither shall be omitted because + the other was unavailable: a routine whose triggers could not be read carries + its intent alongside schedule state `unknown`. The pair is not required to be a + transactional snapshot. Intent is read from the routine row and schedule state + from the trigger rows, so a write landing between those reads can yield a pair + never simultaneously true in the database. That is accepted, not prevented: + nothing here acts on the pair, and the next read reports the new value. + +## Out of scope + +Each exclusion is a contract: a later change that wants one needs its own +requirement. + +- **The startup scan.** Not excluded from the capability, only from this file: + `REQ-OFFICE-ROUTINE-ARMING-003` is in + [Office Routine Arming Startup Scan](routine-arming-startup-scan.md) and is + part of the same delivery. +- **Repair of any kind.** Nothing here enables, disables, creates, or deletes a + trigger, and nothing here changes intent. Not automatically, and not on an + operator's request either — see the two entries below. +- **The operator-initiated re-arm action. Deferred to task + `b0382916-da13-44a5-85be-064ae6a9533c`.** A requirement for it was written + alongside this document and cut before Build, so this surface reports a broken + schedule and offers nothing that repairs it. What the successor needs: an + explicit action over one named routine, setting `enabled = true` and a fresh + `next_run_at` on exactly that routine's unarmed cron triggers that are + schedulable, in trigger order, in one transaction with no partial application; + never creating a trigger, never inferring a cron expression, never + re-scheduling an already-armed trigger, and never reading intent to decide what + to repair. It must keep four rejections distinct: no cron trigger to repair + (including `event_only`, which per AC-OFFICE-ROUTINE-ARMING-002.11 must not be + told to create one); cron triggers present but none schedulable; caller not + authorized for the owning workspace; no such routine. It consumes + AC-OFFICE-ROUTINE-ARMING-001.8's unarmed list and this document's + classification, so it depends on this document and not the reverse. Three + contract defects in the cut text travelled to that card unfixed and are + recorded there. +- **Changing intent gating.** Routine status gating is owned by the routine + status requirement. The existing scheduler consumes a due cron slot when the + routine is not allowed to fire, and the manual and webhook handlers reject + non-firing statuses. This capability only reports the status and schedule + state; it does not change those gates. +- **The coordinator install.** Keeping onboarding from duplicating the + pre-installed routine is + [Office Coordinator Install Idempotency](coordinator-install-idempotency.md). +- **Cron evaluation changes.** Cron parsing and next-occurrence calculation use + the shared `NextCronTime` implementation. It follows `crontab(5)` OR semantics + for restricted day-of-month/day-of-week fields, applies the documented DST + policy, and returns `ErrUnsatisfiableCron` for an expression with no possible + occurrence. The classifier reports that last case as not schedulable. Changes + to those scheduler rules are outside this capability. +- **The routine list's own ordering.** This document pins the order of every + trigger list it reports, and the startup scan document pins its own iteration + order. The order routines appear in the list response is pre-existing behaviour + and is not changed here. +- **Editing a schedule.** Changing a cron expression, timezone, webhook secret or + signing mode is not classification, and would not be re-arming either: a + trigger that is not schedulable cannot be repaired by re-arming it and needs + its expression edited. +- **Disabling a trigger from the UI.** No endpoint sets + `office_routine_triggers.enabled` to false today and this document adds none. + Whether the column should be retired is not decided here. +- **Using `office_routines.last_run_at` as schedule state.** Dispatch updates + this column through `TouchRoutineLastRun`; it is a run-history value, not an + input to schedule-state classification. +- **Runs stranded in `claimed`,** and any reaper for them. A stranded run does not + affect whether the next tick fires. +- **Operator notification.** Inbox items, mail, and paging are separate work. +- **Assignee validity.** A routine armed against a deleted or paused assignee + agent is `armed` here. Whether the woken agent can act is another contract. +- **Trigger reconciliation against a config source.** Config sync creates routines + without triggers and the infrastructure reconciler gives them a `manual` one, + which is how the live install reached `unscheduled_manual_only`. This document + makes that state visible and changes what neither creates. +- **Webhook delivery.** Whether an `event_only` routine's caller actually posts to + it, and whether its signature verifies, is not observed here. `event_only` says + a schedule is absent and an event trigger is enabled, nothing more. diff --git a/docs/specs/office/system-design/coordinator-install-idempotency.md b/docs/specs/office/system-design/coordinator-install-idempotency.md new file mode 100644 index 0000000000..ffaedb6b98 --- /dev/null +++ b/docs/specs/office/system-design/coordinator-install-idempotency.md @@ -0,0 +1,194 @@ +--- +status: current +system: office +requirements: + - REQ-OFFICE-COORDINATOR-INSTALL-001 +--- + +# Office Coordinator Install Idempotency System Design + +## Purpose and boundaries + +Onboarding pre-installs a `Coordinator heartbeat` routine for a coordinator +agent. The installer previously matched an existing routine on workspace + +assignee + canonical name **and** a cron trigger with the exact canonical +expression — a mutable-state match that let a half-finished install (routine +row created, trigger creation failed) or a later trigger edit make the +installer duplicate the routine on the next run. This design redefines +identity as workspace + assignee + canonical name only, completes a +half-finished install instead of duplicating it, and serializes install +attempts across every process sharing the database so two concurrent +installs can never both observe "absent." It does not touch dispatch or any +other trigger-management path; the schedule-state classification it uses to +report an already-scheduled routine is owned by +[routine-schedule-state.md](routine-schedule-state.md). + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-OFFICE-COORDINATOR-INSTALL-001` | [Identity and decision flow](#identity-and-decision-flow), [Locking](#locking), [Failure and recovery](#failure-and-recovery) | + +## Components and responsibilities + +- **`RoutineService.CreateDefaultCoordinatorRoutine`** + (`internal/office/routines/service.go`) — the sole entry point, called by + `internal/office/agents/service.go` and + `internal/office/onboarding/service.go`. Runs the routine phase and the + trigger phase as two separately-locked, separately-committed steps. +- **`Repository.InstallCoordinatorRoutine` / + `Repository.EnsureCoordinatorTrigger`** + (`internal/office/repository/sqlite/coordinator_install.go`) — the two + locked, transactional read-decide-write boundaries the service phases run + inside. +- **`Repository.WithCoordinatorInstallLock`** (same file) — the shared + cross-process serialization mechanism both boundaries use, keyed by an + opaque lock key. +- **`decideCoordinatorTrigger` / `selectOrCreateCoordinatorRoutine`** + (`internal/office/routines/service.go`) — the decision logic each locked + boundary runs: create-or-select for the routine phase, create-or-leave for + the trigger phase. + +## Data and contracts + +### Identity + +`(workspace_id, assignee_agent_profile_id, name=CoordinatorRoutineName)` is +the entire identity. `hasAnyCronTrigger` — any cron trigger, enabled or not, +canonical expression or not — is the only fact the trigger phase uses to +decide whether to act; it never reads `enabled`, `next_run_at`, or the +trigger's expression. + +### Lock keys + +- `CoordinatorInstallLockKey(workspaceID, agentID, canonicalName)` — + `"office-coordinator-install:|||"`, guards the + routine phase. +- `CoordinatorInstallTriggerLockKey(routineID)` — + `"office-coordinator-install-trigger:"`, guards the trigger + phase. Deliberately a distinct namespace so a routine ID can never collide + with an identity key. +- Both are exported functions, not exported namespace constants, so no + caller reassembles a key itself — the same rationale + `internal/workflow/repository.ParticipantRoleSeatLockKey` uses. + +### `models.CoordinatorInstallTx` + +The tx-scoped writer interface `decide` callbacks use: +`ListTriggersByRoutineID`, `CreateRoutine`, `CreateRoutineTrigger` — all +transaction-bound, so a `decide` callback cannot escape the lock's +transaction boundary. + +### `models.ErrCoordinatorInstallContention` + +The sentinel error `WithCoordinatorInstallLock` returns when +`coordinatorInstallLockTimeout` (5s, compile-time constant, no +configuration surface) is reached before the lock is won — +distinguished from the caller's own context ending by comparing which +context is actually expired (see +[Failure and recovery](#failure-and-recovery)). + +## Control flow + +### Identity and decision flow + +`CreateDefaultCoordinatorRoutine` runs two phases in sequence, both against +the same captured `now`: + +1. **Routine phase** — `InstallCoordinatorRoutine` opens the identity lock, + lists every routine matching identity (ordered `created_at, id`), and + hands them to `selectOrCreateCoordinatorRoutine`: zero matches creates a + routine; one or more selects the earliest, logging a warning (and an + `expvar` observation) if more than one matched. +2. **Trigger phase** — once the routine phase has committed, + `EnsureCoordinatorTrigger` opens the routine's own trigger lock, reads its + triggers, and hands them to `decideCoordinatorTrigger`: any existing cron + trigger (of any state) is left untouched and its schedule state logged + (via the shared classifier); no cron trigger creates the canonical one, + with `next_run_at` computed from the single `now` the outer call captured. + +The two phases are separate transactions on purpose: a trigger-creation +failure must never roll back an already-committed routine. + +### Locking + +`WithCoordinatorInstallLock` runs `fn` inside one transaction, bounded to +`coordinatorInstallLockTimeout` via a context derived from (not identical to) +the caller's context: + +- **SQLite:** the writer connection is the only open connection + (`internal/db.OpenSQLite`'s `SetMaxOpenConns(1)`), and SQLite's own + file-level lock plus `busy_timeout` already serializes a second process's + write transaction — opening the transaction is the whole mechanism, the + same pattern `internal/workflow/repository.EnsureRoleSeat` already + establishes. +- **PostgreSQL:** a plain transaction does not by itself stop two callers + both reading "absent" under `READ COMMITTED`, so the transaction + additionally holds `pg_try_advisory_xact_lock(hashtextextended(lockKey, + 0))`, acquired via a bounded poll (`coordinatorInstallLockPollInterval`, + 25ms) rather than the blocking `pg_advisory_xact_lock`, so the same hard + acquisition bound applies on both dialects. The lock releases automatically + at commit/rollback. + +The transaction commits when `fn` returns nil, rolls back otherwise. + +## Failure and recovery + +`reportCoordinatorLockFailure` classifies every non-nil outcome from either +locked phase into exactly one reportable condition, checked in this order: + +1. `decision.errored` — `decide` already reported at its own failure site + (`routine_create_failed`, `trigger_create_failed`, or + `next_occurrence_failed`); nothing reported again here, even though the + busy-text/deadline heuristic below could otherwise also match `decide`'s + own error text. +2. `errors.Is(err, models.ErrCoordinatorInstallContention)` — the lock + timeout was reached → `contention`. +3. `errors.Is(err, context.Canceled) || errors.Is(err, + context.DeadlineExceeded)` on the **caller's own** context → `cancelled`, + distinguishable from contention because `classifyCoordinatorInstallWaitErr` + checks `callerCtx.Err()` before falling back to the bounded-context + classification. +4. `!decision.ran` — the pre-`decide` read (identity lookup or trigger read) + failed before `decide` ever ran → the caller-supplied fallback condition + (`lookup_failed` or `trigger_read_failed`). +5. Otherwise — `decide` ran and returned nil, so the failure is the + transaction's own commit → `commit_failed`. + +`classifyCoordinatorInstallWaitErr` folds `context.DeadlineExceeded`, +`sql.ErrTxDone` (what `database/sql` surfaces from `Commit` when the bounded +context's own deadline already auto-rolled the transaction back), and the +SQLite `"database is locked"` busy text into the contention bucket, but only +after confirming the caller's own context has not itself ended. + +## Persistence + +No new tables. Both phases write only `office_routines` and +`office_routine_triggers` through the existing tx-scoped repository methods. +`internal/persistence/requiredstores` / `storeconformance` already register +office's schema owner; this design adds no new one. + +## Security + +No new authorization surface — the install path runs from onboarding and +agent-provisioning flows that already own the workspace/agent identity they +pass in; an empty `workspace_id` or `agent_id` is rejected before any lock is +acquired. + +## Observability + +`office_coordinator_install_conditions_total` (`expvar.Map`, +`internal/office/routines/coordinator_install_metrics.go`), labelled +`condition;workspace;assignee`. Conditions: `empty_identity`, +`lookup_failed`, `trigger_read_failed`, `duplicate_matches`, +`schedule_state`, `trigger_completed`, `routine_created`, +`next_occurrence_failed`, `trigger_create_failed`, `routine_create_failed`, +`contention`, `cancelled`, `commit_failed`. Each names a condition +*observed*, not a call that completed successfully — a call ultimately +rejected still counts the conditions it detected before rejecting. Also +emitted as structured (`zap`) log lines at the same sites. + +## Related decisions + +None — this design does not introduce a durable boundary or contract beyond +what REQ-OFFICE-COORDINATOR-INSTALL-001 itself specifies. diff --git a/docs/specs/office/system-design/routine-schedule-state.md b/docs/specs/office/system-design/routine-schedule-state.md new file mode 100644 index 0000000000..d24b560625 --- /dev/null +++ b/docs/specs/office/system-design/routine-schedule-state.md @@ -0,0 +1,183 @@ +--- +status: current +system: office +requirements: + - REQ-OFFICE-ROUTINE-ARMING-001 + - REQ-OFFICE-ROUTINE-ARMING-002 + - REQ-OFFICE-ROUTINE-ARMING-003 +--- + +# Office Routine Schedule State System Design + +## Purpose and boundaries + +An Office routine has two independent switches: intent +(`office_routines.status`, operator-set) and schedule state (whether its +triggers can actually fire it, derived). The product reported only intent. +This design adds a second, read-only classification, surfaces it everywhere +intent is already surfaced (routine list/detail reads, the routines UI), and +adds an unattended-install detection path (a startup scan) for the same +classification. It repairs nothing — no code path here creates, edits, or +enables/disables a trigger. Dispatch keeps its existing status gates: the due +trigger query is status-independent, then the service checks the routine before +claiming a cron slot, while the manual and webhook paths reject non-firing +statuses. See [coordinator-install-idempotency.md](coordinator-install-idempotency.md) +for the one trigger-creation path this plan touches. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-OFFICE-ROUTINE-ARMING-001` | [Classification](#classification) | +| `REQ-OFFICE-ROUTINE-ARMING-002` | [Read-path reporting](#read-path-reporting), [Frontend rendering](#frontend-rendering) | +| `REQ-OFFICE-ROUTINE-ARMING-003` | [Startup scan](#startup-scan) | + +## Components and responsibilities + +- **Classifier** (`apps/backend/internal/office/routines/arming.go`) — pure, + total function from one routine's trigger rows to a `ScheduleState` plus an + unarmed-cron-trigger list. No I/O, no routine status input. +- **Batch classification** (`ClassifyRoutines`, same file) — the read + boundary every consumer shares: one batched trigger read across many + routines, with a per-routine fallback read when the batch read itself + fails. +- **Read-path reporting** (`RoutineService.AttachScheduleState`, + `internal/office/routines/service.go`) — the API consumer: wraps the + existing list/get routine responses with the classification. +- **Startup scan** (`RunStartupScan`, + `internal/office/routines/startup_scan.go`) — the unattended-install + consumer: a read-only pass over every enumerable workspace and routine at + boot, ordered after reconciliation, that logs and counts what it finds + instead of returning it to a caller. +- **Frontend rendering** + (`apps/web/app/office/routines/schedule-state.ts`, + `schedule-state-badge.tsx`) — collapses the nine wire `ScheduleState` + values into five label groups and renders `ScheduleStateBadge` / + `UnarmedScheduleHint` on the routine list and detail views + (`routine-row.tsx`). + +## Data and contracts + +### `ScheduleState` + +One of nine values (`internal/office/routines/arming.go`): +`armed`, `trigger_invalid`, `trigger_unscheduled`, `trigger_disabled`, +`event_only`, `unscheduled_manual_only`, `unscheduled_no_trigger`, `unknown`. +`unknown` is reserved for `ClassifyRoutines`' per-routine fallback-read +failure; `ClassifyRoutine` itself never returns it. + +### Classification rule table + +`ClassifyRoutine(triggers []*RoutineTrigger, now time.Time)` evaluates, in +order, stopping at the first match: + +1. Any enabled cron trigger with a next occurrence, or one that fired within + the last `dispatchGrace` (60s, a compile-time constant shared verbatim + with the startup scan) and hasn't been recomputed yet → `armed`. +2. Any enabled cron trigger for which the shared scheduler cannot compute a + next occurrence (`shared.NextCronTime`), including an impossible but + syntactically valid expression → `trigger_invalid`. +3. Any enabled, schedulable cron trigger with no next occurrence recorded → + `trigger_unscheduled`. +4. One or more cron triggers exist but none matched 1-3 (all disabled) → + `trigger_disabled`. +5. No cron trigger, but an enabled webhook trigger exists → `event_only`. +6. No cron trigger, but some other (e.g. manual) trigger exists → + `unscheduled_manual_only`. +7. No trigger at all → `unscheduled_no_trigger`. + +Rule 1 does not require schedulability: a trigger that already claimed a next +occurrence still fires on it even if edited into an unparseable expression +afterward — `unarmedCronTriggers` reports that separately, so the same +trigger can be simultaneously `armed` and listed as unarmed-with-reasons. + +### `UnarmedCronTrigger` + +`{trigger_id, reasons: UnarmedReason[]}`, one entry per cron trigger matching +the disabled-OR-not-schedulable-OR-stalled-past-grace predicate, independent +of the routine's overall `ScheduleState`. `UnarmedReason` is `disabled`, +`not_schedulable`, or `stalled`; more than one may apply to the same trigger. + +### API contract + +`RoutineListResponse` / `RoutineResponse` +(`internal/office/routines/dto.go`) embed `RoutineWithSchedule`: +`{*Routine, schedule_state, unarmed_cron_triggers}` (JSON tags), returned +from `GET /workspaces/:wsId/routines`, `GET /routines/:id`, and the +create/update routine handlers (`internal/office/routines/handler.go`). +`AttachScheduleState` classifies with one `time.Now().UTC()` capture per +call — never per-routine — so every routine in one response is judged +against the same instant. + +### Frontend contract + +`readScheduleState` / `readUnarmedCronTriggers` +(`apps/web/app/office/routines/schedule-state.ts`) tolerate the raw +snake_case wire shape directly, matching the codebase's established +`fetchJson`-does-not-case-convert pattern. `scheduleStateGroup` collapses the +nine wire values into five label groups (`armed`, `broken`, `event_only`, +`no_schedule`, `unknown`) so two routines in different groups never render +the same label. `hasSchedulableUnarmedEntry` distinguishes an unarmed entry +that only needs re-arming from one whose expression needs editing first. + +## Control flow + +**Read path:** handler calls `AttachScheduleState(ctx, routines)` → +`ClassifyRoutines` batch-reads triggers for all routine IDs via one +repository call → falls back to `ListTriggersByRoutineID` per routine only if +the batch call errors → `ClassifyRoutine` runs the pure rule table per +routine → handler serializes `RoutineWithSchedule`. + +**Startup scan:** `Reconciler.ReconcileAll` returns a `ReconcileSignal` +(the only way to construct one is `SignalReconcileComplete`, called after +`ReconcileAll` returns — a zero-value signal, which an unauthorized caller or +a refusal-path test would hold, carries no completion) → +`internal/backendapp/main.go` launches `RunStartupScan` in a goroutine after +logging "Office reconciliation complete" → the scan enumerates workspaces, +then routines per workspace, then classifies each batch via the same +`ClassifyRoutines` path the read API uses → emits one structured log record +and one `expvar` observation per routine, plus a skip counter for any input +read that failed. + +## Failure and recovery + +- A batch trigger read failure degrades to per-routine reads; a + per-routine read failure yields `ScheduleStateUnknown` for that routine + only, never an error returned to the caller. +- The startup scan fails closed at every enumeration boundary (no signal, no + workspaces, one workspace's routines unreadable) by skipping and counting + the skip, never retrying and never aborting the rest of the scan. +- The scan is fire-and-forget (`go officeroutines.RunStartupScan(...)`): no + outcome inside it can fail or delay Office startup. + +## Persistence + +No new tables or columns. The classifier and the scan are read-only over +`office_routines` and `office_routine_triggers` +(`internal/office/repository/sqlite/base.go`). + +## Security + +Read-only; no new authorization surface. Existing routine-read +authorization (workspace scoping) applies unchanged, since the classification +is attached to the same response the caller already had access to. + +## Observability + +- `office_routine_arming_scan_observations_total` — every routine the + startup scan classified, labelled `workspace`, `intent`, `schedule_state`. +- `office_routine_arming_scan_skipped_total` — a scan step abandoned because + an input could not be read, labelled `reason` + (`no_signal`, `workspace_enum_failed`, `routine_enum_failed`, + `classification_unknown`). +- Both are `expvar.Map` counters (`internal/office/routines/arming_metrics.go`), + exposed via the stdlib `/debug/vars` handler in dev mode, following + `internal/orchestrator/office_stall_metrics.go`'s label-string convention. +- The scan also emits one structured (`zap`) log record per routine reached, + at `Warn` for a routine that cannot fire or has no schedule, `Info` + otherwise. + +## Related decisions + +None — this design does not introduce a durable boundary or contract beyond +what the linked requirements themselves specify.