feat(office): report routine schedule state and harden coordinator install (#3763)

Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com>
Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
This commit is contained in:
Henry
2026-09-18 17:38:01 +08:00
committed by GitHub
parent 5181364348
commit 8641c5b1ae
58 changed files with 7293 additions and 136 deletions
+8 -1
View File
@@ -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
@@ -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{},
) {
@@ -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
}
@@ -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
}
@@ -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)
}
}
@@ -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
}
@@ -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)
}
}
@@ -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
@@ -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()
@@ -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
}
@@ -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)
}
}
}
@@ -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)
}
@@ -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)
}
}
@@ -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))
}
}
@@ -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)
}
@@ -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))
}
}
+11 -2
View File
@@ -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.
@@ -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) {
@@ -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)
}
}
+300 -62
View File
@@ -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
@@ -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...)
}
}
@@ -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)
}
}
+36 -17
View File
@@ -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
@@ -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)
}
}
@@ -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))
@@ -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
}
@@ -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)
}
}
@@ -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 },
}}
>
<OfficeTopbarChromeProvider>
<RoutineDetailView initialRoutine={routine} initialTriggers={triggers} />
<TopbarActions />
</OfficeTopbarChromeProvider>
<TooltipProvider>
<OfficeTopbarChromeProvider>
<RoutineDetailView initialRoutine={routine} initialTriggers={triggers} />
<TopbarActions />
</OfficeTopbarChromeProvider>
</TooltipProvider>
</StateProvider>,
);
}
@@ -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
<DetailGeneralCard draft={draft} update={update} agents={agents} />
<DetailTriggerCard draft={draft} update={update} />
<DetailReadOnlyCard
routine={routine}
lastFiredAt={lastFired}
nextRunAt={
isRoutineFiring(draft.status ?? routine.status) ? (cronTrigger?.nextRunAt ?? null) : null
@@ -442,9 +444,11 @@ function DetailTriggerCard({
}
function DetailReadOnlyCard({
routine,
lastFiredAt,
nextRunAt,
}: {
routine: Routine;
lastFiredAt: string | null;
nextRunAt: string | null;
}) {
@@ -454,7 +458,11 @@ function DetailReadOnlyCard({
<CardHeader>
<CardTitle className="text-sm font-medium">{t("office:schedule")}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground space-y-1">
<CardContent className="text-sm text-muted-foreground space-y-3">
<div className="flex flex-wrap items-center gap-2">
<ScheduleStateBadge routine={routine} />
<UnarmedScheduleHint routine={routine} />
</div>
{/* `{{when}}` carries a formatted timestamp, not a translated label. */}
<div>
{t("office:lastFired", { when: lastFiredAt ? timeAgo(lastFiredAt) : t("office:never") })}
+42 -35
View File
@@ -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({
<div>
<div
data-testid={`routine-row-${routine.id}`}
className="flex items-center gap-3 px-4 py-2.5 hover:bg-accent/50 transition-colors cursor-pointer"
className="flex items-start gap-3 px-4 py-2.5 hover:bg-accent/50 transition-colors cursor-pointer"
onClick={() => onClick(routine.id)}
>
<IconChevronDown
className={`h-4 w-4 text-muted-foreground shrink-0 transition-transform ${expanded ? "" : "-rotate-90"}`}
/>
<div className="flex-1 min-w-0">
<div className="flex min-w-0 flex-1 flex-col">
<Link
href={`/office/routines/${routine.id}`}
className="text-sm font-medium truncate cursor-pointer hover:underline"
className="block truncate text-sm font-medium cursor-pointer hover:underline"
onClick={(e) => e.stopPropagation()}
>
{routine.name}
</Link>
<div className="flex items-center gap-2 mt-0.5 text-xs text-muted-foreground">
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 mt-0.5 text-xs text-muted-foreground">
{assignee && <span>{assignee.name}</span>}
{cronTrigger?.cronExpression && (
<span className="font-mono">{cronTrigger.cronExpression}</span>
@@ -131,38 +132,44 @@ export function RoutineRow({
<span>{routine.lastRunAt ? timeAgo(routine.lastRunAt) : t("office:neverRun")}</span>
<span>{concurrencyLabel(t, routine.concurrencyPolicy)}</span>
</div>
<div className="flex flex-wrap items-center gap-2 mt-1">
<Badge variant={isActive ? "default" : "secondary"}>
{isActive ? t("office:on") : t("office:off")}
</Badge>
<ScheduleStateBadge routine={routine} />
<UnarmedScheduleHint routine={routine} />
</div>
</div>
<div className="flex shrink-0 items-center gap-3 pt-0.5">
<Switch
checked={isActive}
onCheckedChange={(checked) => {
onToggle(routine.id, checked);
}}
onClick={(e) => e.stopPropagation()}
className="cursor-pointer"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
size="icon"
className="cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<Link href={`/office/routines/${routine.id}`}>
<IconPencil className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>{t("office:editRoutine")}</TooltipContent>
</Tooltip>
<RoutineActions
onRunNow={() => onRunNow(routine.id)}
onDelete={() => onDelete(routine.id)}
/>
</div>
<Badge variant={isActive ? "default" : "secondary"}>
{isActive ? t("office:on") : t("office:off")}
</Badge>
<Switch
checked={isActive}
onCheckedChange={(checked) => {
onToggle(routine.id, checked);
}}
onClick={(e) => e.stopPropagation()}
className="cursor-pointer"
/>
<Tooltip>
<TooltipTrigger asChild>
<Button
asChild
variant="ghost"
size="icon"
className="cursor-pointer"
onClick={(e) => e.stopPropagation()}
>
<Link href={`/office/routines/${routine.id}`}>
<IconPencil className="h-4 w-4" />
</Link>
</Button>
</TooltipTrigger>
<TooltipContent>{t("office:editRoutine")}</TooltipContent>
</Tooltip>
<RoutineActions
onRunNow={() => onRunNow(routine.id)}
onDelete={() => onDelete(routine.id)}
/>
</div>
{expanded && (
<RoutineExpandedDetail routine={routine} assignee={assignee} template={template} />
@@ -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(<TooltipProvider>{ui}</TooltipProvider>);
}
function routine(overrides: Record<string, unknown> = {}): 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(<ScheduleStateBadge routine={routine({ scheduleState: state })} />);
expect(screen.getByText(label)).toBeTruthy();
});
it("never renders event_only as having no schedule (AC-002.11)", () => {
renderBadge(<ScheduleStateBadge routine={routine({ scheduleState: "event_only" })} />);
expect(screen.queryByText("No schedule")).toBeNull();
});
it("collapses every state in the same group onto the same label", () => {
const labels = new Set<string>();
for (const [state, label] of cases) {
cleanup();
renderBadge(<ScheduleStateBadge routine={routine({ scheduleState: state })} />);
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(
<UnarmedScheduleHint routine={routine({ unarmedCronTriggers: [] })} />,
);
expect(container.firstChild).toBeNull();
});
it("distinguishes an unarmed entry that only needs re-arming", () => {
render(
<UnarmedScheduleHint
routine={routine({
unarmedCronTriggers: [{ triggerId: "t1", reasons: ["disabled"] }],
})}
/>,
);
expect(screen.getByText("Needs re-arming")).toBeTruthy();
});
it("distinguishes an unarmed entry that needs its expression or timezone edited", () => {
render(
<UnarmedScheduleHint
routine={routine({
unarmedCronTriggers: [{ triggerId: "t1", reasons: ["not_schedulable"] }],
})}
/>,
);
expect(screen.getByText("Needs schedule fix")).toBeTruthy();
});
it("reports 're-arming' once any entry in a mixed list is still schedulable", () => {
render(
<UnarmedScheduleHint
routine={routine({
unarmedCronTriggers: [
{ triggerId: "t1", reasons: ["not_schedulable"] },
{ triggerId: "t2", reasons: ["disabled"] },
],
})}
/>,
);
expect(screen.getByText("Needs re-arming")).toBeTruthy();
});
});
@@ -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<ScheduleStateGroup, string> = {
armed: "office:scheduleStateArmed",
broken: "office:scheduleStateBroken",
event_only: "office:scheduleStateEventOnly",
no_schedule: "office:scheduleStateNoSchedule",
unknown: "office:scheduleStateUnknown",
};
const GROUP_DESCRIPTION_KEYS: Record<ScheduleStateGroup, string> = {
armed: "office:scheduleStateArmedDescription",
broken: "office:scheduleStateBrokenDescription",
event_only: "office:scheduleStateEventOnlyDescription",
no_schedule: "office:scheduleStateNoScheduleDescription",
unknown: "office:scheduleStateUnknownDescription",
};
const GROUP_ICONS: Record<ScheduleStateGroup, Icon> = {
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 (
<Tooltip>
<TooltipTrigger asChild>
<Badge variant={GROUP_VARIANTS[group]} className={className} aria-label={label}>
<GroupIcon aria-hidden="true" />
<span>{label}</span>
</Badge>
</TooltipTrigger>
<TooltipContent>{t(GROUP_DESCRIPTION_KEYS[group])}</TooltipContent>
</Tooltip>
);
}
// 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 (
<Badge variant="outline" className={className} aria-label={label}>
<span>{label}</span>
</Badge>
);
}
@@ -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<string, unknown>): 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);
});
});
@@ -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<ScheduleState, ScheduleStateGroup> = {
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);
}
+29
View File
@@ -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<string, unknown> = {
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<void> {
@@ -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);
});
});
@@ -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<unknown>;
}> = [
{
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);
});
});
@@ -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({});
@@ -4,7 +4,10 @@ import type {
Routine,
RoutineRun,
RoutineTrigger,
ScheduleState,
UpdateRoutinePatch,
UnarmedCronTrigger,
UnarmedReason,
} from "@/lib/state/slices/office/types";
type WireRecord = Record<string, unknown>;
@@ -33,6 +36,38 @@ function optBool(record: WireRecord, key: string): boolean | undefined {
return typeof value === "boolean" ? value : undefined;
}
const SCHEDULE_STATES = new Set<ScheduleState>([
"armed",
"trigger_invalid",
"trigger_unscheduled",
"trigger_disabled",
"event_only",
"unscheduled_manual_only",
"unscheduled_no_trigger",
"unknown",
]);
const UNARMED_REASONS = new Set<UnarmedReason>(["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 {
+21
View File
@@ -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";
+12
View File
@@ -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",
+12
View File
@@ -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": "Ũńķńōŵń ēŕŕōŕ",
+12
View File
@@ -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",
+12
View File
@@ -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": "未知错误",
+12
View File
@@ -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": "未知錯誤",
+12
View File
@@ -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": "未知錯誤",
@@ -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 },
}}
>
<RoutineDetailRoute routineId={ROUTINE_ID} />
<TooltipProvider>
<RoutineDetailRoute routineId={ROUTINE_ID} />
</TooltipProvider>
</StateProvider>,
);
}