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:
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,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") })}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "Ũńķńōŵń ēŕŕōŕ",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "未知错误",
|
||||
|
||||
@@ -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": "未知錯誤",
|
||||
|
||||
@@ -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>,
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user