From 6b40d5b2ec88ee099d22a6eb8809fd7b97ae156e Mon Sep 17 00:00:00 2001 From: nova28 Date: Thu, 6 Aug 2026 18:18:59 +0800 Subject: [PATCH] feat(automation): make the automation the object, with its own runs destination (#2190) Co-authored-by: Carlos Florencio --- .../agent/settings/controller/controller.go | 42 +- .../agent/settings/controller/profile_crud.go | 92 +- .../profile_crud_automation_atomicity_test.go | 252 +++++ .../profile_crud_watcher_deps_test.go | 99 ++ .../agent/settings/handlers/handlers.go | 1 + .../repository/sqlite/repository_test.go | 38 + .../analytics/repository/sqlite/stats.go | 42 +- apps/backend/internal/automation/evaluator.go | 2 +- .../automation/github_webhook_subscriber.go | 4 +- .../github_webhook_subscriber_test.go | 1 - apps/backend/internal/automation/handlers.go | 83 +- .../internal/automation/handlers_runs_test.go | 245 +++++ .../internal/automation/handlers_test.go | 152 ++++ apps/backend/internal/automation/models.go | 117 ++- .../internal/automation/run_retention.go | 170 ++++ .../internal/automation/run_retention_test.go | 339 +++++++ apps/backend/internal/automation/scheduler.go | 2 +- .../internal/automation/scheduler_test.go | 19 +- apps/backend/internal/automation/service.go | 378 +++++++- .../internal/automation/service_test.go | 117 +++ .../automation/service_validation_test.go | 342 +++++++ apps/backend/internal/automation/store.go | 506 +++++++++-- .../automation/store_migration_notice_test.go | 160 ++++ .../internal/automation/store_runs_test.go | 409 +++++++++ .../automation/store_summaries_test.go | 351 +++++++ .../backend/internal/automation/store_test.go | 262 +----- .../automation/store_workspace_runs_test.go | 341 +++++++ apps/backend/internal/automation/webhook.go | 2 +- apps/backend/internal/backendapp/e2e_reset.go | 119 ++- apps/backend/internal/backendapp/main.go | 9 + .../internal/backendapp/orchestrator.go | 53 ++ apps/backend/internal/backendapp/services.go | 9 + .../internal/backendapp/turn_adapters.go | 43 + .../internal/office/dashboard/handler_test.go | 1 + .../office/repository/sqlite/dashboard.go | 8 +- .../office/repository/sqlite/tasks.go | 34 +- .../repository/sqlite/tasks_filtered_test.go | 1 + .../office/repository/sqlite/tasks_test.go | 111 +++ .../repository/sqlite/tree_holds_test.go | 1 + .../internal/office/service/base_test.go | 1 + .../backend/internal/office/summary/inputs.go | 4 +- .../orchestrator/event_handlers_agent.go | 18 +- .../orchestrator/event_handlers_automation.go | 433 +++++++-- ...vent_handlers_automation_migration_test.go | 112 +++ .../event_handlers_automation_test.go | 857 ++++++++++++++++++ .../event_handlers_clarification.go | 2 +- .../orchestrator/event_handlers_git.go | 17 +- .../orchestrator/event_handlers_github.go | 6 +- .../event_handlers_streaming_test.go | 26 +- apps/backend/internal/orchestrator/service.go | 19 +- .../task/repository/sqlite/base_schema.go | 6 + .../task/repository/sqlite/session.go | 25 +- .../internal/task/repository/sqlite/task.go | 58 +- .../sqlite/task_automation_origin_test.go | 307 +++++++ .../internal/task/service/service_tasks.go | 6 +- apps/backend/pkg/websocket/actions.go | 3 + apps/web/app/actions/agents.ts | 8 +- apps/web/app/office/components/agent-card.tsx | 2 +- .../app-sidebar/app-sidebar-constants.ts | 1 + .../app-sidebar-primary-nav.test.tsx | 9 + .../app-sidebar/app-sidebar-section.test.tsx | 51 ++ .../app-sidebar/app-sidebar-section.tsx | 20 +- .../app-sidebar/app-sidebar.test.tsx | 45 +- .../components/app-sidebar/app-sidebar.tsx | 5 + .../sections/automations-section.test.tsx | 282 ++++++ .../sections/automations-section.tsx | 164 ++++ .../automation-editor-sections.tsx | 4 - .../automations/automation-editor.tsx | 17 +- .../automations/automation-payload.test.ts | 1 - .../automations/automation-payload.ts | 4 - .../automation-syntax-help.test.tsx | 71 +- .../automations-list-page.test.tsx | 113 +++ .../automations/automations-list-page.tsx | 24 +- .../automations/automations-table.test.tsx | 1 - .../automations/automations-table.tsx | 10 +- .../automations/board-move-notice.tsx | 70 ++ .../automations/config-section.test.tsx | 274 +++++- .../components/automations/config-section.tsx | 215 +++-- .../automations/runs-section.test.tsx | 123 ++- .../components/automations/runs-section.tsx | 118 ++- .../automations/schedule-expression.test.ts | 297 ++++++ .../automations/schedule-expression.ts | 480 ++++++++++ .../automations/schedule-selector.test.tsx | 180 ++++ .../automations/schedule-selector.tsx | 570 +++++++++--- .../components/automations/trigger-card.tsx | 11 +- .../automations/triggers-section.test.tsx | 89 ++ .../automations/triggers-section.tsx | 8 +- .../runs/automation-detail-page.test.tsx | 464 ++++++++++ .../runs/automation-detail-page.tsx | 345 +++++++ .../runs/automation-prompt-card.tsx | 49 + .../components/runs/automation-rows.test.ts | 275 ++++++ apps/web/components/runs/automation-rows.ts | 205 +++++ .../runs/automation-schedule.test.ts | 153 ++++ .../components/runs/automation-schedule.ts | 98 ++ .../components/runs/run-detail-disclosure.tsx | 76 ++ apps/web/components/runs/run-feed-item.tsx | 103 +++ apps/web/components/runs/run-filters.tsx | 106 +++ apps/web/components/runs/run-status.test.ts | 146 +++ apps/web/components/runs/run-status.ts | 88 ++ .../components/runs/run-transcript.test.tsx | 125 +++ apps/web/components/runs/run-transcript.tsx | 111 +++ apps/web/components/runs/runs-drawer.tsx | 125 +++ .../components/runs/runs-list-page.test.tsx | 240 +++++ apps/web/components/runs/runs-list-page.tsx | 242 +++++ .../components/runs/runs-page-client.test.tsx | 262 ++++++ apps/web/components/runs/runs-page-client.tsx | 200 ++++ apps/web/components/runs/runs-rail.tsx | 206 +++++ apps/web/components/runs/runs-view.test.ts | 45 + apps/web/components/runs/runs-view.ts | 53 ++ .../runs/use-automation-activity.test.ts | 189 ++++ .../runs/use-automation-activity.ts | 104 +++ .../runs/use-automation-summaries.test.ts | 106 +++ .../runs/use-automation-summaries.ts | 64 ++ .../components/runs/use-live-refresh.test.ts | 90 ++ apps/web/components/runs/use-live-refresh.ts | 33 + .../runs/use-manual-trigger.test.ts | 189 ++++ .../web/components/runs/use-manual-trigger.ts | 100 ++ .../components/runs/use-rail-width.test.ts | 171 ++++ apps/web/components/runs/use-rail-width.ts | 90 ++ .../runs/use-workspace-automations.test.ts | 129 +++ .../runs/use-workspace-automations.ts | 83 ++ .../runs/use-workspace-runs.test.ts | 211 +++++ .../web/components/runs/use-workspace-runs.ts | 85 ++ .../agent-profile-delete-dialog.test.tsx | 21 + .../settings/agent-profile-delete-dialog.tsx | 41 + .../settings/agent-profile-page-state.ts | 2 + .../components/task/chat/chat-input-area.tsx | 8 + .../task/chat/use-composer-props.ts | 3 + apps/web/e2e/helpers/api-client.ts | 27 + apps/web/e2e/pages/automations-page.ts | 36 +- .../e2e/tests/automations-run-detail.spec.ts | 268 ++++++ .../e2e/tests/automations-settings.spec.ts | 128 ++- .../web/e2e/tests/automations-sidebar.spec.ts | 124 +++ .../tests/mobile-automation-detail.spec.ts | 181 ++++ .../components/shared => hooks}/use-now.ts | 0 apps/web/lib/api/domains/automation-api.ts | 63 +- .../api/domains/automation-runs-api.test.ts | 55 ++ .../lib/automation-board-notice-storage.ts | 33 + apps/web/lib/types/agent-profile-errors.ts | 11 + apps/web/lib/types/automation.ts | 54 +- apps/web/src/locales/en/agents.json | 2 + apps/web/src/locales/en/automations.json | 100 +- apps/web/src/locales/pseudo/agents.json | 2 + apps/web/src/locales/pseudo/automations.json | 100 +- apps/web/src/spa-routes.tsx | 61 ++ apps/web/src/spa-routing.test.ts | 69 ++ docs/WEBSOCKET_API.md | 2 +- .../automation-run-status-refresh/plan.md | 85 ++ .../task-01-live-run-status.md | 82 ++ docs/public/agents-and-profiles.md | 3 +- docs/public/automation-and-mcp.md | 47 +- docs/public/feature-status.md | 4 +- docs/public/websocket-api.md | 3 + docs/specs/INDEX.md | 1 + docs/specs/office/automation-runs.md | 233 +++++ docs/specs/office/automations-settings.md | 166 +++- 156 files changed, 16383 insertions(+), 1117 deletions(-) create mode 100644 apps/backend/internal/agent/settings/controller/profile_crud_automation_atomicity_test.go create mode 100644 apps/backend/internal/automation/handlers_runs_test.go create mode 100644 apps/backend/internal/automation/run_retention.go create mode 100644 apps/backend/internal/automation/run_retention_test.go create mode 100644 apps/backend/internal/automation/service_validation_test.go create mode 100644 apps/backend/internal/automation/store_migration_notice_test.go create mode 100644 apps/backend/internal/automation/store_runs_test.go create mode 100644 apps/backend/internal/automation/store_summaries_test.go create mode 100644 apps/backend/internal/automation/store_workspace_runs_test.go create mode 100644 apps/backend/internal/orchestrator/event_handlers_automation_migration_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/task_automation_origin_test.go create mode 100644 apps/web/components/app-sidebar/sections/automations-section.test.tsx create mode 100644 apps/web/components/app-sidebar/sections/automations-section.tsx create mode 100644 apps/web/components/automations/automations-list-page.test.tsx create mode 100644 apps/web/components/automations/board-move-notice.tsx create mode 100644 apps/web/components/automations/schedule-expression.test.ts create mode 100644 apps/web/components/automations/schedule-expression.ts create mode 100644 apps/web/components/automations/schedule-selector.test.tsx create mode 100644 apps/web/components/automations/triggers-section.test.tsx create mode 100644 apps/web/components/runs/automation-detail-page.test.tsx create mode 100644 apps/web/components/runs/automation-detail-page.tsx create mode 100644 apps/web/components/runs/automation-prompt-card.tsx create mode 100644 apps/web/components/runs/automation-rows.test.ts create mode 100644 apps/web/components/runs/automation-rows.ts create mode 100644 apps/web/components/runs/automation-schedule.test.ts create mode 100644 apps/web/components/runs/automation-schedule.ts create mode 100644 apps/web/components/runs/run-detail-disclosure.tsx create mode 100644 apps/web/components/runs/run-feed-item.tsx create mode 100644 apps/web/components/runs/run-filters.tsx create mode 100644 apps/web/components/runs/run-status.test.ts create mode 100644 apps/web/components/runs/run-status.ts create mode 100644 apps/web/components/runs/run-transcript.test.tsx create mode 100644 apps/web/components/runs/run-transcript.tsx create mode 100644 apps/web/components/runs/runs-drawer.tsx create mode 100644 apps/web/components/runs/runs-list-page.test.tsx create mode 100644 apps/web/components/runs/runs-list-page.tsx create mode 100644 apps/web/components/runs/runs-page-client.test.tsx create mode 100644 apps/web/components/runs/runs-page-client.tsx create mode 100644 apps/web/components/runs/runs-rail.tsx create mode 100644 apps/web/components/runs/runs-view.test.ts create mode 100644 apps/web/components/runs/runs-view.ts create mode 100644 apps/web/components/runs/use-automation-activity.test.ts create mode 100644 apps/web/components/runs/use-automation-activity.ts create mode 100644 apps/web/components/runs/use-automation-summaries.test.ts create mode 100644 apps/web/components/runs/use-automation-summaries.ts create mode 100644 apps/web/components/runs/use-live-refresh.test.ts create mode 100644 apps/web/components/runs/use-live-refresh.ts create mode 100644 apps/web/components/runs/use-manual-trigger.test.ts create mode 100644 apps/web/components/runs/use-manual-trigger.ts create mode 100644 apps/web/components/runs/use-rail-width.test.ts create mode 100644 apps/web/components/runs/use-rail-width.ts create mode 100644 apps/web/components/runs/use-workspace-automations.test.ts create mode 100644 apps/web/components/runs/use-workspace-automations.ts create mode 100644 apps/web/components/runs/use-workspace-runs.test.ts create mode 100644 apps/web/components/runs/use-workspace-runs.ts create mode 100644 apps/web/e2e/tests/automations-run-detail.spec.ts create mode 100644 apps/web/e2e/tests/automations-sidebar.spec.ts create mode 100644 apps/web/e2e/tests/mobile-automation-detail.spec.ts rename apps/web/{app/office/components/shared => hooks}/use-now.ts (100%) create mode 100644 apps/web/lib/api/domains/automation-runs-api.test.ts create mode 100644 apps/web/lib/automation-board-notice-storage.ts create mode 100644 docs/plans/automation-run-status-refresh/plan.md create mode 100644 docs/plans/automation-run-status-refresh/task-01-live-run-status.md create mode 100644 docs/specs/office/automation-runs.md diff --git a/apps/backend/internal/agent/settings/controller/controller.go b/apps/backend/internal/agent/settings/controller/controller.go index e5fe0a1a79..6771898372 100644 --- a/apps/backend/internal/agent/settings/controller/controller.go +++ b/apps/backend/internal/agent/settings/controller/controller.go @@ -57,6 +57,7 @@ type Controller struct { sessionChecker SessionChecker watcherDeps WatcherDependencyChecker routingTierDeps RoutingTierDependencyChecker + automationDeps AutomationDependencyChecker mcpService *mcpconfig.Service modelCache *modelfetcher.Cache hostUtility *hostutility.Manager @@ -88,6 +89,14 @@ func (c *Controller) SetRoutingTierDependencyChecker(r RoutingTierDependencyChec c.routingTierDeps = r } +// SetAutomationDependencyChecker wires in the automation enumerator so +// DeleteProfile can name the automations that would be left pointing at a +// deleted profile. Optional; when unset the delete path keeps its +// pre-automation behaviour. +func (c *Controller) SetAutomationDependencyChecker(a AutomationDependencyChecker) { + c.automationDeps = a +} + // ErrProfileInUseDetail is returned when a profile cannot be deleted because // active sessions or external integration watchers reference it. The UI uses // the breakdown to render a "this will also disable N watchers — continue?" @@ -96,11 +105,13 @@ type ErrProfileInUseDetail struct { ActiveSessions []agentdto.ActiveTaskInfo Watchers []WatcherReference RoutingTiers []RoutingTierReference + Automations []AutomationReference } func (e *ErrProfileInUseDetail) Error() string { - return fmt.Sprintf("agent profile is used by %d active session(s), %d watcher(s), and %d routing tier(s)", - len(e.ActiveSessions), len(e.Watchers), len(e.RoutingTiers)) + return fmt.Sprintf( + "agent profile is used by %d active session(s), %d watcher(s), %d routing tier(s), and %d automation(s)", + len(e.ActiveSessions), len(e.Watchers), len(e.RoutingTiers), len(e.Automations)) } // WatcherReference points at one issue/PR watcher row that uses the profile @@ -132,6 +143,33 @@ type RoutingTierReference struct { // watcher stays enabled-but-orphaned until its next external trigger fires // the lazy preflight, which never happens for filters that match nothing // new after the profile is deleted. +// AutomationReference points at one enabled automation that would be left +// referencing a deleted agent profile. An automation is configuration rather +// than a session: nothing is running, so it does not show up in the active-task +// list, but its next firing would launch against a profile that no longer +// exists. +type AutomationReference struct { + ID string `json:"id"` + Name string `json:"name"` + WorkspaceID string `json:"workspace_id"` +} + +// AutomationDependencyChecker enumerates enabled automations bound to an agent +// profile and disables them when that profile is deleted. +// +// ListEnabledAutomationsByAgentProfile feeds the confirmation dialog. +// DisableAutomationsByAgentProfile runs on the delete path *before* the profile +// row is removed and its error aborts the delete — unlike the watcher +// equivalent, which runs after and is best-effort. The asymmetry is deliberate: +// a watcher left enabled against a deleted profile is repaired by the dispatch +// coordinator's preflight on the next poll, and an automation has no such +// backstop, so the only safe moment to disable it is while the delete can still +// be called off. +type AutomationDependencyChecker interface { + ListEnabledAutomationsByAgentProfile(ctx context.Context, agentProfileID string) ([]AutomationReference, error) + DisableAutomationsByAgentProfile(ctx context.Context, agentProfileID string) ([]AutomationReference, error) +} + type WatcherDependencyChecker interface { ListWatchersByAgentProfile(ctx context.Context, agentProfileID string) ([]WatcherReference, error) DisableWatchersByAgentProfile(ctx context.Context, agentProfileID, cause string) ([]WatcherReference, error) diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index 07e5065572..1258bfca9f 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -297,16 +297,26 @@ func (c *Controller) DeleteProfile(ctx context.Context, id string, force bool) ( if err := c.prepareProfileDeletion(ctx, id, force); err != nil { return nil, err } + // Automations are disabled BEFORE the row is deleted and a failure here + // aborts the whole delete. See disableReferencingAutomations for why this + // pass runs in the opposite order to the watcher pass below. + if err := c.disableReferencingAutomations(ctx, id); err != nil { + return nil, err + } if err := c.repo.DeleteAgentProfile(ctx, id); err != nil { if strings.Contains(err.Error(), "agent profile not found") { return nil, ErrAgentProfileNotFound } return nil, err } - // Eagerly disable referencing watchers only AFTER the row is gone, so a + // Watchers, by contrast, are disabled only AFTER the row is gone, so a // failed delete never strands watchers disabled against a still-live - // profile. If this disable itself fails, the dispatch coordinator's - // preflight self-heals the watchers on their next poll. + // profile. They can afford that ordering because they have a genuine second + // chance: the dispatch coordinator's preflight re-resolves the profile on + // every watcher poll and disables the row itself when the profile has + // vanished. So a failure here is logged and ignored — the next poll fixes + // it. Automations have no such preflight, which is the whole reason they + // are handled above instead of here. if force { c.disableReferencingWatchers(ctx, id, profile.Name) } @@ -319,9 +329,9 @@ func (c *Controller) DeleteProfile(ctx context.Context, id string, force bool) ( // Routing-tier references are hard blockers even when force=true because a // deleted profile would orphan workspace tier mappings. When force is false, // active sessions and watchers return *ErrProfileInUseDetail so the UI can -// render a confirmation dialog. force=true skips only those soft blockers; the -// eager disable of referencing watchers runs in DeleteProfile after the row is -// actually gone. +// render a confirmation dialog. force=true skips only those soft blockers. +// Neither branch disables anything: DeleteProfile disables referencing +// automations before the row goes away and referencing watchers after it does. func (c *Controller) prepareProfileDeletion(ctx context.Context, profileID string, force bool) error { routingTierRefs, err := c.listRoutingTierReferences(ctx, profileID) if err != nil { @@ -348,8 +358,29 @@ func (c *Controller) prepareProfileDeletion(ctx context.Context, profileID strin watcherRefs = refs } } - if len(activeTasks) > 0 || len(watcherRefs) > 0 { - return &ErrProfileInUseDetail{ActiveSessions: activeTasks, Watchers: watcherRefs} + var automationRefs []AutomationReference + if c.automationDeps != nil { + refs, err := c.automationDeps.ListEnabledAutomationsByAgentProfile(ctx, profileID) + if err != nil { + // Fails closed, unlike the watcher lookup above. A watcher this + // path misses is disabled anyway by the force pass afterwards; + // an automation is not, so a lookup that silently returned + // nothing would orphan exactly the references this check was + // added to catch. Refusing is recoverable — the user retries. + return fmt.Errorf("check automations using this profile: %w", err) + } + automationRefs = refs + } + // An enabled automation is a standing instruction to launch against this + // profile. Nothing is running, so it never appears in the active-session + // list, but its next firing would go looking for a profile that is gone — + // and a schedule fails quietly, hours later, with nobody watching. + if len(activeTasks) > 0 || len(watcherRefs) > 0 || len(automationRefs) > 0 { + return &ErrProfileInUseDetail{ + ActiveSessions: activeTasks, + Watchers: watcherRefs, + Automations: automationRefs, + } } } // Clean up ephemeral tasks (quick chat, config chat) using this profile. @@ -369,6 +400,51 @@ func (c *Controller) listRoutingTierReferences(ctx context.Context, profileID st return refs, nil } +// disableReferencingAutomations turns off every automation bound to the profile +// about to be deleted, and reports failure so DeleteProfile can abort. +// +// A force-delete is the user saying "yes, break these" — but an automation is +// not broken loudly. It is a standing instruction on a schedule, so left +// enabled it keeps firing into a profile that no longer exists, hours later, +// with nobody watching. Disabling is the honest outcome of the confirmation +// they just gave, and it is reversible: the automation is still there, and +// re-enabling it after picking a new profile is one toggle. +// +// This runs before the delete and is not best-effort, because the two failure +// directions cost wildly different amounts: +// +// - disable fails, delete aborted — automation still enabled, profile still +// there. Nothing is inconsistent, the error reaches the caller, and a retry +// costs one click. +// - disable fails, delete proceeds — automation enabled and bound to a row +// that is gone. Nothing ever notices. Automations have no equivalent of the +// watcher preflight to re-resolve the profile, so the binding is permanent +// and every future schedule fails quietly. +// +// The residual case is disable-succeeds-then-delete-fails, which leaves an +// automation disabled against a still-live profile. That is the direction we +// choose to lose in: it is visible on the automation page and one toggle to +// undo, where the other direction is silent and unrecoverable. +// +// Runs on both the force and non-force paths. On the non-force path +// prepareProfileDeletion has already refused the delete if any automation was +// enabled, so this is normally a no-op — except in the window where one is +// enabled between that check and this call, which it also closes. +func (c *Controller) disableReferencingAutomations(ctx context.Context, profileID string) error { + if c.automationDeps == nil { + return nil + } + disabled, err := c.automationDeps.DisableAutomationsByAgentProfile(ctx, profileID) + if err != nil { + return fmt.Errorf("disable automations using this profile: %w", err) + } + if len(disabled) > 0 { + c.logger.Info("disabled referencing automations before profile delete", + zap.String("profile_id", profileID), zap.Int("count", len(disabled))) + } + return nil +} + // disableReferencingWatchers stamps the deletion cause onto every watcher // row that referenced this profile so the UI shows "disabled because the // agent profile was deleted" the moment the request returns. Without this diff --git a/apps/backend/internal/agent/settings/controller/profile_crud_automation_atomicity_test.go b/apps/backend/internal/agent/settings/controller/profile_crud_automation_atomicity_test.go new file mode 100644 index 0000000000..b4622f0255 --- /dev/null +++ b/apps/backend/internal/agent/settings/controller/profile_crud_automation_atomicity_test.go @@ -0,0 +1,252 @@ +package controller + +import ( + "context" + "errors" + "testing" + + "github.com/kandev/kandev/internal/agent/agents" + "github.com/kandev/kandev/internal/agent/settings/models" +) + +// The tests in this file guard one invariant: a profile delete must never +// return with the agent_profile row gone and an automation still enabled and +// bound to it. That state is silent and permanent — an automation is a standing +// schedule with nobody watching it, and unlike a watcher it has no preflight to +// re-resolve the profile and disable itself on the next poll. +// +// They assert against modelled state rather than call counts on purpose. The +// defect the reviewer found was never "the disable call is missing"; it was that +// the call could fail, or never happen at all, after the row had already gone. +// Counting calls would have passed against the buggy ordering. + +// automationDeleteWorld models the two rows this bug desynchronises, plus the +// order the controller touched them in. +type automationDeleteWorld struct { + profileDeleted bool + automationEnabled bool + // disableErr, when set, is what DisableAutomationsByAgentProfile returns — + // the injected failure the reviewer asked for. + disableErr error + // afterDisable runs once the disable has taken effect, so a test can slam + // the door (cancel the context) in the window between the two operations. + afterDisable func() + steps []string +} + +func (w *automationDeleteWorld) record(step string) { w.steps = append(w.steps, step) } + +// assertNothingStranded is the whole point of the file. +func (w *automationDeleteWorld) assertNothingStranded(t *testing.T) { + t.Helper() + if w.profileDeleted && w.automationEnabled { + t.Fatalf("stranded automation: the profile row was deleted while an automation bound to it is still enabled (steps: %v)", w.steps) + } +} + +// worldAutomationDeps is an AutomationDependencyChecker backed by the world +// model, so a successful disable actually flips the automation off. +type worldAutomationDeps struct{ w *automationDeleteWorld } + +func (d *worldAutomationDeps) ListEnabledAutomationsByAgentProfile( + context.Context, string, +) ([]AutomationReference, error) { + if !d.w.automationEnabled { + return nil, nil + } + return []AutomationReference{{ID: "auto-1", Name: "Nightly dependency sweep", WorkspaceID: "ws-1"}}, nil +} + +func (d *worldAutomationDeps) DisableAutomationsByAgentProfile( + ctx context.Context, _ string, +) ([]AutomationReference, error) { + d.w.record("disable_automations") + // A real store call fails on a dead context before it touches any row. + if err := ctx.Err(); err != nil { + return nil, err + } + if d.w.disableErr != nil { + return nil, d.w.disableErr + } + if !d.w.automationEnabled { + return nil, nil + } + d.w.automationEnabled = false + if d.w.afterDisable != nil { + d.w.afterDisable() + } + return []AutomationReference{{ID: "auto-1", Name: "Nightly dependency sweep", WorkspaceID: "ws-1"}}, nil +} + +// worldWatcherDeps records when the watcher pass ran so the eager-after-delete +// ordering can be pinned alongside the new automation ordering. +type worldWatcherDeps struct{ w *automationDeleteWorld } + +func (d *worldWatcherDeps) ListWatchersByAgentProfile(context.Context, string) ([]WatcherReference, error) { + return []WatcherReference{{ID: "linear-w1", Kind: "linear"}}, nil +} + +func (d *worldWatcherDeps) DisableWatchersByAgentProfile( + context.Context, string, string, +) ([]WatcherReference, error) { + d.w.record("disable_watchers") + return []WatcherReference{{ID: "linear-w1", Kind: "linear"}}, nil +} + +// ctxAwareStore layers real context handling and world bookkeeping over the +// shared fakeStore, which ignores ctx entirely. A cancellation test running +// against a store that cannot be cancelled would prove nothing. +type ctxAwareStore struct { + *fakeStore + w *automationDeleteWorld +} + +func (s *ctxAwareStore) DeleteAgentProfile(ctx context.Context, id string) error { + s.w.record("delete_profile") + if err := ctx.Err(); err != nil { + return err + } + if err := s.fakeStore.DeleteAgentProfile(ctx, id); err != nil { + return err + } + s.w.profileDeleted = true + return nil +} + +// newAutomationDeleteFixture wires a controller holding one profile that one +// enabled automation and one watcher point at. +func newAutomationDeleteFixture(t *testing.T) (*Controller, *automationDeleteWorld) { + t.Helper() + world := &automationDeleteWorld{automationEnabled: true} + ctrl := newTestController(map[string]agents.Agent{ + "test-agent": &testAgent{id: "test-agent", name: "test-agent", enabled: true}, + }) + st := newFakeStore() + agent := &models.Agent{ID: "agent-1", Name: "test-agent"} + st.agents[agent.ID] = agent + st.byName[agent.Name] = agent + st.profiles[agent.ID] = []*models.AgentProfile{{ID: "prof-1", AgentID: agent.ID, Name: "Kilo Profile"}} + ctrl.repo = &ctxAwareStore{fakeStore: st, w: world} + ctrl.sessionChecker = &fakeSessionChecker{} + ctrl.automationDeps = &worldAutomationDeps{w: world} + ctrl.watcherDeps = &worldWatcherDeps{w: world} + return ctrl, world +} + +// The ordering is the fix. Disabling after the delete leaves a window — however +// short — in which the profile is gone and the automation is not yet off, and +// anything that interrupts the request inside that window makes the state +// permanent. +func TestDeleteProfile_DisablesAutomationsBeforeDeletingTheRow(t *testing.T) { + ctrl, world := newAutomationDeleteFixture(t) + + if _, err := ctrl.DeleteProfile(context.Background(), "prof-1", true); err != nil { + t.Fatalf("force delete should succeed, got %v", err) + } + + want := []string{"disable_automations", "delete_profile", "disable_watchers"} + if len(world.steps) != len(want) { + t.Fatalf("steps = %v, want %v", world.steps, want) + } + for i := range want { + if world.steps[i] != want[i] { + t.Fatalf("steps = %v, want %v", world.steps, want) + } + } + world.assertNothingStranded(t) +} + +// The regression the reviewer asked for. With the disable running after the +// delete and its error swallowed, this ended with the row gone and the +// automation still firing on schedule into nothing. +func TestDeleteProfile_AutomationDisableFailureAbortsTheDelete(t *testing.T) { + ctrl, world := newAutomationDeleteFixture(t) + world.disableErr = errors.New("automation store down") + + _, err := ctrl.DeleteProfile(context.Background(), "prof-1", true) + + if err == nil { + t.Fatal("a failed automation disable must abort the delete, not proceed without it") + } + if !errors.Is(err, world.disableErr) { + t.Errorf("error must carry the underlying store failure, got %v", err) + } + world.assertNothingStranded(t) + if world.profileDeleted { + t.Error("profile row was deleted despite the automation disable failing") + } + if !world.automationEnabled { + t.Error("the automation should be untouched — the delete never happened") + } + // The live row must survive so a retry has something to delete. + if _, getErr := ctrl.repo.GetAgentProfile(context.Background(), "prof-1"); getErr != nil { + t.Errorf("profile should still be retrievable after the aborted delete, got %v", getErr) + } +} + +// Cancellation in the window between the two operations is the version of this +// bug that needs no store outage to reproduce — the user closing the tab is +// enough. The disable has landed by then, so the worst outcome is an automation +// disabled against a profile that is still there: visible, and one toggle back. +func TestDeleteProfile_ContextCancelledAfterAutomationDisableStrandsNothing(t *testing.T) { + ctrl, world := newAutomationDeleteFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + world.afterDisable = cancel + + _, err := ctrl.DeleteProfile(ctx, "prof-1", true) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("delete should surface the cancellation, got %v", err) + } + world.assertNothingStranded(t) + if world.profileDeleted { + t.Error("the profile row must not be deleted under a cancelled context") + } + if world.automationEnabled { + t.Error("the automation disable landed before the cancellation and must have stuck") + } +} + +// Cancellation before anything runs. Both rows are left exactly as they were, +// and the request must have stopped at the automation gate — reaching the +// delete first would mean the gate is downstream of the destructive step again. +func TestDeleteProfile_ContextCancelledBeforeAutomationDisableChangesNothing(t *testing.T) { + ctrl, world := newAutomationDeleteFixture(t) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := ctrl.DeleteProfile(ctx, "prof-1", true) + + if !errors.Is(err, context.Canceled) { + t.Fatalf("delete should surface the cancellation, got %v", err) + } + world.assertNothingStranded(t) + if world.profileDeleted || !world.automationEnabled { + t.Errorf("nothing should have changed, got deleted=%v automation_enabled=%v", + world.profileDeleted, world.automationEnabled) + } + if len(world.steps) != 1 || world.steps[0] != "disable_automations" { + t.Errorf("a dead context must be refused at the automation gate, steps = %v", world.steps) + } +} + +// Watchers keep the opposite ordering on purpose, and it must stay that way: +// disabling them before the delete would strand them disabled against a live +// profile whenever the delete fails, which is a real regression the automation +// fix must not drag along. Their safety net is the dispatch coordinator's +// preflight, which automations do not have. +func TestDeleteProfile_WatcherDisableStillRunsOnlyAfterTheRowIsDeleted(t *testing.T) { + ctrl, world := newAutomationDeleteFixture(t) + world.disableErr = errors.New("automation store down") + + if _, err := ctrl.DeleteProfile(context.Background(), "prof-1", true); err == nil { + t.Fatal("expected the aborted delete for this fixture") + } + + for _, step := range world.steps { + if step == "disable_watchers" { + t.Fatal("watchers must not be disabled when the row was never deleted") + } + } +} diff --git a/apps/backend/internal/agent/settings/controller/profile_crud_watcher_deps_test.go b/apps/backend/internal/agent/settings/controller/profile_crud_watcher_deps_test.go index f9f2358d42..f1e84c0901 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud_watcher_deps_test.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud_watcher_deps_test.go @@ -29,6 +29,27 @@ func (f *fakeSessionChecker) GetActiveTaskInfoByAgentProfile(context.Context, st return f.activeTasks, nil } +// fakeAutomationDependencyChecker returns a canned list of enabled automations +// bound to the profile. +type fakeAutomationDependencyChecker struct { + refs []AutomationReference + err error + disableCalls int +} + +func (f *fakeAutomationDependencyChecker) ListEnabledAutomationsByAgentProfile( + context.Context, string, +) ([]AutomationReference, error) { + return f.refs, f.err +} + +func (f *fakeAutomationDependencyChecker) DisableAutomationsByAgentProfile( + context.Context, string, +) ([]AutomationReference, error) { + f.disableCalls++ + return f.refs, nil +} + // fakeWatcherDependencyChecker returns a canned list of referencing // watchers and records disable invocations so tests can assert on the // force-delete eager-disable contract. @@ -93,6 +114,84 @@ func TestDeleteProfile_BlocksOnReferencingWatchers(t *testing.T) { } } +// An enabled automation never appears in the active-session list — nothing is +// running — but its next firing would launch against a profile that is gone, +// and a schedule fails quietly, hours later, with nobody watching. +func TestDeleteProfile_BlocksOnEnabledAutomations(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{"test-agent": &testAgent{id: "test-agent", name: "test-agent", enabled: true}}) + st := newFakeStore() + agent := &models.Agent{ID: "agent-1", Name: "test-agent"} + st.agents[agent.ID] = agent + st.byName[agent.Name] = agent + st.profiles[agent.ID] = []*models.AgentProfile{{ID: "prof-1", AgentID: agent.ID, Name: "Kilo Profile"}} + ctrl.repo = st + ctrl.sessionChecker = &fakeSessionChecker{} + ctrl.automationDeps = &fakeAutomationDependencyChecker{refs: []AutomationReference{ + {ID: "auto-1", Name: "Nightly dependency sweep", WorkspaceID: "ws-1"}, + }} + + _, err := ctrl.DeleteProfile(context.Background(), "prof-1", false) + + var detail *ErrProfileInUseDetail + if !errors.As(err, &detail) { + t.Fatalf("expected ErrProfileInUseDetail, got %v", err) + } + if len(detail.Automations) != 1 || detail.Automations[0].Name != "Nightly dependency sweep" { + t.Fatalf("expected the automation to be named, got %+v", detail.Automations) + } +} + +// The confirmation exists so the user can proceed knowingly; force is how they +// say so. +func TestDeleteProfile_ForceBypassesAutomationCheck(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{"test-agent": &testAgent{id: "test-agent", name: "test-agent", enabled: true}}) + st := newFakeStore() + agent := &models.Agent{ID: "agent-1", Name: "test-agent"} + st.agents[agent.ID] = agent + st.byName[agent.Name] = agent + st.profiles[agent.ID] = []*models.AgentProfile{{ID: "prof-1", AgentID: agent.ID, Name: "Kilo Profile"}} + ctrl.repo = st + ctrl.sessionChecker = &fakeSessionChecker{} + automationDeps := &fakeAutomationDependencyChecker{refs: []AutomationReference{ + {ID: "auto-1", Name: "Nightly dependency sweep", WorkspaceID: "ws-1"}, + }} + ctrl.automationDeps = automationDeps + + if _, err := ctrl.DeleteProfile(context.Background(), "prof-1", true); err != nil { + t.Fatalf("force delete should proceed past the automation check, got %v", err) + } + // Proceeding is not the whole contract: an automation left enabled would + // keep firing on its schedule into a profile that no longer exists. + if automationDeps.disableCalls != 1 { + t.Fatalf("force delete must disable the orphaned automations, got %d call(s)", + automationDeps.disableCalls) + } +} + +// Fails closed. A watcher this lookup misses is disabled anyway by the force +// pass; an automation is not, so silently treating "lookup failed" as "no +// automations" would orphan exactly the references this check exists to catch. +func TestDeleteProfile_AutomationLookupFailureBlocks(t *testing.T) { + ctrl := newTestController(map[string]agents.Agent{"test-agent": &testAgent{id: "test-agent", name: "test-agent", enabled: true}}) + st := newFakeStore() + agent := &models.Agent{ID: "agent-1", Name: "test-agent"} + st.agents[agent.ID] = agent + st.byName[agent.Name] = agent + st.profiles[agent.ID] = []*models.AgentProfile{{ID: "prof-1", AgentID: agent.ID, Name: "Kilo Profile"}} + ctrl.repo = st + ctrl.sessionChecker = &fakeSessionChecker{} + ctrl.automationDeps = &fakeAutomationDependencyChecker{err: errors.New("store down")} + + _, err := ctrl.DeleteProfile(context.Background(), "prof-1", false) + if err == nil { + t.Fatal("a failed automation lookup must refuse the deletion, not wave it through") + } + var detail *ErrProfileInUseDetail + if errors.As(err, &detail) { + t.Fatalf("a lookup failure is an error, not an in-use conflict: %v", err) + } +} + func TestDeleteProfile_BlocksOnRoutingTierReferencesEvenWithForce(t *testing.T) { ctrl := newTestController(map[string]agents.Agent{"test-agent": &testAgent{id: "test-agent", name: "test-agent", enabled: true}}) st := newFakeStore() diff --git a/apps/backend/internal/agent/settings/handlers/handlers.go b/apps/backend/internal/agent/settings/handlers/handlers.go index 5d2a2a6dc6..ca8ec1f827 100644 --- a/apps/backend/internal/agent/settings/handlers/handlers.go +++ b/apps/backend/internal/agent/settings/handlers/handlers.go @@ -617,6 +617,7 @@ func (h *Handlers) httpDeleteProfile(c *gin.Context) { "active_sessions": inUseErr.ActiveSessions, "watchers": inUseErr.Watchers, "routing_tiers": inUseErr.RoutingTiers, + "automations": inUseErr.Automations, }) return } diff --git a/apps/backend/internal/analytics/repository/sqlite/repository_test.go b/apps/backend/internal/analytics/repository/sqlite/repository_test.go index b37fe0a5ae..a90e85ddc2 100644 --- a/apps/backend/internal/analytics/repository/sqlite/repository_test.go +++ b/apps/backend/internal/analytics/repository/sqlite/repository_test.go @@ -60,6 +60,7 @@ func createTestDB(t *testing.T) *sqlx.DB { title TEXT NOT NULL, state TEXT DEFAULT 'TODO', is_ephemeral INTEGER NOT NULL DEFAULT 0, + origin TEXT NOT NULL DEFAULT '', metadata TEXT DEFAULT '{}', archived_at TIMESTAMP, created_at TIMESTAMP NOT NULL, @@ -797,3 +798,40 @@ func TestGetRepositoryStats_ExcludesEphemeralTasks(t *testing.T) { t.Errorf("expected 1 session (ephemeral excluded), got %d", results[0].SessionCount) } } + +// Automation runs are agent output, not workspace throughput. They used to fall +// out of these aggregates for free because they were ephemeral; now that they +// are ordinary tasks tagged by origin, the exclusion has to be explicit or a +// daily automation quietly inflates a workspace's task count by 365 a year. +func TestGetGlobalStats_ExcludesAutomationRuns(t *testing.T) { + dbConn := createTestDB(t) + repo, err := NewWithDB(dbConn, dbConn) + if err != nil { + t.Fatalf("NewWithDB failed: %v", err) + } + + ctx := context.Background() + nowStr := time.Now().UTC().Format(time.RFC3339) + + execOrFatal(t, dbConn, `INSERT INTO workspaces (id, name, created_at, updated_at) VALUES ('ws-1', 'Test', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO boards (id, workspace_id, name, created_at, updated_at) VALUES ('board-1', 'ws-1', 'Board', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO workflow_steps (id, workflow_id, name, position, created_at, updated_at) VALUES ('step-todo', 'board-1', 'To Do', 0, ?, ?)`, nowStr, nowStr) + + // A human task and an automation run, both non-ephemeral now. + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, origin, created_at, updated_at) VALUES ('task-human', 'ws-1', 'board-1', 'step-todo', 'Human', 0, '', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO tasks (id, workspace_id, board_id, workflow_step_id, title, is_ephemeral, origin, created_at, updated_at) VALUES ('task-automation', 'ws-1', 'board-1', 'step-todo', 'Nightly drift', 0, 'automation_run', ?, ?)`, nowStr, nowStr) + + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-human', 'task-human', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + execOrFatal(t, dbConn, `INSERT INTO task_sessions (id, task_id, agent_profile_id, state, started_at, updated_at) VALUES ('sess-automation', 'task-automation', 'agent-1', 'COMPLETED', ?, ?)`, nowStr, nowStr) + + stats, err := repo.GetGlobalStats(ctx, "ws-1", nil) + if err != nil { + t.Fatalf("GetGlobalStats failed: %v", err) + } + if stats.TotalTasks != 1 { + t.Errorf("expected 1 task (automation run excluded), got %d", stats.TotalTasks) + } + if stats.TotalSessions != 1 { + t.Errorf("expected 1 session (automation run excluded), got %d", stats.TotalSessions) + } +} diff --git a/apps/backend/internal/analytics/repository/sqlite/stats.go b/apps/backend/internal/analytics/repository/sqlite/stats.go index ca7bbd0a96..abe63883dd 100644 --- a/apps/backend/internal/analytics/repository/sqlite/stats.go +++ b/apps/backend/internal/analytics/repository/sqlite/stats.go @@ -11,6 +11,16 @@ import ( "github.com/kandev/kandev/internal/analytics/models" "github.com/kandev/kandev/internal/db/dialect" + taskmodels "github.com/kandev/kandev/internal/task/models" +) + +// Automation runs are hidden from human-facing surfaces by their origin rather +// than by is_ephemeral, so every aggregate here has to say so explicitly. Built +// from the same constant the task and office repositories build theirs from — +// spelling the literal out per query is how one of them gets missed. +const ( + andNotAutomationOrigin = ` AND COALESCE(origin, '') != '` + taskmodels.TaskOriginAutomationRun + `'` + andNotAutomationOriginT = ` AND COALESCE(t.origin, '') != '` + taskmodels.TaskOriginAutomationRun + `'` ) // parseTimeString parses time strings in various SQLite formats @@ -90,7 +100,7 @@ func (r *Repository) GetTaskStats( WHERE (? IS NULL OR s.started_at >= ?) GROUP BY s.task_id ) turn_stats ON turn_stats.task_id = t.id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR t.created_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR t.created_at >= ?) ORDER BY t.updated_at DESC LIMIT ? `, dur, dialect.DurationMs( @@ -184,13 +194,13 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta SUM(CASE WHEN t.state = 'IN_PROGRESS' AND t.archived_at IS NULL THEN 1 ELSE 0 END) AS in_progress_tasks FROM tasks t LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR t.created_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR t.created_at >= ?) ), session_agg AS ( SELECT COUNT(*) AS total_sessions FROM task_sessions s JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) ), turn_agg AS ( SELECT @@ -199,7 +209,7 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta FROM task_session_turns turn JOIN task_sessions s ON s.id = turn.task_session_id JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) ), clean_turn_agg AS ( SELECT @@ -212,7 +222,7 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta FROM task_session_turns turn JOIN task_sessions s ON s.id = turn.task_session_id JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) AND turn.completed_at IS NOT NULL ) clean WHERE dur_ms >= %d AND dur_ms < %d AND msg_count >= %d @@ -225,7 +235,7 @@ func (r *Repository) GetGlobalStats(ctx context.Context, workspaceID string, sta FROM task_session_messages msg JOIN task_sessions s ON s.id = msg.task_session_id JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) ) SELECT task_agg.total_tasks, task_agg.completed_tasks, task_agg.in_progress_tasks, @@ -307,7 +317,7 @@ func (r *Repository) GetDailyActivity(ctx context.Context, workspaceID string, d JOIN tasks t ON t.id = s.task_id LEFT JOIN task_session_messages msg ON msg.task_session_id = s.id AND %s = %s - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` GROUP BY %s ) activity ON activity.activity_date = d.date ORDER BY d.date ASC @@ -355,7 +365,7 @@ func (r *Repository) GetCompletedTaskActivity(ctx context.Context, workspaceID s SELECT task_id, MAX(completed_at) as completed_at FROM task_sessions WHERE completed_at IS NOT NULL GROUP BY task_id ) ts ON ts.task_id = t.id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (t.archived_at IS NOT NULL OR ws.position = (SELECT MAX(ws2.position) FROM workflow_steps ws2 WHERE ws2.workflow_id = ws.workflow_id)) AND COALESCE(ts.completed_at, t.archived_at) IS NOT NULL @@ -449,7 +459,7 @@ func buildRepositoryStatsQuery(drv string) string { FROM task_repositories tr JOIN tasks t ON t.id = tr.task_id LEFT JOIN workflow_steps ws ON ws.id = t.workflow_step_id - WHERE t.is_ephemeral = 0 AND (? IS NULL OR t.created_at >= ?) + WHERE t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR t.created_at >= ?) GROUP BY tr.repository_id ) task_stats ON task_stats.repository_id = r.id LEFT JOIN ( @@ -464,7 +474,7 @@ func buildRepositoryStatsQuery(drv string) string { JOIN task_sessions s ON s.task_id = tr.task_id LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id LEFT JOIN task_session_messages msg ON msg.task_session_id = s.id - WHERE t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) + WHERE t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) GROUP BY tr.repository_id ) session_stats ON session_stats.repository_id = r.id LEFT JOIN ( @@ -474,7 +484,7 @@ func buildRepositoryStatsQuery(drv string) string { JOIN tasks t ON t.id = tr.task_id JOIN task_sessions s ON s.task_id = tr.task_id LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id - WHERE t.is_ephemeral = 0 AND (? IS NULL OR s.started_at >= ?) + WHERE t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (? IS NULL OR s.started_at >= ?) GROUP BY tr.repository_id ) duration_stats ON duration_stats.repository_id = r.id LEFT JOIN ( @@ -486,7 +496,7 @@ func buildRepositoryStatsQuery(drv string) string { FROM task_session_commits c JOIN task_sessions s ON s.id = c.session_id JOIN tasks t ON t.id = s.task_id - WHERE t.is_ephemeral = 0 AND s.repository_id != '' AND (? IS NULL OR c.committed_at >= ?) + WHERE t.is_ephemeral = 0`+andNotAutomationOriginT+` AND s.repository_id != '' AND (? IS NULL OR c.committed_at >= ?) GROUP BY s.repository_id ) git_stats ON git_stats.repository_id = r.id WHERE r.workspace_id = ? AND r.deleted_at IS NULL @@ -520,7 +530,7 @@ func (r *Repository) GetAgentUsage(ctx context.Context, workspaceID string, limi FROM task_sessions s JOIN tasks t ON t.id = s.task_id LEFT JOIN task_session_turns turn ON turn.task_session_id = s.id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND s.agent_profile_id != '' AND (? IS NULL OR s.started_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND s.agent_profile_id != '' AND (? IS NULL OR s.started_at >= ?) GROUP BY s.agent_profile_id ORDER BY session_count DESC LIMIT ? @@ -566,7 +576,7 @@ func (r *Repository) GetGitStats(ctx context.Context, workspaceID string, start FROM task_session_commits c JOIN task_sessions s ON s.id = c.session_id JOIN tasks t ON t.id = s.task_id - WHERE t.workspace_id = ? AND t.is_ephemeral = 0 AND (? IS NULL OR c.committed_at >= ?) + WHERE t.workspace_id = ? AND t.is_ephemeral = 0` + andNotAutomationOriginT + ` AND (? IS NULL OR c.committed_at >= ?) ` var stats models.GitStats @@ -616,6 +626,10 @@ func (r *Repository) ListSessionCodeStats( // via fetchTasksForWorkspaces' excludeConfig=true, so the Host data API's // List and CodeStats reads cover the same session set. where += " AND " + dialect.ExcludeConfigModePredicate(driver, "t.metadata") + // Automation runs are hidden from every human-facing surface by their + // origin; the Host data API is one, so a plugin must not see through it + // what the UI deliberately does not show. + where += andNotAutomationOriginT query := fmt.Sprintf(` SELECT ts.id AS session_id, diff --git a/apps/backend/internal/automation/evaluator.go b/apps/backend/internal/automation/evaluator.go index e1b1db1cbf..02c4f10e08 100644 --- a/apps/backend/internal/automation/evaluator.go +++ b/apps/backend/internal/automation/evaluator.go @@ -162,7 +162,7 @@ func (e *GitHubEvaluator) firePRTrigger(ctx context.Context, t *AutomationTrigge "state": pr.State, }) - if err := e.svc.FireTrigger(ctx, t.AutomationID, t.ID, TriggerTypeGitHubPR, data, dedupKey); err != nil { + if _, err := e.svc.FireTrigger(ctx, t.AutomationID, t.ID, TriggerTypeGitHubPR, data, dedupKey); err != nil { e.logger.Error("failed to fire PR trigger", zap.String("trigger_id", t.ID), zap.Error(err)) } diff --git a/apps/backend/internal/automation/github_webhook_subscriber.go b/apps/backend/internal/automation/github_webhook_subscriber.go index 81f303ba87..8ad840514b 100644 --- a/apps/backend/internal/automation/github_webhook_subscriber.go +++ b/apps/backend/internal/automation/github_webhook_subscriber.go @@ -128,7 +128,7 @@ func (s *GitHubWebhookSubscriber) checkPushTrigger( "pusher_login": payload.PusherLogin, "message": payload.HeadCommitMsg, }) - if err := s.svc.FireTrigger(ctx, t.AutomationID, t.ID, TriggerTypeGitHubPush, data, dedupKey); err != nil { + if _, err := s.svc.FireTrigger(ctx, t.AutomationID, t.ID, TriggerTypeGitHubPush, data, dedupKey); err != nil { s.logger.Error("failed to fire push trigger", zap.String("trigger_id", t.ID), zap.Error(err)) } } @@ -176,7 +176,7 @@ func (s *GitHubWebhookSubscriber) checkCITrigger( "check_run_id": payload.CheckRunID, "html_url": payload.HTMLURL, }) - if err := s.svc.FireTrigger(ctx, t.AutomationID, t.ID, TriggerTypeGitHubCI, data, dedupKey); err != nil { + if _, err := s.svc.FireTrigger(ctx, t.AutomationID, t.ID, TriggerTypeGitHubCI, data, dedupKey); err != nil { s.logger.Error("failed to fire CI trigger", zap.String("trigger_id", t.ID), zap.Error(err)) } } diff --git a/apps/backend/internal/automation/github_webhook_subscriber_test.go b/apps/backend/internal/automation/github_webhook_subscriber_test.go index 274a3abdac..ae2e54ae42 100644 --- a/apps/backend/internal/automation/github_webhook_subscriber_test.go +++ b/apps/backend/internal/automation/github_webhook_subscriber_test.go @@ -36,7 +36,6 @@ func createTestAutomation(t *testing.T, svc *Service, workspaceID string) *Autom Name: "test automation", AgentProfileID: "agent-1", ExecutorProfileID: "exec-1", - ExecutionMode: ExecutionModeRun, }) if err != nil { t.Fatalf("CreateAutomation() error = %v", err) diff --git a/apps/backend/internal/automation/handlers.go b/apps/backend/internal/automation/handlers.go index f9e74d51d8..650e29f09f 100644 --- a/apps/backend/internal/automation/handlers.go +++ b/apps/backend/internal/automation/handlers.go @@ -26,6 +26,9 @@ func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service, log *logger.Log dispatcher.RegisterFunc(ws.ActionAutomationDisable, wsDisable(svc, log)) dispatcher.RegisterFunc(ws.ActionAutomationTrigger, wsManualTrigger(svc, log)) dispatcher.RegisterFunc(ws.ActionAutomationRunsList, wsListRuns(svc, log)) + dispatcher.RegisterFunc(ws.ActionAutomationRunsListWorkspace, wsListWorkspaceRuns(svc, log)) + dispatcher.RegisterFunc(ws.ActionAutomationSummaries, wsListAutomationSummaries(svc, log)) + dispatcher.RegisterFunc(ws.ActionAutomationSummary, wsGetAutomationSummary(svc, log)) dispatcher.RegisterFunc(ws.ActionAutomationTriggerAdd, wsAddTrigger(svc, log)) dispatcher.RegisterFunc(ws.ActionAutomationTriggerUpdate, wsUpdateTrigger(svc, log)) dispatcher.RegisterFunc(ws.ActionAutomationTriggerDelete, wsDeleteTrigger(svc, log)) @@ -188,10 +191,18 @@ func wsManualTrigger(svc *Service, log *logger.Logger) func(ctx context.Context, if len(a.Triggers) > 0 { triggerID = a.Triggers[0].ID } - if fireErr := svc.FireTrigger(ctx, id, triggerID, "manual", data, ""); fireErr != nil { + result, fireErr := svc.FireTrigger(ctx, id, triggerID, "manual", data, "") + if fireErr != nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, fireErr.Error(), nil) } - return ws.NewResponse(msg.ID, msg.Action, map[string]bool{"triggered": true}) + // A skip is not a failure, but it is not a fire either. Reporting + // triggered = true for one leaves the caller — and the person who + // clicked — unable to tell that nothing ran. + return ws.NewResponse(msg.ID, msg.Action, map[string]any{ + "triggered": !result.Skipped, + "skipped": result.Skipped, + "reason": result.Reason, + }) } } @@ -214,6 +225,74 @@ func wsListRuns(svc *Service, log *logger.Logger) func(ctx context.Context, msg } } +// wsListWorkspaceRuns feeds the workspace-wide runs page. Unlike +// wsListRuns it wraps the list in an object: this response is the whole +// page's data, so leaving room for cursors/counts later costs nothing now +// and a bare array would be a breaking change to add them to. +func wsListWorkspaceRuns(svc *Service, log *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + payload, _ := parseMap(msg) + workspaceID, _ := payload["workspace_id"].(string) + if workspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspace_id required", nil) + } + limit := 50 + if l, ok := payload["limit"].(float64); ok && l > 0 { + limit = int(l) + } + runs, err := svc.ListWorkspaceRuns(ctx, workspaceID, limit) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + // Never nil: the client renders an empty feed, not a null. + if runs == nil { + runs = []*WorkspaceAutomationRun{} + } + return ws.NewResponse(msg.ID, msg.Action, map[string]any{"runs": runs}) + } +} + +// wsListAutomationSummaries answers the runs list's health question per +// automation, so a row's "last said" and "still running" do not depend on how +// far back the capped workspace feed happens to reach. +func wsListAutomationSummaries(svc *Service, log *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + payload, _ := parseMap(msg) + workspaceID, _ := payload["workspace_id"].(string) + if workspaceID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "workspace_id required", nil) + } + summaries, err := svc.ListAutomationSummaries(ctx, workspaceID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + // Never nil: a workspace whose automations have never run renders empty + // rows, not a null the client has to guard. + if summaries == nil { + summaries = []*AutomationSummary{} + } + return ws.NewResponse(msg.ID, msg.Action, map[string]any{"summaries": summaries}) + } +} + +// wsGetAutomationSummary answers the same two facts for one automation, for the +// detail page. Nullable rather than an envelope of one: "this automation has +// never run" is a real answer, not an empty list. +func wsGetAutomationSummary(svc *Service, log *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + payload, _ := parseMap(msg) + automationID, _ := payload["automation_id"].(string) + if automationID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "automation_id required", nil) + } + summary, err := svc.GetAutomationSummary(ctx, automationID) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]any{"summary": summary}) + } +} + func wsAddTrigger(svc *Service, log *logger.Logger) func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { return func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { var req AddTriggerRequest diff --git a/apps/backend/internal/automation/handlers_runs_test.go b/apps/backend/internal/automation/handlers_runs_test.go new file mode 100644 index 0000000000..2321a1b407 --- /dev/null +++ b/apps/backend/internal/automation/handlers_runs_test.go @@ -0,0 +1,245 @@ +package automation + +import ( + "context" + "encoding/json" + "testing" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestWsListWorkspaceRuns_RequiresWorkspaceID(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + req, err := ws.NewRequest("req-1", ws.ActionAutomationRunsListWorkspace, map[string]any{"limit": 10}) + if err != nil { + t.Fatal(err) + } + resp, err := wsListWorkspaceRuns(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + var ep ws.ErrorPayload + _ = json.Unmarshal(resp.Payload, &ep) + if ep.Code != ws.ErrorCodeBadRequest { + t.Errorf("expected BAD_REQUEST, got %q", ep.Code) + } +} + +// Locks the wire shape the /runs page is built against: a "runs" object +// key (not a bare array), each row carrying automation_name alongside the +// run's own fields. +func TestWsListWorkspaceRuns_ReturnsAttributedRunsForTheWorkspaceOnly(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + mine := &Automation{WorkspaceID: "ws-A", Name: "nightly sweep", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + theirs := &Automation{WorkspaceID: "ws-B", Name: "not mine", WorkflowID: "wf-2", WorkflowStepID: "s-2"} + for _, a := range []*Automation{mine, theirs} { + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + } + for _, a := range []*Automation{mine, theirs} { + if err := svc.store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSucceeded, + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + } + + req, _ := ws.NewRequest("req-1", ws.ActionAutomationRunsListWorkspace, map[string]any{"workspace_id": "ws-A"}) + resp, err := wsListWorkspaceRuns(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + if resp.Type != ws.MessageTypeResponse { + t.Fatalf("expected response, got %v: %s", resp.Type, string(resp.Payload)) + } + + var got struct { + Runs []*WorkspaceAutomationRun `json:"runs"` + } + if err := json.Unmarshal(resp.Payload, &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(got.Runs) != 1 { + t.Fatalf("expected 1 run from ws-A only, got %d: %s", len(got.Runs), string(resp.Payload)) + } + if got.Runs[0].AutomationID != mine.ID { + t.Errorf("expected the ws-A run, got automation %q", got.Runs[0].AutomationID) + } + if got.Runs[0].AutomationName != "nightly sweep" { + t.Errorf("expected automation_name, got %q", got.Runs[0].AutomationName) + } +} + +// An empty feed must serialize as [], not null: the page maps over this +// array directly. +func TestWsListWorkspaceRuns_EmptyWorkspaceReturnsAnEmptyArray(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + req, _ := ws.NewRequest("req-1", ws.ActionAutomationRunsListWorkspace, map[string]any{"workspace_id": "ws-empty"}) + resp, err := wsListWorkspaceRuns(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(resp.Payload, &raw); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if string(raw["runs"]) != "[]" { + t.Errorf("expected runs: [], got %q", string(raw["runs"])) + } +} + +// The list maps over this array directly, so a workspace whose automations have +// never run must serialize as [], not null. +func TestWsListAutomationSummaries_EmptyWorkspaceReturnsAnEmptyArray(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + req, _ := ws.NewRequest("req-1", ws.ActionAutomationSummaries, map[string]any{"workspace_id": "ws-empty"}) + resp, err := wsListAutomationSummaries(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(resp.Payload, &raw); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if string(raw["summaries"]) != "[]" { + t.Errorf("expected summaries: [], got %q", string(raw["summaries"])) + } +} + +// Without a workspace the query has no scope, so this is a bad request rather +// than an accidental cross-workspace read. +func TestWsListAutomationSummaries_RequiresAWorkspace(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + req, _ := ws.NewRequest("req-1", ws.ActionAutomationSummaries, map[string]any{}) + resp, err := wsListAutomationSummaries(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + if resp.Type != ws.MessageTypeError { + t.Fatalf("expected error, got %v: %s", resp.Type, string(resp.Payload)) + } + var ep ws.ErrorPayload + if err := json.Unmarshal(resp.Payload, &ep); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if ep.Code != ws.ErrorCodeBadRequest { + t.Errorf("expected BAD_REQUEST, got %q", ep.Code) + } +} + +// Summaries carry the automation's own open count, not a count derived from +// whatever slice of the feed the client happened to load. +func TestWsListAutomationSummaries_CarriesTheOpenCountAndLastRun(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + a, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + Name: "nightly sweep", WorkspaceID: "ws-A", WorkflowID: "wf", WorkflowStepID: "s", + }) + if err != nil { + t.Fatal(err) + } + if err := svc.RecordRun(ctx, &AutomationRun{ + AutomationID: a.ID, TriggerType: TriggerTypeScheduled, + Status: RunStatusSucceeded, TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + + req, _ := ws.NewRequest("req-1", ws.ActionAutomationSummaries, map[string]any{"workspace_id": "ws-A"}) + resp, err := wsListAutomationSummaries(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + var got struct { + Summaries []*AutomationSummary `json:"summaries"` + } + if err := json.Unmarshal(resp.Payload, &got); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if len(got.Summaries) != 1 { + t.Fatalf("expected one summary, got %d: %s", len(got.Summaries), string(resp.Payload)) + } + if got.Summaries[0].AutomationID != a.ID { + t.Errorf("expected %s, got %s", a.ID, got.Summaries[0].AutomationID) + } + if got.Summaries[0].LastRun == nil { + t.Fatal("expected the summary to carry the automation's last run") + } + if got.Summaries[0].OpenRuns != 0 { + t.Errorf("a succeeded run is not open: got %d", got.Summaries[0].OpenRuns) + } +} + +// The detail page's variant. "Never run" is a real answer, so the summary is +// nullable rather than an empty envelope the client has to interpret. +func TestWsGetAutomationSummary_NullWhenTheAutomationHasNeverRun(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + a, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + Name: "brand new", WorkspaceID: "ws-A", WorkflowID: "wf", WorkflowStepID: "s", + }) + if err != nil { + t.Fatal(err) + } + + req, _ := ws.NewRequest("req-1", ws.ActionAutomationSummary, map[string]any{"automation_id": a.ID}) + resp, err := wsGetAutomationSummary(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(resp.Payload, &raw); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if string(raw["summary"]) != "null" { + t.Errorf("expected summary: null, got %q", string(raw["summary"])) + } +} + +func TestWsGetAutomationSummary_RequiresAnAutomation(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + req, _ := ws.NewRequest("req-1", ws.ActionAutomationSummary, map[string]any{}) + resp, err := wsGetAutomationSummary(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + if resp.Type != ws.MessageTypeError { + t.Fatalf("expected error, got %v: %s", resp.Type, string(resp.Payload)) + } + var ep ws.ErrorPayload + if err := json.Unmarshal(resp.Payload, &ep); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if ep.Code != ws.ErrorCodeBadRequest { + t.Errorf("expected BAD_REQUEST, got %q", ep.Code) + } +} diff --git a/apps/backend/internal/automation/handlers_test.go b/apps/backend/internal/automation/handlers_test.go index 88d62553b6..5c694453a9 100644 --- a/apps/backend/internal/automation/handlers_test.go +++ b/apps/backend/internal/automation/handlers_test.go @@ -410,3 +410,155 @@ func TestWsDeleteAllRuns_RejectsCrossWorkspace(t *testing.T) { t.Errorf("expected the run to survive a rejected cross-workspace delete-all, got %d runs", len(runs)) } } + +// A manual trigger that the concurrency cap turns away must say so. Before +// this, the handler answered triggered = true for a run that never started, +// so clicking "trigger" on a busy automation looked identical to a fire. +func TestWsManualTrigger_ReportsConcurrencyCapSkip(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + a := &Automation{ + WorkspaceID: "ws-1", + Name: "busy", + WorkflowID: "wf-1", + WorkflowStepID: "s-1", + Enabled: true, + MaxConcurrentRuns: 1, + } + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + // An in-flight run occupies the only slot. + if err := svc.store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: "task-in-flight", + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + + req, err := ws.NewRequest("req-trigger", ws.ActionAutomationTrigger, map[string]string{"id": a.ID}) + if err != nil { + t.Fatal(err) + } + resp, err := wsManualTrigger(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + if resp.Type != ws.MessageTypeResponse { + t.Fatalf("expected response, got %v: %s", resp.Type, string(resp.Payload)) + } + + var payload struct { + Triggered bool `json:"triggered"` + Skipped bool `json:"skipped"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(resp.Payload, &payload); err != nil { + t.Fatal(err) + } + if payload.Triggered { + t.Error("expected triggered = false when the cap turned the trigger away") + } + if !payload.Skipped { + t.Error("expected skipped = true") + } + if payload.Reason == "" { + t.Error("expected a reason the caller can show") + } +} + +func TestWsManualTrigger_ReportsAFire(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + a := &Automation{ + WorkspaceID: "ws-1", + Name: "idle", + WorkflowID: "wf-1", + WorkflowStepID: "s-1", + Enabled: true, + MaxConcurrentRuns: 1, + } + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + + req, err := ws.NewRequest("req-trigger", ws.ActionAutomationTrigger, map[string]string{"id": a.ID}) + if err != nil { + t.Fatal(err) + } + resp, err := wsManualTrigger(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + + var payload struct { + Triggered bool `json:"triggered"` + Skipped bool `json:"skipped"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(resp.Payload, &payload); err != nil { + t.Fatal(err) + } + if !payload.Triggered || payload.Skipped { + t.Fatalf("expected a fire, got triggered=%v skipped=%v reason=%q", + payload.Triggered, payload.Skipped, payload.Reason) + } +} + +// The Run button stays clickable on a disabled automation. Before this the +// handler answered triggered=true and moved last_triggered_at for a run the +// orchestrator would then discard. +func TestWsManualTrigger_DisabledAutomationIsReportedAsSkipped(t *testing.T) { + svc := newTestService(t) + log, _ := logger.NewFromZap(zap.NewNop()) + ctx := context.Background() + + a := &Automation{ + WorkspaceID: "ws-1", Name: "off", WorkflowID: "wf-1", WorkflowStepID: "s-1", + Enabled: false, MaxConcurrentRuns: 1, + } + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + + req, err := ws.NewRequest("req-disabled", ws.ActionAutomationTrigger, map[string]string{"id": a.ID}) + if err != nil { + t.Fatal(err) + } + resp, err := wsManualTrigger(svc, log)(ctx, req) + if err != nil { + t.Fatal(err) + } + var payload struct { + Triggered bool `json:"triggered"` + Skipped bool `json:"skipped"` + Reason string `json:"reason"` + } + if err := json.Unmarshal(resp.Payload, &payload); err != nil { + t.Fatal(err) + } + if payload.Triggered || !payload.Skipped { + t.Fatalf("expected a skip, got triggered=%v skipped=%v", payload.Triggered, payload.Skipped) + } + if payload.Reason == "" { + t.Error("expected a reason naming the disabled automation") + } + // A run that never happened must not advance the schedule's own clock. + reloaded, err := svc.store.GetAutomation(ctx, a.ID) + if err != nil { + t.Fatal(err) + } + if reloaded.LastTriggeredAt != nil { + t.Error("expected last_triggered_at to stay unset for a skipped fire") + } +} + +// The editor's own regex accepts expressions robfig/cron rejects. Saving one +// produced an automation that looked scheduled and never fired. diff --git a/apps/backend/internal/automation/models.go b/apps/backend/internal/automation/models.go index c2b492c871..5f0cce8f71 100644 --- a/apps/backend/internal/automation/models.go +++ b/apps/backend/internal/automation/models.go @@ -62,46 +62,40 @@ const ( RunStatusCancelled RunStatus = "cancelled" ) -// ExecutionMode controls whether an automation firing creates a visible -// kanban task or a fire-and-forget ephemeral run. -type ExecutionMode string - -const ( - // ExecutionModeTask is the default: a trigger fires, a normal kanban - // task is created, the user can see/track/comment on it. - ExecutionModeTask ExecutionMode = "task" - // ExecutionModeRun is the lightweight path: a trigger fires, an - // ephemeral task is created so the session pipeline still works, but - // the task is hidden from the kanban. Only the AutomationRun row is - // surfaced to the user. Intended for "scheduled prompt that produces - // output the user just reads" use cases. - ExecutionModeRun ExecutionMode = "run" -) - -// Valid reports whether the execution mode is a recognised value. -func (m ExecutionMode) Valid() bool { - return m == ExecutionModeTask || m == ExecutionModeRun -} - // Automation is a named rule with triggers, a prompt template, and agent/executor config. type Automation struct { - ID string `json:"id" db:"id"` - WorkspaceID string `json:"workspace_id" db:"workspace_id"` - Name string `json:"name" db:"name"` - Description string `json:"description" db:"description"` - WorkflowID string `json:"workflow_id" db:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id" db:"workflow_step_id"` - AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` - ExecutorProfileID string `json:"executor_profile_id" db:"executor_profile_id"` - Prompt string `json:"prompt" db:"prompt"` - TaskTitleTemplate string `json:"task_title_template" db:"task_title_template"` - ExecutionMode ExecutionMode `json:"execution_mode" db:"execution_mode"` - Enabled bool `json:"enabled" db:"enabled"` - MaxConcurrentRuns int `json:"max_concurrent_runs" db:"max_concurrent_runs"` - WebhookSecret string `json:"-" db:"webhook_secret"` - LastTriggeredAt *time.Time `json:"last_triggered_at,omitempty" db:"last_triggered_at"` - CreatedAt time.Time `json:"created_at" db:"created_at"` - UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + ID string `json:"id" db:"id"` + WorkspaceID string `json:"workspace_id" db:"workspace_id"` + Name string `json:"name" db:"name"` + Description string `json:"description" db:"description"` + // WorkflowID / WorkflowStepID are optional: no automation run is placed + // on a board, so no automation needs a starting column. + WorkflowID string `json:"workflow_id" db:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id" db:"workflow_step_id"` + AgentProfileID string `json:"agent_profile_id" db:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id" db:"executor_profile_id"` + Prompt string `json:"prompt" db:"prompt"` + TaskTitleTemplate string `json:"task_title_template" db:"task_title_template"` + Enabled bool `json:"enabled" db:"enabled"` + MaxConcurrentRuns int `json:"max_concurrent_runs" db:"max_concurrent_runs"` + WebhookSecret string `json:"-" db:"webhook_secret"` + LastTriggeredAt *time.Time `json:"last_triggered_at,omitempty" db:"last_triggered_at"` + CreatedAt time.Time `json:"created_at" db:"created_at"` + UpdatedAt time.Time `json:"updated_at" db:"updated_at"` + + // LegacyBoardCard reports that this automation was created while the + // withdrawn execution_mode still decided where a firing landed, and that + // its stored value is `task` — the DEFAULT, so this covers every + // automation nobody explicitly set to `run`. Those firings used to put a + // card on the kanban and no longer do; the UI uses this to say so once, + // per workspace, instead of leaving the cards to just stop appearing. + // + // Derived in SQL (`execution_mode = 'task'`) rather than by reading the + // column into a field, so the raw mode never enters the Go model and + // cannot grow a firing-path branch: this is a migration-window notice, + // not a second destination. See docs/specs/office/automations-settings.md + // § Migration. + LegacyBoardCard bool `json:"legacy_board_card" db:"legacy_board_card"` // Hydrated separately, not stored as columns on this table. Triggers []AutomationTrigger `json:"triggers" db:"-"` @@ -139,6 +133,41 @@ type AutomationRun struct { TriggerDataJSON string `json:"-" db:"trigger_data"` ErrorMessage string `json:"error_message,omitempty" db:"error_message"` CreatedAt time.Time `json:"created_at" db:"created_at"` + + // Summary is the tail of the agent's last message on the generated task, + // read at list time and truncated for display. Every automation hides its + // task from the board, so without this the run row can report that + // something happened but never what — which is the one thing the reader + // of a scheduled report actually wants. Empty when the run never produced + // a task or the agent never spoke. + Summary string `json:"summary,omitempty" db:"summary"` + // SessionID is the run's primary conversation, empty when the task is gone + // or never started one. The detail view mounts the transcript from it. + SessionID string `json:"session_id,omitempty" db:"session_id"` +} + +// WorkspaceAutomationRun is a run carrying just enough of its owning +// automation to be readable outside that automation's own settings page. +// The workspace-wide feed interleaves runs from every automation, so a row +// that knows only its automation_id is unattributable — the reader sees +// that something fired but not what fired it. +type WorkspaceAutomationRun struct { + AutomationRun + AutomationName string `json:"automation_name" db:"automation_name"` +} + +// AutomationSummary is one automation's health, answered per automation rather +// than inferred from a capped feed: what it last said, and whether anything of +// its own is still running. The runs list reads exactly these two facts. +type AutomationSummary struct { + AutomationID string `json:"automation_id"` + // OpenRuns counts the runs still outstanding under the same definition the + // concurrency cap uses, so "won't fire — still running" and the cap that + // causes it can never disagree. + OpenRuns int `json:"open_runs"` + // LastRun is nil when the automation has never run, or when its runs have + // all been deleted — both of which read as "no runs yet". + LastRun *AutomationRun `json:"last_run,omitempty"` } // --- Trigger config types --- @@ -192,7 +221,6 @@ type CreateAutomationRequest struct { RepositoryIDs []string `json:"repository_ids"` Prompt string `json:"prompt"` TaskTitleTemplate string `json:"task_title_template"` - ExecutionMode ExecutionMode `json:"execution_mode"` MaxConcurrentRuns int `json:"max_concurrent_runs"` Triggers []CreateTriggerSpec `json:"triggers"` } @@ -214,12 +242,11 @@ type UpdateAutomationRequest struct { ExecutorProfileID *string `json:"executor_profile_id,omitempty"` // RepositoryIDs replaces the automation's repository list when non-nil. // nil means "leave unchanged"; an explicit empty slice clears it. - RepositoryIDs []string `json:"repository_ids,omitempty"` - Prompt *string `json:"prompt,omitempty"` - TaskTitleTemplate *string `json:"task_title_template,omitempty"` - ExecutionMode *ExecutionMode `json:"execution_mode,omitempty"` - Enabled *bool `json:"enabled,omitempty"` - MaxConcurrentRuns *int `json:"max_concurrent_runs,omitempty"` + RepositoryIDs []string `json:"repository_ids,omitempty"` + Prompt *string `json:"prompt,omitempty"` + TaskTitleTemplate *string `json:"task_title_template,omitempty"` + Enabled *bool `json:"enabled,omitempty"` + MaxConcurrentRuns *int `json:"max_concurrent_runs,omitempty"` } // AddTriggerRequest adds a trigger to an existing automation. diff --git a/apps/backend/internal/automation/run_retention.go b/apps/backend/internal/automation/run_retention.go new file mode 100644 index 0000000000..1284b9ea98 --- /dev/null +++ b/apps/backend/internal/automation/run_retention.go @@ -0,0 +1,170 @@ +package automation + +import ( + "context" + + taskmodels "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" +) + +// DefaultRunWorktreeRetention is how many of an automation's most recent +// terminal runs keep their git worktree. Every firing is now a persistent task +// that deliberately retains its workspace, so a five-minute schedule would +// otherwise accumulate a full checkout every five minutes, forever — the disk +// on a long-lived install fills with workspaces nobody will ever open again. +// +// Ten is chosen to be larger than any plausible "go back and look at the last +// few runs" window while still bounding the cost at ten checkouts per +// automation. It is deliberately per-automation rather than global: a +// workspace with one noisy automation must not evict the single monthly run of +// a quiet one. +const DefaultRunWorktreeRetention = 10 + +// runWorktreeSweepWindow bounds how many aged-out runs a single finalization +// will look at. Candidates are only ever runs that still hold a checkout (see +// runHasLiveWorktreeSQL), so in the steady state exactly one run enters the +// window per firing and the slack is there to drain a backlog left by a +// restart or by an install that predates retention. Without the cap, an +// automation with a year of history would walk its entire run table on every +// single firing. +// +// The window opens where retention ends and reads *newest*-first, so the run +// that just aged out is always the first candidate. Reading oldest-first would +// permanently park the window on the far end of history, where every candidate +// has already been reclaimed, and the run that actually needs reclaiming would +// never be reached. +const runWorktreeSweepWindow = 200 + +// runHasLiveWorktreeSQL restricts the candidate set to runs that still have a +// checkout to reclaim. It is what makes the sweep converge, and it fixes two +// halves of the same defect. +// +// Nothing else records that a run's workspace was already reclaimed, so +// without this clause every finalization re-offered the same aged-out runs +// forever: a five-minute schedule spent ~57,000 pointless removal attempts a +// day handing already-removed checkouts back to git. Worse, because the window +// is `LIMIT n OFFSET keep` over that unfiltered set, any run that had fallen +// past rank keep+n was permanently out of reach — a pre-existing backlog could +// never drain, no matter how many times the sweep ran. +// +// Excluding the reclaimed ones makes the window slide: each firing sees only +// runs that still cost disk, so the backlog drains across successive firings +// and the steady state is a window one deep. The worktree row is reached +// through task_sessions because that is how a checkout is keyed — a task owns +// sessions, a session owns worktree rows — and `status <> 'deleted'` is the +// exact inverse of what worktree.Manager.ReleaseWorktreeReference writes when +// a checkout goes away. +const runHasLiveWorktreeSQL = ` + EXISTS ( + SELECT 1 FROM task_session_worktrees tsw + JOIN task_sessions wts ON wts.id = tsw.session_id + WHERE wts.task_id = ar.task_id AND tsw.status <> ? + )` + +// PrunableRunTaskIDs names the tasks whose workspaces may be reclaimed, given a +// task whose run has just reached a terminal status. The automation is resolved +// from that task rather than passed in because the orchestrator's finalization +// hook knows only which task finished. +// +// Four things are deliberately excluded. A run that has not reached a stored +// terminal status is still outstanding — its agent may be mid-turn — and is not +// a retention candidate at all, so it neither gets reclaimed nor counts against +// the newest-N that are kept. A run with no task never had a workspace (the +// concurrency-cap skip rows). A run whose workspace has already been reclaimed +// is done with (runHasLiveWorktreeSQL). And a run with a session that is +// STARTING or RUNNING is being worked on right now, however old the row is — a +// user can reply to an aged-out run and put it back to work, and pulling the +// floor out from under a live agent is a data-loss bug, not a disk saving. +// +// The in-use check deliberately looks at *every* session of the task, not only +// the is_primary one. A task can hold a live session that is not flagged +// primary (a resume races the flag, a passthrough session runs alongside), and +// scoping the check to is_primary = 1 let exactly that agent's checkout be +// deleted underneath it. Cheap over-protection here costs one retained +// workspace for one more sweep; under-protection costs a user's work. +// +// This list is a *selection*, not a licence: by the time the orchestrator gets +// round to removing a checkout the run may have gone live, so the caller +// re-checks with RunWorkspaceInUse immediately before each removal. See +// pruneAutomationRunWorktrees. +// +// WAITING_FOR_INPUT is pointedly *not* treated as in-use. That is where every +// successful automation run parks (see the orchestrator's +// handleAutomationTurnComplete), so excluding it would make nothing prunable +// and the retention policy a no-op. Losing the ability to reply to a run older +// than the retention window is the accepted cost of bounding the disk. +// +// Rows are never touched: this is a read, and the run row, its error message, +// and its transcript all outlive the workspace. Reclaiming the checkout is the +// whole of the policy. +func (s *Store) PrunableRunTaskIDs(ctx context.Context, finalizedTaskID string, keep int) ([]string, error) { + if finalizedTaskID == "" { + return nil, nil + } + if keep < 0 { + keep = 0 + } + args := []any{finalizedTaskID, string(RunStatusTriggered), string(RunStatusTaskCreated)} + args = append(args, runWorktreeInUseArgs()...) + args = append(args, worktree.StatusDeleted, runWorktreeSweepWindow, keep) + + var taskIDs []string + err := s.ro.SelectContext(ctx, &taskIDs, ` + SELECT ar.task_id FROM automation_runs ar + WHERE ar.automation_id = ( + SELECT automation_id FROM automation_runs + WHERE task_id = ? ORDER BY created_at DESC, id DESC LIMIT 1 + ) + AND ar.task_id != '' + AND ar.status NOT IN (?, ?) + AND NOT EXISTS ( + SELECT 1 FROM task_sessions ts + WHERE ts.task_id = ar.task_id AND ts.state IN (?, ?) + ) + AND`+runHasLiveWorktreeSQL+` + ORDER BY `+runOrderSQL("ar")+` + LIMIT ? OFFSET ?`, args...) + return taskIDs, err +} + +// RunWorkspaceInUse reports whether any session of the given task is starting +// or running right now. +// +// It exists because PrunableRunTaskIDs answers a question that goes stale. +// Selection happens once per finalization and the removals then run one after +// another against git and the filesystem; a user replying to an aged-out run +// anywhere in that gap puts an agent back to work in a checkout the sweep is +// already committed to deleting. That is silent data loss — the agent's +// working tree disappears mid-turn — and the worktree manager does not catch +// it: removeWorktree counts active references with the worktree's *own* +// session excluded, and for an automation run that own session is precisely +// the one that just came alive. +// +// So the orchestrator re-asks this immediately before each removal, which is +// as close to the side effect as this layer can get. The window is not closed +// — only a lock the manager does not offer would close it — but it shrinks +// from "the whole sweep" to "one query". A run that turns out to be live is +// left alone; it ages out again the next time its automation fires. +func (s *Store) RunWorkspaceInUse(ctx context.Context, taskID string) (bool, error) { + if taskID == "" { + return false, nil + } + var inUse bool + args := append([]any{taskID}, runWorktreeInUseArgs()...) + err := s.ro.GetContext(ctx, &inUse, ` + SELECT EXISTS ( + SELECT 1 FROM task_sessions ts + WHERE ts.task_id = ? AND ts.state IN (?, ?) + )`, args...) + return inUse, err +} + +// runWorktreeInUseArgs binds the session states that mean an agent is holding +// its workspace right now. Kept next to the query, like runTaskStateArgs, so a +// new state can't be added to the IN list without its argument being obvious. +func runWorktreeInUseArgs() []any { + return []any{ + string(taskmodels.TaskSessionStateStarting), + string(taskmodels.TaskSessionStateRunning), + } +} diff --git a/apps/backend/internal/automation/run_retention_test.go b/apps/backend/internal/automation/run_retention_test.go new file mode 100644 index 0000000000..6b6780840c --- /dev/null +++ b/apps/backend/internal/automation/run_retention_test.go @@ -0,0 +1,339 @@ +package automation + +import ( + "context" + "encoding/json" + "fmt" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +// seedRunAt records one run for an automation with its created_at pinned, so +// retention's ordering assertions don't depend on how many nanoseconds apart +// two CreateRun calls landed. +func seedRunAt(t *testing.T, store *Store, automationID, taskID string, status RunStatus, at time.Time) { + t.Helper() + r := &AutomationRun{ + AutomationID: automationID, + TriggerType: TriggerTypeScheduled, + TaskID: taskID, + Status: status, + TriggerData: json.RawMessage(`{}`), + } + if err := store.CreateRun(context.Background(), r); err != nil { + t.Fatal(err) + } + setRunCreatedAt(t, store, r.ID, at) +} + +// seedTerminalRunSeries records n succeeded runs for an automation, one minute +// apart, owning tasks named -000 … -NNN oldest first. Each task +// gets the checkout a real run leaves behind, because a run without one is not +// a retention candidate at all. +func seedTerminalRunSeries(t *testing.T, store *Store, automationID, prefix string, n int) []string { + t.Helper() + base := time.Now().UTC().Truncate(time.Second).Add(-time.Duration(n) * time.Hour) + taskIDs := make([]string, 0, n) + for i := range n { + taskID := fmt.Sprintf("%s-%03d", prefix, i) + seedRunAt(t, store, automationID, taskID, RunStatusSucceeded, base.Add(time.Duration(i)*time.Minute)) + seedLiveWorktree(t, store, taskID) + taskIDs = append(taskIDs, taskID) + } + return taskIDs +} + +// seedLiveWorktree gives a task the session + worktree rows that say it still +// holds a checkout on disk. Retention offers only runs that have one, so every +// series has to carry them or the assertions below would all pass on an empty +// result. The session is deliberately not primary and not live — it is here to +// own the worktree row, not to make the task look busy. +func seedLiveWorktree(t *testing.T, store *Store, taskID string) { + t.Helper() + sessionID := taskID + "-wt-session" + if _, err := store.db.Exec( + `INSERT INTO task_sessions (id, task_id, is_primary, state) VALUES (?, ?, 0, 'COMPLETED')`, + sessionID, taskID, + ); err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec( + `INSERT INTO task_session_worktrees (id, session_id, worktree_id, worktree_path, status) + VALUES (?, ?, ?, ?, 'active')`, + taskID+"-tsw", sessionID, "wt-"+taskID, "/workspaces/"+taskID, + ); err != nil { + t.Fatal(err) + } +} + +// reclaimWorktree marks a task's checkout gone exactly as +// worktree.Manager.ReleaseWorktreeReference does when a removal completes. +func reclaimWorktree(t *testing.T, store *Store, taskID string) { + t.Helper() + if _, err := store.db.Exec( + `UPDATE task_session_worktrees SET status = 'deleted', deleted_at = ? WHERE worktree_id = ?`, + time.Now().UTC(), "wt-"+taskID, + ); err != nil { + t.Fatal(err) + } +} + +func newRetentionAutomation(t *testing.T, store *Store, name string) *Automation { + t.Helper() + a := &Automation{WorkspaceID: "ws-1", Name: name, WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := store.CreateAutomation(context.Background(), a); err != nil { + t.Fatal(err) + } + return a +} + +func assertTaskIDs(t *testing.T, got, want []string) { + t.Helper() + if len(got) != len(want) { + t.Fatalf("expected %v, got %v", want, got) + } + for i := range want { + if got[i] != want[i] { + t.Fatalf("expected %v, got %v", want, got) + } + } +} + +// The defect this closes: every firing keeps a full worktree forever, so a +// frequent schedule fills the disk. Retention has to draw the line at a fixed +// count of the most recent runs and offer everything older — no more, so the +// runs a user actually goes back to are still openable. +func TestPrunableRunTaskIDs_KeepsTheNewestTerminalRunsAndOffersOnlyTheRest(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + a := newRetentionAutomation(t, store, "nightly sweep") + + tasks := seedTerminalRunSeries(t, store, a.ID, "task", DefaultRunWorktreeRetention+3) + newest := tasks[len(tasks)-1] + + got, err := store.PrunableRunTaskIDs(context.Background(), newest, DefaultRunWorktreeRetention) + if err != nil { + t.Fatal(err) + } + // Newest-first among the aged-out remainder: the run that just fell out of + // the window leads, so a single sweep always reaches it. + assertTaskIDs(t, got, []string{tasks[2], tasks[1], tasks[0]}) +} + +// A run still at task_created may have an agent mid-turn. It is not a +// retention candidate at all — neither reclaimed, nor counted toward the N +// that are kept, which would otherwise let three stuck runs evict three +// perfectly good workspaces. +func TestPrunableRunTaskIDs_IgnoresRunsThatHaveNotReachedATerminalStatus(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + a := newRetentionAutomation(t, store, "nightly sweep") + + base := time.Now().UTC().Truncate(time.Second) + for i := range 3 { + openTaskID := "open-" + string(rune('a'+i)) + seedRunAt(t, store, a.ID, openTaskID, RunStatusTaskCreated, base.Add(time.Duration(i)*time.Minute)) + // Given a checkout of its own, so the run's *status* is the only thing + // keeping it out of the result. + seedLiveWorktree(t, store, openTaskID) + } + tasks := seedTerminalRunSeries(t, store, a.ID, "done", DefaultRunWorktreeRetention) + + got, err := store.PrunableRunTaskIDs(context.Background(), tasks[len(tasks)-1], DefaultRunWorktreeRetention) + if err != nil { + t.Fatal(err) + } + if len(got) != 0 { + t.Fatalf("expected nothing prunable — only %d terminal runs exist — got %v", DefaultRunWorktreeRetention, got) + } +} + +// A user can reply to an aged-out run and put it back to work. Reclaiming the +// workspace out from under a live agent is data loss, not a disk saving — so +// age loses to a session that is starting or running. WAITING_FOR_INPUT is +// where every *successful* run parks, so it must not count as in-use or +// nothing would ever be prunable. +func TestPrunableRunTaskIDs_SkipsRunsWhoseAgentIsStillWorking(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + a := newRetentionAutomation(t, store, "nightly sweep") + + tasks := seedTerminalRunSeries(t, store, a.ID, "task", DefaultRunWorktreeRetention+3) + insertPrimarySession(t, store, tasks[0], "WAITING_FOR_INPUT") + insertPrimarySession(t, store, tasks[1], "RUNNING") + insertPrimarySession(t, store, tasks[2], "STARTING") + + got, err := store.PrunableRunTaskIDs(context.Background(), tasks[len(tasks)-1], DefaultRunWorktreeRetention) + if err != nil { + t.Fatal(err) + } + assertTaskIDs(t, got, []string{tasks[0]}) +} + +// Retention is per-automation on purpose: one noisy automation must not evict +// the single monthly run of a quiet one sharing its workspace. +func TestPrunableRunTaskIDs_IsScopedToTheAutomationOwningTheFinalizedTask(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + noisy := newRetentionAutomation(t, store, "every five minutes") + quiet := newRetentionAutomation(t, store, "monthly report") + + noisyTasks := seedTerminalRunSeries(t, store, noisy.ID, "noisy", DefaultRunWorktreeRetention+3) + quietTasks := seedTerminalRunSeries(t, store, quiet.ID, "quiet", DefaultRunWorktreeRetention+3) + + got, err := store.PrunableRunTaskIDs(context.Background(), noisyTasks[len(noisyTasks)-1], DefaultRunWorktreeRetention) + if err != nil { + t.Fatal(err) + } + assertTaskIDs(t, got, []string{noisyTasks[2], noisyTasks[1], noisyTasks[0]}) + for _, quietTask := range quietTasks { + for _, offered := range got { + if offered == quietTask { + t.Fatalf("a run from another automation (%s) was offered for reclamation", quietTask) + } + } + } +} + +// Runs with no task never had a workspace — the concurrency-cap skip rows are +// the common case — so they must not consume a slot in the aged-out window and +// push a real candidate out of reach. +func TestPrunableRunTaskIDs_IgnoresRunsThatNeverProducedATask(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + a := newRetentionAutomation(t, store, "nightly sweep") + + tasks := seedTerminalRunSeries(t, store, a.ID, "task", DefaultRunWorktreeRetention+1) + base := time.Now().UTC().Truncate(time.Second) + // A session row carrying the empty task id, so the checkout predicate would + // happily match these rows and only the empty-task_id filter keeps them out. + seedLiveWorktree(t, store, "") + for i := range 5 { + seedRunAt(t, store, a.ID, "", RunStatusSkipped, base.Add(time.Duration(i)*time.Minute)) + } + + got, err := store.PrunableRunTaskIDs(context.Background(), tasks[len(tasks)-1], DefaultRunWorktreeRetention) + if err != nil { + t.Fatal(err) + } + assertTaskIDs(t, got, []string{tasks[0]}) +} + +// A live session does not have to be the primary one. A resume can race the +// is_primary flag over to a new row, and a passthrough session runs alongside +// the primary — in both cases an agent is holding the checkout while +// is_primary = 1 points somewhere else. Scoping the in-use check to the +// primary session meant exactly that agent's workspace was offered up for +// deletion, which is the data-loss case the check exists to prevent. +func TestPrunableRunTaskIDs_SkipsRunsWhoseNonPrimarySessionIsStillWorking(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + a := newRetentionAutomation(t, store, "nightly sweep") + + tasks := seedTerminalRunSeries(t, store, a.ID, "task", DefaultRunWorktreeRetention+3) + insertStaleSession(t, store, tasks[0], "RUNNING") + insertPrimarySession(t, store, tasks[0], "WAITING_FOR_INPUT") + + got, err := store.PrunableRunTaskIDs(context.Background(), tasks[len(tasks)-1], DefaultRunWorktreeRetention) + if err != nil { + t.Fatal(err) + } + assertTaskIDs(t, got, []string{tasks[2], tasks[1]}) +} + +// Nothing else records that a run's workspace has been reclaimed, so a run +// whose checkout is already gone was offered again on every single +// finalization — a five-minute schedule re-attempting the same ~200 removals +// forever. The worktree row going away is the record. +func TestPrunableRunTaskIDs_StopsOfferingARunWhoseWorkspaceIsAlreadyReclaimed(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + a := newRetentionAutomation(t, store, "nightly sweep") + + tasks := seedTerminalRunSeries(t, store, a.ID, "task", DefaultRunWorktreeRetention+3) + reclaimWorktree(t, store, tasks[0]) + + got, err := store.PrunableRunTaskIDs(context.Background(), tasks[len(tasks)-1], DefaultRunWorktreeRetention) + if err != nil { + t.Fatal(err) + } + assertTaskIDs(t, got, []string{tasks[2], tasks[1]}) +} + +// The other half of the same defect. The sweep window is LIMIT n OFFSET keep +// over the candidate set, so while reclaimed runs stayed in that set the +// window never moved: a run that had fallen past rank keep+n could not be +// reached by any number of sweeps, and an install that predated retention kept +// its backlog forever. Dropping reclaimed runs out of the set slides the +// window down, so the backlog drains one firing at a time. +func TestPrunableRunTaskIDs_DrainsABacklogAcrossSuccessiveSweeps(t *testing.T) { + ctx := context.Background() + store := setupTestStore(t) + createTasksTable(t, store) + a := newRetentionAutomation(t, store, "every five minutes") + + // Five runs deeper than one sweep can see, so the oldest five start out + // beyond the reach of any single firing. + const stranded = 5 + total := DefaultRunWorktreeRetention + runWorktreeSweepWindow + stranded + tasks := seedTerminalRunSeries(t, store, a.ID, "task", total) + newest := tasks[len(tasks)-1] + + first, err := store.PrunableRunTaskIDs(ctx, newest, DefaultRunWorktreeRetention) + if err != nil { + t.Fatal(err) + } + if len(first) != runWorktreeSweepWindow { + t.Fatalf("expected a full %d-run window, got %d", runWorktreeSweepWindow, len(first)) + } + for _, offered := range first { + for _, unreachable := range tasks[:stranded] { + if offered == unreachable { + t.Fatalf("precondition failed: %s was expected to sit past the sweep window", unreachable) + } + } + } + + for _, taskID := range first { + reclaimWorktree(t, store, taskID) + } + + second, err := store.PrunableRunTaskIDs(ctx, newest, DefaultRunWorktreeRetention) + if err != nil { + t.Fatal(err) + } + assertTaskIDs(t, second, []string{tasks[4], tasks[3], tasks[2], tasks[1], tasks[0]}) +} + +// The pre-removal re-check the sweep makes once per workspace. It answers for +// the task, not for one session of it, and it agrees with the selection query +// about what counts as in use — WAITING_FOR_INPUT is where every successful +// run parks and must not read as busy. +func TestRunWorkspaceInUse_ReportsAnySessionThatIsStartingOrRunning(t *testing.T) { + ctx := context.Background() + store := setupTestStore(t) + createTasksTable(t, store) + + insertPrimarySession(t, store, "task-parked", "WAITING_FOR_INPUT") + insertPrimarySession(t, store, "task-replied", "STARTING") + insertStaleSession(t, store, "task-passthrough", "RUNNING") + insertPrimarySession(t, store, "task-passthrough", "WAITING_FOR_INPUT") + + for taskID, want := range map[string]bool{ + "task-parked": false, + "task-replied": true, + "task-passthrough": true, + "task-with-no-rows": false, + "": false, + } { + got, err := store.RunWorkspaceInUse(ctx, taskID) + if err != nil { + t.Fatal(err) + } + if got != want { + t.Fatalf("RunWorkspaceInUse(%q) = %v, want %v", taskID, got, want) + } + } +} diff --git a/apps/backend/internal/automation/scheduler.go b/apps/backend/internal/automation/scheduler.go index 0fcbad2657..63c4ff6655 100644 --- a/apps/backend/internal/automation/scheduler.go +++ b/apps/backend/internal/automation/scheduler.go @@ -151,7 +151,7 @@ func (cs *CronScheduler) fire(ctx context.Context, t *AutomationTrigger, now tim }) dedupKey := fmt.Sprintf("scheduled:%s:%d", t.ID, now.Unix()/60) // Dedup by minute - if err := cs.svc.FireTrigger(ctx, t.AutomationID, t.ID, TriggerTypeScheduled, data, dedupKey); err != nil { + if _, err := cs.svc.FireTrigger(ctx, t.AutomationID, t.ID, TriggerTypeScheduled, data, dedupKey); err != nil { cs.logger.Error("failed to fire scheduled trigger", zap.String("trigger_id", t.ID), zap.String("automation_id", t.AutomationID), diff --git a/apps/backend/internal/automation/scheduler_test.go b/apps/backend/internal/automation/scheduler_test.go index cb08fb9ddd..1aaed7c1c3 100644 --- a/apps/backend/internal/automation/scheduler_test.go +++ b/apps/backend/internal/automation/scheduler_test.go @@ -52,9 +52,18 @@ func TestFireTrigger_SkippedForConcurrencyCap_UpdatesLastEvaluatedAt(t *testing. t.Fatal(err) } - if err := svc.FireTrigger(ctx, a.ID, trig.ID, TriggerTypeScheduled, json.RawMessage(`{}`), "scheduled:trig:1"); err != nil { + result, err := svc.FireTrigger(ctx, a.ID, trig.ID, TriggerTypeScheduled, json.RawMessage(`{}`), "scheduled:trig:1") + if err != nil { t.Fatalf("FireTrigger returned error for a skip: %v", err) } + // A skip must be reported as one. Callers that tell a human what happened + // cannot distinguish it from a real fire otherwise. + if !result.Skipped { + t.Fatal("expected FireTrigger to report the concurrency-cap skip") + } + if result.Reason == "" { + t.Fatal("expected a reason explaining the skip") + } runs, err := svc.store.ListRuns(ctx, a.ID, 10) if err != nil { @@ -182,7 +191,7 @@ func TestFireTrigger_ConcurrencyCapCheckError_DoesNotAdvanceLastEvaluatedAt(t *t t.Fatal(err) } - if err := svc.FireTrigger(ctx, a.ID, trig.ID, TriggerTypeScheduled, json.RawMessage(`{}`), ""); err == nil { + if _, err := svc.FireTrigger(ctx, a.ID, trig.ID, TriggerTypeScheduled, json.RawMessage(`{}`), ""); err == nil { t.Fatal("expected FireTrigger to return the concurrency-cap check error") } @@ -240,9 +249,13 @@ func TestFireTrigger_ArchivedTaskRun_DoesNotBlockConcurrencyCap(t *testing.T) { t.Fatal(err) } - if err := svc.FireTrigger(ctx, a.ID, trig.ID, TriggerTypeScheduled, json.RawMessage(`{}`), "new-run"); err != nil { + result, err := svc.FireTrigger(ctx, a.ID, trig.ID, TriggerTypeScheduled, json.RawMessage(`{}`), "new-run") + if err != nil { t.Fatalf("FireTrigger returned error: %v", err) } + if result.Skipped { + t.Fatalf("expected the trigger to fire, got skipped: %s", result.Reason) + } runs, err := svc.store.ListRuns(ctx, a.ID, 10) if err != nil { diff --git a/apps/backend/internal/automation/service.go b/apps/backend/internal/automation/service.go index a2fae8f237..796049605a 100644 --- a/apps/backend/internal/automation/service.go +++ b/apps/backend/internal/automation/service.go @@ -5,6 +5,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "sync" "time" @@ -38,6 +39,21 @@ var ErrDuplicateRepositoryID = errors.New("automation: duplicate repository_ids // because this check guards cross-workspace repository access. var ErrRepositoryLookupUnavailable = errors.New("automation: repository validation is not available") +// ErrAutomationNotFound covers both a missing automation and one in a workspace +// the caller cannot reach, so authorization never leaks which of the two it is. +var ErrAutomationNotFound = errors.New("automation: not found") + +// ErrAgentProfileNotFound is returned when a create or update names an agent +// profile ID that does not resolve to a live profile row. +// +// Deleting a profile disables the automations bound to it before the row goes +// away, which only ever covered bindings that were valid to begin with. A +// request naming a profile that never existed — or one deleted long enough ago +// that nothing remembers it — sailed straight past that, and the result is the +// exact shape the delete ordering was built to prevent: an enabled automation +// pointed at a profile that isn't there, failing quietly on a schedule. +var ErrAgentProfileNotFound = errors.New("automation: agent profile not found") + // TaskDeleter deletes a task and cleans up its resources. // Satisfied by *taskservice.Service; injected to avoid a cyclic import. // Implementations should return errors wrapping ErrTaskNotFound when the @@ -46,18 +62,56 @@ type TaskDeleter interface { DeleteTask(ctx context.Context, id string) error } +// WorkflowLocator resolves the workspace a workflow belongs to. Satisfied by +// the task service; injected to avoid a cyclic import, like TaskDeleter. +// +// Without it the server accepts any non-empty workflow id, so a request naming +// another workspace's workflow is stored verbatim. The editor no longer offers +// those, but a UI filter is not an authorization boundary. +type WorkflowLocator interface { + WorkflowWorkspaceID(ctx context.Context, workflowID string) (string, error) +} + +// AgentProfileLookup answers whether an agent profile ID resolves to a live +// (not soft-deleted) profile row. Satisfied by an adapter over the agent +// settings store and injected rather than imported, for the same reason +// TaskDeleter and WorkflowLocator are: the agent settings controller already +// reaches into this package to disable automations before a profile delete, so +// importing it back here would close the cycle. +// +// Existence only, deliberately. Agent profiles are either workspace-scoped or +// global (an empty workspace_id), and an automation's binding has never been +// checked against either, so answering the narrower "does this row exist" is +// what closes the defect without retroactively invalidating bindings that are +// merely cross-workspace. +// +// The (bool, error) split is load-bearing: a definitive "no such profile" is +// what this rejects, a driver failure is not, and collapsing the two would let +// one flaky read reject a perfectly good binding. +type AgentProfileLookup interface { + AgentProfileExists(ctx context.Context, profileID string) (bool, error) +} + // Service coordinates automation operations. type Service struct { store *Store eventBus bus.EventBus logger *logger.Logger taskDeleter TaskDeleter // optional; nil-safe + // workflowLocator gates workflow ownership. Optional: when nil (isolated + // tests) ownership is not enforced. + workflowLocator WorkflowLocator // repoLookup validates repository_ids on create/update — every ID must // resolve to a repository belonging to the automation's workspace. Nil // = validation skipped (not yet wired at startup, or an isolated test). repoLookup RepositoryLookup + // agentProfileLookup validates agent_profile_id on create/update. Nil = + // validation skipped, like workflowLocator above and unlike repoLookup — + // see validateAgentProfileID for why this one does not fail closed. + agentProfileLookup AgentProfileLookup + // authorizeWorkspace gates automation access by workspace ownership // (opt-in auth). Nil = unscoped (internal schedulers/pollers, auth // disabled). Set via SetWorkspaceAuthorizer. @@ -92,6 +146,71 @@ func (s *Service) SetTaskDeleter(d TaskDeleter) { s.taskDeleter = d } +// SetWorkflowLocator wires the workflow ownership check. +func (s *Service) SetWorkflowLocator(l WorkflowLocator) { + s.workflowLocator = l +} + +// authorizeWorkflowOwnership rejects a workflow belonging to a workspace other +// than the one the automation is being saved into. +func (s *Service) authorizeWorkflowOwnership(ctx context.Context, workspaceID, workflowID string) error { + if s.workflowLocator == nil || workflowID == "" { + return nil + } + owner, err := s.workflowLocator.WorkflowWorkspaceID(ctx, workflowID) + if err != nil { + return fmt.Errorf("resolve workflow workspace: %w", err) + } + if owner != workspaceID { + return fmt.Errorf("workflow does not belong to this workspace") + } + return nil +} + +// SetAgentProfileLookup wires the agent-profile existence check applied to +// agent_profile_id on create/update. +func (s *Service) SetAgentProfileLookup(l AgentProfileLookup) { + s.agentProfileLookup = l +} + +// validateAgentProfileID rejects a binding to an agent profile that is not +// there. Without it the only enforcement is executor.PrepareSession at launch +// time — long after the automation has been persisted, scheduled and fired — +// so the automation looks configured on screen and dies on every firing. +// +// Two skips, for different reasons: +// +// - No lookup wired: skip, matching workflowLocator rather than repoLookup's +// fail-closed. repoLookup guards cross-workspace repository access, so +// "unconfigured" must not mean "unchecked" there. This check is referential +// integrity over an ID that grants nothing, and it applies to nearly every +// automation rather than only those carrying a repository list — so failing +// closed would turn one missing wire into "no automation can be saved at +// all", in a subsystem whose startup is explicitly non-fatal. +// +// - Empty profileID: accepted, because it is a *different* defect and this is +// not the change that should fix it. An empty ID is not an inherit-the- +// default path — nothing on the firing path substitutes a workspace default, +// and the launch fails with ErrNoAgentProfileID — but the editor's save +// button still allows it and most of this package's tests construct +// automations without one. Rejecting it here would refuse data the UI is +// currently able to produce, which is a product decision, not a defect fix. +func (s *Service) validateAgentProfileID(ctx context.Context, profileID string) error { + if s.agentProfileLookup == nil || profileID == "" { + return nil + } + exists, err := s.agentProfileLookup.AgentProfileExists(ctx, profileID) + if err != nil { + // Surfaced, not swallowed into "missing": a driver failure must not be + // reported to the user as a profile that does not exist. + return fmt.Errorf("resolve agent profile %q: %w", profileID, err) + } + if !exists { + return fmt.Errorf("%w: %s", ErrAgentProfileNotFound, profileID) + } + return nil +} + // SetWorkspaceAuthorizer wires the per-user workspace-access check (opt-in // auth). The authorizer must return nil for contexts without a request // identity (internal callers). @@ -126,6 +245,13 @@ func (s *Service) authorizeAutomation(ctx context.Context, id string) error { if err != nil { return err } + // GetAutomation reports a missing row as (nil, nil), so a stale id — a + // bookmarked page for a deleted automation, a client retrying after a + // delete — reaches here with nothing to authorize. Dereferencing it panics + // the backend on an ordinary not-found. + if a == nil { + return ErrAutomationNotFound + } return s.authorizeWorkspace(ctx, a.WorkspaceID) } @@ -148,20 +274,11 @@ func (s *Service) CreateAutomation(ctx context.Context, req *CreateAutomationReq maxRuns = 1 } - mode := req.ExecutionMode - if !mode.Valid() { - mode = ExecutionModeTask - } - // Workflow + step are required for task-mode automations (the run is - // surfaced on the kanban and needs a starting column). Run-mode is - // ephemeral and bypasses the workflow entirely. - if mode == ExecutionModeTask { - if req.WorkflowID == "" { - return nil, fmt.Errorf("workflow_id is required") - } - if req.WorkflowStepID == "" { - return nil, fmt.Errorf("workflow_step_id is required") - } + // Workflow + step are optional for every automation: no automation run is + // placed on a board, so no automation needs a starting column. Ownership + // is still enforced when one is supplied. + if err := s.authorizeWorkflowOwnership(ctx, req.WorkspaceID, req.WorkflowID); err != nil { + return nil, err } a := &Automation{ WorkspaceID: req.WorkspaceID, @@ -174,19 +291,27 @@ func (s *Service) CreateAutomation(ctx context.Context, req *CreateAutomationReq RepositoryIDs: req.RepositoryIDs, Prompt: req.Prompt, TaskTitleTemplate: req.TaskTitleTemplate, - ExecutionMode: mode, Enabled: true, MaxConcurrentRuns: maxRuns, } if err := s.validateRepositoryIDs(ctx, req.WorkspaceID, req.RepositoryIDs); err != nil { return nil, err } + if err := s.validateAgentProfileID(ctx, req.AgentProfileID); err != nil { + return nil, err + } if err := s.store.CreateAutomation(ctx, a); err != nil { return nil, fmt.Errorf("create automation: %w", err) } - // Create initial triggers. + // Create initial triggers. The cron check is the same one AddTrigger and + // UpdateTrigger apply: without it an expression the scheduler cannot parse + // is accepted at creation and rejected on the first edit, and in between the + // automation simply never fires with nothing on screen to say why. for _, ts := range req.Triggers { + if err := validateScheduledConfig(ts.Type, ts.Config); err != nil { + return nil, err + } t := &AutomationTrigger{ AutomationID: a.ID, Type: ts.Type, @@ -225,24 +350,52 @@ func (s *Service) UpdateAutomation(ctx context.Context, id string, req *UpdateAu if err := s.authorizeAutomation(ctx, id); err != nil { return nil, err } - if req.RepositoryIDs != nil { - existing, err := s.store.GetAutomation(ctx, id) - if err != nil { - return nil, err - } - if existing == nil { - return nil, fmt.Errorf("automation not found: %s", id) - } - if err := s.validateRepositoryIDs(ctx, existing.WorkspaceID, req.RepositoryIDs); err != nil { + // Checked here rather than inside authorizeUpdatedReferences because, + // unlike the workflow and repository checks there, profile existence is not + // asked relative to the automation's workspace and so needs no stored row + // to answer. Rebinding is the path that matters most: an automation is + // edited far more often than it is created, and a profile that has since + // been deleted is still offered by any stale editor tab. + if req.AgentProfileID != nil { + if err := s.validateAgentProfileID(ctx, *req.AgentProfileID); err != nil { return nil, err } } + if err := s.authorizeUpdatedReferences(ctx, id, req); err != nil { + return nil, err + } if err := s.store.UpdateAutomation(ctx, id, req); err != nil { return nil, err } return s.store.GetAutomation(ctx, id) } +// authorizeUpdatedReferences checks the fields of req that name something the +// automation's workspace must own — its repositories and its workflow. Both +// need the stored automation to learn that workspace, so it is loaded once +// here rather than by each check. +func (s *Service) authorizeUpdatedReferences(ctx context.Context, id string, req *UpdateAutomationRequest) error { + if req.RepositoryIDs == nil && req.WorkflowID == nil { + return nil + } + existing, err := s.store.GetAutomation(ctx, id) + if err != nil { + return err + } + if existing == nil { + return fmt.Errorf("automation not found: %s", id) + } + if req.RepositoryIDs != nil { + if err := s.validateRepositoryIDs(ctx, existing.WorkspaceID, req.RepositoryIDs); err != nil { + return err + } + } + if req.WorkflowID == nil { + return nil + } + return s.authorizeWorkflowOwnership(ctx, existing.WorkspaceID, *req.WorkflowID) +} + // validateRepositoryIDs rejects a duplicate entry, or any ID that isn't a // repository belonging to workspaceID. An empty list always passes. A // non-empty list with no RepositoryLookup wired fails closed @@ -298,6 +451,32 @@ func (s *Service) DisableAutomation(ctx context.Context, id string) error { // --- Trigger CRUD --- +// validateScheduledConfig rejects a cron expression the scheduler could never +// run. Without it the editor's regex is the only gate, and it is both too +// permissive (accepting "60 * * * *", "*/0 * * * *", reversed ranges like +// "10-5 * * * *") and too strict (rejecting named fields such as MON or JAN +// that robfig/cron accepts). Either way the user saves a schedule that then +// silently never fires. Parsing with the scheduler's own parser is the only +// definition of valid that matters here. +func validateScheduledConfig(triggerType TriggerType, raw json.RawMessage) error { + if triggerType != TriggerTypeScheduled || len(raw) == 0 { + return nil + } + var cfg ScheduledTriggerConfig + if err := json.Unmarshal(raw, &cfg); err != nil { + return fmt.Errorf("invalid scheduled trigger config: %w", err) + } + // An empty expression means "not scheduled yet", not invalid — the editor + // writes it while the user is still choosing. + if strings.TrimSpace(cfg.CronExpression) == "" { + return nil + } + if _, err := nextCronFire(cfg.CronExpression, cfg.Timezone, time.Now().UTC()); err != nil { + return fmt.Errorf("invalid schedule %q: %w", cfg.CronExpression, err) + } + return nil +} + // AddTrigger adds a trigger to an automation. func (s *Service) AddTrigger(ctx context.Context, req *AddTriggerRequest) (*AutomationTrigger, error) { if req.AutomationID == "" { @@ -306,6 +485,9 @@ func (s *Service) AddTrigger(ctx context.Context, req *AddTriggerRequest) (*Auto if err := s.authorizeAutomation(ctx, req.AutomationID); err != nil { return nil, err } + if err := validateScheduledConfig(req.Type, req.Config); err != nil { + return nil, err + } t := &AutomationTrigger{ AutomationID: req.AutomationID, Type: req.Type, @@ -323,6 +505,17 @@ func (s *Service) UpdateTrigger(ctx context.Context, id string, req *UpdateTrigg if err := s.authorizeTrigger(ctx, id); err != nil { return err } + if req.Config != nil { + existing, err := s.store.GetTrigger(ctx, id) + if err != nil { + return err + } + if existing != nil { + if err := validateScheduledConfig(existing.Type, *req.Config); err != nil { + return err + } + } + } return s.store.UpdateTrigger(ctx, id, req) } @@ -357,6 +550,38 @@ func (s *Service) ListRuns(ctx context.Context, automationID string, limit int) return s.store.ListRuns(ctx, automationID, limit) } +// ListWorkspaceRuns returns recent runs across every automation in the +// workspace. Authorization is on the workspace itself rather than +// per-automation (as ListRuns does), because the workspace is the whole +// scope of the query — every row it can return already belongs to it. +func (s *Service) ListWorkspaceRuns(ctx context.Context, workspaceID string, limit int) ([]*WorkspaceAutomationRun, error) { + if err := s.authorizeWs(ctx, workspaceID); err != nil { + return nil, err + } + return s.store.ListWorkspaceRuns(ctx, workspaceID, limit) +} + +// ListAutomationSummaries returns one health summary per automation in the +// workspace. Authorized on the workspace for the same reason ListWorkspaceRuns +// is: every row it can return already belongs to that workspace. +func (s *Service) ListAutomationSummaries(ctx context.Context, workspaceID string) ([]*AutomationSummary, error) { + if err := s.authorizeWs(ctx, workspaceID); err != nil { + return nil, err + } + return s.store.ListAutomationSummaries(ctx, workspaceID) +} + +// GetAutomationSummary returns one automation's summary, authorized on the +// automation itself. The detail page reads it for the same reason the list +// reads the workspace-wide set: its own run window is capped, so an open run +// older than that window would leave the page claiming nothing is in flight. +func (s *Service) GetAutomationSummary(ctx context.Context, automationID string) (*AutomationSummary, error) { + if err := s.authorizeAutomation(ctx, automationID); err != nil { + return nil, err + } + return s.store.GetAutomationSummary(ctx, automationID) +} + // GetRun returns a single run by ID, or nil if not found. func (s *Service) GetRun(ctx context.Context, id string) (*AutomationRun, error) { run, err := s.store.GetRun(ctx, id) @@ -395,6 +620,14 @@ func (s *Service) DeleteRun(ctx context.Context, runID string) error { if err != nil { return fmt.Errorf("get run: %w", err) } + // Authorized here rather than in the WS handler, like every other exported + // operation on this service. A destructive call that depends on its caller + // remembering to check is one new caller away from not being checked. + if run != nil { + if authErr := s.authorizeAutomation(ctx, run.AutomationID); authErr != nil { + return authErr + } + } if run != nil && run.TaskID != "" && s.taskDeleter != nil { if delErr := s.taskDeleter.DeleteTask(ctx, run.TaskID); delErr != nil { if !errors.Is(delErr, ErrTaskNotFound) { @@ -411,6 +644,9 @@ func (s *Service) DeleteRun(ctx context.Context, runID string) error { // DeleteAllRuns removes every run for an automation, deleting each associated // task first. Task deletion is best-effort: not-found errors are ignored. func (s *Service) DeleteAllRuns(ctx context.Context, automationID string) error { + if err := s.authorizeAutomation(ctx, automationID); err != nil { + return err + } defer s.automationRunLock(automationID)() if s.taskDeleter != nil { taskIDs, err := s.store.ListRunTaskIDs(ctx, automationID) @@ -433,29 +669,56 @@ func (s *Service) DeleteAllRuns(ctx context.Context, automationID string) error // --- Trigger firing --- +// FireResult reports what FireTrigger actually did. A skip is not an error: +// the trigger was evaluated and deliberately not run. Callers that report back +// to a human must distinguish the two, otherwise a deliberate skip is +// indistinguishable from a fire that happened. +type FireResult struct { + Skipped bool + // Reason is human-readable and set only when Skipped. + Reason string +} + // FireTrigger publishes an AutomationTriggered event for the given trigger. // The orchestrator handles task creation in response. -func (s *Service) FireTrigger(ctx context.Context, automationID, triggerID string, triggerType TriggerType, triggerData json.RawMessage, dedupKey string) error { +func (s *Service) FireTrigger(ctx context.Context, automationID, triggerID string, triggerType TriggerType, triggerData json.RawMessage, dedupKey string) (FireResult, error) { + // Admission decisions live in one place so every caller — scheduler, + // webhook, and the manual Run button — gets the same answer about whether a + // fire actually happened. + a, loadErr := s.store.GetAutomation(ctx, automationID) + if loadErr != nil { + return FireResult{}, fmt.Errorf("load automation: %w", loadErr) + } + if a == nil { + return FireResult{Skipped: true, Reason: "automation no longer exists"}, nil + } + // The UI offers Run on a disabled automation. The orchestrator discards the + // event downstream, so without this the caller is told a run started that + // never could — and last_triggered_at moves for a run that never ran. + if !a.Enabled { + return FireResult{Skipped: true, Reason: "automation is disabled"}, nil + } + // Check dedup. if dedupKey != "" { exists, err := s.store.HasRunWithDedupKey(ctx, automationID, dedupKey) if err != nil { - return fmt.Errorf("check dedup: %w", err) + return FireResult{}, fmt.Errorf("check dedup: %w", err) } if exists { s.logger.Debug("skipping duplicate trigger", zap.String("automation_id", automationID), zap.String("dedup_key", dedupKey)) - return nil + return FireResult{Skipped: true, Reason: "this trigger has already fired"}, nil } } // Enforce max_concurrent_runs: a run is "active" while still in // task_created (succeeded/failed/skipped don't count). If at the cap, // record a skipped run so the user can see the cap kicked in. - skipped, capErr := s.maybeSkipForConcurrencyCap(ctx, automationID, triggerID, triggerType, triggerData, dedupKey) + capReason, capErr := s.maybeSkipForConcurrencyCap(ctx, a, triggerID, triggerType, triggerData, dedupKey) if capErr != nil { - return capErr + return FireResult{}, capErr } // Record that the trigger was evaluated now that the cap check itself @@ -474,8 +737,8 @@ func (s *Service) FireTrigger(ctx context.Context, automationID, triggerID strin s.logger.Warn("failed to update last_evaluated_at", zap.String("trigger_id", triggerID), zap.Error(updateErr)) } - if skipped { - return nil + if capReason != "" { + return FireResult{Skipped: true, Reason: capReason}, nil } evt := &AutomationTriggeredEvent{ @@ -493,14 +756,14 @@ func (s *Service) FireTrigger(ctx context.Context, automationID, triggerID strin event := bus.NewEvent(events.AutomationTriggered, "automation_service", evt) if err := s.eventBus.Publish(ctx, events.AutomationTriggered, event); err != nil { - return fmt.Errorf("publish automation triggered: %w", err) + return FireResult{}, fmt.Errorf("publish automation triggered: %w", err) } s.logger.Info("automation trigger fired", zap.String("automation_id", automationID), zap.String("trigger_id", triggerID), zap.String("type", string(triggerType))) - return nil + return FireResult{}, nil } // RecordRun records a trigger run outcome. @@ -508,24 +771,23 @@ func (s *Service) RecordRun(ctx context.Context, run *AutomationRun) error { return s.createRunLocked(ctx, run) } -// maybeSkipForConcurrencyCap enforces max_concurrent_runs. Returns (skipped, -// err). When skipped, a "skipped" run row is persisted so the user can see -// the cap kicked in. -func (s *Service) maybeSkipForConcurrencyCap(ctx context.Context, automationID, triggerID string, triggerType TriggerType, triggerData json.RawMessage, dedupKey string) (bool, error) { - a, err := s.store.GetAutomation(ctx, automationID) - if err != nil || a == nil { - return false, nil // not our problem here; FireTrigger will hit it again downstream - } +// maybeSkipForConcurrencyCap enforces max_concurrent_runs. Returns the +// human-readable skip reason, empty when the trigger may proceed. When +// skipped, a "skipped" run row is persisted so the user can see the cap +// kicked in. +func (s *Service) maybeSkipForConcurrencyCap(ctx context.Context, a *Automation, triggerID string, triggerType TriggerType, triggerData json.RawMessage, dedupKey string) (string, error) { if a.MaxConcurrentRuns <= 0 { - return false, nil + return "", nil } + automationID := a.ID active, err := s.store.CountActiveRuns(ctx, automationID) if err != nil { - return false, fmt.Errorf("count active runs: %w", err) + return "", fmt.Errorf("count active runs: %w", err) } if active < a.MaxConcurrentRuns { - return false, nil + return "", nil } + reason := fmt.Sprintf("max_concurrent_runs=%d reached", a.MaxConcurrentRuns) skipRun := &AutomationRun{ AutomationID: automationID, TriggerID: triggerID, @@ -533,7 +795,7 @@ func (s *Service) maybeSkipForConcurrencyCap(ctx context.Context, automationID, Status: RunStatusSkipped, DedupKey: dedupKey, TriggerData: triggerData, - ErrorMessage: fmt.Sprintf("max_concurrent_runs=%d reached", a.MaxConcurrentRuns), + ErrorMessage: reason, } if recErr := s.createRunLocked(ctx, skipRun); recErr != nil { s.logger.Warn("failed to record skipped run", zap.Error(recErr)) @@ -542,12 +804,12 @@ func (s *Service) maybeSkipForConcurrencyCap(ctx context.Context, automationID, zap.String("automation_id", automationID), zap.Int("active", active), zap.Int("max", a.MaxConcurrentRuns)) - return true, nil + return reason, nil } // MarkRunFailedByTaskID transitions a still-pending run (task_created) into // the failed state. Used when something downstream of task creation aborts -// the run, e.g. a permission prompt for a run-mode automation. +// the run, e.g. a permission prompt an automation run can't answer. func (s *Service) MarkRunFailedByTaskID(ctx context.Context, taskID, errMsg string) error { return s.store.MarkRunFailedByTaskID(ctx, taskID, errMsg) } @@ -558,6 +820,26 @@ func (s *Service) MarkRunSucceededByTaskID(ctx context.Context, taskID string) e return s.store.MarkRunSucceededByTaskID(ctx, taskID) } +// PrunableRunTaskIDs names the tasks whose workspaces the caller may reclaim +// now that finalizedTaskID's run has reached a terminal status. See the store +// method for what counts as prunable. +// +// Unauthorized on purpose, like RecordRun and MarkRun{Failed,Succeeded}ByTaskID +// beside it: the only caller is the orchestrator's own run finalization, which +// runs on a background goroutine with no user in its context. An authorized +// call there would fail for every automation rather than for none. +func (s *Service) PrunableRunTaskIDs(ctx context.Context, finalizedTaskID string, keep int) ([]string, error) { + return s.store.PrunableRunTaskIDs(ctx, finalizedTaskID, keep) +} + +// RunWorkspaceInUse reports whether an agent is holding the given task's +// workspace right now. Unauthorized for the same reason as PrunableRunTaskIDs: +// its only caller is the orchestrator's own sweep, re-checking the answer that +// PrunableRunTaskIDs gave it before it deletes anything. +func (s *Service) RunWorkspaceInUse(ctx context.Context, taskID string) (bool, error) { + return s.store.RunWorkspaceInUse(ctx, taskID) +} + // GetWebhookSecret returns the webhook secret for an automation. func (s *Service) GetWebhookSecret(ctx context.Context, id string) (string, error) { a, err := s.store.GetAutomation(ctx, id) diff --git a/apps/backend/internal/automation/service_test.go b/apps/backend/internal/automation/service_test.go index 6319a2aedc..0916b22fc8 100644 --- a/apps/backend/internal/automation/service_test.go +++ b/apps/backend/internal/automation/service_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "strings" "sync" "testing" "time" @@ -509,6 +510,15 @@ func TestWorkspaceAuthorizerGatesAccess(t *testing.T) { if _, err := svc.ListRuns(ctx, a.ID, 10); !errors.Is(err, denied) { t.Fatalf("ListRuns: %v", err) } + if _, err := svc.ListWorkspaceRuns(ctx, "ws-a", 10); !errors.Is(err, denied) { + t.Fatalf("ListWorkspaceRuns: %v", err) + } + if _, err := svc.ListAutomationSummaries(ctx, "ws-a"); !errors.Is(err, denied) { + t.Fatalf("ListAutomationSummaries: %v", err) + } + if _, err := svc.GetAutomationSummary(ctx, a.ID); !errors.Is(err, denied) { + t.Fatalf("GetAutomationSummary: %v", err) + } if _, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{Name: "x", WorkspaceID: "ws-a", WorkflowID: "wf", WorkflowStepID: "s"}); !errors.Is(err, denied) { t.Fatalf("CreateAutomation: %v", err) } @@ -518,3 +528,110 @@ func TestWorkspaceAuthorizerGatesAccess(t *testing.T) { t.Fatalf("allowed workspace list: %v", err) } } + +// Workflow and workflow step are optional for every automation: no automation +// run is placed on a board, so no automation needs a starting column. Creation +// used to reject this outright for the (now withdrawn) task execution mode. +func TestCreateAutomation_SucceedsWithoutWorkflowOrStep(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + a, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", + Name: "nightly report", + Prompt: "summarise yesterday", + AgentProfileID: "agent-1", + ExecutorProfileID: "exec-1", + }) + if err != nil { + t.Fatalf("CreateAutomation without workflow: %v", err) + } + if a.WorkflowID != "" || a.WorkflowStepID != "" { + t.Fatalf("expected no workflow placement, got %q/%q", a.WorkflowID, a.WorkflowStepID) + } + if !a.Enabled || a.MaxConcurrentRuns != 1 { + t.Fatalf("expected an enabled automation with the default concurrency, got %+v", a) + } + + // It must round-trip through the store too — the read path no longer + // projects the withdrawn execution_mode column. + got, err := svc.GetAutomation(ctx, a.ID) + if err != nil { + t.Fatalf("GetAutomation: %v", err) + } + if got == nil || got.Name != "nightly report" { + t.Fatalf("expected the stored automation back, got %+v", got) + } +} + +// execution_mode is accepted and ignored on input — an old client that still +// sends it must not break, and the value must not come back out. +func TestCreateAutomation_IgnoresExecutionModeOnTheWire(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + var req CreateAutomationRequest + raw := `{"workspace_id":"ws-1","name":"legacy client","execution_mode":"run"}` + if err := json.Unmarshal([]byte(raw), &req); err != nil { + t.Fatalf("decode legacy payload: %v", err) + } + a, err := svc.CreateAutomation(ctx, &req) + if err != nil { + t.Fatalf("CreateAutomation with legacy execution_mode: %v", err) + } + + encoded, err := json.Marshal(a) + if err != nil { + t.Fatalf("marshal automation: %v", err) + } + if strings.Contains(string(encoded), "execution_mode") { + t.Fatalf("execution_mode must be omitted from responses, got %s", string(encoded)) + } +} + +// GetAutomation reports a missing row as (nil, nil). A stale id — a bookmarked +// page for a deleted automation, a client retrying after a delete — used to +// dereference that nil and panic the backend on an ordinary not-found. +func TestAuthorizeAutomation_MissingAutomationIsNotFoundNotAPanic(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + svc.SetWorkspaceAuthorizer(func(context.Context, string) error { return nil }) + + for name, call := range map[string]func() error{ + "ListRuns": func() error { _, err := svc.ListRuns(ctx, "gone", 10); return err }, + "GetAutomationSummary": func() error { _, err := svc.GetAutomationSummary(ctx, "gone"); return err }, + "DeleteAllRuns": func() error { return svc.DeleteAllRuns(ctx, "gone") }, + } { + t.Run(name, func(t *testing.T) { + err := call() + if !errors.Is(err, ErrAutomationNotFound) { + t.Fatalf("expected ErrAutomationNotFound, got %v", err) + } + }) + } +} + +// The destructive run operations authorize themselves rather than relying on +// the WS handler to remember: a new caller must not be able to skip the check. +func TestDeleteAllRuns_RefusesAForeignWorkspace(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + a, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + Name: "sweep", WorkspaceID: "ws-owned", WorkflowID: "wf", WorkflowStepID: "s", + }) + if err != nil { + t.Fatal(err) + } + denied := errors.New("denied") + svc.SetWorkspaceAuthorizer(func(_ context.Context, workspaceID string) error { + if workspaceID == "ws-owned" { + return denied + } + return nil + }) + + if err := svc.DeleteAllRuns(ctx, a.ID); !errors.Is(err, denied) { + t.Fatalf("expected the workspace check to refuse, got %v", err) + } +} diff --git a/apps/backend/internal/automation/service_validation_test.go b/apps/backend/internal/automation/service_validation_test.go new file mode 100644 index 0000000000..998ba5ad07 --- /dev/null +++ b/apps/backend/internal/automation/service_validation_test.go @@ -0,0 +1,342 @@ +package automation + +import ( + "context" + "encoding/json" + "errors" + "testing" +) + +func TestAddTrigger_RejectsCronTheSchedulerCannotRun(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "x", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + + for _, expr := range []string{"60 * * * *", "*/0 * * * *", "10-5 * * * *", "not a cron"} { + cfg := json.RawMessage(`{"cron_expression":"` + expr + `"}`) + if _, err := svc.AddTrigger(ctx, &AddTriggerRequest{ + AutomationID: a.ID, Type: TriggerTypeScheduled, Config: cfg, Enabled: true, + }); err == nil { + t.Errorf("expected %q to be rejected", expr) + } + } +} + +// The backend parser accepts named fields the editor's regex refuses, so +// validation must not be stricter than the scheduler either. +func TestAddTrigger_AcceptsWhatTheSchedulerAccepts(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "x", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + + for _, expr := range []string{"0 9 * * 1-5", "@daily", "@every 2h30m", "0 9,17 * * *", ""} { + cfg := json.RawMessage(`{"cron_expression":"` + expr + `"}`) + if _, err := svc.AddTrigger(ctx, &AddTriggerRequest{ + AutomationID: a.ID, Type: TriggerTypeScheduled, Config: cfg, Enabled: true, + }); err != nil { + t.Errorf("expected %q to be accepted, got %v", expr, err) + } + } +} + +type stubWorkflowLocator struct{ workspaceID string } + +func (s stubWorkflowLocator) WorkflowWorkspaceID(context.Context, string) (string, error) { + return s.workspaceID, nil +} + +// The editor filtering foreign workflows out of a dropdown is not a boundary — +// a request naming one directly has to be refused server-side. +func TestCreateAutomation_RejectsAForeignWorkflow(t *testing.T) { + svc := newTestService(t) + svc.SetWorkflowLocator(stubWorkflowLocator{workspaceID: "ws-other"}) + ctx := context.Background() + + _, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "cross", WorkflowID: "wf-foreign", WorkflowStepID: "s-1", + }) + if err == nil { + t.Fatal("expected a workflow from another workspace to be rejected") + } +} + +func TestCreateAutomation_AcceptsItsOwnWorkflow(t *testing.T) { + svc := newTestService(t) + svc.SetWorkflowLocator(stubWorkflowLocator{workspaceID: "ws-1"}) + ctx := context.Background() + + if _, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "local", WorkflowID: "wf-local", WorkflowStepID: "s-1", + }); err != nil { + t.Fatalf("expected a same-workspace workflow to be accepted, got %v", err) + } +} + +// The update path has the same boundary as create and is the one a long-lived +// automation actually travels: it is edited far more often than it is created. +// A cron the scheduler cannot parse was accepted at creation and only rejected +// on the first edit. In between, the automation sat there looking configured +// and never fired — the worst shape for this failure, because nothing on screen +// said anything was wrong. +func TestCreateAutomation_RejectsAnUnparseableSchedule(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + _, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "bad cron", + Triggers: []CreateTriggerSpec{{ + Type: TriggerTypeScheduled, + Config: json.RawMessage(`{"cron_expression":"not-a-cron"}`), + Enabled: true, + }}, + }) + if err == nil { + t.Fatal("expected an unparseable cron to be rejected at creation, as it is on edit") + } +} + +func TestCreateAutomation_AcceptsAScheduleTheSchedulerParses(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + if _, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "good cron", + Triggers: []CreateTriggerSpec{{ + Type: TriggerTypeScheduled, + Config: json.RawMessage(`{"cron_expression":"0 9 * * MON-FRI"}`), + Enabled: true, + }}, + }); err != nil { + t.Fatalf("expected a named-weekday cron to be accepted, got %v", err) + } +} + +func TestUpdateAutomation_RejectsAForeignWorkflow(t *testing.T) { + svc := newTestService(t) + svc.SetWorkflowLocator(stubWorkflowLocator{workspaceID: "ws-1"}) + ctx := context.Background() + + created, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "local", WorkflowID: "wf-local", WorkflowStepID: "s-1", + }) + if err != nil { + t.Fatalf("seed automation: %v", err) + } + + // Now the locator reports every workflow as belonging elsewhere, which is + // what a request naming another workspace's workflow looks like. + svc.SetWorkflowLocator(stubWorkflowLocator{workspaceID: "ws-other"}) + foreign := "wf-foreign" + if _, err := svc.UpdateAutomation(ctx, created.ID, &UpdateAutomationRequest{ + WorkflowID: &foreign, + }); err == nil { + t.Fatal("expected a workflow from another workspace to be rejected on update") + } +} + +func TestUpdateAutomation_AcceptsItsOwnWorkflow(t *testing.T) { + svc := newTestService(t) + svc.SetWorkflowLocator(stubWorkflowLocator{workspaceID: "ws-1"}) + ctx := context.Background() + + created, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "local", WorkflowID: "wf-local", WorkflowStepID: "s-1", + }) + if err != nil { + t.Fatalf("seed automation: %v", err) + } + + own := "wf-other-local" + if _, err := svc.UpdateAutomation(ctx, created.ID, &UpdateAutomationRequest{ + WorkflowID: &own, + }); err != nil { + t.Fatalf("expected a same-workspace workflow to be accepted on update, got %v", err) + } +} + +// fakeAgentProfileLookup implements AgentProfileLookup: live names the profile +// IDs that resolve, err short-circuits every call to simulate a driver +// failure, and asked records what was actually looked up so the empty-ID +// short-circuit can be asserted rather than inferred from a nil error. +type fakeAgentProfileLookup struct { + live map[string]bool + err error + asked []string +} + +func (f *fakeAgentProfileLookup) AgentProfileExists(_ context.Context, profileID string) (bool, error) { + f.asked = append(f.asked, profileID) + if f.err != nil { + return false, f.err + } + return f.live[profileID], nil +} + +// Disabling an automation before its profile row is deleted only ever covered +// bindings that were valid to start with. A create naming a profile ID that +// never existed reaches the same end state — an enabled automation bound to a +// profile that is not there — with no deletion involved at all. +func TestCreateAutomation_RejectsAProfileThatDoesNotExist(t *testing.T) { + svc := newTestService(t) + svc.SetAgentProfileLookup(&fakeAgentProfileLookup{live: map[string]bool{"agent-live": true}}) + ctx := context.Background() + + _, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "orphan", AgentProfileID: "agent-ghost", + }) + if !errors.Is(err, ErrAgentProfileNotFound) { + t.Fatalf("expected ErrAgentProfileNotFound, got %v", err) + } +} + +func TestCreateAutomation_AcceptsAProfileThatExists(t *testing.T) { + svc := newTestService(t) + svc.SetAgentProfileLookup(&fakeAgentProfileLookup{live: map[string]bool{"agent-live": true}}) + ctx := context.Background() + + a, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "fine", AgentProfileID: "agent-live", + }) + if err != nil { + t.Fatalf("expected a live profile to be accepted, got %v", err) + } + if a.AgentProfileID != "agent-live" { + t.Fatalf("expected the profile binding to be stored, got %q", a.AgentProfileID) + } +} + +// Rebinding is the path a long-lived automation actually travels — it is +// edited far more often than it is created — and the profile a stale editor +// tab still offers may have been deleted since that tab was opened. +func TestUpdateAutomation_RejectsAProfileThatDoesNotExist(t *testing.T) { + svc := newTestService(t) + svc.SetAgentProfileLookup(&fakeAgentProfileLookup{live: map[string]bool{"agent-live": true}}) + ctx := context.Background() + + created, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "seed", AgentProfileID: "agent-live", + }) + if err != nil { + t.Fatalf("seed automation: %v", err) + } + + ghost := "agent-ghost" + if _, err := svc.UpdateAutomation(ctx, created.ID, &UpdateAutomationRequest{ + AgentProfileID: &ghost, + }); !errors.Is(err, ErrAgentProfileNotFound) { + t.Fatalf("expected ErrAgentProfileNotFound on update, got %v", err) + } + + // The rejected rebind must not have landed: a create-side guard that lets + // the update through would leave exactly the orphan it was added to stop. + got, err := svc.GetAutomation(ctx, created.ID) + if err != nil { + t.Fatalf("reload automation: %v", err) + } + if got.AgentProfileID != "agent-live" { + t.Fatalf("expected the original binding to survive a rejected rebind, got %q", got.AgentProfileID) + } +} + +func TestUpdateAutomation_AcceptsAProfileThatExists(t *testing.T) { + svc := newTestService(t) + svc.SetAgentProfileLookup(&fakeAgentProfileLookup{live: map[string]bool{ + "agent-live": true, "agent-other": true, + }}) + ctx := context.Background() + + created, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "seed", AgentProfileID: "agent-live", + }) + if err != nil { + t.Fatalf("seed automation: %v", err) + } + + other := "agent-other" + updated, err := svc.UpdateAutomation(ctx, created.ID, &UpdateAutomationRequest{ + AgentProfileID: &other, + }) + if err != nil { + t.Fatalf("expected a live profile to be accepted on update, got %v", err) + } + if updated.AgentProfileID != "agent-other" { + t.Fatalf("expected the rebind to land, got %q", updated.AgentProfileID) + } +} + +// An unset agent_profile_id is a separate defect from a dangling one, and this +// is not the change that fixes it. Nothing on the firing path substitutes a +// workspace default — the launch dies at executor.PrepareSession with +// ErrNoAgentProfileID — but the editor's save button still permits it, so +// rejecting it here would refuse data the UI currently produces. It +// short-circuits before the lookup rather than being answered by it, so the +// choice cannot be quietly reversed by a lookup that happens to say "yes" to +// the empty string. +func TestAgentProfileValidation_LeavesAnUnsetProfileAlone(t *testing.T) { + lookup := &fakeAgentProfileLookup{live: map[string]bool{"agent-live": true}} + svc := newTestService(t) + svc.SetAgentProfileLookup(lookup) + ctx := context.Background() + + created, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "no profile", + }) + if err != nil { + t.Fatalf("expected an unset agent_profile_id to be accepted on create, got %v", err) + } + + cleared := "" + if _, err := svc.UpdateAutomation(ctx, created.ID, &UpdateAutomationRequest{ + AgentProfileID: &cleared, + }); err != nil { + t.Fatalf("expected clearing agent_profile_id to be accepted on update, got %v", err) + } + + if len(lookup.asked) != 0 { + t.Fatalf("expected the empty profile ID never to reach the lookup, it asked for %v", lookup.asked) + } +} + +// Without a lookup wired the check is skipped, not failed closed — unlike +// RepositoryLookup, which guards cross-workspace access and so must refuse +// when unconfigured. This one applies to nearly every automation, so failing +// closed would turn a single missing wire into "nothing can be saved". +func TestAgentProfileValidation_SkippedWithoutALookup(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + + if _, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "unwired", AgentProfileID: "agent-ghost", + }); err != nil { + t.Fatalf("expected creation to succeed with no lookup wired, got %v", err) + } +} + +// A driver failure is not a missing profile. Collapsing the two would let one +// flaky read reject a binding that is perfectly good — and report to the user +// that their profile does not exist, which would send them looking in the +// wrong place entirely. +func TestAgentProfileValidation_SurfacesALookupFailureAsItself(t *testing.T) { + boom := errors.New("database is locked") + svc := newTestService(t) + svc.SetAgentProfileLookup(&fakeAgentProfileLookup{err: boom}) + ctx := context.Background() + + _, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "flaky", AgentProfileID: "agent-live", + }) + if !errors.Is(err, boom) { + t.Fatalf("expected the lookup failure to be surfaced, got %v", err) + } + if errors.Is(err, ErrAgentProfileNotFound) { + t.Fatal("a lookup failure must not be reported as a missing profile") + } +} diff --git a/apps/backend/internal/automation/store.go b/apps/backend/internal/automation/store.go index d0d81d9af7..f43fc360a1 100644 --- a/apps/backend/internal/automation/store.go +++ b/apps/backend/internal/automation/store.go @@ -8,6 +8,7 @@ import ( "encoding/json" "errors" "fmt" + "strings" "time" "github.com/google/uuid" @@ -45,6 +46,12 @@ const createTablesSQL = ` repository_id TEXT NOT NULL DEFAULT '', prompt TEXT DEFAULT '', task_title_template TEXT DEFAULT '', + -- execution_mode is retained so existing rows need no migration. The + -- task/run choice is withdrawn: no firing path consults it. The one + -- surviving reader is the migration notice, which derives a single + -- boolean from it (see automationColumns). New rows are written with + -- an explicit '' — the 'task' DEFAULT below only ever describes rows + -- that predate the withdrawal. execution_mode TEXT NOT NULL DEFAULT 'task', enabled BOOLEAN DEFAULT 1, max_concurrent_runs INTEGER DEFAULT 1, @@ -82,6 +89,14 @@ const createTablesSQL = ` CREATE INDEX IF NOT EXISTS idx_automation_runs_automation ON automation_runs(automation_id); CREATE INDEX IF NOT EXISTS idx_automation_runs_dedup ON automation_runs(automation_id, dedup_key); + CREATE INDEX IF NOT EXISTS idx_automation_runs_created_at ON automation_runs(created_at DESC); + -- The summary query asks each automation for its newest run, ordered by + -- (created_at, id) — the ordering every run query uses. Without the + -- composite the per-automation lookup sorts that automation's whole run + -- history, once per candidate row, which is quadratic in a workspace's run + -- count. /automations runs this on load and every poll while anything is + -- running, so the sort shows up as seconds of latency, not milliseconds. + CREATE INDEX IF NOT EXISTS idx_automation_runs_automation_created ON automation_runs(automation_id, created_at DESC, id DESC); CREATE TABLE IF NOT EXISTS automation_repositories ( id TEXT PRIMARY KEY, @@ -108,18 +123,35 @@ const createTablesSQL = ` // this package doesn't have yet. Every query that scans a full Automation // row uses the explicit automationColumns list, which omits it, so its // continued presence in the table is inert. +// +// migrateExecutionModeSQL still runs because the notice derivation in +// automationColumns needs the column to exist on every DB it queries, +// including one initialised before the column was ever added. const ( migrateTaskTitleSQL = `ALTER TABLE automations ADD COLUMN task_title_template TEXT DEFAULT ''` migrateExecutionModeSQL = `ALTER TABLE automations ADD COLUMN execution_mode TEXT NOT NULL DEFAULT 'task'` migrateRepositoryIDSQL = `ALTER TABLE automations ADD COLUMN repository_id TEXT NOT NULL DEFAULT ''` ) -// automationColumns is the explicit column list for every query that scans -// a full Automation row. Excludes the legacy repository_id column — see -// the comment above migrateRepositoryIDSQL. +// automationColumns is the explicit column list for every query that scans a +// full Automation row. Spelled out rather than `SELECT *` because the table +// carries columns the Automation struct does not: the legacy repository_id +// (superseded by automation_repositories — see the comment above +// migrateRepositoryIDSQL) and the withdrawn execution_mode, neither of which +// sqlx could map. sqlx runs in safe mode, so a returned column with no struct +// destination is a runtime error, not a compile-time one. +// +// execution_mode is read here, and only here, as the comparison +// `execution_mode = 'task'` aliased to legacy_board_card. The raw value is +// deliberately never selected: what the reader needs is the one bit "did this +// automation used to put a card on the board", for a migration notice that +// closes once. Projecting the mode itself would hand every future caller a +// mode to branch on, and the whole point of withdrawing it is that no firing +// path has one. See docs/specs/office/automations-settings.md § Migration. const automationColumns = `id, workspace_id, name, description, workflow_id, workflow_step_id, - agent_profile_id, executor_profile_id, prompt, task_title_template, execution_mode, - enabled, max_concurrent_runs, webhook_secret, last_triggered_at, created_at, updated_at` + agent_profile_id, executor_profile_id, prompt, task_title_template, + enabled, max_concurrent_runs, webhook_secret, last_triggered_at, created_at, updated_at, + execution_mode = 'task' AS legacy_board_card` func (s *Store) initSchema() error { if _, err := s.db.Exec(createTablesSQL); err != nil { @@ -190,25 +222,30 @@ func (s *Store) CreateAutomation(ctx context.Context, a *Automation) error { now := time.Now().UTC() a.CreatedAt = now a.UpdatedAt = now - if a.ExecutionMode == "" { - a.ExecutionMode = ExecutionModeTask - } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return err } defer func() { _ = tx.Rollback() }() + // execution_mode is written as the empty string rather than left to the + // column's DEFAULT. Nothing reads the mode to decide anything — but the + // DEFAULT is 'task', which is exactly the value the migration notice + // treats as "this automation used to put a card on the board". Letting + // the DEFAULT fill it would make every automation created from here on + // indistinguishable from a pre-upgrade one, and the one-time notice would + // never stop being true. Empty means "no mode was ever chosen", which is + // the honest record for a row created after the choice was withdrawn. _, err = tx.ExecContext(ctx, ` INSERT INTO automations (id, workspace_id, name, description, workflow_id, workflow_step_id, agent_profile_id, executor_profile_id, prompt, task_title_template, execution_mode, enabled, max_concurrent_runs, webhook_secret, last_triggered_at, created_at, updated_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '', ?, ?, ?, ?, ?, ?)`, a.ID, a.WorkspaceID, a.Name, a.Description, a.WorkflowID, a.WorkflowStepID, a.AgentProfileID, a.ExecutorProfileID, - a.Prompt, a.TaskTitleTemplate, string(a.ExecutionMode), + a.Prompt, a.TaskTitleTemplate, a.Enabled, a.MaxConcurrentRuns, a.WebhookSecret, a.LastTriggeredAt, a.CreatedAt, a.UpdatedAt) if err != nil { @@ -292,6 +329,63 @@ func (s *Store) listRepositoryIDsForAutomations(ctx context.Context, automationI return result, nil } +// AgentProfileBinding names one automation bound to an agent profile. +type AgentProfileBinding struct { + ID string `db:"id"` + Name string `db:"name"` + WorkspaceID string `db:"workspace_id"` +} + +// ListEnabledByAgentProfile returns the enabled automations that would launch +// against the given agent profile. +// +// Only enabled ones: a disabled automation is not going to fire, so naming it +// as a reason you cannot delete a profile is noise. Triggers and repositories +// are deliberately not hydrated — the caller wants identity, not the object. +func (s *Store) ListEnabledByAgentProfile(ctx context.Context, agentProfileID string) ([]AgentProfileBinding, error) { + if agentProfileID == "" { + return nil, nil + } + var rows []AgentProfileBinding + err := s.ro.SelectContext(ctx, &rows, s.ro.Rebind(` + SELECT id, name, workspace_id FROM automations + WHERE agent_profile_id = ? AND enabled = 1 + ORDER BY name + `), agentProfileID) + if err != nil { + return nil, err + } + return rows, nil +} + +// DisableByAgentProfile turns off every enabled automation bound to the given +// agent profile, returning the ones it disabled so the caller can report. +// +// Used when a profile is force-deleted: an automation left enabled would keep +// firing on its schedule into a profile that no longer exists. Disabling rather +// than deleting keeps the automation recoverable — the user picks a new profile +// and toggles it back on. +func (s *Store) DisableByAgentProfile(ctx context.Context, agentProfileID string) ([]AgentProfileBinding, error) { + if agentProfileID == "" { + return nil, nil + } + bindings, err := s.ListEnabledByAgentProfile(ctx, agentProfileID) + if err != nil { + return nil, err + } + if len(bindings) == 0 { + return nil, nil + } + _, err = s.db.ExecContext(ctx, s.db.Rebind(` + UPDATE automations SET enabled = 0, updated_at = ? + WHERE agent_profile_id = ? AND enabled = 1 + `), time.Now().UTC(), agentProfileID) + if err != nil { + return nil, fmt.Errorf("disable automations for agent profile: %w", err) + } + return bindings, nil +} + // ListAutomations returns all automations for a workspace with triggers and // repository_ids hydrated. func (s *Store) ListAutomations(ctx context.Context, workspaceID string) ([]*Automation, error) { @@ -365,12 +459,12 @@ func (s *Store) UpdateAutomation(ctx context.Context, id string, req *UpdateAuto UPDATE automations SET name = ?, description = ?, workflow_id = ?, workflow_step_id = ?, agent_profile_id = ?, executor_profile_id = ?, prompt = ?, task_title_template = ?, - execution_mode = ?, enabled = ?, max_concurrent_runs = ?, updated_at = ? + enabled = ?, max_concurrent_runs = ?, updated_at = ? WHERE id = ?`, a.Name, a.Description, a.WorkflowID, a.WorkflowStepID, a.AgentProfileID, a.ExecutorProfileID, a.Prompt, a.TaskTitleTemplate, - string(a.ExecutionMode), a.Enabled, a.MaxConcurrentRuns, a.UpdatedAt, id) + a.Enabled, a.MaxConcurrentRuns, a.UpdatedAt, id) if err != nil { return err } @@ -416,12 +510,6 @@ func applyAutomationUpdate(a *Automation, req *UpdateAutomationRequest) { if req.TaskTitleTemplate != nil { a.TaskTitleTemplate = *req.TaskTitleTemplate } - if req.ExecutionMode != nil { - a.ExecutionMode = *req.ExecutionMode - } - if !a.ExecutionMode.Valid() { - a.ExecutionMode = ExecutionModeTask - } } // DeleteAutomation removes an automation and its triggers/runs (CASCADE). @@ -465,6 +553,20 @@ func (s *Store) GetTriggerAutomationID(ctx context.Context, triggerID string) (s return automationID, err } +// GetTrigger returns a single trigger, or nil when it no longer exists. +func (s *Store) GetTrigger(ctx context.Context, id string) (*AutomationTrigger, error) { + var triggers []AutomationTrigger + if err := s.ro.SelectContext(ctx, &triggers, + `SELECT * FROM automation_triggers WHERE id = ?`, id); err != nil { + return nil, err + } + if len(triggers) == 0 { + return nil, nil + } + hydrateTriggers(triggers) + return &triggers[0], nil +} + // ListTriggers returns all triggers for an automation. func (s *Store) ListTriggers(ctx context.Context, automationID string) ([]AutomationTrigger, error) { var triggers []AutomationTrigger @@ -572,7 +674,7 @@ func (s *Store) CreateRun(ctx context.Context, r *AutomationRun) error { // MarkRunFailedByTaskID flips the most recent task_created run for a task // into the failed state. Used when a downstream condition (e.g. a permission -// prompt that a run-mode automation can't answer) makes the run effectively +// prompt an automation run can't answer) makes the run effectively // dead. No-op if no matching run is found. func (s *Store) MarkRunFailedByTaskID(ctx context.Context, taskID, errMsg string) error { return s.updateRunTerminalStatus(ctx, taskID, RunStatusFailed, errMsg) @@ -626,67 +728,280 @@ func (s *Store) ListRuns(ctx context.Context, automationID string, limit int) ([ return runs, nil } -func (s *Store) listRunsWithTaskState(ctx context.Context, automationID string, limit int) ([]*AutomationRun, error) { - // Assumes a task_created run always carries a non-empty ar.task_id: the - // sole production write path (orchestrator's recordSuccessRun) sets - // TaskID and Status together in the same INSERT. If that's ever - // violated, the LEFT JOIN never matches an empty task_id against a - // real task row, so the run falls into the "no live task" branch below - // and displays as cancelled rather than its raw stored status — - // reachable today only through the e2e run-seeding endpoint, never in - // production. - // - // Three read-time overrides of a still-open task_created run, in - // priority order: (1) the task row is gone entirely — deleted, outcome - // unrecoverable — shown as cancelled; (2) the task was archived - // (archived_at set, via the UI or by the agent itself, e.g. an - // "archive this task" instruction) — shown as archived, which takes - // precedence over the session check below; (3) the task's current - // (is_primary) session is CANCELLED — set only when an agent run was - // manually stopped (coordinator/MCP stop_task, or the UI Stop button) - // — a deliberate cancellation, shown as cancelled. Task.state is - // deliberately NOT consulted here: stopping an agent leaves the task - // itself at whatever state the stop caller chose (e.g. REVIEW) and - // only ever marks the *session* CANCELLED — see - // orchestrator.handleAgentStopped's "we do NOT update task state - // here" note. Filtering on is_primary picks the task's current - // session, so a resumed-and-completed task isn't misclassified by a - // stale cancelled session left over from an earlier stop. EXISTS - // (rather than a LEFT JOIN) keeps the query correct even if the - // "at most one is_primary=1 row per task" invariant is ever violated - // — a join would fan out and duplicate the run. - var runs []*AutomationRun - err := s.ro.SelectContext(ctx, &runs, ` - SELECT ar.id, ar.automation_id, ar.trigger_id, ar.trigger_type, ar.task_id, - CASE - WHEN ar.status = ? AND t.id IS NULL THEN ? - WHEN ar.status = ? AND t.archived_at IS NOT NULL THEN ? - WHEN ar.status = ? AND EXISTS ( - SELECT 1 FROM task_sessions ts - WHERE ts.task_id = ar.task_id AND ts.is_primary = 1 AND ts.state = ? - ) THEN ? - ELSE ar.status - END AS status, - ar.dedup_key, ar.trigger_data, ar.error_message, ar.created_at - FROM automation_runs ar - LEFT JOIN tasks t ON t.id = ar.task_id - WHERE ar.automation_id = ? - ORDER BY ar.created_at DESC LIMIT ?`, +// runTaskStateColumnsSQL is the read-time projection of a run, shared +// verbatim by the per-automation and workspace-wide list queries. It is a +// const rather than duplicated SQL because the status derivation below is +// the app's definition of what a run's status *means* to a reader: two +// copies would drift, and the same run would then report one status on the +// automation's settings page and another in the workspace feed. Assumes +// the caller aliases automation_runs as ar and LEFT JOINs tasks as t, and +// binds runTaskStateArgs() ahead of its own WHERE/LIMIT parameters. +// +// Assumes a task_created run always carries a non-empty ar.task_id: the +// sole production write path (orchestrator's recordSuccessRun) sets TaskID +// and Status together in the same INSERT. If that's ever violated, the +// LEFT JOIN never matches an empty task_id against a real task row, so the +// run falls into the "no live task" branch below and displays as cancelled +// rather than its raw stored status — reachable today only through the e2e +// run-seeding endpoint, never in production. +// +// Three read-time overrides of a still-open task_created run, in priority +// order: (1) the task row is gone entirely — deleted, outcome +// unrecoverable — shown as cancelled; (2) the task was archived +// (archived_at set, via the UI or by the agent itself, e.g. an "archive +// this task" instruction) — shown as archived, which takes precedence over +// the session check below; (3) the task's current (is_primary) session is +// CANCELLED — set only when an agent run was manually stopped +// (coordinator/MCP stop_task, or the UI Stop button) — a deliberate +// cancellation, shown as cancelled. Task.state is deliberately NOT +// consulted here: stopping an agent leaves the task itself at whatever +// state the stop caller chose (e.g. REVIEW) and only ever marks the +// *session* CANCELLED — see orchestrator.handleAgentStopped's "we do NOT +// update task state here" note. Filtering on is_primary picks the task's +// current session, so a resumed-and-completed task isn't misclassified by +// a stale cancelled session left over from an earlier stop. EXISTS (rather +// than a LEFT JOIN) keeps the query correct even if the "at most one +// is_primary=1 row per task" invariant is ever violated — a join would fan +// out and duplicate the run. +const runTaskStateColumnsSQL = ` + ar.id, ar.automation_id, ar.trigger_id, ar.trigger_type, + -- A run keeps its task_id after the task row is deleted, which reads as a + -- transcript that can still be opened. Report no task rather than a link + -- that dead-ends; the derived cancelled status below already says why. + CASE WHEN t.id IS NULL THEN '' ELSE ar.task_id END AS task_id, + CASE + WHEN ar.status = ? AND t.id IS NULL THEN ? + WHEN ar.status = ? AND t.archived_at IS NOT NULL THEN ? + WHEN ar.status = ? AND EXISTS ( + SELECT 1 FROM task_sessions ts + WHERE ts.task_id = ar.task_id AND ts.is_primary = 1 AND ts.state = ? + ) THEN ? + ELSE ar.status + END AS status, + ar.dedup_key, ar.trigger_data, ar.error_message, ar.created_at, + COALESCE(( + SELECT substr(m.content, 1, 280) FROM task_session_messages m + WHERE m.task_id = ar.task_id + AND m.author_type = 'agent' + AND m.type = 'message' + ORDER BY m.created_at DESC, m.id DESC LIMIT 1 + ), '') AS summary, + -- The run's conversation. The detail view reads the transcript in place + -- rather than sending the reader to the task page, and the chat panel is + -- driven by a session id, not a task id — resolving it here keeps that + -- one query instead of one per run on the client. + COALESCE(( + SELECT ts.id FROM task_sessions ts + WHERE ts.task_id = ar.task_id AND ts.is_primary = 1 + LIMIT 1 + ), '') AS session_id` + +// runTaskStateArgs binds the placeholders in runTaskStateColumnsSQL, in +// order. Kept next to the SQL so a new WHEN can't be added without the +// matching argument being obvious. +func runTaskStateArgs() []any { + return []any{ string(RunStatusTaskCreated), string(RunStatusCancelled), string(RunStatusTaskCreated), string(RunStatusArchived), string(RunStatusTaskCreated), string(taskmodels.TaskSessionStateCancelled), string(RunStatusCancelled), - automationID, limit) + } +} + +func (s *Store) listRunsWithTaskState(ctx context.Context, automationID string, limit int) ([]*AutomationRun, error) { + var runs []*AutomationRun + err := s.ro.SelectContext(ctx, &runs, ` + SELECT`+runTaskStateColumnsSQL+` + FROM automation_runs ar + LEFT JOIN tasks t ON t.id = ar.task_id + WHERE ar.automation_id = ? + ORDER BY `+runOrderSQL("ar")+` LIMIT ?`, + append(runTaskStateArgs(), automationID, limit)...) return runs, err } func (s *Store) listRunsRaw(ctx context.Context, automationID string, limit int) ([]*AutomationRun, error) { var runs []*AutomationRun err := s.ro.SelectContext(ctx, &runs, - `SELECT * FROM automation_runs WHERE automation_id = ? ORDER BY created_at DESC LIMIT ?`, + `SELECT * FROM automation_runs WHERE automation_id = ? ORDER BY created_at DESC, id DESC LIMIT ?`, automationID, limit) return runs, err } +// maxWorkspaceRunsLimit caps the workspace-wide feed. Unlike ListRuns, which +// is scoped to one automation the user is already looking at, this query +// spans every automation in the workspace — an uncapped limit would let a +// single client pull the entire run history over the socket. +const maxWorkspaceRunsLimit = 200 + +// ListWorkspaceRuns returns recent runs across every automation in a +// workspace, newest first, each attributed to its automation. Status +// derivation and summary are identical to ListRuns — same +// runTaskStateColumnsSQL — so a run reads the same way in the workspace +// feed as it does on its own automation's page. Falls back to raw stored +// status when the tasks table isn't present, for the same reason ListRuns +// does (isolated automation-only tests; production always has it). +func (s *Store) ListWorkspaceRuns(ctx context.Context, workspaceID string, limit int) ([]*WorkspaceAutomationRun, error) { + if limit <= 0 { + limit = 50 + } + if limit > maxWorkspaceRunsLimit { + limit = maxWorkspaceRunsLimit + } + runs, err := s.listWorkspaceRunsWithTaskState(ctx, workspaceID, limit) + if db.IsMissingTableError(err) { + runs, err = s.listWorkspaceRunsRaw(ctx, workspaceID, limit) + } + if err != nil { + return nil, err + } + for _, r := range runs { + r.TriggerData = json.RawMessage(r.TriggerDataJSON) + } + return runs, nil +} + +func (s *Store) listWorkspaceRunsWithTaskState(ctx context.Context, workspaceID string, limit int) ([]*WorkspaceAutomationRun, error) { + // The automations join is an INNER join, not a LEFT one: a run whose + // automation is gone can't be attributed to anything the reader can + // open, and the ON DELETE CASCADE means it shouldn't exist anyway. + var runs []*WorkspaceAutomationRun + err := s.ro.SelectContext(ctx, &runs, ` + SELECT`+runTaskStateColumnsSQL+`, + a.name AS automation_name + FROM automation_runs ar + JOIN automations a ON a.id = ar.automation_id + LEFT JOIN tasks t ON t.id = ar.task_id + WHERE a.workspace_id = ? + ORDER BY `+runOrderSQL("ar")+` LIMIT ?`, + append(runTaskStateArgs(), workspaceID, limit)...) + return runs, err +} + +// ListAutomationSummaries returns one row per automation in a workspace that +// has ever run: its newest run and how many of its runs are still open. +// +// The runs list used to derive both by scanning the workspace feed, which is +// capped. Past the cap an automation's newest run falls out of the window and +// its row reports "No runs yet" and idle — the two things a health indicator +// must never get wrong — and the open count backing "won't fire: still +// running" silently drops to zero. Answering per automation makes the row's +// claims independent of how noisy its neighbours are. +func (s *Store) ListAutomationSummaries(ctx context.Context, workspaceID string) ([]*AutomationSummary, error) { + return s.listSummaries(ctx, "a.workspace_id = ?", workspaceID) +} + +// GetAutomationSummary returns one automation's summary, or nil if it has never +// run. The detail page needs the same authoritative open count the list uses: +// its own run window is capped too, so an open run older than the window would +// otherwise leave the page reporting that nothing is in flight. +func (s *Store) GetAutomationSummary(ctx context.Context, automationID string) (*AutomationSummary, error) { + summaries, err := s.listSummaries(ctx, "ar.automation_id = ?", automationID) + if err != nil || len(summaries) == 0 { + return nil, err + } + return summaries[0], nil +} + +// listSummaries answers both facts in ONE statement. +// +// Two queries would be two snapshots: a run created between them reads as +// `last_run = task_created` with `open_runs = 0`, so the row renders idle and +// the client never starts polling — permanently stale until a manual refresh. +// The open count is a correlated subquery over the same automation, which also +// means an automation with an open run always has a latest run, so no second +// pass is needed to invent rows for counts without runs. +func (s *Store) listSummaries(ctx context.Context, scope string, arg any) ([]*AutomationSummary, error) { + rows, err := s.selectSummaries(ctx, scope, arg) + if db.IsMissingTableError(err) { + rows, err = s.selectSummariesRaw(ctx, scope, arg) + } + if err != nil { + return nil, err + } + summaries := make([]*AutomationSummary, 0, len(rows)) + for _, row := range rows { + run := row.AutomationRun + run.TriggerData = json.RawMessage(run.TriggerDataJSON) + summaries = append(summaries, &AutomationSummary{ + AutomationID: run.AutomationID, + OpenRuns: row.OpenRuns, + LastRun: &run, + }) + } + return summaries, nil +} + +// summaryRow is the wire shape of the single summary query: a run plus its +// automation's open count. +type summaryRow struct { + AutomationRun + OpenRuns int `db:"open_runs"` +} + +// openRunsSubquerySQL counts an automation's outstanding runs alongside its +// latest one. Aliased aro/tro so it can nest inside a query that already uses +// ar/t, and it binds openRunArgs() like every other user of the predicate. +var openRunsSubquerySQL = `( + SELECT COUNT(*) FROM automation_runs aro + LEFT JOIN tasks tro ON tro.id = aro.task_id + WHERE aro.automation_id = ar.automation_id AND ` + openRunPredicateAliased("aro", "tro") + ` + ) AS open_runs` + +// latestRunPerAutomationSQL picks each automation's newest run by the same +// ordering every other run query uses (created_at then id), so "the newest run" +// means the row that leads the feed rather than an arbitrary tie-break. +var latestRunPerAutomationSQL = `ar.id = ( + SELECT ar2.id FROM automation_runs ar2 + WHERE ar2.automation_id = ar.automation_id + ORDER BY ` + runOrderSQL("ar2") + ` LIMIT 1 + )` + +func (s *Store) selectSummaries(ctx context.Context, scope string, arg any) ([]summaryRow, error) { + var rows []summaryRow + args := runTaskStateArgs() + args = append(args, openRunArgs()...) + args = append(args, arg) + err := s.ro.SelectContext(ctx, &rows, ` + SELECT`+runTaskStateColumnsSQL+`, + `+openRunsSubquerySQL+` + FROM automation_runs ar + JOIN automations a ON a.id = ar.automation_id + LEFT JOIN tasks t ON t.id = ar.task_id + WHERE `+scope+` AND `+latestRunPerAutomationSQL, + args...) + return rows, err +} + +// selectSummariesRaw is the no-tasks-table fallback the run lists also carry +// (isolated automation-only tests; production always has the table). Without +// tasks there is nothing to derive from, so the stored status stands and the +// open count is a plain status match. +func (s *Store) selectSummariesRaw(ctx context.Context, scope string, arg any) ([]summaryRow, error) { + var rows []summaryRow + err := s.ro.SelectContext(ctx, &rows, ` + SELECT ar.*, ( + SELECT COUNT(*) FROM automation_runs aro + WHERE aro.automation_id = ar.automation_id AND aro.status = ? + ) AS open_runs + FROM automation_runs ar + JOIN automations a ON a.id = ar.automation_id + WHERE `+scope+` AND `+latestRunPerAutomationSQL, + string(RunStatusTaskCreated), arg) + return rows, err +} + +func (s *Store) listWorkspaceRunsRaw(ctx context.Context, workspaceID string, limit int) ([]*WorkspaceAutomationRun, error) { + var runs []*WorkspaceAutomationRun + err := s.ro.SelectContext(ctx, &runs, ` + SELECT ar.*, a.name AS automation_name + FROM automation_runs ar + JOIN automations a ON a.id = ar.automation_id + WHERE a.workspace_id = ? + ORDER BY `+runOrderSQL("ar")+` LIMIT ?`, + workspaceID, limit) + return runs, err +} + // HasRunWithDedupKey checks if a run with the given dedup key already exists. func (s *Store) HasRunWithDedupKey(ctx context.Context, automationID, dedupKey string) (bool, error) { if dedupKey == "" { @@ -714,24 +1029,55 @@ func (s *Store) CountActiveRuns(ctx context.Context, automationID string) (int, return count, err } +// openRunPredicateSQL is what "this run is still outstanding" means, shared by +// the concurrency-cap count and the per-automation summary the runs list reads. +// One definition, for the same reason runTaskStateColumnsSQL is one: the cap +// deciding a run is open while the list shows the automation idle is a +// contradiction the user cannot resolve from the screen. Assumes automation_runs +// aliased ar with tasks LEFT JOINed as t, and binds two arguments — +// RunStatusTaskCreated and the cancelled session state. +// +// Same non-empty-task_id assumption as listRunsWithTaskState: an empty +// ar.task_id never matches a real task row, so such a run falls out of the +// open set instead of erroring. See listRunsWithTaskState for why the current +// (is_primary) session's state, not the task's own state, is the cancellation +// signal, and why NOT EXISTS rather than a LEFT JOIN. +const openRunPredicateSQL = `ar.status = ? + AND t.id IS NOT NULL AND t.archived_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM task_sessions ts + WHERE ts.task_id = ar.task_id AND ts.is_primary = 1 AND ts.state = ? + )` + +// openRunPredicateAliased is the same predicate under different table aliases, +// for the summary query where it nests inside a statement already using ar/t. +// Derived from openRunPredicateSQL rather than written twice so the definition +// of "open" stays in one place. +func openRunPredicateAliased(runAlias, taskAlias string) string { + replaced := strings.ReplaceAll(openRunPredicateSQL, "ar.", runAlias+".") + return strings.ReplaceAll(replaced, "t.", taskAlias+".") +} + +// runOrderSQL is how every run query orders: newest first, with the id breaking +// ties. Without the tie-break two runs written in the same second can order one +// way in the feed and the other way in the summary, so the list's "last said" +// would disagree with the entry that leads the automation's own activity. +func runOrderSQL(alias string) string { + return alias + ".created_at DESC, " + alias + ".id DESC" +} + +// openRunArgs binds openRunPredicateSQL's placeholders, in order. +func openRunArgs() []any { + return []any{string(RunStatusTaskCreated), string(taskmodels.TaskSessionStateCancelled)} +} + func (s *Store) countActiveRunsWithTaskState(ctx context.Context, automationID string) (int, error) { - // Same non-empty-task_id assumption as listRunsWithTaskState above: an - // empty ar.task_id never matches a real task row either, so such a run - // silently falls out of the active count below instead of erroring. - // See listRunsWithTaskState for why the current (is_primary) session's - // state, not the task's own state, is the cancellation signal, and why - // NOT EXISTS rather than a LEFT JOIN. var count int err := s.ro.GetContext(ctx, &count, ` SELECT COUNT(*) FROM automation_runs ar LEFT JOIN tasks t ON t.id = ar.task_id - WHERE ar.automation_id = ? AND ar.status = ? - AND t.id IS NOT NULL AND t.archived_at IS NULL - AND NOT EXISTS ( - SELECT 1 FROM task_sessions ts - WHERE ts.task_id = ar.task_id AND ts.is_primary = 1 AND ts.state = ? - )`, - automationID, string(RunStatusTaskCreated), string(taskmodels.TaskSessionStateCancelled)) + WHERE ar.automation_id = ? AND `+openRunPredicateSQL, + append([]any{automationID}, openRunArgs()...)...) return count, err } diff --git a/apps/backend/internal/automation/store_migration_notice_test.go b/apps/backend/internal/automation/store_migration_notice_test.go new file mode 100644 index 0000000000..d7df969d8d --- /dev/null +++ b/apps/backend/internal/automation/store_migration_notice_test.go @@ -0,0 +1,160 @@ +package automation + +import ( + "context" + "testing" + "time" + + "github.com/jmoiron/sqlx" + _ "github.com/mattn/go-sqlite3" +) + +// seedPreWithdrawalAutomation writes an automation row the way an install +// running the old code wrote one — with an execution_mode the user chose. +// Going through raw SQL is the point: CreateAutomation no longer offers that +// choice, so the only honest way to produce a pre-upgrade row is to write one. +func seedPreWithdrawalAutomation(t *testing.T, store *Store, id, workspaceID, executionMode string) { + t.Helper() + now := time.Now().UTC() + _, err := store.db.Exec( + `INSERT INTO automations (id, workspace_id, name, description, workflow_id, workflow_step_id, + agent_profile_id, executor_profile_id, prompt, task_title_template, execution_mode, + enabled, max_concurrent_runs, webhook_secret, created_at, updated_at) + VALUES (?, ?, ?, '', 'wf-1', 'step-1', 'agent-1', 'exec-1', 'report', '', ?, 1, 1, 'secret', ?, ?)`, + id, workspaceID, id, executionMode, now, now) + if err != nil { + t.Fatalf("seed %s automation %s: %v", executionMode, id, err) + } +} + +// An automation stored in `task` mode is the one whose board cards stop +// appearing after the upgrade, and `task` was the DEFAULT — so this is the +// common case, not an exotic one. The list read path is where the automations +// UI learns it has something to say. +func TestListAutomations_FlagsOnlyAutomationsStoredInTaskExecutionMode(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + seedPreWithdrawalAutomation(t, store, "a-task", "ws-1", "task") + seedPreWithdrawalAutomation(t, store, "a-run", "ws-1", "run") + + automations, err := store.ListAutomations(ctx, "ws-1") + if err != nil { + t.Fatalf("ListAutomations: %v", err) + } + if len(automations) != 2 { + t.Fatalf("expected both seeded automations, got %d", len(automations)) + } + flags := map[string]bool{} + for _, a := range automations { + flags[a.ID] = a.LegacyBoardCard + } + if !flags["a-task"] { + t.Error("an automation stored in task mode used to produce a board card and must be flagged") + } + if flags["a-run"] { + t.Error("an automation stored in run mode never produced a board card; nothing changed for it") + } +} + +// The same derivation has to hold on the single-automation read, or the +// detail surface would disagree with the list it was opened from. +func TestGetAutomation_FlagsAutomationStoredInTaskExecutionMode(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + seedPreWithdrawalAutomation(t, store, "a-task", "ws-1", "task") + + got, err := store.GetAutomation(ctx, "a-task") + if err != nil { + t.Fatalf("GetAutomation: %v", err) + } + if got == nil { + t.Fatal("expected the seeded automation back") + } + if !got.LegacyBoardCard { + t.Error("expected the stored task mode to be reported on the single-automation read too") + } +} + +// The notice must close. execution_mode's column DEFAULT is 'task', so an +// automation created after the withdrawal would look pre-upgrade forever if +// the insert left the DEFAULT to fill it — the "one-time" notice would come +// back for every automation the user ever creates. +func TestCreateAutomation_NewAutomationIsNotFlaggedAsLegacy(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + a := &Automation{ + WorkspaceID: "ws-1", + Name: "created after the withdrawal", + WorkflowID: "wf-1", + WorkflowStepID: "step-1", + Enabled: true, + MaxConcurrentRuns: 1, + } + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatalf("CreateAutomation: %v", err) + } + + got, err := store.GetAutomation(ctx, a.ID) + if err != nil { + t.Fatalf("GetAutomation: %v", err) + } + if got == nil { + t.Fatal("expected the created automation back") + } + if got.LegacyBoardCard { + t.Error("an automation created after the withdrawal never had a board card to lose") + } +} + +// A DB initialised before execution_mode existed at all still has to answer +// the derivation — the ALTER that adds the column runs on every boot for +// exactly this reason, and the column's DEFAULT correctly reports those rows +// as pre-upgrade ones. +func TestInitSchema_DerivesLegacyFlagOnADBPredatingExecutionMode(t *testing.T) { + db, err := sqlx.Open("sqlite3", ":memory:") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = db.Close() }) + + preSchema := ` + CREATE TABLE automations ( + id TEXT PRIMARY KEY, workspace_id TEXT NOT NULL, name TEXT NOT NULL, + description TEXT DEFAULT '', workflow_id TEXT NOT NULL, workflow_step_id TEXT NOT NULL, + agent_profile_id TEXT NOT NULL, executor_profile_id TEXT NOT NULL, + prompt TEXT DEFAULT '', enabled BOOLEAN DEFAULT 1, max_concurrent_runs INTEGER DEFAULT 1, + webhook_secret TEXT DEFAULT '', last_triggered_at DATETIME, + created_at DATETIME NOT NULL, updated_at DATETIME NOT NULL + ); + ` + if _, err := db.Exec(preSchema); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + _, err = db.Exec( + `INSERT INTO automations (id, workspace_id, name, workflow_id, workflow_step_id, + agent_profile_id, executor_profile_id, created_at, updated_at) + VALUES ('a-ancient', 'ws-1', 'Ancient', 'wf-1', 's-1', '', '', ?, ?)`, now, now) + if err != nil { + t.Fatal(err) + } + + store, err := NewStore(db, db) + if err != nil { + t.Fatalf("NewStore on a pre-execution_mode DB: %v", err) + } + + got, err := store.GetAutomation(context.Background(), "a-ancient") + if err != nil { + t.Fatalf("GetAutomation: %v", err) + } + if got == nil { + t.Fatal("expected the ancient automation back") + } + if !got.LegacyBoardCard { + t.Error("an automation that predates execution_mode ran in the task default and lost its card too") + } +} diff --git a/apps/backend/internal/automation/store_runs_test.go b/apps/backend/internal/automation/store_runs_test.go new file mode 100644 index 0000000000..e3f632a296 --- /dev/null +++ b/apps/backend/internal/automation/store_runs_test.go @@ -0,0 +1,409 @@ +package automation + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +func createTasksTable(t *testing.T, store *Store) { + t.Helper() + if _, err := store.db.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, archived_at DATETIME)`); err != nil { + t.Fatal(err) + } + if _, err := store.db.Exec(`CREATE TABLE task_sessions (id TEXT PRIMARY KEY, task_id TEXT NOT NULL, is_primary INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'CREATED')`); err != nil { + t.Fatal(err) + } + // listRunsWithTaskState reads the agent's last message for the run summary. + // Without this table the whole query fails as missing-table and ListRuns + // silently falls back to the raw status, losing the archived/cancelled + // derivation these tests assert. + if _, err := store.db.Exec(`CREATE TABLE task_session_messages (id TEXT PRIMARY KEY, task_id TEXT NOT NULL DEFAULT '', author_type TEXT NOT NULL DEFAULT 'user', content TEXT NOT NULL DEFAULT '', type TEXT NOT NULL DEFAULT 'message', created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP)`); err != nil { + t.Fatal(err) + } + // PrunableRunTaskIDs only offers runs that still hold a checkout, and a + // checkout is reached task → session → worktree row. Without this table the + // query fails outright rather than returning nothing. + if _, err := store.db.Exec(`CREATE TABLE task_session_worktrees (id TEXT PRIMARY KEY, session_id TEXT NOT NULL, worktree_id TEXT NOT NULL, repository_id TEXT NOT NULL DEFAULT '', worktree_path TEXT DEFAULT '', status TEXT NOT NULL DEFAULT 'active', deleted_at TIMESTAMP)`); err != nil { + t.Fatal(err) + } +} + +func insertTask(t *testing.T, store *Store, id string, archived bool) { + t.Helper() + var archivedAt interface{} + if archived { + archivedAt = time.Now().UTC() + } + if _, err := store.db.Exec(`INSERT INTO tasks (id, archived_at) VALUES (?, ?)`, id, archivedAt); err != nil { + t.Fatal(err) + } +} + +// insertPrimarySession seeds the given task's *current* session (is_primary +// = 1) with an explicit models.TaskSessionState string (e.g. "CANCELLED"), +// so tests can exercise the genuine-cancellation branch of +// listRunsWithTaskState/countActiveRunsWithTaskState independently of +// archived_at. Mirrors production: a task has at most one is_primary = 1 +// session at a time (SetPrimarySession unsets the rest on resume). +func insertPrimarySession(t *testing.T, store *Store, taskID, state string) { + t.Helper() + if _, err := store.db.Exec( + `INSERT INTO task_sessions (id, task_id, is_primary, state) VALUES (?, ?, 1, ?)`, + taskID+"-session", taskID, state, + ); err != nil { + t.Fatal(err) + } +} + +// insertStaleSession seeds a non-primary (is_primary = 0) session for the +// given task — e.g. a CANCELLED session left over from a stop, before the +// task was resumed and its is_primary flag moved to a fresh session. Used +// to prove the current-session filter isn't fooled by cancellation +// history that no longer reflects the task's live state. +func insertStaleSession(t *testing.T, store *Store, taskID, state string) { + t.Helper() + if _, err := store.db.Exec( + `INSERT INTO task_sessions (id, task_id, is_primary, state) VALUES (?, ?, 0, ?)`, + taskID+"-stale-session", taskID, state, + ); err != nil { + t.Fatal(err) + } +} + +// TestCountActiveRuns_ExcludesArchivedCancelledOrMissingTask reproduces the +// reported bug: an automation-generated task that gets archived (manually, +// via auto-archive, via cascade, or by the agent itself) or is explicitly +// cancelled leaves its automation run stuck at task_created forever unless +// CountActiveRuns checks the task's current state. A run whose task is +// archived, cancelled, gone entirely, or never recorded (empty task_id — +// see countActiveRunsWithTaskState's docstring in store.go) no longer +// represents outstanding work and must not count against +// max_concurrent_runs. +func TestCountActiveRuns_ExcludesArchivedCancelledOrMissingTask(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + + insertTask(t, store, "task-active", false) + insertTask(t, store, "task-archived", true) + insertTask(t, store, "task-cancelled", false) + insertPrimarySession(t, store, "task-cancelled", "CANCELLED") + // Resumed after a stop: the stale CANCELLED session is no longer + // primary, so this task must still count as active. + insertTask(t, store, "task-resumed", false) + insertStaleSession(t, store, "task-resumed", "CANCELLED") + insertPrimarySession(t, store, "task-resumed", "RUNNING") + // "task-missing" deliberately has no row in tasks at all; "" (the + // task_id column default) exercises the same no-live-task branch + // through a different route. + + for _, tid := range []string{"task-active", "task-archived", "task-cancelled", "task-resumed", "task-missing", ""} { + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: tid, + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + } + + count, err := store.CountActiveRuns(ctx, a.ID) + if err != nil { + t.Fatal(err) + } + if count != 2 { + t.Fatalf("expected 2 active runs (task-active and task-resumed are open), got %d", count) + } +} + +// TestCountActiveRuns_FallsBackWhenTasksTableAbsent guards the isolated +// automation-only test DB (and any other DB where the task repository +// hasn't initialised yet): CountActiveRuns must keep counting by status +// alone rather than error when the tasks table doesn't exist. +func TestCountActiveRuns_FallsBackWhenTasksTableAbsent(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: "task-xyz", + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + + count, err := store.CountActiveRuns(ctx, a.ID) + if err != nil { + t.Fatal(err) + } + if count != 1 { + t.Fatalf("expected 1 active run when tasks table is absent, got %d", count) + } +} + +// TestListRuns_DerivesArchivedCancelledAndActiveStatus ensures the "Recent +// Runs" list the settings UI reads stops labeling a run "Running" once its +// generated task is archived, cancelled, gone, or never recorded (empty +// task_id — see listRunsWithTaskState's docstring in store.go). Archived +// (regardless of whether the UI or the agent itself triggered it) must be +// visually distinct from a genuine user cancellation (task state +// CANCELLED), and archived_at takes precedence when a task is both +// cancelled and archived. Runs that already reached a real terminal +// outcome are left untouched. +func TestListRuns_DerivesArchivedCancelledAndActiveStatus(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + insertTask(t, store, "task-active", false) + insertTask(t, store, "task-archived", true) + insertTask(t, store, "task-cancelled", false) + insertPrimarySession(t, store, "task-cancelled", "CANCELLED") + insertTask(t, store, "task-cancelled-and-archived", true) + insertPrimarySession(t, store, "task-cancelled-and-archived", "CANCELLED") + // Stopped once (stale, non-primary CANCELLED session), then resumed + // with a fresh primary session that completed. Must read as its raw + // stored status, not cancelled — the stale session no longer reflects + // the task's live state. + insertTask(t, store, "task-resumed", false) + insertStaleSession(t, store, "task-resumed", "CANCELLED") + insertPrimarySession(t, store, "task-resumed", "COMPLETED") + + active := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-active", TriggerData: json.RawMessage(`{}`)} + archived := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-archived", TriggerData: json.RawMessage(`{}`)} + cancelled := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-cancelled", TriggerData: json.RawMessage(`{}`)} + cancelledAndArchived := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-cancelled-and-archived", TriggerData: json.RawMessage(`{}`)} + resumed := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-resumed", TriggerData: json.RawMessage(`{}`)} + missing := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-missing", TriggerData: json.RawMessage(`{}`)} + emptyTaskID := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "", TriggerData: json.RawMessage(`{}`)} + succeededOnArchived := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusSucceeded, TaskID: "task-archived", TriggerData: json.RawMessage(`{}`)} + allRuns := []*AutomationRun{active, archived, cancelled, cancelledAndArchived, resumed, missing, emptyTaskID, succeededOnArchived} + for _, r := range allRuns { + if err := store.CreateRun(ctx, r); err != nil { + t.Fatal(err) + } + } + + got, err := store.ListRuns(ctx, a.ID, 10) + if err != nil { + t.Fatal(err) + } + statusByID := map[string]RunStatus{} + for _, r := range got { + statusByID[r.ID] = r.Status + } + if s := statusByID[active.ID]; s != RunStatusTaskCreated { + t.Errorf("active task run: expected task_created, got %q", s) + } + if s := statusByID[archived.ID]; s != RunStatusArchived { + t.Errorf("archived task run: expected archived, got %q", s) + } + if s := statusByID[cancelled.ID]; s != RunStatusCancelled { + t.Errorf("cancelled task run: expected cancelled, got %q", s) + } + if s := statusByID[cancelledAndArchived.ID]; s != RunStatusArchived { + t.Errorf("cancelled-and-archived task run: expected archived_at to take precedence (archived), got %q", s) + } + if s := statusByID[resumed.ID]; s != RunStatusTaskCreated { + t.Errorf("resumed-after-cancel task run: expected task_created (stale session ignored), got %q", s) + } + if s := statusByID[missing.ID]; s != RunStatusCancelled { + t.Errorf("missing task run: expected cancelled, got %q", s) + } + if s := statusByID[emptyTaskID.ID]; s != RunStatusCancelled { + t.Errorf("empty task_id run: expected cancelled, got %q", s) + } + if s := statusByID[succeededOnArchived.ID]; s != RunStatusSucceeded { + t.Errorf("already-succeeded run on now-archived task: expected succeeded preserved, got %q", s) + } +} + +// TestListRuns_FallsBackWhenTasksTableAbsent mirrors +// TestCountActiveRuns_FallsBackWhenTasksTableAbsent for the display path. +func TestListRuns_FallsBackWhenTasksTableAbsent(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: "task-xyz", + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + + got, err := store.ListRuns(ctx, a.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Status != RunStatusTaskCreated { + t.Fatalf("expected 1 run with status task_created when tasks table is absent, got %+v", got) + } +} + +// A run-mode automation hides its task, so the run row is the only place the +// reader can learn what the agent actually reported. Before this the row could +// say a run succeeded but never what it said. +func TestListRuns_CarriesTheAgentsLastMessageAsSummary(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + createTasksTable(t, store) + + a := &Automation{WorkspaceID: "ws-1", Name: "reporter", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + insertTask(t, store, "task-report", false) + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSucceeded, + TaskID: "task-report", + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + + seed := func(id, author, content string, at time.Time) { + t.Helper() + if _, err := store.db.Exec( + `INSERT INTO task_session_messages (id, task_id, author_type, content, type, created_at) VALUES (?,?,?,?,?,?)`, + id, "task-report", author, content, "message", at); err != nil { + t.Fatal(err) + } + } + base := time.Now().UTC() + seed("m1", "user", "run the sweep", base) + seed("m2", "agent", "Working on it.", base.Add(time.Minute)) + seed("m3", "agent", "Sweep complete across all 32 specs.", base.Add(2*time.Minute)) + + runs, err := store.ListRuns(ctx, a.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(runs) != 1 { + t.Fatalf("expected 1 run, got %d", len(runs)) + } + if runs[0].Summary != "Sweep complete across all 32 specs." { + t.Errorf("expected the agent's LAST message, got %q", runs[0].Summary) + } +} + +func TestListRuns_SummaryIsEmptyWhenTheAgentNeverSpoke(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + createTasksTable(t, store) + + a := &Automation{WorkspaceID: "ws-1", Name: "quiet", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + // A skipped run never produces a task, so there is nothing to summarise. + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSkipped, + ErrorMessage: "max_concurrent_runs=1 reached", + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + + runs, err := store.ListRuns(ctx, a.ID, 10) + if err != nil { + t.Fatal(err) + } + if runs[0].Summary != "" { + t.Errorf("expected no summary, got %q", runs[0].Summary) + } +} + +// setRunCreatedAt pins a run's created_at so ordering assertions don't +// depend on how many nanoseconds apart two CreateRun calls landed. +func setRunCreatedAt(t *testing.T, store *Store, runID string, at time.Time) { + t.Helper() + if _, err := store.db.Exec(`UPDATE automation_runs SET created_at = ? WHERE id = ?`, at, runID); err != nil { + t.Fatal(err) + } +} + +// The workspace-wide feed is the only view that interleaves runs from +// different automations, so it's the only place a run can be mistaken for +// another automation's — hence the name rides along with each row. +// A run from a second workspace is seeded *newest of all* so exclusion +// can't be mistaken for it simply falling off the end of the ordering. + +// The detail view mounts a run's transcript in place, and the chat panel is +// driven by a session id rather than a task id. Resolving it in the run +// projection keeps that one query instead of one lookup per run on the client. +func TestListRuns_CarriesTheRunsPrimarySession(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "sweep", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + insertTask(t, store, "task-with-session", false) + insertPrimarySession(t, store, "task-with-session", "COMPLETED") + insertTask(t, store, "task-without-session", false) + + withSession := &AutomationRun{ + AutomationID: a.ID, TriggerType: TriggerTypeScheduled, + Status: RunStatusSucceeded, TaskID: "task-with-session", TriggerData: json.RawMessage(`{}`), + } + without := &AutomationRun{ + AutomationID: a.ID, TriggerType: TriggerTypeScheduled, + Status: RunStatusSkipped, TaskID: "task-without-session", TriggerData: json.RawMessage(`{}`), + } + for _, r := range []*AutomationRun{withSession, without} { + if err := store.CreateRun(ctx, r); err != nil { + t.Fatal(err) + } + } + + runs, err := store.ListRuns(ctx, a.ID, 50) + if err != nil { + t.Fatal(err) + } + byID := map[string]*AutomationRun{} + for _, r := range runs { + byID[r.ID] = r + } + if got := byID[withSession.ID]; got == nil || got.SessionID == "" { + t.Fatalf("expected the run to carry its primary session, got %+v", got) + } + // A run with no conversation reports none rather than a dangling id the + // transcript would try to mount. + if got := byID[without.ID]; got == nil || got.SessionID != "" { + t.Fatalf("expected no session for a run that never started one, got %+v", got) + } +} diff --git a/apps/backend/internal/automation/store_summaries_test.go b/apps/backend/internal/automation/store_summaries_test.go new file mode 100644 index 0000000000..49cc136c2a --- /dev/null +++ b/apps/backend/internal/automation/store_summaries_test.go @@ -0,0 +1,351 @@ +package automation + +import ( + "context" + "encoding/json" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +func TestListAutomationSummaries_SurvivesAFeedFullOfSomeoneElsesRuns(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + quiet := &Automation{WorkspaceID: "ws-1", Name: "weekly audit", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + noisy := &Automation{WorkspaceID: "ws-1", Name: "pr triage", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + for _, a := range []*Automation{quiet, noisy} { + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + } + + base := time.Now().UTC().Truncate(time.Second) + quietRun := &AutomationRun{ + AutomationID: quiet.ID, TriggerType: TriggerTypeScheduled, + Status: RunStatusSucceeded, TriggerData: json.RawMessage(`{}`), + } + if err := store.CreateRun(ctx, quietRun); err != nil { + t.Fatal(err) + } + setRunCreatedAt(t, store, quietRun.ID, base) + + // More than the feed's cap, all newer than the quiet automation's only run. + for i := 0; i < maxWorkspaceRunsLimit+10; i++ { + r := &AutomationRun{ + AutomationID: noisy.ID, TriggerType: TriggerTypeScheduled, + Status: RunStatusSucceeded, TriggerData: json.RawMessage(`{}`), + } + if err := store.CreateRun(ctx, r); err != nil { + t.Fatal(err) + } + setRunCreatedAt(t, store, r.ID, base.Add(time.Duration(i+1)*time.Minute)) + } + + summaries, err := store.ListAutomationSummaries(ctx, "ws-1") + if err != nil { + t.Fatal(err) + } + byAutomation := map[string]*AutomationSummary{} + for _, s := range summaries { + byAutomation[s.AutomationID] = s + } + got := byAutomation[quiet.ID] + if got == nil || got.LastRun == nil { + t.Fatalf("the quiet automation's last run must survive a full feed, got %+v", got) + } + if got.LastRun.ID != quietRun.ID { + t.Errorf("expected %s, got %s", quietRun.ID, got.LastRun.ID) + } +} + +// One row per automation, carrying its newest run by the same ordering the +// feed sorts by — so "last said" on the list is the entry that leads the feed. +func TestListAutomationSummaries_ReportsTheNewestRunPerAutomation(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "sweep", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + foreign := &Automation{WorkspaceID: "ws-2", Name: "not mine", WorkflowID: "wf-9", WorkflowStepID: "s-9"} + for _, x := range []*Automation{a, foreign} { + if err := store.CreateAutomation(ctx, x); err != nil { + t.Fatal(err) + } + } + + base := time.Now().UTC().Truncate(time.Second) + older := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusFailed, TriggerData: json.RawMessage(`{}`)} + newer := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusSucceeded, TriggerData: json.RawMessage(`{}`)} + elsewhere := &AutomationRun{AutomationID: foreign.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusSucceeded, TriggerData: json.RawMessage(`{}`)} + for _, r := range []*AutomationRun{older, newer, elsewhere} { + if err := store.CreateRun(ctx, r); err != nil { + t.Fatal(err) + } + } + setRunCreatedAt(t, store, older.ID, base) + setRunCreatedAt(t, store, newer.ID, base.Add(time.Minute)) + + summaries, err := store.ListAutomationSummaries(ctx, "ws-1") + if err != nil { + t.Fatal(err) + } + if len(summaries) != 1 { + t.Fatalf("expected one summary for ws-1's single automation, got %d", len(summaries)) + } + if summaries[0].AutomationID != a.ID { + t.Fatalf("another workspace's automation leaked in: %+v", summaries[0]) + } + if summaries[0].LastRun == nil || summaries[0].LastRun.ID != newer.ID { + t.Fatalf("expected the newest run %s, got %+v", newer.ID, summaries[0].LastRun) + } + if string(summaries[0].LastRun.TriggerData) != `{}` { + t.Errorf("expected trigger_data hydrated from its stored JSON, got %q", string(summaries[0].LastRun.TriggerData)) + } +} + +// The open count backs the list's "won't fire — still running" reason, so it +// has to mean exactly what the concurrency cap means. A run whose task was +// archived or cancelled is not holding anything. +func TestListAutomationSummaries_CountsOpenRunsTheWayTheCapDoes(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "sweep", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + insertTask(t, store, "task-open", false) + insertTask(t, store, "task-archived", true) + insertTask(t, store, "task-cancelled", false) + insertPrimarySession(t, store, "task-cancelled", "CANCELLED") + + for _, taskID := range []string{"task-open", "task-archived", "task-cancelled"} { + r := &AutomationRun{ + AutomationID: a.ID, TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, TaskID: taskID, TriggerData: json.RawMessage(`{}`), + } + if err := store.CreateRun(ctx, r); err != nil { + t.Fatal(err) + } + } + + summaries, err := store.ListAutomationSummaries(ctx, "ws-1") + if err != nil { + t.Fatal(err) + } + if len(summaries) != 1 { + t.Fatalf("expected one summary, got %d", len(summaries)) + } + if summaries[0].OpenRuns != 1 { + t.Errorf("expected 1 open run (archived and cancelled are closed out), got %d", summaries[0].OpenRuns) + } + capCount, err := store.CountActiveRuns(ctx, a.ID) + if err != nil { + t.Fatal(err) + } + if summaries[0].OpenRuns != capCount { + t.Errorf("summary says %d open, the cap says %d — these must never disagree", + summaries[0].OpenRuns, capCount) + } +} + +// An automation that has never run gets no summary row at all; the list treats +// a missing row as "no runs yet" rather than inventing one per automation. +func TestListAutomationSummaries_OmitsAutomationsThatHaveNeverRun(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + never := &Automation{WorkspaceID: "ws-1", Name: "brand new", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, never); err != nil { + t.Fatal(err) + } + + summaries, err := store.ListAutomationSummaries(ctx, "ws-1") + if err != nil { + t.Fatal(err) + } + if len(summaries) != 0 { + t.Fatalf("expected no summaries for a workspace whose automations never ran, got %+v", summaries) + } +} + +// Same fallback the run lists take: automation-only tests run without a tasks +// table, and the query must degrade to the stored status rather than error. +func TestListAutomationSummaries_FallsBackWhenTasksTableAbsent(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "sweep", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + r := &AutomationRun{ + AutomationID: a.ID, TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, TaskID: "task-1", TriggerData: json.RawMessage(`{}`), + } + if err := store.CreateRun(ctx, r); err != nil { + t.Fatal(err) + } + + summaries, err := store.ListAutomationSummaries(ctx, "ws-1") + if err != nil { + t.Fatalf("expected a graceful fallback without a tasks table, got %v", err) + } + if len(summaries) != 1 || summaries[0].LastRun == nil { + t.Fatalf("expected one summary with its run, got %+v", summaries) + } + if summaries[0].LastRun.Status != RunStatusTaskCreated { + t.Errorf("expected the stored status, got %q", summaries[0].LastRun.Status) + } + if summaries[0].OpenRuns != 1 { + t.Errorf("expected the raw open count of 1, got %d", summaries[0].OpenRuns) + } +} + +// Two queries would be two snapshots. A run created between them reads as a +// still-open last run with an open count of zero, so the row renders idle and +// the client never starts polling — permanently stale until a manual refresh. +// One statement cannot disagree with itself. +func TestListAutomationSummaries_OpenCountAgreesWithTheRunItReports(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "sweep", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + insertTask(t, store, "task-open", false) + open := &AutomationRun{ + AutomationID: a.ID, TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, TaskID: "task-open", TriggerData: json.RawMessage(`{}`), + } + if err := store.CreateRun(ctx, open); err != nil { + t.Fatal(err) + } + + summaries, err := store.ListAutomationSummaries(ctx, "ws-1") + if err != nil { + t.Fatal(err) + } + if len(summaries) != 1 { + t.Fatalf("expected one summary, got %d", len(summaries)) + } + got := summaries[0] + if got.LastRun == nil || got.LastRun.Status != RunStatusTaskCreated { + t.Fatalf("expected the open run as the last run, got %+v", got.LastRun) + } + if got.OpenRuns != 1 { + t.Errorf("a summary reporting an open last run must count it: got %d", got.OpenRuns) + } +} + +// The feed and the summary must pick the same run when timestamps tie, or the +// list's "last said" contradicts the entry leading the automation's activity. +func TestListAutomationSummaries_BreaksTiedTimestampsLikeTheFeed(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "sweep", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + tied := time.Now().UTC().Truncate(time.Second) + var ids []string + for i := 0; i < 3; i++ { + r := &AutomationRun{ + AutomationID: a.ID, TriggerType: TriggerTypeScheduled, + Status: RunStatusSucceeded, TriggerData: json.RawMessage(`{}`), + } + if err := store.CreateRun(ctx, r); err != nil { + t.Fatal(err) + } + setRunCreatedAt(t, store, r.ID, tied) + ids = append(ids, r.ID) + } + + feed, err := store.ListWorkspaceRuns(ctx, "ws-1", 50) + if err != nil { + t.Fatal(err) + } + summaries, err := store.ListAutomationSummaries(ctx, "ws-1") + if err != nil { + t.Fatal(err) + } + if len(feed) != len(ids) || len(summaries) != 1 || summaries[0].LastRun == nil { + t.Fatalf("unexpected shape: feed=%d summaries=%d", len(feed), len(summaries)) + } + if summaries[0].LastRun.ID != feed[0].ID { + t.Errorf("summary's last run %s must be the run leading the feed %s", + summaries[0].LastRun.ID, feed[0].ID) + } +} + +// The detail page needs the same authoritative open count the list uses: its +// own run window is capped, so an open run older than the window would leave +// the page reporting that nothing is in flight. +func TestGetAutomationSummary_AnswersForOneAutomation(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + mine := &Automation{WorkspaceID: "ws-1", Name: "mine", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + other := &Automation{WorkspaceID: "ws-1", Name: "other", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + for _, x := range []*Automation{mine, other} { + if err := store.CreateAutomation(ctx, x); err != nil { + t.Fatal(err) + } + } + insertTask(t, store, "task-mine", false) + insertTask(t, store, "task-other", false) + for _, spec := range []struct { + automationID string + taskID string + }{{mine.ID, "task-mine"}, {other.ID, "task-other"}} { + r := &AutomationRun{ + AutomationID: spec.automationID, TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, TaskID: spec.taskID, TriggerData: json.RawMessage(`{}`), + } + if err := store.CreateRun(ctx, r); err != nil { + t.Fatal(err) + } + } + + got, err := store.GetAutomationSummary(ctx, mine.ID) + if err != nil { + t.Fatal(err) + } + if got == nil || got.AutomationID != mine.ID { + t.Fatalf("expected this automation's summary, got %+v", got) + } + if got.OpenRuns != 1 { + t.Errorf("expected 1 open run for this automation alone, got %d", got.OpenRuns) + } +} + +// An automation that has never run has no summary — nil, not an error and not +// a zero-valued row the caller would render as real. +func TestGetAutomationSummary_NilWhenItHasNeverRun(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "brand new", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + + got, err := store.GetAutomationSummary(ctx, a.ID) + if err != nil { + t.Fatal(err) + } + if got != nil { + t.Fatalf("expected nil for an automation that has never run, got %+v", got) + } +} diff --git a/apps/backend/internal/automation/store_test.go b/apps/backend/internal/automation/store_test.go index 4b55825a99..384d42e609 100644 --- a/apps/backend/internal/automation/store_test.go +++ b/apps/backend/internal/automation/store_test.go @@ -529,248 +529,54 @@ func TestDeleteAllRuns(t *testing.T) { // ListRuns task-state join create them; production always has the real // tables already migrated by the task repository before automation // triggers can fire. -func createTasksTable(t *testing.T, store *Store) { - t.Helper() - if _, err := store.db.Exec(`CREATE TABLE tasks (id TEXT PRIMARY KEY, archived_at DATETIME)`); err != nil { - t.Fatal(err) - } - if _, err := store.db.Exec(`CREATE TABLE task_sessions (id TEXT PRIMARY KEY, task_id TEXT NOT NULL, is_primary INTEGER NOT NULL DEFAULT 0, state TEXT NOT NULL DEFAULT 'CREATED')`); err != nil { - t.Fatal(err) - } -} -func insertTask(t *testing.T, store *Store, id string, archived bool) { - t.Helper() - var archivedAt interface{} - if archived { - archivedAt = time.Now().UTC() - } - if _, err := store.db.Exec(`INSERT INTO tasks (id, archived_at) VALUES (?, ?)`, id, archivedAt); err != nil { - t.Fatal(err) - } -} - -// insertPrimarySession seeds the given task's *current* session (is_primary -// = 1) with an explicit models.TaskSessionState string (e.g. "CANCELLED"), -// so tests can exercise the genuine-cancellation branch of -// listRunsWithTaskState/countActiveRunsWithTaskState independently of -// archived_at. Mirrors production: a task has at most one is_primary = 1 -// session at a time (SetPrimarySession unsets the rest on resume). -func insertPrimarySession(t *testing.T, store *Store, taskID, state string) { - t.Helper() - if _, err := store.db.Exec( - `INSERT INTO task_sessions (id, task_id, is_primary, state) VALUES (?, ?, 1, ?)`, - taskID+"-session", taskID, state, - ); err != nil { - t.Fatal(err) - } -} - -// insertStaleSession seeds a non-primary (is_primary = 0) session for the -// given task — e.g. a CANCELLED session left over from a stop, before the -// task was resumed and its is_primary flag moved to a fresh session. Used -// to prove the current-session filter isn't fooled by cancellation -// history that no longer reflects the task's live state. -func insertStaleSession(t *testing.T, store *Store, taskID, state string) { - t.Helper() - if _, err := store.db.Exec( - `INSERT INTO task_sessions (id, task_id, is_primary, state) VALUES (?, ?, 0, ?)`, - taskID+"-stale-session", taskID, state, - ); err != nil { - t.Fatal(err) - } -} - -// TestCountActiveRuns_ExcludesArchivedCancelledOrMissingTask reproduces the -// reported bug: an automation-generated task that gets archived (manually, -// via auto-archive, via cascade, or by the agent itself) or is explicitly -// cancelled leaves its automation run stuck at task_created forever unless -// CountActiveRuns checks the task's current state. A run whose task is -// archived, cancelled, gone entirely, or never recorded (empty task_id — -// see countActiveRunsWithTaskState's docstring in store.go) no longer -// represents outstanding work and must not count against -// max_concurrent_runs. -func TestCountActiveRuns_ExcludesArchivedCancelledOrMissingTask(t *testing.T) { +// Only enabled automations count as a reason you cannot delete a profile: a +// disabled one is not going to fire, so naming it would be noise in the +// confirmation dialog. +func TestListEnabledByAgentProfile_OnlyEnabledAndOnlyThatProfile(t *testing.T) { store := setupTestStore(t) - createTasksTable(t, store) ctx := context.Background() - a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} - if err := store.CreateAutomation(ctx, a); err != nil { - t.Fatal(err) - } - - insertTask(t, store, "task-active", false) - insertTask(t, store, "task-archived", true) - insertTask(t, store, "task-cancelled", false) - insertPrimarySession(t, store, "task-cancelled", "CANCELLED") - // Resumed after a stop: the stale CANCELLED session is no longer - // primary, so this task must still count as active. - insertTask(t, store, "task-resumed", false) - insertStaleSession(t, store, "task-resumed", "CANCELLED") - insertPrimarySession(t, store, "task-resumed", "RUNNING") - // "task-missing" deliberately has no row in tasks at all; "" (the - // task_id column default) exercises the same no-live-task branch - // through a different route. - - for _, tid := range []string{"task-active", "task-archived", "task-cancelled", "task-resumed", "task-missing", ""} { - if err := store.CreateRun(ctx, &AutomationRun{ - AutomationID: a.ID, - TriggerType: TriggerTypeScheduled, - Status: RunStatusTaskCreated, - TaskID: tid, - TriggerData: json.RawMessage(`{}`), - }); err != nil { - t.Fatal(err) + for _, spec := range []struct { + name string + profile string + enabled bool + expected bool + }{ + {"nightly sweep", "profile-a", true, true}, + {"paused sweep", "profile-a", false, false}, + {"someone else's", "profile-b", true, false}, + } { + a := &Automation{ + WorkspaceID: "ws-1", Name: spec.name, + AgentProfileID: spec.profile, Enabled: spec.enabled, + } + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatalf("CreateAutomation(%s): %v", spec.name, err) } } - count, err := store.CountActiveRuns(ctx, a.ID) + got, err := store.ListEnabledByAgentProfile(ctx, "profile-a") if err != nil { - t.Fatal(err) + t.Fatalf("ListEnabledByAgentProfile: %v", err) } - if count != 2 { - t.Fatalf("expected 2 active runs (task-active and task-resumed are open), got %d", count) + if len(got) != 1 || got[0].Name != "nightly sweep" { + t.Fatalf("expected only the enabled automation for profile-a, got %+v", got) + } + if got[0].WorkspaceID != "ws-1" { + t.Errorf("workspace should travel with the reference, got %q", got[0].WorkspaceID) } } -// TestCountActiveRuns_FallsBackWhenTasksTableAbsent guards the isolated -// automation-only test DB (and any other DB where the task repository -// hasn't initialised yet): CountActiveRuns must keep counting by status -// alone rather than error when the tasks table doesn't exist. -func TestCountActiveRuns_FallsBackWhenTasksTableAbsent(t *testing.T) { +func TestListEnabledByAgentProfile_EmptyProfileMatchesNothing(t *testing.T) { store := setupTestStore(t) - ctx := context.Background() - - a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} - if err := store.CreateAutomation(ctx, a); err != nil { - t.Fatal(err) - } - if err := store.CreateRun(ctx, &AutomationRun{ - AutomationID: a.ID, - TriggerType: TriggerTypeScheduled, - Status: RunStatusTaskCreated, - TaskID: "task-xyz", - TriggerData: json.RawMessage(`{}`), - }); err != nil { - t.Fatal(err) - } - - count, err := store.CountActiveRuns(ctx, a.ID) + // An unset agent_profile_id would otherwise match every automation that + // also has none, which is the opposite of "these block your deletion". + got, err := store.ListEnabledByAgentProfile(context.Background(), "") if err != nil { - t.Fatal(err) + t.Fatalf("ListEnabledByAgentProfile: %v", err) } - if count != 1 { - t.Fatalf("expected 1 active run when tasks table is absent, got %d", count) - } -} - -// TestListRuns_DerivesArchivedCancelledAndActiveStatus ensures the "Recent -// Runs" list the settings UI reads stops labeling a run "Running" once its -// generated task is archived, cancelled, gone, or never recorded (empty -// task_id — see listRunsWithTaskState's docstring in store.go). Archived -// (regardless of whether the UI or the agent itself triggered it) must be -// visually distinct from a genuine user cancellation (task state -// CANCELLED), and archived_at takes precedence when a task is both -// cancelled and archived. Runs that already reached a real terminal -// outcome are left untouched. -func TestListRuns_DerivesArchivedCancelledAndActiveStatus(t *testing.T) { - store := setupTestStore(t) - createTasksTable(t, store) - ctx := context.Background() - - a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} - if err := store.CreateAutomation(ctx, a); err != nil { - t.Fatal(err) - } - insertTask(t, store, "task-active", false) - insertTask(t, store, "task-archived", true) - insertTask(t, store, "task-cancelled", false) - insertPrimarySession(t, store, "task-cancelled", "CANCELLED") - insertTask(t, store, "task-cancelled-and-archived", true) - insertPrimarySession(t, store, "task-cancelled-and-archived", "CANCELLED") - // Stopped once (stale, non-primary CANCELLED session), then resumed - // with a fresh primary session that completed. Must read as its raw - // stored status, not cancelled — the stale session no longer reflects - // the task's live state. - insertTask(t, store, "task-resumed", false) - insertStaleSession(t, store, "task-resumed", "CANCELLED") - insertPrimarySession(t, store, "task-resumed", "COMPLETED") - - active := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-active", TriggerData: json.RawMessage(`{}`)} - archived := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-archived", TriggerData: json.RawMessage(`{}`)} - cancelled := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-cancelled", TriggerData: json.RawMessage(`{}`)} - cancelledAndArchived := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-cancelled-and-archived", TriggerData: json.RawMessage(`{}`)} - resumed := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-resumed", TriggerData: json.RawMessage(`{}`)} - missing := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-missing", TriggerData: json.RawMessage(`{}`)} - emptyTaskID := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "", TriggerData: json.RawMessage(`{}`)} - succeededOnArchived := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusSucceeded, TaskID: "task-archived", TriggerData: json.RawMessage(`{}`)} - allRuns := []*AutomationRun{active, archived, cancelled, cancelledAndArchived, resumed, missing, emptyTaskID, succeededOnArchived} - for _, r := range allRuns { - if err := store.CreateRun(ctx, r); err != nil { - t.Fatal(err) - } - } - - got, err := store.ListRuns(ctx, a.ID, 10) - if err != nil { - t.Fatal(err) - } - statusByID := map[string]RunStatus{} - for _, r := range got { - statusByID[r.ID] = r.Status - } - if s := statusByID[active.ID]; s != RunStatusTaskCreated { - t.Errorf("active task run: expected task_created, got %q", s) - } - if s := statusByID[archived.ID]; s != RunStatusArchived { - t.Errorf("archived task run: expected archived, got %q", s) - } - if s := statusByID[cancelled.ID]; s != RunStatusCancelled { - t.Errorf("cancelled task run: expected cancelled, got %q", s) - } - if s := statusByID[cancelledAndArchived.ID]; s != RunStatusArchived { - t.Errorf("cancelled-and-archived task run: expected archived_at to take precedence (archived), got %q", s) - } - if s := statusByID[resumed.ID]; s != RunStatusTaskCreated { - t.Errorf("resumed-after-cancel task run: expected task_created (stale session ignored), got %q", s) - } - if s := statusByID[missing.ID]; s != RunStatusCancelled { - t.Errorf("missing task run: expected cancelled, got %q", s) - } - if s := statusByID[emptyTaskID.ID]; s != RunStatusCancelled { - t.Errorf("empty task_id run: expected cancelled, got %q", s) - } - if s := statusByID[succeededOnArchived.ID]; s != RunStatusSucceeded { - t.Errorf("already-succeeded run on now-archived task: expected succeeded preserved, got %q", s) - } -} - -// TestListRuns_FallsBackWhenTasksTableAbsent mirrors -// TestCountActiveRuns_FallsBackWhenTasksTableAbsent for the display path. -func TestListRuns_FallsBackWhenTasksTableAbsent(t *testing.T) { - store := setupTestStore(t) - ctx := context.Background() - - a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} - if err := store.CreateAutomation(ctx, a); err != nil { - t.Fatal(err) - } - if err := store.CreateRun(ctx, &AutomationRun{ - AutomationID: a.ID, - TriggerType: TriggerTypeScheduled, - Status: RunStatusTaskCreated, - TaskID: "task-xyz", - TriggerData: json.RawMessage(`{}`), - }); err != nil { - t.Fatal(err) - } - - got, err := store.ListRuns(ctx, a.ID, 10) - if err != nil { - t.Fatal(err) - } - if len(got) != 1 || got[0].Status != RunStatusTaskCreated { - t.Fatalf("expected 1 run with status task_created when tasks table is absent, got %+v", got) + if len(got) != 0 { + t.Fatalf("expected no matches for an empty profile id, got %+v", got) } } diff --git a/apps/backend/internal/automation/store_workspace_runs_test.go b/apps/backend/internal/automation/store_workspace_runs_test.go new file mode 100644 index 0000000000..53f8c05c53 --- /dev/null +++ b/apps/backend/internal/automation/store_workspace_runs_test.go @@ -0,0 +1,341 @@ +package automation + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + _ "github.com/mattn/go-sqlite3" +) + +func TestListWorkspaceRuns_InterleavesAutomationsNewestFirstAndExcludesOtherWorkspaces(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + sweep := &Automation{WorkspaceID: "ws-1", Name: "nightly sweep", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + triage := &Automation{WorkspaceID: "ws-1", Name: "pr triage", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + foreign := &Automation{WorkspaceID: "ws-2", Name: "someone else's", WorkflowID: "wf-9", WorkflowStepID: "s-9"} + for _, a := range []*Automation{sweep, triage, foreign} { + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + } + + base := time.Now().UTC().Truncate(time.Second) + seed := func(a *Automation, at time.Time) *AutomationRun { + t.Helper() + r := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusSucceeded, TriggerData: json.RawMessage(`{}`)} + if err := store.CreateRun(ctx, r); err != nil { + t.Fatal(err) + } + setRunCreatedAt(t, store, r.ID, at) + return r + } + oldest := seed(sweep, base) + middle := seed(triage, base.Add(time.Minute)) + newest := seed(sweep, base.Add(2*time.Minute)) + seed(foreign, base.Add(3*time.Minute)) + + got, err := store.ListWorkspaceRuns(ctx, "ws-1", 50) + if err != nil { + t.Fatal(err) + } + if len(got) != 3 { + t.Fatalf("expected 3 runs from ws-1 only, got %d", len(got)) + } + wantOrder := []string{newest.ID, middle.ID, oldest.ID} + for i, want := range wantOrder { + if got[i].ID != want { + t.Fatalf("run %d: expected %s (newest-first), got %s", i, want, got[i].ID) + } + } + if got[0].AutomationName != "nightly sweep" { + t.Errorf("newest run: expected nightly sweep, got %q", got[0].AutomationName) + } + if got[1].AutomationName != "pr triage" { + t.Errorf("middle run: expected pr triage, got %q", got[1].AutomationName) + } + if string(got[0].TriggerData) != `{}` { + t.Errorf("expected trigger_data hydrated from its stored JSON, got %q", string(got[0].TriggerData)) + } +} + +// The workspace feed must not tell a different story about the same run +// than the automation's own page does. Mirrors +// TestListRuns_DerivesArchivedCancelledAndActiveStatus. +func TestListWorkspaceRuns_DerivesArchivedCancelledAndActiveStatus(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + insertTask(t, store, "task-active", false) + insertTask(t, store, "task-archived", true) + insertTask(t, store, "task-cancelled", false) + insertPrimarySession(t, store, "task-cancelled", "CANCELLED") + insertTask(t, store, "task-cancelled-and-archived", true) + insertPrimarySession(t, store, "task-cancelled-and-archived", "CANCELLED") + insertTask(t, store, "task-resumed", false) + insertStaleSession(t, store, "task-resumed", "CANCELLED") + insertPrimarySession(t, store, "task-resumed", "COMPLETED") + + active := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-active", TriggerData: json.RawMessage(`{}`)} + archived := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-archived", TriggerData: json.RawMessage(`{}`)} + cancelled := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-cancelled", TriggerData: json.RawMessage(`{}`)} + cancelledAndArchived := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-cancelled-and-archived", TriggerData: json.RawMessage(`{}`)} + resumed := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-resumed", TriggerData: json.RawMessage(`{}`)} + missing := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "task-missing", TriggerData: json.RawMessage(`{}`)} + emptyTaskID := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, TaskID: "", TriggerData: json.RawMessage(`{}`)} + succeededOnArchived := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusSucceeded, TaskID: "task-archived", TriggerData: json.RawMessage(`{}`)} + for _, r := range []*AutomationRun{active, archived, cancelled, cancelledAndArchived, resumed, missing, emptyTaskID, succeededOnArchived} { + if err := store.CreateRun(ctx, r); err != nil { + t.Fatal(err) + } + } + + got, err := store.ListWorkspaceRuns(ctx, "ws-1", 50) + if err != nil { + t.Fatal(err) + } + statusByID := map[string]RunStatus{} + for _, r := range got { + statusByID[r.ID] = r.Status + } + for _, tc := range []struct { + name string + id string + want RunStatus + }{ + {"active task run", active.ID, RunStatusTaskCreated}, + {"archived task run", archived.ID, RunStatusArchived}, + {"cancelled task run", cancelled.ID, RunStatusCancelled}, + {"cancelled-and-archived task run (archived wins)", cancelledAndArchived.ID, RunStatusArchived}, + {"resumed-after-cancel task run (stale session ignored)", resumed.ID, RunStatusTaskCreated}, + {"missing task run", missing.ID, RunStatusCancelled}, + {"empty task_id run", emptyTaskID.ID, RunStatusCancelled}, + {"already-succeeded run on now-archived task", succeededOnArchived.ID, RunStatusSucceeded}, + } { + if s := statusByID[tc.id]; s != tc.want { + t.Errorf("%s: expected %q, got %q", tc.name, tc.want, s) + } + } +} + +// A run-mode automation hides its task, so in the workspace feed the +// summary is the entire visible outcome of the run. +func TestListWorkspaceRuns_CarriesTheAgentsLastMessageAsSummary(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "reporter", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + insertTask(t, store, "task-report", false) + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSucceeded, + TaskID: "task-report", + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + + seed := func(id, author, content string, at time.Time) { + t.Helper() + if _, err := store.db.Exec( + `INSERT INTO task_session_messages (id, task_id, author_type, content, type, created_at) VALUES (?,?,?,?,?,?)`, + id, "task-report", author, content, "message", at); err != nil { + t.Fatal(err) + } + } + base := time.Now().UTC() + seed("m1", "user", "run the sweep", base) + seed("m2", "agent", "Working on it.", base.Add(time.Minute)) + seed("m3", "agent", "Sweep complete across all 32 specs.", base.Add(2*time.Minute)) + + runs, err := store.ListWorkspaceRuns(ctx, "ws-1", 50) + if err != nil { + t.Fatal(err) + } + if len(runs) != 1 { + t.Fatalf("expected 1 run, got %d", len(runs)) + } + if runs[0].Summary != "Sweep complete across all 32 specs." { + t.Errorf("expected the agent's LAST message, got %q", runs[0].Summary) + } +} + +// TestListWorkspaceRuns_FallsBackWhenTasksTableAbsent mirrors +// TestListRuns_FallsBackWhenTasksTableAbsent: the workspace feed still has +// to attribute each run even on the fallback path, so the automations join +// must survive the missing-tasks-table retry. +func TestListWorkspaceRuns_FallsBackWhenTasksTableAbsent(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: "task-xyz", + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + + got, err := store.ListWorkspaceRuns(ctx, "ws-1", 50) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || got[0].Status != RunStatusTaskCreated { + t.Fatalf("expected 1 run with status task_created when tasks table is absent, got %+v", got) + } + if got[0].AutomationName != "A" { + t.Errorf("expected automation_name on the fallback path too, got %q", got[0].AutomationName) + } +} + +// The workspace feed spans every automation, so an uncapped limit is a +// whole-history dump over the socket — the cap is a server-side guarantee, +// not a client courtesy. +func TestListWorkspaceRuns_LimitIsRespectedAndCapped(t *testing.T) { + store := setupTestStore(t) + createTasksTable(t, store) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "A", WorkflowID: "wf-1", WorkflowStepID: "s-1", Enabled: true} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + for i := 0; i < maxWorkspaceRunsLimit+10; i++ { + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSucceeded, + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + } + + got, err := store.ListWorkspaceRuns(ctx, "ws-1", 5) + if err != nil { + t.Fatal(err) + } + if len(got) != 5 { + t.Fatalf("expected the requested 5 runs, got %d", len(got)) + } + + got, err = store.ListWorkspaceRuns(ctx, "ws-1", 0) + if err != nil { + t.Fatal(err) + } + if len(got) != 50 { + t.Fatalf("expected the default of 50 runs for a zero limit, got %d", len(got)) + } + + got, err = store.ListWorkspaceRuns(ctx, "ws-1", 10000) + if err != nil { + t.Fatal(err) + } + if len(got) != maxWorkspaceRunsLimit { + t.Fatalf("expected the limit capped at %d, got %d", maxWorkspaceRunsLimit, len(got)) + } +} + +// The feed shows two lines of prose, so the tail of a long agent message is +// truncated server-side rather than shipping the whole report to every row. +// Without this the substr could be dropped and every test would stay green. +func TestListWorkspaceRuns_TruncatesALongSummary(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + createTasksTable(t, store) + + a := &Automation{WorkspaceID: "ws-1", Name: "verbose", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + insertTask(t, store, "task-long", false) + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSucceeded, + TaskID: "task-long", + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + long := strings.Repeat("x", 500) + if _, err := store.db.Exec( + `INSERT INTO task_session_messages (id, task_id, author_type, content, type, created_at) VALUES (?,?,?,?,?,?)`, + "m-long", "task-long", "agent", long, "message", time.Now().UTC()); err != nil { + t.Fatal(err) + } + + runs, err := store.ListWorkspaceRuns(ctx, "ws-1", 10) + if err != nil { + t.Fatal(err) + } + if len(runs) != 1 { + t.Fatalf("expected 1 run, got %d", len(runs)) + } + if got := len(runs[0].Summary); got != 280 { + t.Errorf("expected the summary truncated to 280 chars, got %d", got) + } +} + +// A run keeps its task_id after the task row is deleted. Reporting it would +// render the feed entry as a link to a transcript that no longer exists. +func TestListWorkspaceRuns_DropsTaskIDWhenTheTaskIsGone(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + createTasksTable(t, store) + + a := &Automation{WorkspaceID: "ws-1", Name: "gone", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + // Deliberately no task row for this id. + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: "task-deleted", + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + + runs, err := store.ListWorkspaceRuns(ctx, "ws-1", 10) + if err != nil { + t.Fatal(err) + } + if len(runs) != 1 { + t.Fatalf("expected 1 run, got %d", len(runs)) + } + if runs[0].TaskID != "" { + t.Errorf("expected no task id for a deleted task, got %q", runs[0].TaskID) + } + if runs[0].Status != RunStatusCancelled { + t.Errorf("expected the derived cancelled status, got %q", runs[0].Status) + } +} + +// The runs list used to read each automation's health out of the capped +// workspace feed. Past the cap an automation's newest run falls outside the +// window and its row claims "No runs yet" — the one thing a health indicator +// must never get wrong. Summaries answer per automation, so a noisy neighbour +// cannot push a quiet automation's last run out of view. diff --git a/apps/backend/internal/automation/webhook.go b/apps/backend/internal/automation/webhook.go index c2d033a497..b55af6c145 100644 --- a/apps/backend/internal/automation/webhook.go +++ b/apps/backend/internal/automation/webhook.go @@ -80,7 +80,7 @@ func (h *WebhookHandler) Handle(c *gin.Context) { triggerData, _ = json.Marshal(map[string]string{"body": string(body)}) } - if fireErr := h.svc.FireTrigger(c.Request.Context(), automationID, triggerID, TriggerTypeWebhook, triggerData, ""); fireErr != nil { + if _, fireErr := h.svc.FireTrigger(c.Request.Context(), automationID, triggerID, TriggerTypeWebhook, triggerData, ""); fireErr != nil { h.logger.Error("failed to fire webhook trigger", zap.String("automation_id", automationID), zap.Error(fireErr)) diff --git a/apps/backend/internal/backendapp/e2e_reset.go b/apps/backend/internal/backendapp/e2e_reset.go index c6e5d4c8d8..4939e63196 100644 --- a/apps/backend/internal/backendapp/e2e_reset.go +++ b/apps/backend/internal/backendapp/e2e_reset.go @@ -49,7 +49,7 @@ func registerE2EResetRoutes( // Automation seeding helpers for E2E tests that need runs without going // through the WS API (works on any Node version). if automationSvc != nil { - api.POST("/automations", handleE2ECreateAutomation(automationSvc, log)) + api.POST("/automations", handleE2ECreateAutomation(automationSvc, repo, log)) api.POST("/automation-runs", handleE2ECreateAutomationRun(automationSvc, log)) } // Seeds a task_sessions row directly (e.g. a CANCELLED primary session) @@ -61,6 +61,12 @@ func registerE2EResetRoutes( // seed "the user last read up through an earlier message" without a real // background agent turn. api.PATCH("/task-sessions/:id/read-cursor", handleE2ESetSessionReadCursor(repo, log)) + // Stamps a task's origin. Production sets origin=automation_run inside the + // automation firing path, so a task seeded through the ordinary task API is + // an ordinary task — it shows on the kanban and in task lists, which is the + // opposite of what an automation run does. Specs that assert on (or + // photograph) that hiding need the seeded state to match production. + api.PATCH("/tasks/:id/origin", handleE2ESetTaskOrigin(repo, log)) log.Info("registered E2E endpoints (test-only)") } @@ -326,11 +332,30 @@ type e2eCreateAutomationRequest struct { Name string `json:"name"` WorkflowID string `json:"workflow_id"` WorkflowStepID string `json:"workflow_step_id"` + // Prompt is the automation's standing instruction. Optional, but the run + // view only renders the instruction card when there is one, so a spec + // asserting on where that card lives has to seed it. + Prompt string `json:"prompt"` + // LegacyBoardCard rewrites the row's execution_mode to 'task' after + // creation, reproducing on disk exactly what an install that predates the + // withdrawal of execution modes carries. There is no input path to this: + // CreateAutomation deliberately writes the empty string so a row created + // today can never be mistaken for a pre-upgrade one (see + // automation.Store.CreateAutomation), and the field is ignored on the + // wire. The migration notice is derived from this column by production + // SQL (`execution_mode = 'task' AS legacy_board_card`), so seeding the + // column is the only honest way to put a workspace in the state the + // notice exists to explain. + LegacyBoardCard bool `json:"legacy_board_card"` } // handleE2ECreateAutomation seeds an automation for E2E tests via HTTP so tests // don't require Node 24 (WS API requires a global WebSocket that isn't in Node 20). -func handleE2ECreateAutomation(svc *automation.Service, log *logger.Logger) gin.HandlerFunc { +func handleE2ECreateAutomation( + svc *automation.Service, + repo *sqliterepo.Repository, + log *logger.Logger, +) gin.HandlerFunc { return func(c *gin.Context) { var body e2eCreateAutomationRequest if err := c.ShouldBindJSON(&body); err != nil || body.WorkspaceID == "" || body.Name == "" { @@ -342,6 +367,7 @@ func handleE2ECreateAutomation(svc *automation.Service, log *logger.Logger) gin. Name: body.Name, WorkflowID: body.WorkflowID, WorkflowStepID: body.WorkflowStepID, + Prompt: body.Prompt, MaxConcurrentRuns: 10, }) if err != nil { @@ -349,6 +375,16 @@ func handleE2ECreateAutomation(svc *automation.Service, log *logger.Logger) gin. c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) return } + if body.LegacyBoardCard { + // Automations live in the same SQLite database the task repository + // holds open, so no second handle is needed. + if _, err := repo.DB().ExecContext(c.Request.Context(), + `UPDATE automations SET execution_mode = 'task' WHERE id = ?`, a.ID); err != nil { + log.Error("e2e: failed to backdate automation execution_mode", zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + } c.JSON(http.StatusCreated, gin.H{"id": a.ID, "workspace_id": a.WorkspaceID, "name": a.Name}) } } @@ -459,6 +495,62 @@ type e2eSetSessionReadCursorRequest struct { // read up through an earlier message" deterministically, since replaying an // older messageId through the real mark-read endpoint is now a rejected // no-op rather than a rewind. +type e2eSetTaskOriginRequest struct { + Origin string `json:"origin"` +} + +// e2eForceColumn is the shape both force-set seeders share: bind one field, +// write one column by id, and translate "no rows" into a 404 so a spec that +// seeds against a typo fails where the mistake is rather than three assertions +// later. Extracted because the two handlers were byte-for-byte alike apart +// from the table, and a copied handler is a handler that drifts. +func e2eForceColumn( + c *gin.Context, + repo *sqliterepo.Repository, + log *logger.Logger, + opts e2eForceColumnOpts, +) { + result, err := repo.DB().ExecContext(c.Request.Context(), opts.query, opts.value, time.Now().UTC(), opts.id) + if err != nil { + log.Error("e2e: "+opts.failLog, zap.Error(err)) + c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) + return + } + rows, _ := result.RowsAffected() + if rows == 0 { + c.JSON(http.StatusNotFound, gin.H{errKey: opts.subject + " not found: " + opts.id}) + return + } + c.JSON(http.StatusOK, gin.H{"id": opts.id, opts.responseKey: opts.value}) +} + +type e2eForceColumnOpts struct { + query string + id string + value any + subject string + responseKey string + failLog string +} + +func handleE2ESetTaskOrigin(repo *sqliterepo.Repository, log *logger.Logger) gin.HandlerFunc { + return func(c *gin.Context) { + var body e2eSetTaskOriginRequest + if err := c.ShouldBindJSON(&body); err != nil || body.Origin == "" { + c.JSON(http.StatusBadRequest, gin.H{errKey: "origin is required"}) + return + } + e2eForceColumn(c, repo, log, e2eForceColumnOpts{ + query: `UPDATE tasks SET origin = ?, updated_at = ? WHERE id = ?`, + id: c.Param("id"), + value: body.Origin, + subject: "task", + responseKey: "origin", + failLog: "failed to set task origin", + }) + } +} + func handleE2ESetSessionReadCursor(repo *sqliterepo.Repository, log *logger.Logger) gin.HandlerFunc { return func(c *gin.Context) { sessionID := c.Param("id") @@ -467,20 +559,13 @@ func handleE2ESetSessionReadCursor(repo *sqliterepo.Repository, log *logger.Logg c.JSON(http.StatusBadRequest, gin.H{errKey: "message_id is required"}) return } - result, err := repo.DB().ExecContext(c.Request.Context(), - `UPDATE task_sessions SET last_read_message_id = ?, updated_at = ? WHERE id = ?`, - body.MessageID, time.Now().UTC(), sessionID, - ) - if err != nil { - log.Error("e2e: failed to force-set session read cursor", zap.Error(err)) - c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) - return - } - rows, _ := result.RowsAffected() - if rows == 0 { - c.JSON(http.StatusNotFound, gin.H{errKey: "session not found: " + sessionID}) - return - } - c.JSON(http.StatusOK, gin.H{"id": sessionID, "last_read_message_id": body.MessageID}) + e2eForceColumn(c, repo, log, e2eForceColumnOpts{ + query: `UPDATE task_sessions SET last_read_message_id = ?, updated_at = ? WHERE id = ?`, + id: sessionID, + value: body.MessageID, + subject: "session", + responseKey: "last_read_message_id", + failLog: "failed to force-set session read cursor", + }) } } diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 4a88386a33..3f85b702ac 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -526,6 +526,15 @@ func startAgentInfrastructure( agentSettingsController.SetRoutingTierDependencyChecker(&routingTierDepsAdapter{ repo: repos.Office, }) + // An enabled automation is a standing instruction to launch against a + // profile. Nothing is running, so it never reaches the active-session list, + // but deleting the profile would leave the schedule firing into nothing — + // quietly, hours later. Name them in the confirmation instead. + if services.Automation != nil { + agentSettingsController.SetAutomationDependencyChecker(&automationDepsAdapter{ + store: services.Automation.Service.Store(), + }) + } // Wire GitHub service into orchestrator for PR auto-detection on push if services.GitHub != nil { diff --git a/apps/backend/internal/backendapp/orchestrator.go b/apps/backend/internal/backendapp/orchestrator.go index dff36fce95..14f3764f08 100644 --- a/apps/backend/internal/backendapp/orchestrator.go +++ b/apps/backend/internal/backendapp/orchestrator.go @@ -15,6 +15,7 @@ import ( "github.com/kandev/kandev/internal/agent/runtime/lifecycle" agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" + automationpkg "github.com/kandev/kandev/internal/automation" "github.com/kandev/kandev/internal/common/config" "github.com/kandev/kandev/internal/common/gitref" "github.com/kandev/kandev/internal/common/logger" @@ -120,6 +121,9 @@ func provideOrchestrator( taskSvc.SetRowLivenessProber(agentManagerClient) taskSvc.SetContextWindowResetter(orchestratorSvc.ResetContextWindow) taskSvc.SetGitArchiveCapture(orchestratorSvc) + // Automation runs keep their worktrees so they stay repliable, which makes + // them the one task kind nothing else ever cleans up; the orchestrator needs + // the manager to enforce the per-automation retention window. orchestratorSvc.SetWorktreeManager(lifecycleMgr.WorktreeManager()) msgCreator := &messageCreatorAdapter{svc: taskSvc, logger: log} @@ -513,6 +517,55 @@ func (a *profileLookupAdapter) LookupProfile(ctx context.Context, profileID stri // // Each integration's package is optional in dev mode; nil-safe so the // adapter degrades gracefully when one isn't wired. +// automationDepsAdapter lets the agent-settings controller name the enabled +// automations bound to a profile without importing the automation package's +// types into it. +type automationDepsAdapter struct { + store *automationpkg.Store +} + +func (a *automationDepsAdapter) ListEnabledAutomationsByAgentProfile( + ctx context.Context, profileID string, +) ([]agentsettingscontroller.AutomationReference, error) { + if a == nil || a.store == nil { + return nil, nil + } + bindings, err := a.store.ListEnabledByAgentProfile(ctx, profileID) + if err != nil { + return nil, err + } + refs := make([]agentsettingscontroller.AutomationReference, 0, len(bindings)) + for _, b := range bindings { + refs = append(refs, agentsettingscontroller.AutomationReference{ + ID: b.ID, + Name: b.Name, + WorkspaceID: b.WorkspaceID, + }) + } + return refs, nil +} + +func (a *automationDepsAdapter) DisableAutomationsByAgentProfile( + ctx context.Context, profileID string, +) ([]agentsettingscontroller.AutomationReference, error) { + if a == nil || a.store == nil { + return nil, nil + } + bindings, err := a.store.DisableByAgentProfile(ctx, profileID) + if err != nil { + return nil, err + } + refs := make([]agentsettingscontroller.AutomationReference, 0, len(bindings)) + for _, b := range bindings { + refs = append(refs, agentsettingscontroller.AutomationReference{ + ID: b.ID, + Name: b.Name, + WorkspaceID: b.WorkspaceID, + }) + } + return refs, nil +} + type watcherDepsAdapter struct { linear *linearpkg.Service jira *jirapkg.Service diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index 74e91e770d..88ad98e1f0 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -170,6 +170,15 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories automationComponents.Service.SetTaskDeleter(&automationTaskDeleterAdapter{svc: taskSvc}) // Per-user workspace scoping for the automation HTTP/WS surface. automationComponents.Service.SetWorkspaceAuthorizer(taskSvc.AuthorizeWorkspaceAccess) + // A UI filter is not an authorization boundary: reject a workflow owned + // by another workspace even when a request names it directly. + automationComponents.Service.SetWorkflowLocator(&automationWorkflowLocatorAdapter{svc: taskSvc}) + // Profile deletion disables the automations bound to a profile before + // the row goes, but nothing ever checked that the binding pointed at a + // real profile in the first place — so a create or rebind naming an id + // that never existed produced the same orphan without any delete + // involved. + automationComponents.Service.SetAgentProfileLookup(&automationAgentProfileLookupAdapter{store: repos.AgentSettings}) } services := &Services{ diff --git a/apps/backend/internal/backendapp/turn_adapters.go b/apps/backend/internal/backendapp/turn_adapters.go index 8dc4865078..5a993926e7 100644 --- a/apps/backend/internal/backendapp/turn_adapters.go +++ b/apps/backend/internal/backendapp/turn_adapters.go @@ -2,6 +2,7 @@ package backendapp import ( "context" + "database/sql" "errors" "fmt" @@ -132,6 +133,48 @@ func (a *taskDeleterAdapter) translateDeleteErr(err error) error { // automation.ErrTaskNotFound so the automation run-cleanup paths can // classify the "already gone" case via errors.Is without importing the task // repository's package. +// automationWorkflowLocatorAdapter lets the automation service verify that a +// workflow belongs to the workspace an automation is saved into, without the +// automation package importing the task service. +type automationWorkflowLocatorAdapter struct { + svc *taskservice.Service +} + +func (a *automationWorkflowLocatorAdapter) WorkflowWorkspaceID(ctx context.Context, workflowID string) (string, error) { + wf, err := a.svc.GetWorkflow(ctx, workflowID) + if err != nil { + return "", err + } + if wf == nil { + return "", nil + } + return wf.WorkspaceID, nil +} + +// automationAgentProfileLookupAdapter satisfies automation.AgentProfileLookup +// over the agent settings store, so the automation service can refuse a +// binding to a profile that isn't there without importing the settings +// controller that already imports it. +// +// GetAgentProfile filters soft-deleted rows and reports a miss as +// sql.ErrNoRows, which is the only shape translated to (false, nil). Every +// other error is returned verbatim so a driver failure is never mistaken for a +// deleted profile — the service surfaces it instead of rejecting the binding. +type automationAgentProfileLookupAdapter struct { + store settingsstore.Repository +} + +func (a *automationAgentProfileLookupAdapter) AgentProfileExists(ctx context.Context, profileID string) (bool, error) { + profile, err := a.store.GetAgentProfile(ctx, profileID) + if errors.Is(err, sql.ErrNoRows) { + return false, nil + } + if err != nil { + return false, err + } + return profile != nil, nil +} + type automationTaskDeleterAdapter struct { svc *taskservice.Service } diff --git a/apps/backend/internal/office/dashboard/handler_test.go b/apps/backend/internal/office/dashboard/handler_test.go index cfb8a3051d..bc7bb714c9 100644 --- a/apps/backend/internal/office/dashboard/handler_test.go +++ b/apps/backend/internal/office/dashboard/handler_test.go @@ -137,6 +137,7 @@ func newTestDeps(t *testing.T) *testDeps { labels TEXT DEFAULT '[]', identifier TEXT DEFAULT '', is_ephemeral INTEGER DEFAULT 0, + origin TEXT DEFAULT 'manual', execution_policy TEXT DEFAULT '', execution_state TEXT DEFAULT '', workflow_id TEXT NOT NULL DEFAULT '', diff --git a/apps/backend/internal/office/repository/sqlite/dashboard.go b/apps/backend/internal/office/repository/sqlite/dashboard.go index 6caefa4b1f..6647e9debf 100644 --- a/apps/backend/internal/office/repository/sqlite/dashboard.go +++ b/apps/backend/internal/office/repository/sqlite/dashboard.go @@ -29,7 +29,7 @@ func (r *Repository) QueryRunActivity(ctx context.Context, workspaceID string, d FROM task_sessions ts WHERE ts.started_at >= ? AND ts.task_id IN ( - SELECT id FROM tasks WHERE workspace_id = ? AND is_ephemeral = 0 + SELECT id FROM tasks WHERE workspace_id = ? AND is_ephemeral = 0`+andNotAutomationOrigin+` ) GROUP BY DATE(ts.started_at) ORDER BY date @@ -53,7 +53,7 @@ func (r *Repository) QueryTaskBreakdown(ctx context.Context, workspaceID string) SELECT COALESCE(state,'') as state, COUNT(*) as count FROM tasks WHERE workspace_id = ? - AND is_ephemeral = 0 + AND is_ephemeral = 0`+andNotAutomationOrigin+` AND archived_at IS NULL GROUP BY state `), workspaceID) @@ -85,7 +85,7 @@ func (r *Repository) QueryRecentTasks(ctx context.Context, workspaceID string, l updated_at FROM tasks WHERE workspace_id = ? - AND is_ephemeral = 0 + AND is_ephemeral = 0`+andNotAutomationOrigin+` AND archived_at IS NULL ORDER BY updated_at DESC LIMIT ? @@ -119,7 +119,7 @@ func (r *Repository) QueryRecentSessions(ctx context.Context, workspaceID string ts.completed_at FROM task_sessions ts WHERE ts.task_id IN ( - SELECT id FROM tasks WHERE workspace_id = ? AND is_ephemeral = 0 + SELECT id FROM tasks WHERE workspace_id = ? AND is_ephemeral = 0`+andNotAutomationOrigin+` ) ORDER BY ts.started_at DESC LIMIT ? diff --git a/apps/backend/internal/office/repository/sqlite/tasks.go b/apps/backend/internal/office/repository/sqlite/tasks.go index f32831cfec..222a1c525c 100644 --- a/apps/backend/internal/office/repository/sqlite/tasks.go +++ b/apps/backend/internal/office/repository/sqlite/tasks.go @@ -9,6 +9,7 @@ import ( "github.com/jmoiron/sqlx" + taskmodels "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" ) @@ -19,6 +20,16 @@ import ( // safely removable. var ErrTaskNotFound = errors.New("task not found") +// Automation runs never appear in a task list: they are hidden by their +// provenance, not by ephemerality (docs/specs/office/automations-settings.md). +// is_ephemeral keeps its original quick-chat meaning, so every list read here +// pairs the two. +const ( + notAutomationOriginT = `COALESCE(t.origin, '') != '` + taskmodels.TaskOriginAutomationRun + `'` + andNotAutomationOrigin = ` AND COALESCE(origin, '') != '` + taskmodels.TaskOriginAutomationRun + `'` + andNotAutomationOriginT = " AND " + notAutomationOriginT +) + // systemTasksJoin is the JOIN onto the workflows table used by every // task list/search query that needs to know whether a task lives in a // kandev-managed system workflow (coordination, future routines). @@ -253,6 +264,7 @@ func (r *Repository) ListTasksByWorkspace(ctx context.Context, workspaceID strin "t.workspace_id = ?", "t.archived_at IS NULL", "t.is_ephemeral = 0", + notAutomationOriginT, } if !includeSystem && len(sysArgs) > 0 { where = append(where, "COALESCE(w.workflow_template_id,'') NOT IN ("+sysPh+")") @@ -439,6 +451,7 @@ func buildTaskWhereClause( "t.workspace_id = ?", "t.archived_at IS NULL", "t.is_ephemeral = 0", + notAutomationOriginT, } if len(opts.Status) > 0 { ph, vals := bindList(opts.Status) @@ -549,7 +562,7 @@ func (r *Repository) ListChildTasks(ctx context.Context, parentID string) ([]*Ta FROM tasks t WHERE t.parent_id = ? AND t.archived_at IS NULL - AND t.is_ephemeral = 0 + AND t.is_ephemeral = 0`+andNotAutomationOriginT+` ORDER BY t.created_at ASC `), parentID) if err != nil { @@ -620,7 +633,7 @@ func (r *Repository) searchTasksFTS(ctx context.Context, workspaceID, query stri WHERE fts.tasks_fts MATCH ? AND t.workspace_id = ? AND t.archived_at IS NULL - AND t.is_ephemeral = 0 + AND t.is_ephemeral = 0`+andNotAutomationOriginT+` ORDER BY rank LIMIT ? `), ftsQuery, workspaceID, limit) @@ -651,7 +664,7 @@ func (r *Repository) searchTasksLike(ctx context.Context, workspaceID, query str FROM tasks t WHERE t.workspace_id = ? AND t.archived_at IS NULL - AND t.is_ephemeral = 0 + AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (t.title LIKE ? OR t.description LIKE ? OR t.identifier LIKE ?) ORDER BY t.updated_at DESC LIMIT ? @@ -680,13 +693,17 @@ func scanSearchResults(rows *sqlx.Rows) ([]*TaskSearchResult, error) { // the given agent that are in an actionable state (TODO or IN_PROGRESS) // and not archived. Resolves the assignee through the runner projection // so per-task overrides and step-primary fallbacks both count. +// +// Automation runs are excluded: an automation configured against a workflow +// step resolves to that step's runner, so counting its long-lived run would +// inflate the agent's load forever and starve it of real work. func (r *Repository) CountActionableTasksForAgent(ctx context.Context, agentID string) (int, error) { var count int err := r.ro.QueryRowxContext(ctx, r.ro.Rebind(` SELECT COUNT(*) FROM tasks t WHERE `+RunnerProjection("t")+` = ? AND t.state IN ('TODO', 'IN_PROGRESS') - AND t.archived_at IS NULL + AND t.archived_at IS NULL`+andNotAutomationOriginT+` `), agentID).Scan(&count) return count, err } @@ -841,11 +858,16 @@ func (r *Repository) CountTasksByWorkspace(ctx context.Context, workspaceID stri SELECT COUNT(*) FROM tasks WHERE workspace_id = ? AND archived_at IS NULL - AND is_ephemeral = 0 + AND is_ephemeral = 0`+andNotAutomationOrigin+` `), workspaceID).Scan(&count) return count, err } +// ListUnstartedTasks feeds scheduler recovery, which launches whatever it +// finds. Automation runs are excluded: they are started once, explicitly, at +// trigger time, and recovery picking one up would launch it a second time +// through a lifecycle path that knows nothing about the automation's run row +// or its concurrency cap. func (r *Repository) ListUnstartedTasks( ctx context.Context, lookbackHours int, limit int, ) ([]*UnstartedTaskRow, error) { @@ -858,7 +880,7 @@ func (r *Repository) ListUnstartedTasks( WHERE t.state = 'TODO' AND `+taskrepo.IsFromOfficePredicate("t")+` AND `+RunnerProjection("t")+` != '' - AND t.archived_at IS NULL + AND t.archived_at IS NULL`+andNotAutomationOriginT+` AND t.created_at >= datetime('now', '-' || ? || ' hours') AND NOT EXISTS ( SELECT 1 FROM runs w diff --git a/apps/backend/internal/office/repository/sqlite/tasks_filtered_test.go b/apps/backend/internal/office/repository/sqlite/tasks_filtered_test.go index 89d9eaf979..b80d2d58fe 100644 --- a/apps/backend/internal/office/repository/sqlite/tasks_filtered_test.go +++ b/apps/backend/internal/office/repository/sqlite/tasks_filtered_test.go @@ -28,6 +28,7 @@ func ensureTasksTable(t *testing.T, repo *sqlite.Repository) { labels TEXT DEFAULT '[]', identifier TEXT DEFAULT '', is_ephemeral INTEGER NOT NULL DEFAULT 0, + origin TEXT DEFAULT 'manual', archived_at DATETIME, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP diff --git a/apps/backend/internal/office/repository/sqlite/tasks_test.go b/apps/backend/internal/office/repository/sqlite/tasks_test.go index 4f3c80a27f..ec965e7ed7 100644 --- a/apps/backend/internal/office/repository/sqlite/tasks_test.go +++ b/apps/backend/internal/office/repository/sqlite/tasks_test.go @@ -43,6 +43,7 @@ func newSearchTestRepo(t *testing.T) *sqlite.Repository { labels TEXT DEFAULT '[]', identifier TEXT DEFAULT '', is_ephemeral INTEGER DEFAULT 0, + origin TEXT DEFAULT 'manual', archived_at TIMESTAMP, created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP @@ -750,3 +751,113 @@ func TestCountActionableTasksForAgent_AgentIsolation(t *testing.T) { t.Errorf("agent-b: expected 2, got %d", countB) } } + +// Automation runs are hidden from the task list by their origin, not by +// is_ephemeral: they are ordinary persistent tasks with their own destination +// (docs/specs/office/automations-settings.md). The quick chat alongside them +// still behaves exactly as it did. +func TestOfficeTaskListsExcludeAutomationOriginTasks(t *testing.T) { + repo := newSearchTestRepo(t) + ctx := context.Background() + + insertTask(t, repo, ctx, "human", "ws-auto", "Nightly sweep report", "", "KAN-1") + now := "2025-01-01 00:00:00" + if _, err := repo.ExecRaw(ctx, ` + INSERT INTO tasks + (id, workspace_id, title, identifier, origin, created_at, updated_at) + VALUES ('auto', 'ws-auto', 'Nightly sweep run', 'KAN-2', 'automation_run', ?, ?) + `, now, now); err != nil { + t.Fatalf("insert automation task: %v", err) + } + + listed, err := repo.ListTasksByWorkspace(ctx, "ws-auto", true) + if err != nil { + t.Fatalf("ListTasksByWorkspace: %v", err) + } + if len(listed) != 1 || listed[0].ID != "human" { + t.Fatalf("expected only the human task, got %+v", listed) + } + + found, err := repo.SearchTasks(ctx, "ws-auto", "Nightly sweep", 10) + if err != nil { + t.Fatalf("SearchTasks: %v", err) + } + for _, task := range found { + if task.ID == "auto" { + t.Fatalf("automation-origin task must not be searchable in the task list, got %+v", found) + } + } + + count, err := repo.CountTasksByWorkspace(ctx, "ws-auto") + if err != nil { + t.Fatalf("CountTasksByWorkspace: %v", err) + } + if count != 1 { + t.Fatalf("CountTasksByWorkspace = %d, want 1", count) + } +} + +// An automation configured against a workflow step resolves to that step's +// runner. Its run is long-lived and never leaves IN_PROGRESS on its own, so +// counting it would inflate the agent's load permanently and starve it of the +// real work the count exists to balance. +func TestCountActionableTasksForAgent_ExcludesAutomationRuns(t *testing.T) { + repo := newSearchTestRepo(t) + ctx := context.Background() + + agentID := "agent-automation" + insertAssignedTask(t, repo, ctx, "task-human", "ws1", agentID, "IN_PROGRESS", "") + insertAssignedTask(t, repo, ctx, "task-automation", "ws1", agentID, "IN_PROGRESS", "") + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET origin = 'automation_run' WHERE id = ?`, "task-automation", + ); err != nil { + t.Fatalf("tag automation origin: %v", err) + } + + count, err := repo.CountActionableTasksForAgent(ctx, agentID) + if err != nil { + t.Fatalf("CountActionableTasksForAgent: %v", err) + } + if count != 1 { + t.Errorf("expected 1 actionable task (automation run excluded), got %d", count) + } +} + +// Scheduler recovery launches whatever this returns. An automation run is +// started once, explicitly, at trigger time; recovery picking it up would +// start it a second time through a path that knows nothing about the run row +// or the automation's concurrency cap. +func TestListUnstartedTasks_ExcludesAutomationRuns(t *testing.T) { + repo := newSearchTestRepo(t) + ctx := context.Background() + + insertAssignedTask(t, repo, ctx, "unstarted-human", "ws1", "agent-recovery", "TODO", "") + insertAssignedTask(t, repo, ctx, "unstarted-automation", "ws1", "agent-recovery", "TODO", "") + // ListUnstartedTasks only considers office tasks, so both rows need a + // project before origin can be what separates them — otherwise the query + // returns nothing and the test passes for the wrong reason. + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET project_id = 'project-recovery' WHERE id IN (?, ?)`, + "unstarted-human", "unstarted-automation", + ); err != nil { + t.Fatalf("scope tasks to a project: %v", err) + } + if _, err := repo.ExecRaw(ctx, + `UPDATE tasks SET origin = 'automation_run' WHERE id = ?`, "unstarted-automation", + ); err != nil { + t.Fatalf("tag automation origin: %v", err) + } + + rows, err := repo.ListUnstartedTasks(ctx, 24, 50) + if err != nil { + t.Fatalf("ListUnstartedTasks: %v", err) + } + for _, row := range rows { + if row.ID == "unstarted-automation" { + t.Fatalf("scheduler recovery must not pick up an automation run, got %+v", rows) + } + } + if len(rows) != 1 || rows[0].ID != "unstarted-human" { + t.Fatalf("expected only the human task, got %+v", rows) + } +} diff --git a/apps/backend/internal/office/repository/sqlite/tree_holds_test.go b/apps/backend/internal/office/repository/sqlite/tree_holds_test.go index 83b0420560..19d905fcee 100644 --- a/apps/backend/internal/office/repository/sqlite/tree_holds_test.go +++ b/apps/backend/internal/office/repository/sqlite/tree_holds_test.go @@ -19,6 +19,7 @@ func newTreeHoldTestRepo(t *testing.T) *sqlite.Repository { state TEXT NOT NULL DEFAULT 'TODO', parent_id TEXT DEFAULT '', is_ephemeral INTEGER DEFAULT 0, + origin TEXT DEFAULT 'manual', checkout_agent_id TEXT, checkout_at DATETIME, archived_at DATETIME, diff --git a/apps/backend/internal/office/service/base_test.go b/apps/backend/internal/office/service/base_test.go index 11e2718889..51175aa559 100644 --- a/apps/backend/internal/office/service/base_test.go +++ b/apps/backend/internal/office/service/base_test.go @@ -60,6 +60,7 @@ func newTestService(t *testing.T, overrides ...service.ServiceOptions) *service. priority TEXT NOT NULL DEFAULT 'medium' CHECK (priority IN ('critical','high','medium','low')), position INTEGER DEFAULT 0, is_ephemeral INTEGER DEFAULT 0, + origin TEXT DEFAULT 'manual', parent_id TEXT DEFAULT '', execution_policy TEXT DEFAULT '', execution_state TEXT DEFAULT '', diff --git a/apps/backend/internal/office/summary/inputs.go b/apps/backend/internal/office/summary/inputs.go index c1baf6fbfd..8301a71a5a 100644 --- a/apps/backend/internal/office/summary/inputs.go +++ b/apps/backend/internal/office/summary/inputs.go @@ -187,7 +187,7 @@ func loadActivityStats( FROM tasks WHERE workspace_id = ? AND archived_at IS NULL - AND is_ephemeral = 0 + AND is_ephemeral = 0 AND COALESCE(origin,'') != 'automation_run' `), workspaceID).Scan(&stats.InProgress, &stats.OpenTasks) return stats @@ -207,7 +207,7 @@ func loadBlockers(ctx context.Context, reader *sqlx.DB, workspaceID string) []Bl WHERE workspace_id = ? AND state = 'BLOCKED' AND archived_at IS NULL - AND is_ephemeral = 0 + AND is_ephemeral = 0 AND COALESCE(origin,'') != 'automation_run' ORDER BY updated_at DESC LIMIT 10 `), workspaceID) diff --git a/apps/backend/internal/orchestrator/event_handlers_agent.go b/apps/backend/internal/orchestrator/event_handlers_agent.go index d35f79f6cf..8bc3a1c6c2 100644 --- a/apps/backend/internal/orchestrator/event_handlers_agent.go +++ b/apps/backend/internal/orchestrator/event_handlers_agent.go @@ -967,7 +967,7 @@ func (s *Service) handleAgentCompletedLocked(ctx context.Context, data watcher.A zap.String("session_id", data.SessionID)) s.setSessionWaitingForInput(ctx, data.TaskID, data.SessionID, session) go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID) - // captureGitStatusSnapshot and finalizeAutomationRunIfEphemeral are deferred + // captureGitStatusSnapshot and finalizeAutomationRun are deferred // until a later agent turn completes without pending clarifications, or the // user dismisses a stale overlay (clarification.stale_dismissed). return @@ -996,9 +996,9 @@ func (s *Service) handleAgentCompletedLocked(ctx context.Context, data watcher.A // Clean up the agent execution (stop agentctl, release port) go s.cleanupAgentExecution(data.AgentExecutionID, data.TaskID, data.SessionID) - // Finalize run-mode automation runs: mark status=succeeded and reap - // the ephemeral worktree right away (the 24h Office GC is too late). - s.finalizeAutomationRunIfEphemeral(ctx, data.TaskID, data.SessionID, true, "") + // Finalize the automation run: mark status=succeeded so the automation's + // concurrency slot is released. The worktree stays. + s.finalizeAutomationRun(ctx, data.TaskID, true, "") } // handleAgentFailed handles agent failure events @@ -1049,7 +1049,7 @@ func (s *Service) handleAgentFailedLocked(ctx context.Context, data watcher.Agen // Transient provider errors (529 Overloaded) get a paced, visible // retry-with-backoff before any red banner. This is the ONLY non-terminal // failure path, so it runs before automation finalization below — otherwise - // a transient 529 on a run-mode automation would mark the run failed and + // a transient 529 on an automation run would mark the run failed and // reap its ephemeral worktree out from under the in-flight retry. // handleTransientFailure returns false (falling through) for non-transient // errors, office tasks, or an exhausted budget. @@ -1064,15 +1064,15 @@ func (s *Service) handleAgentFailedLocked(ctx context.Context, data watcher.Agen context.WithoutCancel(ctx), data.TaskID, data.SessionID, data.AgentExecutionID, ) - // Terminal from here. Finalize run-mode automation runs — every branch + // Terminal from here. Finalize the automation run — every branch // below returns early (session-backed recoverable failure, no-session retry), - // and run-mode automations need their AutomationRun flipped + worktree reaped - // on *every* terminal failure path. + // and automations need their AutomationRun flipped on *every* terminal + // failure path. errMsg := data.ErrorMessage if errMsg == "" { errMsg = "agent failed" } - s.finalizeAutomationRunIfEphemeral(ctx, data.TaskID, data.SessionID, false, errMsg) + s.finalizeAutomationRun(ctx, data.TaskID, false, errMsg) // Make all agent CLI failures recoverable — let the user choose to resume or start fresh. if data.SessionID != "" { diff --git a/apps/backend/internal/orchestrator/event_handlers_automation.go b/apps/backend/internal/orchestrator/event_handlers_automation.go index aeb95c3f23..31a9788cd8 100644 --- a/apps/backend/internal/orchestrator/event_handlers_automation.go +++ b/apps/backend/internal/orchestrator/event_handlers_automation.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "os" + "sort" "strings" "go.uber.org/zap" @@ -28,16 +30,56 @@ type AutomationService interface { MarkRunSucceededByTaskID(ctx context.Context, taskID string) error } +// taskCleaner deletes a task a firing has abandoned. +// +// Deliberately one method wide, and asserted at the call site rather than added +// to repoStore: that interface is implemented by several mocks, and none of +// them should have to grow a method for a path they never take. +type taskCleaner interface { + DeleteTask(ctx context.Context, id string) error +} + +// automationRunRetention names the runs whose workspaces have aged out, and +// answers whether one of them has since gone live. Kept off AutomationService +// and asserted at the call site for the same reason taskCleaner is kept off +// repoStore: several test stubs implement that interface and none of them +// should grow a method for a path they never take. +// +// Both halves live on one interface on purpose. Selection and the pre-removal +// re-check are two readings of the same question a moment apart, and a service +// that could answer only the first would hand the sweep a list it has no way +// to re-validate — the assertion failing (and nothing being reclaimed) is the +// safe outcome, so it must be all or nothing. +type automationRunRetention interface { + PrunableRunTaskIDs(ctx context.Context, finalizedTaskID string, keep int) ([]string, error) + RunWorkspaceInUse(ctx context.Context, taskID string) (bool, error) +} + +// automationWorktreeReaper is the slice of worktree.Manager that retention +// uses. Narrowed to an interface so the sweep can be tested without a real +// git checkout on disk, and so the orchestrator states exactly which two +// capabilities it depends on. +type automationWorktreeReaper interface { + GetAllByTaskID(ctx context.Context, taskID string) ([]*worktree.Worktree, error) + RemoveByID(ctx context.Context, worktreeID string, removeBranch bool) error +} + // SetAutomationService sets the automation service for handling automation triggers. func (s *Service) SetAutomationService(svc AutomationService) { s.automationService = svc } -// SetWorktreeManager sets the worktree manager. Used to clean up ephemeral -// worktrees for run-mode automation tasks on completion. Nil-safe — handlers -// skip worktree cleanup when not set. +// SetWorktreeManager wires the worktree manager used to reclaim the workspaces +// of automation runs that have aged out of the retention window. +// +// Takes the concrete manager rather than the interface so a nil manager stays +// nil here: assigning a typed-nil pointer into an interface field produces a +// non-nil interface, and every nil check downstream would then pass and panic. func (s *Service) SetWorktreeManager(mgr *worktree.Manager) { - s.worktreeMgr = mgr + if mgr == nil { + return + } + s.worktreeReaper = mgr } // subscribeAutomationEvents subscribes to automation-related events on the event bus. @@ -101,22 +143,16 @@ func (s *Service) createAutomationTask(ctx context.Context, evt *automation.Auto return } - // Run-mode automations create an ephemeral task hidden from the kanban — - // the user surfaces them through the AutomationRun row instead. The - // existing session/launch pipeline still runs against the task. Ephemeral - // tasks reject a non-empty workflow_id, so we strip workflow fields in - // run-mode even if the automation row still carries them. - isRunMode := a.ExecutionMode == automation.ExecutionModeRun - workflowID := a.WorkflowID - workflowStepID := a.WorkflowStepID - if isRunMode { - workflowID = "" - workflowStepID = "" - } + // Every automation produces the same kind of run: an ordinary, persistent + // task tagged with origin=automation_run. The origin — not is_ephemeral — + // is what keeps it off the kanban and out of task lists, so the task keeps + // its worktree and stays repliable. Workflow fields are passed through as + // configured; they are optional and nothing about the row is special-cased + // on write. task, taskErr := s.reviewTaskCreator.CreateReviewTask(ctx, &ReviewTaskRequest{ WorkspaceID: a.WorkspaceID, - WorkflowID: workflowID, - WorkflowStepID: workflowStepID, + WorkflowID: a.WorkflowID, + WorkflowStepID: a.WorkflowStepID, Title: title, Description: prompt, Repositories: repositories, @@ -127,10 +163,8 @@ func (s *Service) createAutomationTask(ctx context.Context, evt *automation.Auto "trigger_type": string(evt.TriggerType), models.MetaKeyAgentProfileID: a.AgentProfileID, models.MetaKeyExecutorProfileID: a.ExecutorProfileID, - "execution_mode": string(a.ExecutionMode), }, - IsEphemeral: isRunMode, - Origin: models.TaskOriginAutomationRun, + Origin: models.TaskOriginAutomationRun, }) if taskErr != nil { s.logger.Error("failed to create automation task", @@ -139,8 +173,20 @@ func (s *Service) createAutomationTask(ctx context.Context, evt *automation.Auto return } - // Record successful run. - s.recordSuccessRun(ctx, evt, task.ID) + // The run row is the only record that this firing happened: it carries the + // concurrency accounting and it is the sole way the work is reachable, + // since the task is hidden from every board and list. Launching an agent + // without it would leave an automation running that nothing can see and + // nothing can finalize, so a failed write stops the firing here — and takes + // the task with it, or the hidden task outlives the run it belonged to. + if err := s.recordSuccessRun(ctx, evt, task.ID); err != nil { + s.logger.Error("failed to record automation run; abandoning the firing", + zap.String("automation_id", a.ID), + zap.String("task_id", task.ID), + zap.Error(err)) + s.deleteAbandonedTask(ctx, a.ID, task.ID) + return + } // Associate PR with task for github_pr triggers (same as PR Watcher). if evt.TriggerType == automation.TriggerTypeGitHubPR { @@ -150,15 +196,12 @@ func (s *Service) createAutomationTask(ctx context.Context, evt *automation.Auto s.logger.Info("created automation task", zap.String("task_id", task.ID), zap.String("automation_id", a.ID), - zap.String("execution_mode", string(a.ExecutionMode)), zap.String("trigger_type", string(evt.TriggerType))) - // Auto-start: always for run-mode (the user never sees the task, so no - // kanban drag triggers it); otherwise honour the workflow step's - // auto_start_agent on_enter setting. - if !isRunMode && (task.QueuedForStepID != "" || !s.shouldAutoStartStep(ctx, task.WorkflowStepID)) { - return - } + // Auto-start unconditionally: nobody opens an automation's task to drag + // it, so the trigger has to be the start signal. A workflow step's + // auto_start_agent setting is irrelevant here — it describes board + // behaviour and automation runs never reach the board. s.autoStartAutomationTask(ctx, a, task, task.WorkflowStepID) } @@ -179,6 +222,11 @@ func (s *Service) autoStartAutomationTask(ctx context.Context, a *automation.Aut if err != nil { s.logger.Error("failed to auto-start automation task", zap.String("task_id", task.ID), zap.Error(err)) + // The run row was written before the launch, so a start that never + // happened would otherwise sit at task_created forever and hold a + // max_concurrent_runs slot — one failed launch would stop the + // automation permanently, with no completion event coming to free it. + s.markAutomationRunTerminal(ctx, task.ID, false, err.Error()) return } s.logger.Info("auto-started automation task", @@ -387,10 +435,33 @@ func (s *Service) recordFailedRun(ctx context.Context, evt *automation.Automatio } } -func (s *Service) recordSuccessRun(ctx context.Context, evt *automation.AutomationTriggeredEvent, taskID string) { - if s.automationService == nil { +// recordSuccessRun writes the row that makes a firing visible and countable. +// It reports failure rather than logging it: the caller must not launch an +// agent for a run nothing can see. +// deleteAbandonedTask removes a task whose run row was never written. +// +// A failure here is reported rather than swallowed: the task is then genuinely +// orphaned — hidden by its origin, pointed at by no run — and only a human +// reading this line will know it is there. +func (s *Service) deleteAbandonedTask(ctx context.Context, automationID, taskID string) { + cleaner, ok := s.repo.(taskCleaner) + if !ok { return } + if err := cleaner.DeleteTask(ctx, taskID); err != nil { + s.logger.Error("failed to delete the task of an unrecorded automation run", + zap.String("automation_id", automationID), + zap.String("task_id", taskID), + zap.Error(err)) + } +} + +func (s *Service) recordSuccessRun( + ctx context.Context, evt *automation.AutomationTriggeredEvent, taskID string, +) error { + if s.automationService == nil { + return nil + } run := &automation.AutomationRun{ AutomationID: evt.AutomationID, TriggerID: evt.TriggerID, @@ -400,12 +471,10 @@ func (s *Service) recordSuccessRun(ctx context.Context, evt *automation.Automati DedupKey: evt.DedupKey, TriggerData: evt.TriggerData, } - if recordErr := s.automationService.RecordRun(ctx, run); recordErr != nil { - s.logger.Error("failed to record automation run", zap.Error(recordErr)) - } + return s.automationService.RecordRun(ctx, run) } -// handleAutomationTurnComplete closes out a run-mode automation on the stream +// handleAutomationTurnComplete closes out an automation run on the stream // complete event. ACP agents can stay alive after stop_reason=end_turn, so // waiting for agent.completed would leave the AutomationRun stuck at // task_created and keep the executor alive. @@ -419,7 +488,10 @@ func (s *Service) handleAutomationTurnComplete( if err != nil || task == nil { return false } - if !task.IsEphemeral || task.Origin != models.TaskOriginAutomationRun { + // Keyed on origin alone. Automation tasks are no longer ephemeral, and + // gating on IsEphemeral here would leave every run stuck at task_created, + // holding a max_concurrent_runs slot until a human archived it. + if task.Origin != models.TaskOriginAutomationRun { return false } @@ -428,7 +500,13 @@ func (s *Service) handleAutomationTurnComplete( if cancelled && errMsg == "" { errMsg = stopReasonCancelled } - nextState := models.TaskSessionStateCompleted + // A successful run parks in WAITING_FOR_INPUT, which is where the ordinary + // agent.completed path leaves a finished turn. COMPLETED is terminal and + // explicitly not resumable ("create a new session instead"), so using it + // here would make every finished run unrepliable — the opposite of "a run + // is a thread, not a receipt". Success is recorded on the AutomationRun + // row; the session state only says whether the conversation can continue. + nextState := models.TaskSessionStateWaitingForInput if cancelled { nextState = models.TaskSessionStateCancelled } else if !success { @@ -437,15 +515,18 @@ func (s *Service) handleAutomationTurnComplete( s.updateTaskSessionState(ctx, taskID, sessionID, nextState, errMsg, false, session) s.markAutomationRunTerminal(ctx, taskID, success, errMsg) s.stopAutomationAgent(ctx, taskID, sessionID, session) - s.reapAutomationWorktree(ctx, taskID, sessionID) + // The worktree is deliberately left in place: the files a run writes are + // usually the point of running it, and an agent that ends by asking a + // question needs a workspace in which to be answered. return true } -// finalizeAutomationRunIfEphemeral closes out a run-mode automation run when -// its agent terminates. For ephemeral automation tasks it (a) flips the -// AutomationRun row from task_created → succeeded|failed, and (b) reaps the -// per-run worktree immediately. Regular tasks are untouched. -func (s *Service) finalizeAutomationRunIfEphemeral(ctx context.Context, taskID, sessionID string, success bool, errMsg string) { +// finalizeAutomationRun flips an automation run's row from task_created → +// succeeded|failed when its agent terminates, so max_concurrent_runs frees up +// without anyone archiving anything. Keyed on origin — every automation task +// carries origin=automation_run and none of them are ephemeral. Regular tasks +// are untouched, and no worktree is torn down. +func (s *Service) finalizeAutomationRun(ctx context.Context, taskID string, success bool, errMsg string) { if taskID == "" { return } @@ -453,12 +534,11 @@ func (s *Service) finalizeAutomationRunIfEphemeral(ctx context.Context, taskID, if err != nil || task == nil { return } - if !task.IsEphemeral || task.Origin != models.TaskOriginAutomationRun { + if task.Origin != models.TaskOriginAutomationRun { return } s.markAutomationRunTerminal(ctx, taskID, success, errMsg) - s.reapAutomationWorktree(ctx, taskID, sessionID) } func (s *Service) markAutomationRunTerminal(ctx context.Context, taskID string, success bool, errMsg string) { @@ -476,6 +556,239 @@ func (s *Service) markAutomationRunTerminal(ctx context.Context, taskID string, zap.Error(markErr)) } } + // Every terminal transition is also the moment one more run enters the + // retention window and pushes the oldest one out, so this is the only hook + // that keeps up with the firing rate on its own — no sweeper, no schedule. + s.pruneAutomationRunWorktrees(ctx, taskID) +} + +// pruneAutomationRunWorktrees reclaims the workspaces of the runs that just +// aged out of this automation's retention window. Run rows, error messages and +// transcripts are untouched: an old run stays readable, it just no longer has a +// checkout to be replied in. +// +// Nothing here can fail the run. The firing already happened and its outcome is +// already recorded; a reclaim that doesn't happen costs disk, while an error +// propagated from here would turn a successful automation into a failed one and +// leave the run row disagreeing with what the agent actually did. +func (s *Service) pruneAutomationRunWorktrees(ctx context.Context, taskID string) { + if s.worktreeReaper == nil || taskID == "" { + return + } + retention, ok := s.automationService.(automationRunRetention) + if !ok { + return + } + agedOut, err := retention.PrunableRunTaskIDs(ctx, taskID, automation.DefaultRunWorktreeRetention) + if err != nil { + // Warned, not returned. The retry queue drained below holds workspaces + // already known to have survived a removal, and they are owed + // regardless of whether this automation's aged-out lookup succeeded — + // a failed query is no reason to keep sitting on disk nobody wants. + s.logger.Warn("failed to list automation runs past the worktree retention window", + zap.String("task_id", taskID), + zap.Error(err)) + } + for _, agedOutTaskID := range s.automationWorkspaceSweepOrder(agedOut) { + s.reclaimAutomationRunWorkspace(ctx, retention, agedOutTaskID) + } +} + +// reclaimAutomationRunWorkspace removes one aged-out run's worktrees, leaving +// its branch behind: the commits a run produced are usually the reason it was +// worth running, and they cost a ref rather than a checkout. A run's task can +// own several worktrees (one per repository), so all of them go. +func (s *Service) reclaimAutomationRunWorkspace( + ctx context.Context, retention automationRunRetention, taskID string, +) { + worktrees, err := s.worktreeReaper.GetAllByTaskID(ctx, taskID) + if err != nil { + s.logger.Warn("failed to list worktrees of an aged-out automation run", + zap.String("task_id", taskID), + zap.Error(err)) + return + } + for _, wt := range worktrees { + if wt == nil || !automationWorkspaceNeedsReclaiming(wt) { + continue + } + // Re-asked per worktree rather than once per task, because every + // removal before this one widened the gap since selection. The whole + // task is abandoned the moment it looks live: its worktrees are one + // agent's working set, and reclaiming the rest of them out from under + // a live turn is the same data loss in a smaller package. + if s.automationRunWentLive(ctx, retention, taskID) { + return + } + s.removeAgedOutRunWorktree(ctx, taskID, wt) + } +} + +// automationWorkspaceNeedsReclaiming decides whether a worktree row is still +// costing disk, from the disk rather than from the row. +// +// GetAllByTaskID reports deleted rows too, so some skip rule is needed or an +// already-reclaimed checkout would be handed back to git on every firing. The +// tempting rule — skip anything the row calls deleted — trusts a claim the +// worktree manager does not actually stand behind: removeWorktree logs a +// failed directory removal at Warn and then marks the row deleted and returns +// nil regardless. A row saying "deleted" therefore means "somebody tried", +// not "the bytes are gone", and taking it at face value is how a retention +// feature ends up freeing nothing while reporting that it freed everything. +// +// So a deleted row still gets reclaimed if its directory is there, which is +// also what makes such a failure retryable rather than terminal. +func automationWorkspaceNeedsReclaiming(wt *worktree.Worktree) bool { + if wt.Status != worktree.StatusDeleted { + return true + } + return automationWorkspaceOnDisk(wt.Path) +} + +// automationWorkspaceOnDisk reports whether a worktree's directory is still +// present. Lstat, not Stat: a dangling symlink left where a checkout used to +// be is still an entry that has to go, and following the link would call it +// gone. An empty path names nothing, so there is nothing to reclaim. +func automationWorkspaceOnDisk(path string) bool { + if path == "" { + return false + } + _, err := os.Lstat(path) + return err == nil +} + +// automationRunWentLive re-checks, immediately before a removal, whether the +// run has gone back to work since it was selected. See +// automation.Store.RunWorkspaceInUse for why the check cannot live in the +// selection query alone and why the worktree manager's own reference count +// does not cover this case. +// +// A failed check counts as live. The cost of being wrong that way is one +// checkout kept until the next firing; the cost of being wrong the other way +// is a user's agent losing its working tree mid-turn. +func (s *Service) automationRunWentLive( + ctx context.Context, retention automationRunRetention, taskID string, +) bool { + inUse, err := retention.RunWorkspaceInUse(ctx, taskID) + if err != nil { + s.logger.Warn("could not re-check whether an aged-out automation run went live; keeping its workspace", + zap.String("task_id", taskID), + zap.Error(err)) + return true + } + if inUse { + s.logger.Info("kept the workspace of an aged-out automation run that went live before it could be reclaimed", + zap.String("task_id", taskID)) + } + return inUse +} + +// removeAgedOutRunWorktree removes one worktree and verifies the result before +// claiming anything. +// +// The verification is the point. RemoveByID returning nil is not evidence that +// the checkout is gone — the manager swallows a failed directory removal (see +// automationWorkspaceNeedsReclaiming) — so a sweep that logged success on a nil +// error was reporting reclaimed disk that was still fully occupied, and the run +// then dropped out of the candidate set for good. Stat-ing the path afterwards +// is the only honest confirmation available from this side of the interface. +// +// Neither failure path is fatal to the run: the firing already happened and its +// outcome is already recorded. They are queued instead, so the next +// finalization tries again rather than writing the workspace off. +func (s *Service) removeAgedOutRunWorktree(ctx context.Context, taskID string, wt *worktree.Worktree) { + if err := s.worktreeReaper.RemoveByID(ctx, wt.ID, false); err != nil { + if errors.Is(err, worktree.ErrWorktreeNotFound) { + return + } + s.logger.Warn("failed to reclaim the workspace of an aged-out automation run", + zap.String("task_id", taskID), + zap.String("worktree_id", wt.ID), + zap.Error(err)) + s.queueAutomationWorkspaceReclaim(taskID) + return + } + if automationWorkspaceOnDisk(wt.Path) { + s.logger.Error("workspace of an aged-out automation run survived its removal; no disk was reclaimed", + zap.String("task_id", taskID), + zap.String("worktree_id", wt.ID), + zap.String("path", wt.Path)) + s.queueAutomationWorkspaceReclaim(taskID) + return + } + s.logger.Info("reclaimed the workspace of an aged-out automation run", + zap.String("task_id", taskID), + zap.String("worktree_id", wt.ID), + zap.Int("retention", automation.DefaultRunWorktreeRetention)) +} + +// maxPendingAutomationWorkspaceReclaims bounds the retry queue. An install +// where dozens of removals are failing has a problem retention cannot fix by +// remembering more of them, and the queue must not become the leak it exists +// to stop. +const maxPendingAutomationWorkspaceReclaims = 64 + +// queueAutomationWorkspaceReclaim marks a run's workspace as still owed. +// +// Retention's candidate set is "runs that still have a live worktree row", and +// a removal that fails after the manager has already marked the row deleted +// leaves a run that no query will ever offer again — its directory is on disk, +// invisible to the sweep that was supposed to reclaim it. This queue is that +// run's only way back, so the next finalization retries it alongside the runs +// that aged out naturally. +// +// In-memory on purpose: it is a retry hint, not a record. A restart loses it, +// which costs the disk one directory until something else cleans it up — the +// Error log above is what a human is meant to act on in that case. +func (s *Service) queueAutomationWorkspaceReclaim(taskID string) { + s.unreclaimedWorkspacesMu.Lock() + defer s.unreclaimedWorkspacesMu.Unlock() + if _, queued := s.unreclaimedWorkspaces[taskID]; queued { + return + } + if len(s.unreclaimedWorkspaces) >= maxPendingAutomationWorkspaceReclaims { + s.logger.Warn("dropping an automation workspace from the reclaim retry queue; too many removals are failing", + zap.String("task_id", taskID), + zap.Int("queued", len(s.unreclaimedWorkspaces))) + return + } + if s.unreclaimedWorkspaces == nil { + s.unreclaimedWorkspaces = map[string]struct{}{} + } + s.unreclaimedWorkspaces[taskID] = struct{}{} +} + +// automationWorkspaceSweepOrder is what this finalization will actually try: +// the runs whose removal previously failed, then the ones that have just aged +// out, with the overlap collapsed so a run in both lists is attempted once. +// +// The retries lead because they are known-wasted disk, whereas an aged-out run +// is merely eligible. Draining unconditionally keeps the queue from outliving +// the problem — a task that fails again re-queues itself from +// removeAgedOutRunWorktree, and one that has since been cleaned up simply +// finds nothing to do. +func (s *Service) automationWorkspaceSweepOrder(agedOut []string) []string { + s.unreclaimedWorkspacesMu.Lock() + pending := make([]string, 0, len(s.unreclaimedWorkspaces)) + for taskID := range s.unreclaimedWorkspaces { + pending = append(pending, taskID) + } + clear(s.unreclaimedWorkspaces) + s.unreclaimedWorkspacesMu.Unlock() + // Map iteration order is random; the sweep's log and its test both read + // better when the same failures are retried in the same order every time. + sort.Strings(pending) + + seen := make(map[string]struct{}, len(pending)+len(agedOut)) + order := make([]string, 0, len(pending)+len(agedOut)) + for _, taskID := range append(pending, agedOut...) { + if _, duplicate := seen[taskID]; duplicate { + continue + } + seen[taskID] = struct{}{} + order = append(order, taskID) + } + return order } func (s *Service) stopAutomationAgent( @@ -512,31 +825,3 @@ func (s *Service) stopAutomationAgent( zap.Error(err)) } } - -// reapAutomationWorktree removes the ephemeral worktree (and its branch) -// associated with a finished run-mode automation session. -func (s *Service) reapAutomationWorktree(ctx context.Context, taskID, sessionID string) { - if s.worktreeMgr == nil || sessionID == "" { - return - } - running, err := s.repo.GetExecutorRunningBySessionID(ctx, sessionID) - if err != nil || running == nil || running.WorktreeID == "" { - return - } - if err := s.worktreeMgr.RemoveByID(ctx, running.WorktreeID, true); err != nil { - if errors.Is(err, worktree.ErrWorktreeNotFound) { - s.logger.Debug("automation worktree already reaped", - zap.String("task_id", taskID), - zap.String("worktree_id", running.WorktreeID)) - return - } - s.logger.Warn("failed to reap automation worktree", - zap.String("task_id", taskID), - zap.String("worktree_id", running.WorktreeID), - zap.Error(err)) - return - } - s.logger.Info("reaped ephemeral automation worktree", - zap.String("task_id", taskID), - zap.String("worktree_id", running.WorktreeID)) -} diff --git a/apps/backend/internal/orchestrator/event_handlers_automation_migration_test.go b/apps/backend/internal/orchestrator/event_handlers_automation_migration_test.go new file mode 100644 index 0000000000..5d42959017 --- /dev/null +++ b/apps/backend/internal/orchestrator/event_handlers_automation_migration_test.go @@ -0,0 +1,112 @@ +package orchestrator + +import ( + "context" + "testing" + "time" + + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + + "github.com/kandev/kandev/internal/automation" + "github.com/kandev/kandev/internal/task/models" +) + +// storeBackedAutomationService serves the firing path out of a real +// automation.Store, so the variable in the test below is the stored +// execution_mode itself rather than a struct field a test typed in by hand. +// The three run-recording methods are stubs: this is a test about what a +// firing builds, not about run bookkeeping, which the sibling file covers. +type storeBackedAutomationService struct { + store *automation.Store +} + +func (s *storeBackedAutomationService) GetAutomation( + ctx context.Context, id string, +) (*automation.Automation, error) { + return s.store.GetAutomation(ctx, id) +} + +func (s *storeBackedAutomationService) RecordRun(context.Context, *automation.AutomationRun) error { + return nil +} + +func (s *storeBackedAutomationService) MarkRunFailedByTaskID(context.Context, string, string) error { + return nil +} + +func (s *storeBackedAutomationService) MarkRunSucceededByTaskID(context.Context, string) error { + return nil +} + +// seedStoredAutomation writes one automation carrying the given stored +// execution_mode, the way an install running the pre-withdrawal code wrote +// it, and returns a service that reads it back through the real store. +func seedStoredAutomation(t *testing.T, executionMode string) *storeBackedAutomationService { + t.Helper() + db, err := sqlx.Open("sqlite3", ":memory:") + require.NoError(t, err) + t.Cleanup(func() { _ = db.Close() }) + + store, err := automation.NewStore(db, db) + require.NoError(t, err) + + now := time.Now().UTC() + _, err = db.Exec( + `INSERT INTO automations (id, workspace_id, name, description, workflow_id, workflow_step_id, + agent_profile_id, executor_profile_id, prompt, task_title_template, execution_mode, + enabled, max_concurrent_runs, webhook_secret, created_at, updated_at) + VALUES ('a-1', 'ws-1', 'nightly sweep', '', 'wf-1', 'step-1', 'agent-1', 'exec-1', + 'sweep the logs', 'Nightly sweep', ?, 1, 1, 'secret', ?, ?)`, + executionMode, now, now) + require.NoError(t, err) + + return &storeBackedAutomationService{store: store} +} + +// The single-destination invariant, stated as a comparison rather than as a +// list of properties: two automations differing ONLY in the execution_mode +// stored against them must produce byte-identical task requests. The old +// `task` mode is what put a card on the kanban, and honouring it again is the +// one thing withdrawing it was supposed to make impossible — so this asserts +// the whole request, not the fields a reviewer thought to name. +func TestCreateAutomationTask_IsIdenticalRegardlessOfStoredExecutionMode(t *testing.T) { + built := map[string]*ReviewTaskRequest{} + flags := map[string]bool{} + + for _, mode := range []string{"task", "run"} { + repo := setupTestRepo(t) + seedAutomationWorkspaceRepo(t, repo, "ws-1") + autoSvc := seedStoredAutomation(t, mode) + creator := &stubReviewTaskCreator{task: &models.Task{ID: "t-created"}} + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.SetAutomationService(autoSvc) + svc.reviewTaskCreator = creator + + loaded, err := autoSvc.GetAutomation(context.Background(), "a-1") + require.NoError(t, err) + require.NotNil(t, loaded) + flags[mode] = loaded.LegacyBoardCard + + svc.createAutomationTask(context.Background(), &automation.AutomationTriggeredEvent{ + AutomationID: "a-1", TriggerID: "trg-1", TriggerType: automation.TriggerTypeScheduled, + }) + require.NotNil(t, creator.got, "the %s-mode automation created no task at all", mode) + built[mode] = creator.got + } + + // Without this the comparison could pass by both sides being blank: a + // derivation that never fires makes every automation look alike, which is + // agreement for the wrong reason. The two rows must genuinely differ in + // what the migration notice reads before "and yet they fire alike" means + // anything. + require.True(t, flags["task"], "the task-mode row must be recognised as a pre-upgrade automation") + require.False(t, flags["run"], "the run-mode row must not be") + + require.Equal(t, built["run"], built["task"], + "a stored execution_mode must not reach the firing path; every automation has one destination") + require.Equal(t, models.TaskOriginAutomationRun, built["task"].Origin, + "and that destination is the hidden, origin-tagged automation run") + require.False(t, built["task"].IsEphemeral) +} diff --git a/apps/backend/internal/orchestrator/event_handlers_automation_test.go b/apps/backend/internal/orchestrator/event_handlers_automation_test.go index 0904dec47a..481715d145 100644 --- a/apps/backend/internal/orchestrator/event_handlers_automation_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_automation_test.go @@ -3,14 +3,30 @@ package orchestrator import ( "context" "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" "strings" + "sync" "testing" "time" + "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" + "go.uber.org/zap/zaptest/observer" + "github.com/kandev/kandev/internal/automation" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/task/repository" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" taskservice "github.com/kandev/kandev/internal/task/service" + "github.com/kandev/kandev/internal/worktree" + v1 "github.com/kandev/kandev/pkg/api/v1" ) // seedAutomationWorkspaceRepos creates a workspace with the given repository @@ -134,3 +150,844 @@ func TestResolveAutomationRepository_GitHubPRIgnoresConfiguredRepositoryIDs(t *t t.Errorf("expected checkout branch from the PR's head branch, got %q", resolved[0].CheckoutBranch) } } + +// stubReviewTaskCreator records the request the automation handler builds and +// returns a task the caller can then look up in the repo. +type stubReviewTaskCreator struct { + got *ReviewTaskRequest + task *models.Task + err error +} + +func (s *stubReviewTaskCreator) CreateReviewTask(_ context.Context, req *ReviewTaskRequest) (*models.Task, error) { + s.got = req + if s.err != nil { + return nil, s.err + } + return s.task, nil +} + +// stubAutomationService serves one automation and records the runs written. +type stubAutomationService struct { + automation *automation.Automation + runs []*automation.AutomationRun + succeeded []string + failed map[string]string + recordRunErr error + + // Retention: what the sweep is offered, what it asked for, and a failure + // to prove finalization does not depend on the answer. + prunable []string + prunableKeep int + prunableErr error + inUse map[string]bool +} + +func (s *stubAutomationService) GetAutomation(context.Context, string) (*automation.Automation, error) { + return s.automation, nil +} + +func (s *stubAutomationService) RecordRun(_ context.Context, run *automation.AutomationRun) error { + if s.recordRunErr != nil { + return s.recordRunErr + } + s.runs = append(s.runs, run) + return nil +} + +func (s *stubAutomationService) MarkRunFailedByTaskID(_ context.Context, taskID, errMsg string) error { + if s.failed == nil { + s.failed = map[string]string{} + } + s.failed[taskID] = errMsg + return nil +} + +func (s *stubAutomationService) MarkRunSucceededByTaskID(_ context.Context, taskID string) error { + s.succeeded = append(s.succeeded, taskID) + return nil +} + +func (s *stubAutomationService) PrunableRunTaskIDs(_ context.Context, _ string, keep int) ([]string, error) { + s.prunableKeep = keep + if s.prunableErr != nil { + return nil, s.prunableErr + } + return s.prunable, nil +} + +func (s *stubAutomationService) RunWorkspaceInUse(_ context.Context, taskID string) (bool, error) { + return s.inUse[taskID], nil +} + +// seedAutomationTask writes a task exactly as the automation path now creates +// one: ordinary and persistent, tagged only by its origin. +func seedAutomationTask(t *testing.T, repo *sqliterepo.Repository, taskID, origin string, ephemeral bool) { + t.Helper() + ctx := context.Background() + now := time.Now().UTC() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ + ID: "ws-" + taskID, Name: "Automation", CreatedAt: now, UpdatedAt: now, + })) + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: taskID, + WorkspaceID: "ws-" + taskID, + Title: "Automation run", + State: v1.TaskStateInProgress, + Origin: origin, + IsEphemeral: ephemeral, + CreatedAt: now, + UpdatedAt: now, + })) +} + +// seedAutomationWorkspaceRepo gives the automation path a repository to +// resolve; without one a firing is recorded as a failed run and never reaches +// task creation. +func seedAutomationWorkspaceRepo(t *testing.T, repo *sqliterepo.Repository, workspaceID string) { + t.Helper() + ctx := context.Background() + now := time.Now().UTC() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ + ID: workspaceID, Name: "Automation", CreatedAt: now, UpdatedAt: now, + })) + require.NoError(t, repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-" + workspaceID, WorkspaceID: workspaceID, Name: "app", + SourceType: "local", LocalPath: t.TempDir(), DefaultBranch: "main", + })) +} + +// A firing produces one kind of run: an ordinary task tagged by origin. It is +// hidden by that origin, so it must NOT be marked ephemeral — ephemerality is +// what used to reap its worktree and strand its run at task_created. +func TestCreateAutomationTask_TagsOriginAndLeavesTaskNonEphemeral(t *testing.T) { + repo := setupTestRepo(t) + creator := &stubReviewTaskCreator{task: &models.Task{ID: "t-created"}} + autoSvc := &stubAutomationService{automation: &automation.Automation{ + ID: "a-1", WorkspaceID: "ws-1", Name: "nightly sweep", Prompt: "sweep", + WorkflowID: "wf-1", WorkflowStepID: "step-1", Enabled: true, + }} + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.SetAutomationService(autoSvc) + svc.reviewTaskCreator = creator + seedAutomationWorkspaceRepo(t, repo, "ws-1") + + svc.createAutomationTask(context.Background(), &automation.AutomationTriggeredEvent{ + AutomationID: "a-1", TriggerID: "trg-1", TriggerType: automation.TriggerTypeScheduled, + }) + + require.NotNil(t, creator.got, "expected the automation to create its task; runs=%+v", autoSvc.runs) + require.Equal(t, models.TaskOriginAutomationRun, creator.got.Origin) + require.False(t, creator.got.IsEphemeral, + "automation tasks are hidden by origin; marking them ephemeral reaps the worktree and strands the run") + require.Equal(t, "wf-1", creator.got.WorkflowID, "configured workflow fields pass through unchanged") + require.Equal(t, "step-1", creator.got.WorkflowStepID) + require.NotContains(t, creator.got.Metadata, "execution_mode", + "the execution mode is withdrawn and must not be stamped on the task") +} + +// Workflow and step are optional for every automation: no run is placed on a +// board, so no automation needs a starting column. +func TestCreateAutomationTask_WorksWithoutWorkflowOrStep(t *testing.T) { + repo := setupTestRepo(t) + creator := &stubReviewTaskCreator{task: &models.Task{ID: "t-created"}} + autoSvc := &stubAutomationService{automation: &automation.Automation{ + ID: "a-2", WorkspaceID: "ws-1", Name: "no workflow", Prompt: "report", Enabled: true, + }} + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.SetAutomationService(autoSvc) + svc.reviewTaskCreator = creator + seedAutomationWorkspaceRepo(t, repo, "ws-1") + + svc.createAutomationTask(context.Background(), &automation.AutomationTriggeredEvent{ + AutomationID: "a-2", TriggerID: "trg-2", TriggerType: automation.TriggerTypeScheduled, + }) + + require.NotNil(t, creator.got, "a workflow-less automation must still create its task") + require.Empty(t, creator.got.WorkflowID) + require.Empty(t, creator.got.WorkflowStepID) + require.Equal(t, models.TaskOriginAutomationRun, creator.got.Origin) +} + +// The deadlock the execution-mode split caused: a non-ephemeral automation +// task never reached a terminal run status, so its automation_run row sat at +// task_created forever and held a max_concurrent_runs slot until a human +// archived it. Finalization keys on origin alone. +func TestFinalizeAutomationRun_NonEphemeralTaskReachesTerminalStatus(t *testing.T) { + repo := setupTestRepo(t) + seedAutomationTask(t, repo, "t-auto", models.TaskOriginAutomationRun, false) + + autoSvc := &stubAutomationService{} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.SetAutomationService(autoSvc) + + svc.finalizeAutomationRun(context.Background(), "t-auto", true, "") + require.Equal(t, []string{"t-auto"}, autoSvc.succeeded, + "a non-ephemeral automation task must still reach a terminal run status") + + svc.finalizeAutomationRun(context.Background(), "t-auto", false, "agent failed") + require.Equal(t, "agent failed", autoSvc.failed["t-auto"]) +} + +// Only automation-origin tasks are finalized; an ordinary task must not have +// an automation run flipped underneath it. +func TestFinalizeAutomationRun_IgnoresNonAutomationTasks(t *testing.T) { + repo := setupTestRepo(t) + seedAutomationTask(t, repo, "t-manual", models.TaskOriginManual, false) + + autoSvc := &stubAutomationService{} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.SetAutomationService(autoSvc) + + svc.finalizeAutomationRun(context.Background(), "t-manual", true, "") + require.Empty(t, autoSvc.succeeded) + require.Empty(t, autoSvc.failed) +} + +// A run's files are the point of running it, and an agent that ends by asking +// a question needs its workspace to still exist. The turn-complete path +// finalizes the run and stops the agent, but leaves the session's worktree +// association — and therefore the worktree — in place. +func TestHandleAutomationTurnComplete_FinalizesWithoutReapingTheWorktree(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedAutomationRunSession(t, repo, "t-keep", "s-keep", "exec-keep") + require.NoError(t, repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + SessionID: "s-keep", + WorktreeID: "wt-keep", + RepositoryID: "repo-1", + WorktreePath: "/tmp/kandev/t-keep", + })) + + mgr := &mockAgentManager{} + autoSvc := &stubAutomationService{} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), mgr) + svc.SetAutomationService(autoSvc) + + session, err := repo.GetTaskSession(ctx, "s-keep") + require.NoError(t, err) + handled := svc.handleAutomationTurnComplete(ctx, "t-keep", "s-keep", session, "end_turn", false, "") + require.True(t, handled) + require.Equal(t, []string{"t-keep"}, autoSvc.succeeded) + + worktrees, err := repo.ListTaskSessionWorktrees(ctx, "s-keep") + require.NoError(t, err) + require.Len(t, worktrees, 1, + "the automation run's worktree must survive its turn — the files it wrote are the point of the run") + require.Equal(t, "wt-keep", worktrees[0].WorktreeID) +} + +// A finished run has to stay answerable. COMPLETED is a terminal session state +// that explicitly refuses resume ("create a new session instead"), so parking a +// successful run there would turn every report into a receipt — the user opens +// it, types a follow-up, and is told the session has ended. +func TestHandleAutomationTurnComplete_LeavesASuccessfulRunAnswerable(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedAutomationRunSession(t, repo, "t-reply", "s-reply", "exec-reply") + + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{}) + svc.SetAutomationService(&stubAutomationService{}) + + session, err := repo.GetTaskSession(ctx, "s-reply") + require.NoError(t, err) + require.True(t, svc.handleAutomationTurnComplete(ctx, "t-reply", "s-reply", session, "end_turn", false, "")) + + after, err := repo.GetTaskSession(ctx, "s-reply") + require.NoError(t, err) + require.Equal(t, models.TaskSessionStateWaitingForInput, after.State, + "a successful run parks where an ordinary finished turn parks, so the user can reply to it") + require.False(t, isTerminalSessionState(after.State)) +} + +// A failed or cancelled run is a different matter: those states are terminal by +// design and carry the failure the user needs to see. +func TestHandleAutomationTurnComplete_KeepsFailureStatesTerminal(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedAutomationRunSession(t, repo, "t-fail", "s-fail", "exec-fail") + + autoSvc := &stubAutomationService{} + svc := createTestServiceWithAgent(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{}) + svc.SetAutomationService(autoSvc) + + session, err := repo.GetTaskSession(ctx, "s-fail") + require.NoError(t, err) + require.True(t, svc.handleAutomationTurnComplete(ctx, "t-fail", "s-fail", session, "error", true, "boom")) + + after, err := repo.GetTaskSession(ctx, "s-fail") + require.NoError(t, err) + require.Equal(t, models.TaskSessionStateFailed, after.State) + require.Equal(t, "boom", autoSvc.failed["t-fail"]) +} + +// The run row is written before the launch. If the launch then fails and +// nothing marks the run terminal, no completion event is ever coming — the run +// sits at task_created and holds the concurrency slot, so one bad launch stops +// the automation permanently. +func TestAutoStartAutomationTask_FailedLaunchReleasesTheConcurrencySlot(t *testing.T) { + repo := setupTestRepo(t) + seedAutomationTask(t, repo, "t-nostart", models.TaskOriginAutomationRun, false) + + autoSvc := &stubAutomationService{} + // Fail the launch the way a real one fails: inside StartTask, after the + // run row has already been written. + taskRepo := newMockTaskRepo() + taskRepo.getTaskErr = errors.New("executor unavailable") + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, &mockAgentManager{}) + svc.SetAutomationService(autoSvc) + + svc.autoStartAutomationTask( + context.Background(), + &automation.Automation{ID: "a-nostart", WorkspaceID: "ws-t-nostart"}, + &models.Task{ID: "t-nostart", Description: "sweep"}, + "", + ) + + require.Contains(t, autoSvc.failed, "t-nostart", + "a launch that never happened must still mark the run terminal, or the cap jams forever") + require.NotEmpty(t, autoSvc.failed["t-nostart"], "the launch error is what explains the failed run") + require.Empty(t, autoSvc.succeeded) +} + +// The run row is the only record a firing happened: it carries the concurrency +// accounting, and it is the sole way the work is reachable, since the task is +// hidden from every board and list. Launching an agent without it would leave +// an automation running that nothing can see and nothing can finalize. +func TestCreateAutomationTask_DoesNotLaunchWhenTheRunCannotBeRecorded(t *testing.T) { + repo := setupTestRepo(t) + creator := &stubReviewTaskCreator{task: &models.Task{ID: "t-unrecorded"}} + autoSvc := &stubAutomationService{ + automation: &automation.Automation{ + ID: "a-1", WorkspaceID: "ws-1", Name: "sweep", Prompt: "sweep", Enabled: true, + }, + recordRunErr: errors.New("disk full"), + } + + taskRepo := newMockTaskRepo() + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), taskRepo, &mockAgentManager{}) + svc.SetAutomationService(autoSvc) + svc.reviewTaskCreator = creator + seedAutomationWorkspaceRepo(t, repo, "ws-1") + + svc.createAutomationTask(context.Background(), &automation.AutomationTriggeredEvent{ + AutomationID: "a-1", TriggerID: "trg-1", TriggerType: automation.TriggerTypeScheduled, + }) + + require.NotNil(t, creator.got, "the task is created before the run row, as it is today") + require.Empty(t, taskRepo.tasks, "no agent may be launched for a run nothing can see") + require.Empty(t, autoSvc.succeeded) +} + +// A firing produces both a task and its run row, or neither. The task is hidden +// from every board and list by its origin, so one left behind with no run +// pointing at it is invisible, unfinalizable, and holds a concurrency slot +// nobody can see or clear. +func TestCreateAutomationTask_DeletesTheTaskWhenTheRunCannotBeRecorded(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedAutomationWorkspaceRepo(t, repo, "ws-1") + // A real row, so "was it deleted" is a question the repository can answer. + seedAutomationTask(t, repo, "t-orphan", models.TaskOriginAutomationRun, false) + + creator := &stubReviewTaskCreator{task: &models.Task{ID: "t-orphan"}} + autoSvc := &stubAutomationService{ + automation: &automation.Automation{ + ID: "a-1", WorkspaceID: "ws-1", Name: "sweep", Prompt: "sweep", Enabled: true, + }, + recordRunErr: errors.New("disk full"), + } + svc := createTestServiceWithScheduler(repo, newMockStepGetter(), newMockTaskRepo(), &mockAgentManager{}) + svc.SetAutomationService(autoSvc) + svc.reviewTaskCreator = creator + + svc.createAutomationTask(ctx, &automation.AutomationTriggeredEvent{ + AutomationID: "a-1", TriggerID: "trg-1", TriggerType: automation.TriggerTypeScheduled, + }) + + require.NotNil(t, creator.got, "the task is created before the run row, as it is today") + surviving, err := repo.GetTask(ctx, "t-orphan") + if err == nil { + require.Nil(t, surviving, + "a task whose run row was never written must not outlive the firing") + } +} + +// --- Run-worktree retention --- + +// fakeWorktreeReaper stands in for worktree.Manager. Retention's job is +// deciding *which* workspaces go and leaving everything else alone, so git is +// not involved — but the directory and the worktree row are, because the +// contract under test is about what is actually on disk afterwards. +// +// Two production behaviours are modelled exactly, and getting either of them +// convenient instead of accurate would hand the sweep a pass it does not earn: +// +// - the row is marked deleted (in memory *and* in the DB the candidate query +// reads) as removeWorktree's ReleaseWorktreeReference does, and +// - with swallowRemoval set, a failed directory removal returns *nil*, which +// is what removeWorktree does today: it logs the failure at Warn, marks the +// row deleted and falls through. A fake that returned an error there would +// be testing a manager that does not exist. +type fakeWorktreeReaper struct { + mu sync.Mutex + db *sqlx.DB + byTask map[string][]*worktree.Worktree + removed []string + branchRemovals []bool + listErr error + removeErr error + swallowRemoval bool +} + +func newFakeWorktreeReaper() *fakeWorktreeReaper { + return &fakeWorktreeReaper{byTask: map[string][]*worktree.Worktree{}} +} + +func (f *fakeWorktreeReaper) seed(taskID, path string) { + f.mu.Lock() + defer f.mu.Unlock() + f.byTask[taskID] = append(f.byTask[taskID], &worktree.Worktree{ + ID: "wt-" + taskID, TaskID: taskID, Path: path, Status: worktree.StatusActive, + }) +} + +func (f *fakeWorktreeReaper) setSwallowRemoval(swallow bool) { + f.mu.Lock() + defer f.mu.Unlock() + f.swallowRemoval = swallow +} + +func (f *fakeWorktreeReaper) GetAllByTaskID(_ context.Context, taskID string) ([]*worktree.Worktree, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.listErr != nil { + return nil, f.listErr + } + return f.byTask[taskID], nil +} + +func (f *fakeWorktreeReaper) RemoveByID(_ context.Context, worktreeID string, removeBranch bool) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.removeErr != nil { + return f.removeErr + } + f.removed = append(f.removed, worktreeID) + f.branchRemovals = append(f.branchRemovals, removeBranch) + for _, wts := range f.byTask { + for _, wt := range wts { + if wt.ID != worktreeID { + continue + } + // Marked deleted whether or not the directory went away — that + // unconditional release is the trap this fake exists to reproduce. + wt.Status = worktree.StatusDeleted + f.releaseRow(worktreeID) + if !f.swallowRemoval && wt.Path != "" { + _ = os.RemoveAll(wt.Path) + } + } + } + return nil +} + +// releaseRow mirrors ReleaseWorktreeReference in the database the retention +// query reads, so a reclaimed run really does drop out of the candidate set +// instead of the test asserting that it would. +func (f *fakeWorktreeReaper) releaseRow(worktreeID string) { + if f.db == nil { + return + } + _, _ = f.db.Exec( + `UPDATE task_session_worktrees SET status = ?, deleted_at = ? WHERE worktree_id = ?`, + worktree.StatusDeleted, time.Now().UTC(), worktreeID, + ) +} + +func (f *fakeWorktreeReaper) survivingWorktrees() []string { + f.mu.Lock() + defer f.mu.Unlock() + var alive []string + for _, wts := range f.byTask { + for _, wt := range wts { + if wt.Status != worktree.StatusDeleted { + alive = append(alive, wt.ID) + } + } + } + return alive +} + +// automationRetentionFixture wires the orchestrator against a *real* automation +// store sharing the task repository's database. The claim under test is that +// reclaiming a workspace leaves the run row and its task behind, and a stub +// automation service could only ever restate that claim rather than test it. +type automationRetentionFixture struct { + svc *Service + repo *sqliterepo.Repository + autoStore *automation.Store + autoSvc *automation.Service + db *sqlx.DB + reaper *fakeWorktreeReaper + workspaceRoot string +} + +func setupAutomationRetentionFixture(t *testing.T) *automationRetentionFixture { + t.Helper() + root := t.TempDir() + dbConn, err := db.OpenSQLite(filepath.Join(root, "retention.db")) + require.NoError(t, err) + sqlxDB := sqlx.NewDb(dbConn, "sqlite3") + t.Cleanup(func() { _ = sqlxDB.Close() }) + + repo, cleanup, err := repository.Provide(sqlxDB, sqlxDB, nil) + require.NoError(t, err) + t.Cleanup(func() { _ = cleanup() }) + + autoStore, err := automation.NewStore(sqlxDB, sqlxDB) + require.NoError(t, err) + + reaper := newFakeWorktreeReaper() + reaper.db = sqlxDB + autoSvc := automation.NewService(autoStore, nil, testLogger()) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.SetAutomationService(autoSvc) + svc.worktreeReaper = reaper + + return &automationRetentionFixture{ + svc: svc, repo: repo, autoStore: autoStore, autoSvc: autoSvc, + db: sqlxDB, reaper: reaper, workspaceRoot: filepath.Join(root, "workspaces"), + } +} + +// workspacePath is where a run's checkout lives on disk. Retention's claim is +// about bytes, so the fixture puts real directories there and the assertions +// read them back. +func (f *automationRetentionFixture) workspacePath(taskID string) string { + return filepath.Join(f.workspaceRoot, taskID) +} + +// seedSessionWorktree writes the rows that make a task look like it holds a +// checkout: a session, a worktree row hanging off it, and the directory itself. +// The candidate query walks task → session → worktree, so a run without these +// is not a retention candidate at all. +func (f *automationRetentionFixture) seedSessionWorktree(t *testing.T, taskID string) { + t.Helper() + require.NoError(t, os.MkdirAll(f.workspacePath(taskID), 0o750)) + now := time.Now().UTC() + sessionID := taskID + "-session" + _, err := f.db.Exec( + `INSERT INTO task_sessions (id, task_id, state, is_primary, started_at, updated_at) + VALUES (?, ?, 'WAITING_FOR_INPUT', 1, ?, ?)`, + sessionID, taskID, now, now) + require.NoError(t, err) + _, err = f.db.Exec( + `INSERT INTO task_session_worktrees + (id, session_id, worktree_id, repository_id, worktree_path, status, created_at, updated_at) + VALUES (?, ?, ?, 'repo-1', ?, 'active', ?, ?)`, + taskID+"-tsw", sessionID, "wt-"+taskID, f.workspacePath(taskID), now, now) + require.NoError(t, err) +} + +// startSession puts a second session on a task in a live state — what replying +// to an aged-out run does. +func (f *automationRetentionFixture) startSession(t *testing.T, taskID, state string) { + t.Helper() + now := time.Now().UTC() + _, err := f.db.Exec( + `INSERT INTO task_sessions (id, task_id, state, is_primary, started_at, updated_at) + VALUES (?, ?, ?, 0, ?, ?)`, + taskID+"-reply", taskID, state, now, now) + require.NoError(t, err) +} + +// seedRunSeries writes one automation plus n runs, oldest first, each with its +// own task and workspace. Every run but the last is already terminal; the last +// is the one still at task_created that the finalization under test closes out. +func (f *automationRetentionFixture) seedRunSeries(t *testing.T, n int) (*automation.Automation, []string) { + t.Helper() + ctx := context.Background() + a := &automation.Automation{WorkspaceID: "ws-retention", Name: "every five minutes", Enabled: true} + require.NoError(t, f.autoStore.CreateAutomation(ctx, a)) + + base := time.Now().UTC().Truncate(time.Second).Add(-time.Duration(n) * time.Hour) + taskIDs := make([]string, 0, n) + for i := range n { + taskID := fmt.Sprintf("t-run-%02d", i) + seedAutomationTask(t, f.repo, taskID, models.TaskOriginAutomationRun, false) + f.seedSessionWorktree(t, taskID) + f.reaper.seed(taskID, f.workspacePath(taskID)) + + status := automation.RunStatusSucceeded + if i == n-1 { + status = automation.RunStatusTaskCreated + } + run := &automation.AutomationRun{ + AutomationID: a.ID, TriggerType: automation.TriggerTypeScheduled, + TaskID: taskID, Status: status, TriggerData: json.RawMessage(`{}`), + } + require.NoError(t, f.autoStore.CreateRun(ctx, run)) + _, err := f.db.Exec(`UPDATE automation_runs SET created_at = ? WHERE id = ?`, + base.Add(time.Duration(i)*time.Minute), run.ID) + require.NoError(t, err) + + taskIDs = append(taskIDs, taskID) + } + return a, taskIDs +} + +// The reported defect: every firing becomes a persistent task that deliberately +// retains its worktree, with no limit and no cleanup, so a frequent schedule +// accumulates full checkouts until the disk fills. Finalizing a run must +// reclaim the workspaces that just aged out of the retention window — and only +// those, so the recent runs a user goes back to are still openable. +func TestFinalizeAutomationRun_ReclaimsOnlyTheWorkspacesPastTheRetentionWindow(t *testing.T) { + f := setupAutomationRetentionFixture(t) + _, tasks := f.seedRunSeries(t, automation.DefaultRunWorktreeRetention+3) + + f.svc.finalizeAutomationRun(context.Background(), tasks[len(tasks)-1], true, "") + + require.ElementsMatch(t, []string{"wt-" + tasks[0], "wt-" + tasks[1], "wt-" + tasks[2]}, f.reaper.removed, + "exactly the three runs beyond the newest %d must lose their workspace", + automation.DefaultRunWorktreeRetention) + + var expectedSurvivors []string + for _, taskID := range tasks[3:] { + expectedSurvivors = append(expectedSurvivors, "wt-"+taskID) + } + require.ElementsMatch(t, expectedSurvivors, f.reaper.survivingWorktrees(), + "every run inside the retention window keeps its workspace") + + for _, removedBranch := range f.reaper.branchRemovals { + require.False(t, removedBranch, + "only the checkout is reclaimed; the branch holds the commits the run produced") + } +} + +// Retention reclaims disk, not history. A run that lost its workspace must +// still be listable, still carry its task id, and still have a task row behind +// it — the transcript is the whole record of what a hidden automation run did. +func TestFinalizeAutomationRun_KeepsRunRowsAndTasksAfterReclaimingWorkspaces(t *testing.T) { + ctx := context.Background() + f := setupAutomationRetentionFixture(t) + a, tasks := f.seedRunSeries(t, automation.DefaultRunWorktreeRetention+3) + + f.svc.finalizeAutomationRun(ctx, tasks[len(tasks)-1], true, "") + require.Len(t, f.reaper.removed, 3, "precondition: the sweep actually reclaimed something") + + runs, err := f.autoStore.ListRuns(ctx, a.ID, 100) + require.NoError(t, err) + require.Len(t, runs, len(tasks), "no run row may be deleted by a workspace reclaim") + + byTaskID := map[string]*automation.AutomationRun{} + for _, run := range runs { + byTaskID[run.TaskID] = run + } + for _, taskID := range tasks[:3] { + require.Contains(t, byTaskID, taskID, "a reclaimed run must still be reachable by its task id") + require.Equal(t, automation.RunStatusSucceeded, byTaskID[taskID].Status, + "reclaiming a workspace must not rewrite the run's recorded outcome") + + task, taskErr := f.repo.GetTask(ctx, taskID) + require.NoError(t, taskErr) + require.NotNil(t, task, "the task carrying the transcript must outlive its workspace") + } +} + +// A run stays inside the aged-out window for as long as its row lives, so +// every later firing looks at it again. Handing an already-reclaimed worktree +// back to git on every subsequent run would turn a bounded sweep into a +// permanent stream of failures. +func TestFinalizeAutomationRun_DoesNotReclaimTheSameWorkspaceTwice(t *testing.T) { + ctx := context.Background() + f := setupAutomationRetentionFixture(t) + _, tasks := f.seedRunSeries(t, automation.DefaultRunWorktreeRetention+3) + + f.svc.finalizeAutomationRun(ctx, tasks[len(tasks)-1], true, "") + require.Len(t, f.reaper.removed, 3) + + f.svc.finalizeAutomationRun(ctx, tasks[len(tasks)-1], true, "") + require.Len(t, f.reaper.removed, 3, "an already-reclaimed workspace must not be removed again") +} + +// The firing already happened and its outcome is already known. A reclaim that +// fails costs disk; a reclaim that propagates its failure would leave the run +// row disagreeing with what the agent actually did. +func TestFinalizeAutomationRun_SurvivesAWorkspaceReclaimFailure(t *testing.T) { + ctx := context.Background() + f := setupAutomationRetentionFixture(t) + f.reaper.removeErr = errors.New("git worktree remove: permission denied") + a, tasks := f.seedRunSeries(t, automation.DefaultRunWorktreeRetention+3) + + f.svc.finalizeAutomationRun(ctx, tasks[len(tasks)-1], true, "") + + runs, err := f.autoStore.ListRuns(ctx, a.ID, 100) + require.NoError(t, err) + var finalized *automation.AutomationRun + for _, run := range runs { + if run.TaskID == tasks[len(tasks)-1] { + finalized = run + } + } + require.NotNil(t, finalized) + require.Equal(t, automation.RunStatusSucceeded, finalized.Status, + "a failed workspace reclaim must not stop the run from reaching its terminal status") + require.Empty(t, f.reaper.removed) +} + +// Same contract one layer up: if retention cannot even work out what has aged +// out, the run still finalizes and frees its concurrency slot. +func TestMarkAutomationRunTerminal_SurvivesARetentionLookupFailure(t *testing.T) { + repo := setupTestRepo(t) + seedAutomationTask(t, repo, "t-auto", models.TaskOriginAutomationRun, false) + + autoSvc := &stubAutomationService{prunableErr: errors.New("database is locked")} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.SetAutomationService(autoSvc) + svc.worktreeReaper = newFakeWorktreeReaper() + + svc.finalizeAutomationRun(context.Background(), "t-auto", true, "") + + require.Equal(t, []string{"t-auto"}, autoSvc.succeeded, + "a retention lookup failure must not stop the run from reaching its terminal status") + require.Equal(t, automation.DefaultRunWorktreeRetention, autoSvc.prunableKeep, + "the sweep must ask for the shipped retention default, not an inlined number") +} + +// An install with no worktree manager wired (and every test that builds the +// service without one) keeps every workspace rather than panicking. +func TestMarkAutomationRunTerminal_SkipsRetentionWithoutAWorktreeManager(t *testing.T) { + repo := setupTestRepo(t) + seedAutomationTask(t, repo, "t-auto", models.TaskOriginAutomationRun, false) + + autoSvc := &stubAutomationService{prunable: []string{"t-old"}} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.SetAutomationService(autoSvc) + svc.SetWorktreeManager(nil) + + svc.finalizeAutomationRun(context.Background(), "t-auto", true, "") + + require.Equal(t, []string{"t-auto"}, autoSvc.succeeded) +} + +// racingRetention lets a test act inside the gap the defect lives in: after +// retention has named a run, before the sweep deletes its checkout. A fixture +// that only sets state up front cannot reach that gap at all — the selection +// query would simply never offer the run — so nothing static can stand in for +// this. +type racingRetention struct { + *automation.Service + onSelected func([]string) +} + +func (r *racingRetention) PrunableRunTaskIDs(ctx context.Context, taskID string, keep int) ([]string, error) { + selected, err := r.Service.PrunableRunTaskIDs(ctx, taskID, keep) + if r.onSelected != nil { + r.onSelected(selected) + } + return selected, err +} + +// raceAfterSelection re-wires the service so hook runs between the sweep's +// selection and its first removal. +func (f *automationRetentionFixture) raceAfterSelection(hook func([]string)) { + f.svc.SetAutomationService(&racingRetention{Service: f.autoSvc, onSelected: hook}) +} + +// observeLogs swaps in a logger whose entries the test can read back, for the +// claims retention only ever makes out loud. +func (f *automationRetentionFixture) observeLogs(t *testing.T) *observer.ObservedLogs { + t.Helper() + core, logs := observer.New(zapcore.DebugLevel) + log, err := logger.NewFromZap(zap.New(core)) + require.NoError(t, err) + f.svc.logger = log + return logs +} + +// The time-of-check/time-of-use defect. Retention decides what has aged out +// once, then removes checkouts one after another; a user replying to an +// aged-out run anywhere in that gap gets an agent working in a directory the +// sweep is already committed to deleting. +// +// The worktree manager does not save us here: removeWorktree counts active +// references with the worktree's *own* session excluded, and for an automation +// run that own session is exactly the one that just came alive. So the sweep +// has to ask again itself, immediately before each removal. +func TestFinalizeAutomationRun_KeepsTheWorkspaceOfARunThatGoesLiveAfterSelection(t *testing.T) { + f := setupAutomationRetentionFixture(t) + _, tasks := f.seedRunSeries(t, automation.DefaultRunWorktreeRetention+3) + revived := tasks[0] + + var selection []string + f.raceAfterSelection(func(selected []string) { + selection = append([]string(nil), selected...) + f.startSession(t, revived, "RUNNING") + }) + + f.svc.finalizeAutomationRun(context.Background(), tasks[len(tasks)-1], true, "") + + require.Contains(t, selection, revived, + "precondition: the run was named as prunable before it went live") + require.NotContains(t, f.reaper.removed, "wt-"+revived, + "a run that went live between selection and removal must keep its checkout") + require.DirExists(t, f.workspacePath(revived), + "the live agent's working tree must still be there") + require.ElementsMatch(t, []string{"wt-" + tasks[1], "wt-" + tasks[2]}, f.reaper.removed, + "the runs that stayed idle are still reclaimed") +} + +// A removal that frees nothing must not be reported as one. The worktree +// manager logs a failed directory removal at Warn, marks the row deleted and +// returns nil, so a sweep that trusts the nil error announces reclaimed disk +// that is still fully occupied — and the run then drops out of the candidate +// set for good. Stat-ing the path is the only honest confirmation available. +func TestFinalizeAutomationRun_DoesNotReportAReclaimWhoseDirectorySurvived(t *testing.T) { + f := setupAutomationRetentionFixture(t) + logs := f.observeLogs(t) + f.reaper.setSwallowRemoval(true) + _, tasks := f.seedRunSeries(t, automation.DefaultRunWorktreeRetention+3) + + f.svc.finalizeAutomationRun(context.Background(), tasks[len(tasks)-1], true, "") + + require.Len(t, f.reaper.removed, 3, "precondition: removal was attempted for all three") + for _, taskID := range tasks[:3] { + require.DirExists(t, f.workspacePath(taskID), + "precondition: the manager swallowed the failure and left the directory behind") + } + require.Empty(t, logs.FilterMessage("reclaimed the workspace of an aged-out automation run").All(), + "a removal that freed no disk must not be logged as a reclaim") + require.Len(t, logs.FilterLevelExact(zapcore.ErrorLevel).All(), 3, + "each surviving workspace must be surfaced as a failure, with its path") +} + +// …and it must be retryable rather than terminal. By the time the failure is +// visible the manager has already marked the worktree row deleted, so the run +// no longer has a live checkout as far as the candidate query is concerned and +// no later sweep would ever look at it again. The retry queue is its only way +// back. +func TestFinalizeAutomationRun_RetriesAWorkspaceThatSurvivedItsRemoval(t *testing.T) { + ctx := context.Background() + f := setupAutomationRetentionFixture(t) + f.reaper.setSwallowRemoval(true) + _, tasks := f.seedRunSeries(t, automation.DefaultRunWorktreeRetention+3) + + f.svc.finalizeAutomationRun(ctx, tasks[len(tasks)-1], true, "") + require.Len(t, f.reaper.removed, 3, "precondition: the first sweep tried and freed nothing") + + agedOut, err := f.autoSvc.PrunableRunTaskIDs(ctx, tasks[len(tasks)-1], automation.DefaultRunWorktreeRetention) + require.NoError(t, err) + require.Empty(t, agedOut, + "precondition: the rows now say deleted, so retention alone can no longer see these runs") + + f.reaper.setSwallowRemoval(false) + f.svc.finalizeAutomationRun(ctx, tasks[len(tasks)-1], true, "") + + require.Len(t, f.reaper.removed, 6, "each survivor must be handed back to the manager") + for _, taskID := range tasks[:3] { + require.NoDirExists(t, f.workspacePath(taskID), "the retry must actually free the disk") + } +} diff --git a/apps/backend/internal/orchestrator/event_handlers_clarification.go b/apps/backend/internal/orchestrator/event_handlers_clarification.go index 665cc85d63..1a69e50cb7 100644 --- a/apps/backend/internal/orchestrator/event_handlers_clarification.go +++ b/apps/backend/internal/orchestrator/event_handlers_clarification.go @@ -90,7 +90,7 @@ func (s *Service) handleClarificationStaleDismissed(ctx context.Context, event * } s.captureGitStatusSnapshot(writeCtx, data.SessionID) - s.finalizeAutomationRunIfEphemeral(writeCtx, data.TaskID, data.SessionID, true, "") + s.finalizeAutomationRun(writeCtx, data.TaskID, true, "") transitioned := s.processOnTurnCompleteViaEngine(writeCtx, data.TaskID, session) if !transitioned { s.writeTaskReviewState(writeCtx, data.TaskID, data.SessionID) diff --git a/apps/backend/internal/orchestrator/event_handlers_git.go b/apps/backend/internal/orchestrator/event_handlers_git.go index 63027d4ac4..dc31d6e432 100644 --- a/apps/backend/internal/orchestrator/event_handlers_git.go +++ b/apps/backend/internal/orchestrator/event_handlers_git.go @@ -601,14 +601,14 @@ func (s *Service) handlePermissionRequest(ctx context.Context, data watcher.Perm } } - // Run-mode automation tasks are hidden from the kanban, so there is no UI - // for the user to answer a permission prompt. Auto-reject and mark the run - // failed so the failure shows up in the automation's Recent Runs. + // Automation tasks are hidden from the kanban, so there is no UI for the + // user to answer a permission prompt. Auto-reject and mark the run failed + // so the failure shows up in the automation's Recent Runs. s.failAutomationRunOnPermission(ctx, data) } // failAutomationRunOnPermission checks whether the permission request belongs -// to a run-mode automation task and, if so, rejects the prompt and marks the +// to an automation task and, if so, rejects the prompt and marks the // corresponding automation_run row as failed. func (s *Service) failAutomationRunOnPermission(ctx context.Context, data watcher.PermissionRequestData) { if s.automationService == nil || data.TaskID == "" { @@ -618,7 +618,10 @@ func (s *Service) failAutomationRunOnPermission(ctx context.Context, data watche if err != nil || task == nil { return } - if !task.IsEphemeral || task.Origin != models.TaskOriginAutomationRun { + // Keyed on origin alone — automation tasks are no longer ephemeral, and a + // prompt nobody can answer would otherwise hang the run at task_created + // forever, holding a max_concurrent_runs slot. + if task.Origin != models.TaskOriginAutomationRun { return } @@ -626,13 +629,13 @@ func (s *Service) failAutomationRunOnPermission(ctx context.Context, data watche // also true here because the session is going to be marked failed anyway. optionID := pickRejectOption(data.Options) if err := s.RespondToPermission(ctx, data.TaskSessionID, data.PendingID, optionID, true, true); err != nil { - s.logger.Warn("failed to auto-reject permission for run-mode automation", + s.logger.Warn("failed to auto-reject permission for automation run", zap.String("task_id", data.TaskID), zap.String("pending_id", data.PendingID), zap.Error(err)) } - errMsg := fmt.Sprintf("Permission required: %s — run-mode automations cannot answer prompts", data.Title) + errMsg := fmt.Sprintf("Permission required: %s — automation runs cannot answer prompts", data.Title) if err := s.automationService.MarkRunFailedByTaskID(ctx, data.TaskID, errMsg); err != nil { s.logger.Warn("failed to mark automation run failed after permission prompt", zap.String("task_id", data.TaskID), zap.Error(err)) diff --git a/apps/backend/internal/orchestrator/event_handlers_github.go b/apps/backend/internal/orchestrator/event_handlers_github.go index d5f4c86679..48f5b0c6fa 100644 --- a/apps/backend/internal/orchestrator/event_handlers_github.go +++ b/apps/backend/internal/orchestrator/event_handlers_github.go @@ -123,9 +123,9 @@ type ReviewTaskRequest struct { Description string Metadata map[string]interface{} Repositories []ReviewTaskRepository - // IsEphemeral hides the task from the kanban — used by run-mode - // automations whose output should surface in the automation's run - // history rather than as a tracked task. + // IsEphemeral marks a quick-chat-style task. Automation runs do NOT set + // it: they are hidden from the board by their Origin instead, which is + // what lets them keep their worktree and reach a terminal run status. IsEphemeral bool // Origin tags the task with a provenance label (see task/models.TaskOrigin*). // Defaults to TaskOriginManual when empty. diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go index 14a1eff5a3..85a80c49c5 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go @@ -1327,7 +1327,7 @@ func TestCompleteStreamFromCompletedExecutionSkipsDuplicateOfficeTeardown(t *tes func TestCompleteStreamFromCompletedExecutionSkipsDuplicateAutomationFinalize(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) - seedRunModeAutomationSession(t, repo, "t-auto-terminal", "s-auto-terminal", "exec-auto-terminal") + seedAutomationRunSession(t, repo, "t-auto-terminal", "s-auto-terminal", "exec-auto-terminal") taskRepo := newMockTaskRepo() mgr := &mockAgentManager{} @@ -2328,7 +2328,9 @@ func TestHandleCompleteStreamEvent_NaturalOfficeCompleteStillIdle(t *testing.T) "natural office turn completion must still call StopAgent to tear down the executor") } -func seedRunModeAutomationSession( +// Automation tasks are ordinary, non-ephemeral tasks tagged by origin — the +// task/run execution mode is withdrawn, so nothing here sets IsEphemeral. +func seedAutomationRunSession( t *testing.T, repo *sqliterepo.Repository, taskID, sessionID, executionID string, @@ -2348,13 +2350,9 @@ func seedRunModeAutomationSession( Title: "Automation run", Description: "run this", State: v1.TaskStateInProgress, - IsEphemeral: true, Origin: models.TaskOriginAutomationRun, - Metadata: map[string]interface{}{ - "execution_mode": string(automation.ExecutionModeRun), - }, - CreatedAt: now, - UpdatedAt: now, + CreatedAt: now, + UpdatedAt: now, })) require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ ID: sessionID, @@ -2366,10 +2364,10 @@ func seedRunModeAutomationSession( seedExecutorRunning(t, repo, sessionID, taskID, executionID) } -func TestHandleCompleteStreamEvent_RunModeAutomationStopsAndFinalizes(t *testing.T) { +func TestHandleCompleteStreamEvent_AutomationRunStopsAndFinalizes(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) - seedRunModeAutomationSession(t, repo, "t-auto-run", "s-auto-run", "exec-auto-run") + seedAutomationRunSession(t, repo, "t-auto-run", "s-auto-run", "exec-auto-run") mgr := &mockAgentManager{} automationSvc := &mockAutomationRunService{} @@ -2394,7 +2392,9 @@ func TestHandleCompleteStreamEvent_RunModeAutomationStopsAndFinalizes(t *testing require.Empty(t, automationSvc.failedTaskIDs) gotSession, err := repo.GetTaskSession(ctx, "s-auto-run") require.NoError(t, err) - require.Equal(t, models.TaskSessionStateCompleted, gotSession.State) + // Not COMPLETED: that state refuses resume, which would make a finished + // report unanswerable. Success lives on the AutomationRun row instead. + require.Equal(t, models.TaskSessionStateWaitingForInput, gotSession.State) mgr.mu.Lock() stopCalls := append([]stopAgentCall(nil), mgr.stopAgentArgs...) @@ -2402,7 +2402,7 @@ func TestHandleCompleteStreamEvent_RunModeAutomationStopsAndFinalizes(t *testing require.Equal(t, []stopAgentCall{{ExecutionID: "exec-auto-run"}}, stopCalls) } -func TestHandleCompleteStreamEvent_RunModeAutomationFailureStopsAndFinalizes(t *testing.T) { +func TestHandleCompleteStreamEvent_AutomationRunFailureStopsAndFinalizes(t *testing.T) { cases := []struct { name string data map[string]interface{} @@ -2444,7 +2444,7 @@ func TestHandleCompleteStreamEvent_RunModeAutomationFailureStopsAndFinalizes(t * taskID := "t-auto-" + tc.name sessionID := "s-auto-" + tc.name executionID := "exec-auto-" + tc.name - seedRunModeAutomationSession(t, repo, taskID, sessionID, executionID) + seedAutomationRunSession(t, repo, taskID, sessionID, executionID) mgr := &mockAgentManager{} automationSvc := &mockAutomationRunService{} diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index 2d3b4ddaf5..f56bc08ca7 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -40,7 +40,6 @@ import ( "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/workflow/engine" wfmodels "github.com/kandev/kandev/internal/workflow/models" - "github.com/kandev/kandev/internal/worktree" v1 "github.com/kandev/kandev/pkg/api/v1" ) @@ -479,10 +478,20 @@ type Service struct { // Automation service for handling automation triggers automationService AutomationService - // Worktree manager — used to clean up ephemeral worktrees for run-mode - // automation tasks immediately on completion rather than waiting for - // the 24h Office GC. Nil-safe. - worktreeMgr *worktree.Manager + // Worktree reaper — reclaims the workspaces of automation runs that have + // aged out of the per-automation retention window. Nil-safe: most tests + // construct the service without one, and an install with no worktree + // manager simply keeps every run's checkout. Set via SetWorktreeManager. + worktreeReaper automationWorktreeReaper + + // unreclaimedWorkspaces holds the automation runs whose workspace removal + // was attempted and did not actually free the directory. Retention selects + // candidates by "still has a live worktree row", and a failed removal can + // leave a run with no live row and a full checkout, invisible to every + // later sweep — this is how it gets retried instead of written off. See + // queueAutomationWorkspaceReclaim. + unreclaimedWorkspaces map[string]struct{} + unreclaimedWorkspacesMu sync.Mutex // Clarification canceller — cancels pending clarifications when agent's turn completes clarificationCanceller ClarificationCanceller diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index c6ea8a8cdf..888564d80d 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -651,6 +651,12 @@ func (r *Repository) initMessageTurnSchema() error { CREATE INDEX IF NOT EXISTS idx_messages_created_at ON task_session_messages(created_at); CREATE INDEX IF NOT EXISTS idx_messages_session_created ON task_session_messages(task_session_id, created_at); CREATE INDEX IF NOT EXISTS idx_messages_turn_id ON task_session_messages(turn_id); + -- The automation run log reads each run's last agent message by task_id + -- (automation.listRunsWithTaskState). Every other index here leads with + -- task_session_id, so without this that lookup falls back to the global + -- created_at index and rescans it once per run row. + CREATE INDEX IF NOT EXISTS idx_messages_task_author_created + ON task_session_messages(task_id, author_type, type, created_at DESC); -- idx_messages_session_updated is created in runMigrations() after the -- updated_at ADD COLUMN + backfill. Creating it here would fail on existing -- DBs where CREATE TABLE IF NOT EXISTS is a no-op and the column does not diff --git a/apps/backend/internal/task/repository/sqlite/session.go b/apps/backend/internal/task/repository/sqlite/session.go index 0835a7c4d3..6721d7dba2 100644 --- a/apps/backend/internal/task/repository/sqlite/session.go +++ b/apps/backend/internal/task/repository/sqlite/session.go @@ -1625,13 +1625,20 @@ func (r *Repository) BatchGetSessionsByTaskIDs(ctx context.Context, taskIDs []st func (r *Repository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, agentProfileID string) (bool, error) { var exists int - // Exclude ephemeral tasks (quick chat, config chat) - they shouldn't block profile deletion + // Exclude ephemeral tasks (quick chat, config chat) - they shouldn't block profile deletion. + // Automation runs are excluded only where they are parked: a finished run + // rests in WAITING_FOR_INPUT so it stays answerable, and counting those would + // let one nightly report block its agent profile from ever being deleted. A + // run that is still working is using the profile now and blocks like any + // other task — see GetActiveTaskInfoByAgentProfile for the same split. err := r.ro.QueryRowContext(ctx, r.ro.Rebind(` SELECT 1 FROM task_sessions ts JOIN tasks t ON ts.task_id = t.id WHERE ts.agent_profile_id = ? AND ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') AND t.is_ephemeral = 0 + AND NOT (COALESCE(t.origin, '') = '`+models.TaskOriginAutomationRun+`' + AND ts.state = 'WAITING_FOR_INPUT') LIMIT 1 `), agentProfileID).Scan(&exists) if err == sql.ErrNoRows { @@ -1641,11 +1648,27 @@ func (r *Repository) HasActiveTaskSessionsByAgentProfile(ctx context.Context, ag } func (r *Repository) GetActiveTaskInfoByAgentProfile(ctx context.Context, agentProfileID string) ([]agentdto.ActiveTaskInfo, error) { + // This list is "what is blocking this deletion", and automation runs sit on + // both sides of that question. + // + // A run that is CREATED, STARTING or RUNNING is using the profile right now. + // Deleting it out from under live work is the same failure as for any other + // task, so it blocks — the row names a task the user cannot find on a board, + // but the alternative is a silent kill. + // + // A run parked in WAITING_FOR_INPUT is different. That is where every + // finished automation run comes to rest, by design, so counting those would + // let one nightly report block its profile's deletion forever. Those are + // excluded, which makes them non-resumable if the profile goes: replying to + // an old run afterwards fails. That is the accepted trade — see + // docs/specs/office/automations-settings.md. rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT DISTINCT t.id, t.title, t.is_ephemeral FROM task_sessions ts JOIN tasks t ON t.id = ts.task_id WHERE ts.agent_profile_id = ? AND ts.state IN ('CREATED', 'STARTING', 'RUNNING', 'WAITING_FOR_INPUT') + AND NOT (COALESCE(t.origin, '') = '`+models.TaskOriginAutomationRun+`' + AND ts.state = 'WAITING_FOR_INPUT') `), agentProfileID) if err != nil { return nil, err diff --git a/apps/backend/internal/task/repository/sqlite/task.go b/apps/backend/internal/task/repository/sqlite/task.go index 5939accc93..e54c93fab1 100644 --- a/apps/backend/internal/task/repository/sqlite/task.go +++ b/apps/backend/internal/task/repository/sqlite/task.go @@ -33,6 +33,16 @@ type taskScanColumn struct { selectExpr func(alias string) string } +// Automation runs are hidden from the board and from task lists by their +// provenance, not by ephemerality: their tasks are ordinary persistent tasks +// that keep their worktree and stay repliable, they just have their own +// destination (docs/specs/office/automations-settings.md). is_ephemeral keeps +// its original quick-chat meaning, so every board read pairs the two. +const ( + andNotAutomationOrigin = ` AND COALESCE(origin, '') != '` + models.TaskOriginAutomationRun + `'` + andNotAutomationOriginT = ` AND COALESCE(t.origin, '') != '` + models.TaskOriginAutomationRun + `'` +) + var taskScanColumns = []taskScanColumn{ {name: "id"}, {name: "workspace_id"}, @@ -338,7 +348,7 @@ func (r *Repository) lockWorkflowStepsForAdmission(ctx context.Context, tx *sql. } func (r *Repository) countAdmittedInTx(ctx context.Context, tx *sql.Tx, stepID, excludeTaskID string) (int, error) { - query := `SELECT COUNT(*) FROM tasks WHERE workflow_step_id = ? AND wip_admitted = 1 AND archived_at IS NULL AND is_ephemeral = 0` + query := `SELECT COUNT(*) FROM tasks WHERE workflow_step_id = ? AND wip_admitted = 1 AND archived_at IS NULL AND is_ephemeral = 0` + andNotAutomationOrigin args := []interface{}{stepID} if excludeTaskID != "" { query += " AND id != ?" @@ -362,7 +372,7 @@ func (r *Repository) ensureWorkflowStepCapacity(ctx context.Context, tx *sql.Tx, WHERE workflow_step_id = ? AND wip_admitted = 1 AND archived_at IS NULL - AND is_ephemeral = 0 + AND is_ephemeral = 0`+andNotAutomationOrigin+` `), targetStepID).Scan(&occupants); err != nil { return err } @@ -750,7 +760,7 @@ func (r *Repository) UpdateTaskIfWorkflowStepHasCapacity(ctx context.Context, ta AND wip_admitted = 1 AND id != ? AND archived_at IS NULL - AND is_ephemeral = 0 + AND is_ephemeral = 0`+andNotAutomationOrigin+` `), targetStepID, excludeTaskID).Scan(&occupants); err != nil { return err } @@ -808,7 +818,7 @@ func (r *Repository) PromoteQueuedTaskIfWorkflowStepHasCapacity( AND wip_admitted = 1 AND id != ? AND archived_at IS NULL - AND is_ephemeral = 0 + AND is_ephemeral = 0`+andNotAutomationOrigin+` `), destinationStepID, task.ID).Scan(&occupants); err != nil { return false, err } @@ -823,7 +833,7 @@ func (r *Repository) PromoteQueuedTaskIfWorkflowStepHasCapacity( AND workflow_step_id = ? AND (queued_for_step_id = ? OR queued_for_step_id = '' OR queued_for_step_id IS NULL) AND archived_at IS NULL - AND is_ephemeral = 0 + AND is_ephemeral = 0`+andNotAutomationOrigin+` `), task.WorkspaceID, task.WorkflowID, task.WorkflowStepID, task.Title, task.Description, task.State, task.Priority, task.Position, dialect.BoolToInt(task.WIPAdmitted), task.QueuedForStepID, task.QueuedAt, string(metadata), task.ParentID, task.UpdatedAt, task.Origin, task.ProjectID, task.Labels, task.Identifier, task.ID, fromStepID, destinationStepID) if err != nil { return false, err @@ -874,7 +884,7 @@ func (r *Repository) ListTasks(ctx context.Context, workflowID string) ([]*model rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT `+taskSelectColumns("t")+` FROM tasks t - WHERE t.workflow_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0 + WHERE t.workflow_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0`+andNotAutomationOriginT+` ORDER BY t.created_at ASC `), workflowID) if err != nil { @@ -888,7 +898,7 @@ func (r *Repository) ListTasks(ctx context.Context, workflowID string) ([]*model // CountTasksByWorkflow returns the number of non-archived, non-ephemeral tasks in a workflow func (r *Repository) CountTasksByWorkflow(ctx context.Context, workflowID string) (int, error) { var count int - err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`SELECT COUNT(*) FROM tasks WHERE workflow_id = ? AND archived_at IS NULL AND is_ephemeral = 0`), workflowID).Scan(&count) + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`SELECT COUNT(*) FROM tasks WHERE workflow_id = ? AND archived_at IS NULL AND is_ephemeral = 0`+andNotAutomationOrigin), workflowID).Scan(&count) if err != nil { return 0, err } @@ -898,7 +908,7 @@ func (r *Repository) CountTasksByWorkflow(ctx context.Context, workflowID string // CountTasksByWorkflowStep returns the number of non-archived, non-ephemeral tasks in a workflow step func (r *Repository) CountTasksByWorkflowStep(ctx context.Context, stepID string) (int, error) { var count int - err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`SELECT COUNT(*) FROM tasks WHERE workflow_step_id = ? AND archived_at IS NULL AND is_ephemeral = 0`), stepID).Scan(&count) + err := r.ro.QueryRowContext(ctx, r.ro.Rebind(`SELECT COUNT(*) FROM tasks WHERE workflow_step_id = ? AND archived_at IS NULL AND is_ephemeral = 0`+andNotAutomationOrigin), stepID).Scan(&count) if err != nil { return 0, err } @@ -915,7 +925,7 @@ func (r *Repository) CountTasksByWorkflowStepExcludingTask(ctx context.Context, AND id != ? AND wip_admitted = 1 AND archived_at IS NULL - AND is_ephemeral = 0 + AND is_ephemeral = 0`+andNotAutomationOrigin+` `), stepID, excludeTaskID).Scan(&count) if err != nil { return 0, err @@ -932,7 +942,7 @@ func (r *Repository) CountAdmittedTasksByWorkflowStep(ctx context.Context, stepI WHERE workflow_step_id = ? AND wip_admitted = 1 AND archived_at IS NULL - AND is_ephemeral = 0 + AND is_ephemeral = 0`+andNotAutomationOrigin+` `), stepID).Scan(&count) return count, err } @@ -969,7 +979,7 @@ func (r *Repository) NextPullCandidateExcluding(ctx context.Context, stepID stri FROM tasks t WHERE t.workflow_step_id = ? AND t.archived_at IS NULL - AND t.is_ephemeral = 0 + AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (t.queued_for_step_id = '' OR t.queued_for_step_id IS NULL) `+excludeClause+` ORDER BY @@ -1016,7 +1026,7 @@ func (r *Repository) NextQueuedTaskForStepExcluding(ctx context.Context, feederS FROM tasks t WHERE t.workflow_step_id = ? AND t.archived_at IS NULL - AND t.is_ephemeral = 0 + AND t.is_ephemeral = 0`+andNotAutomationOriginT+` AND (t.queued_for_step_id = '' OR t.queued_for_step_id IS NULL OR t.queued_for_step_id = ?) `+excludeClause+` ORDER BY @@ -1051,7 +1061,7 @@ func (r *Repository) ListChildren(ctx context.Context, parentID string) ([]*mode rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT `+taskSelectColumns("t")+` FROM tasks t - WHERE t.parent_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0 + WHERE t.parent_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0`+andNotAutomationOriginT+` ORDER BY t.created_at ASC, t.id ASC `), parentID) if err != nil { @@ -1071,7 +1081,7 @@ func (r *Repository) ListChildCompletionRows(ctx context.Context, parentID strin err := r.ro.SelectContext(ctx, &rows, r.ro.Rebind(` SELECT id, state, title, workflow_step_id, updated_at FROM tasks - WHERE parent_id = ? AND archived_at IS NULL AND is_ephemeral = 0 + WHERE parent_id = ? AND archived_at IS NULL AND is_ephemeral = 0`+andNotAutomationOrigin+` ORDER BY created_at ASC, id ASC `), parentID) if err != nil { @@ -1091,7 +1101,7 @@ func (r *Repository) ListChildrenIncludingArchived(ctx context.Context, parentID rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT `+taskSelectColumns("t")+` FROM tasks t - WHERE t.parent_id = ? AND t.is_ephemeral = 0 + WHERE t.parent_id = ? AND t.is_ephemeral = 0`+andNotAutomationOriginT+` ORDER BY t.created_at ASC, t.id ASC `), parentID) if err != nil { @@ -1135,7 +1145,7 @@ func (r *Repository) ListSiblings(ctx context.Context, taskID string) ([]*models AND t.workspace_id = ? AND t.id != ? AND t.archived_at IS NULL - AND t.is_ephemeral = 0 + AND t.is_ephemeral = 0`+andNotAutomationOriginT+` ORDER BY t.created_at ASC, t.id ASC `), self.ParentID, self.WorkspaceID, self.ID) if err != nil { @@ -1150,7 +1160,7 @@ func (r *Repository) ListTasksByWorkflowStep(ctx context.Context, workflowStepID rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT `+taskSelectColumns("t")+` FROM tasks t - WHERE t.workflow_step_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0 ORDER BY t.created_at ASC + WHERE t.workflow_step_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0`+andNotAutomationOriginT+` ORDER BY t.created_at ASC `), workflowStepID) if err != nil { return nil, err @@ -1167,7 +1177,7 @@ func (r *Repository) ListQueuedTasks(ctx context.Context) ([]*models.Task, error SELECT `+taskSelectColumns("t")+` FROM tasks t WHERE t.queued_for_step_id IS NOT NULL AND t.queued_for_step_id != '' - AND t.archived_at IS NULL AND t.is_ephemeral = 0 + AND t.archived_at IS NULL AND t.is_ephemeral = 0`+andNotAutomationOriginT+` ORDER BY t.queued_at ASC, t.created_at ASC, t.id ASC `)) if err != nil { @@ -1211,6 +1221,10 @@ func (r *Repository) ListTasksByWorkspaceWithArchiveMode(ctx context.Context, wo filter += " AND is_ephemeral = 0" } // If includeEphemeral is true and onlyEphemeral is false, include both + // Automation runs are excluded regardless of the ephemeral toggles: they + // are hidden by provenance, and "include quick chats" is not a request to + // see them. + filter += andNotAutomationOrigin if onlyArchived { filter += " AND archived_at IS NOT NULL" @@ -1305,7 +1319,7 @@ func rewriteFilterForAlias(filter, alias string) string { return filter } out := filter - for _, col := range []string{"is_ephemeral", "archived_at", "metadata", "workflow_id"} { + for _, col := range []string{"is_ephemeral", "archived_at", "metadata", "workflow_id", "origin"} { // Replace " " only when not already prefixed by `alias.`. out = simplePrefixCol(out, col, alias) } @@ -2222,7 +2236,7 @@ func (r *Repository) ListTasksByProject(ctx context.Context, projectID string) ( rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` SELECT `+taskSelectColumns("t")+` FROM tasks t - WHERE t.project_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0 + WHERE t.project_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0`+andNotAutomationOriginT+` ORDER BY t.created_at ASC `), projectID) if err != nil { @@ -2240,7 +2254,7 @@ func (r *Repository) ListTasksByAssignee(ctx context.Context, agentInstanceID st SELECT `+taskSelectColumns("t")+` FROM tasks t WHERE `+runnerProjection("t")+` = ? - AND t.archived_at IS NULL AND t.is_ephemeral = 0 + AND t.archived_at IS NULL AND t.is_ephemeral = 0`+andNotAutomationOriginT+` ORDER BY t.created_at ASC `), agentInstanceID) if err != nil { @@ -2253,7 +2267,7 @@ func (r *Repository) ListTasksByAssignee(ctx context.Context, agentInstanceID st // ListTaskTree returns a flat list of non-archived tasks for a workspace, suitable for // building a tree using each task's ParentID field. func (r *Repository) ListTaskTree(ctx context.Context, workspaceID string, filters models.TaskTreeFilters) ([]*models.Task, error) { - query := `SELECT ` + taskSelectColumns("t") + ` FROM tasks t WHERE t.workspace_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0` + query := `SELECT ` + taskSelectColumns("t") + ` FROM tasks t WHERE t.workspace_id = ? AND t.archived_at IS NULL AND t.is_ephemeral = 0` + andNotAutomationOriginT args := []interface{}{workspaceID} if filters.ProjectID != "" { diff --git a/apps/backend/internal/task/repository/sqlite/task_automation_origin_test.go b/apps/backend/internal/task/repository/sqlite/task_automation_origin_test.go new file mode 100644 index 0000000000..013a13b552 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/task_automation_origin_test.go @@ -0,0 +1,307 @@ +package sqlite + +import ( + "context" + "testing" + + "github.com/kandev/kandev/internal/task/models" + wfmodels "github.com/kandev/kandev/internal/workflow/models" + workflowrepo "github.com/kandev/kandev/internal/workflow/repository" +) + +// newAutoOriginWorkflowStore builds a workflow repository over the same DB as +// repo, so these tests can create the step their fixture tasks sit on. +func newAutoOriginWorkflowStore(t *testing.T, repo *Repository) *workflowrepo.Repository { + t.Helper() + store, err := workflowrepo.NewWithDB(repo.db, repo.db, nil) + if err != nil { + t.Fatalf("initialize workflow repository: %v", err) + } + return store +} + +// Automation runs are hidden from the board and from task lists by their +// origin, not by is_ephemeral: they are ordinary persistent tasks that keep +// their worktree and stay repliable, they just have their own destination. +// is_ephemeral keeps its original quick-chat meaning, so these tests pin both +// halves — the automation task disappears from every list read, and the quick +// chat next to it keeps behaving exactly as it did. +const ( + autoOriginWorkspaceID = "ws-auto-origin" + autoOriginWorkflowID = "wf-auto-origin" + autoOriginStepID = "step-auto-origin" +) + +type autoOriginFixture struct { + repo *Repository + boardTaskID string + autoTaskID string + quickTaskID string +} + +func seedAutomationOriginFixture(t *testing.T) *autoOriginFixture { + t.Helper() + ctx := context.Background() + repo := newRepoForSessionTests(t) + + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: autoOriginWorkspaceID, Name: "Automation origin"}); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: autoOriginWorkflowID, WorkspaceID: autoOriginWorkspaceID, Name: "WF"}); err != nil { + t.Fatalf("CreateWorkflow: %v", err) + } + if err := newAutoOriginWorkflowStore(t, repo).CreateStep(ctx, &wfmodels.WorkflowStep{ + ID: autoOriginStepID, WorkflowID: autoOriginWorkflowID, Name: "Todo", Position: 1, + }); err != nil { + t.Fatalf("CreateStep: %v", err) + } + + create := func(id, title, origin string, ephemeral bool, withWorkflow bool) { + t.Helper() + task := &models.Task{ + ID: id, + WorkspaceID: autoOriginWorkspaceID, + Title: title, + State: "BACKLOG", + Priority: "medium", + Origin: origin, + IsEphemeral: ephemeral, + } + if withWorkflow { + task.WorkflowID = autoOriginWorkflowID + task.WorkflowStepID = autoOriginStepID + } + if err := repo.CreateTask(ctx, task); err != nil { + t.Fatalf("CreateTask(%s): %v", id, err) + } + } + + // The automation task sits in the same workflow step as the human task: + // only its origin may keep it off the board. + create("t-board", "human work", models.TaskOriginManual, false, true) + create("t-auto", "nightly sweep", models.TaskOriginAutomationRun, false, true) + create("t-quick", "quick chat", models.TaskOriginManual, true, false) + + return &autoOriginFixture{repo: repo, boardTaskID: "t-board", autoTaskID: "t-auto", quickTaskID: "t-quick"} +} + +func taskIDs(tasks []*models.Task) []string { + ids := make([]string, 0, len(tasks)) + for _, task := range tasks { + ids = append(ids, task.ID) + } + return ids +} + +func assertExcludesAutomation(t *testing.T, what string, tasks []*models.Task, wantPresent string) { + t.Helper() + ids := taskIDs(tasks) + foundWanted := false + for _, id := range ids { + if id == "t-auto" { + t.Fatalf("%s: automation-origin task must not appear, got %v", what, ids) + } + if id == wantPresent { + foundWanted = true + } + } + if wantPresent != "" && !foundWanted { + t.Fatalf("%s: expected %s to still be listed, got %v", what, wantPresent, ids) + } +} + +// The kanban reads tasks by workflow and by workflow step. An automation run +// created against a workflow step must not surface in either. +func TestBoardQueriesExcludeAutomationOriginTasks(t *testing.T) { + f := seedAutomationOriginFixture(t) + ctx := context.Background() + + byWorkflow, err := f.repo.ListTasks(ctx, autoOriginWorkflowID) + if err != nil { + t.Fatalf("ListTasks: %v", err) + } + assertExcludesAutomation(t, "ListTasks", byWorkflow, f.boardTaskID) + + byStep, err := f.repo.ListTasksByWorkflowStep(ctx, autoOriginStepID) + if err != nil { + t.Fatalf("ListTasksByWorkflowStep: %v", err) + } + assertExcludesAutomation(t, "ListTasksByWorkflowStep", byStep, f.boardTaskID) + + count, err := f.repo.CountTasksByWorkflowStep(ctx, autoOriginStepID) + if err != nil { + t.Fatalf("CountTasksByWorkflowStep: %v", err) + } + if count != 1 { + t.Fatalf("CountTasksByWorkflowStep = %d, want 1 (the automation run must not occupy a column)", count) + } +} + +// The task list is the other surface an automation run must stay out of — +// including its search path, and including the "show me quick chats too" +// variant, since including ephemeral tasks is not a request to see automation +// output. +func TestTaskListQueriesExcludeAutomationOriginTasks(t *testing.T) { + f := seedAutomationOriginFixture(t) + ctx := context.Background() + + list, total, err := f.repo.ListTasksByWorkspace(ctx, autoOriginWorkspaceID, "", "", "", 1, 50, "", false, false, false, false) + if err != nil { + t.Fatalf("ListTasksByWorkspace: %v", err) + } + assertExcludesAutomation(t, "ListTasksByWorkspace", list, f.boardTaskID) + if total != 1 { + t.Fatalf("ListTasksByWorkspace total = %d, want 1", total) + } + + withEphemeral, _, err := f.repo.ListTasksByWorkspace(ctx, autoOriginWorkspaceID, "", "", "", 1, 50, "", false, true, false, false) + if err != nil { + t.Fatalf("ListTasksByWorkspace(includeEphemeral): %v", err) + } + assertExcludesAutomation(t, "ListTasksByWorkspace(includeEphemeral)", withEphemeral, f.quickTaskID) + + searched, _, err := f.repo.ListTasksByWorkspace(ctx, autoOriginWorkspaceID, "", "", "sweep", 1, 50, "", false, false, false, false) + if err != nil { + t.Fatalf("ListTasksByWorkspace(search): %v", err) + } + if len(searched) != 0 { + t.Fatalf("search for the automation's own title must find nothing, got %v", taskIDs(searched)) + } + + tree, err := f.repo.ListTaskTree(ctx, autoOriginWorkspaceID, models.TaskTreeFilters{}) + if err != nil { + t.Fatalf("ListTaskTree: %v", err) + } + assertExcludesAutomation(t, "ListTaskTree", tree, f.boardTaskID) +} + +// Quick chat owns is_ephemeral and must be unaffected by the origin filter: +// the only-ephemeral listing still returns exactly the quick chat. +func TestOnlyEphemeralListingStillReturnsQuickChat(t *testing.T) { + f := seedAutomationOriginFixture(t) + ctx := context.Background() + + only, _, err := f.repo.ListTasksByWorkspace(ctx, autoOriginWorkspaceID, "", "", "", 1, 50, "", false, false, true, false) + if err != nil { + t.Fatalf("ListTasksByWorkspace(onlyEphemeral): %v", err) + } + if got := taskIDs(only); len(got) != 1 || got[0] != f.quickTaskID { + t.Fatalf("onlyEphemeral listing = %v, want [%s]", got, f.quickTaskID) + } +} + +// A finished automation run parks in WAITING_FOR_INPUT so it stays answerable, +// and that state is in the "active session" set these two queries use. Without +// the origin exclusion one nightly report would block its agent profile from +// ever being deleted, and would show up in the user's list of what is blocking +// it — naming a task they cannot find on any board. +func TestAgentProfileBlockersExcludeAutomationRuns(t *testing.T) { + f := seedAutomationOriginFixture(t) + ctx := context.Background() + + const profileID = "agent-profile-1" + for _, spec := range []struct{ sessionID, taskID string }{ + {"sess-auto", f.autoTaskID}, + } { + if err := f.repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: spec.sessionID, + TaskID: spec.taskID, + AgentProfileID: profileID, + State: models.TaskSessionStateWaitingForInput, + }); err != nil { + t.Fatalf("CreateTaskSession(%s): %v", spec.sessionID, err) + } + } + + blocked, err := f.repo.HasActiveTaskSessionsByAgentProfile(ctx, profileID) + if err != nil { + t.Fatalf("HasActiveTaskSessionsByAgentProfile: %v", err) + } + if blocked { + t.Fatal("a parked automation run must not block agent-profile deletion") + } + + blockers, err := f.repo.GetActiveTaskInfoByAgentProfile(ctx, profileID) + if err != nil { + t.Fatalf("GetActiveTaskInfoByAgentProfile: %v", err) + } + for _, info := range blockers { + if info.TaskID == f.autoTaskID { + t.Fatalf("automation run must not appear in the blocker list, got %+v", blockers) + } + } +} + +// A run that is still working is using the profile right now. Excluding it +// would let the profile be deleted out from under live automation work, leaving +// an enabled automation and an in-flight run pointing at nothing — so only the +// parked state is exempt, not the origin. +func TestAgentProfileBlockersReportRunningAutomationRuns(t *testing.T) { + f := seedAutomationOriginFixture(t) + ctx := context.Background() + + const profileID = "agent-profile-running" + if err := f.repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "sess-auto-running", + TaskID: f.autoTaskID, + AgentProfileID: profileID, + State: models.TaskSessionStateRunning, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + blocked, err := f.repo.HasActiveTaskSessionsByAgentProfile(ctx, profileID) + if err != nil { + t.Fatalf("HasActiveTaskSessionsByAgentProfile: %v", err) + } + if !blocked { + t.Error("a running automation run must block agent-profile deletion") + } + + blockers, err := f.repo.GetActiveTaskInfoByAgentProfile(ctx, profileID) + if err != nil { + t.Fatalf("GetActiveTaskInfoByAgentProfile: %v", err) + } + var found bool + for _, info := range blockers { + if info.TaskID == f.autoTaskID { + found = true + } + } + if !found { + t.Errorf("a running automation run must be named as a blocker, got %+v", blockers) + } +} + +// The same states on a human task still block, so the exclusion above narrows +// the query by origin rather than defeating it. +func TestAgentProfileBlockersStillReportHumanTasks(t *testing.T) { + f := seedAutomationOriginFixture(t) + ctx := context.Background() + + const profileID = "agent-profile-2" + if err := f.repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "sess-human", + TaskID: f.boardTaskID, + AgentProfileID: profileID, + State: models.TaskSessionStateWaitingForInput, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + blocked, err := f.repo.HasActiveTaskSessionsByAgentProfile(ctx, profileID) + if err != nil { + t.Fatalf("HasActiveTaskSessionsByAgentProfile: %v", err) + } + if !blocked { + t.Fatal("a human task waiting on input must still block deletion") + } + + blockers, err := f.repo.GetActiveTaskInfoByAgentProfile(ctx, profileID) + if err != nil { + t.Fatalf("GetActiveTaskInfoByAgentProfile: %v", err) + } + if len(blockers) != 1 || blockers[0].TaskID != f.boardTaskID { + t.Fatalf("expected only the human task as a blocker, got %+v", blockers) + } +} diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 5ed885c9bc..a9cf24ce41 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -342,7 +342,11 @@ func (s *Service) validateCreateTaskRequest(req *CreateTaskRequest) error { return err } isOffice := isOfficeRequest(req) - if !req.IsEphemeral && !isOffice && req.WorkflowID == "" { + // Automation runs never land on a board, so they need no workflow — the + // trigger is the start signal, not a column. They are still ordinary, + // persistent tasks; only their origin keeps them out of board reads. + isAutomationRun := req.Origin == models.TaskOriginAutomationRun + if !req.IsEphemeral && !isOffice && !isAutomationRun && req.WorkflowID == "" { return fmt.Errorf("workflow_id is required for non-ephemeral tasks") } if req.IsEphemeral && req.WorkflowID != "" { diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 13108d6596..bda29c3b94 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -613,6 +613,9 @@ const ( ActionAutomationDisable = "automation.disable" ActionAutomationTrigger = "automation.trigger" ActionAutomationRunsList = "automation.runs.list" + ActionAutomationRunsListWorkspace = "automation.runs.list_workspace" + ActionAutomationSummaries = "automation.summaries" + ActionAutomationSummary = "automation.summary" ActionAutomationTriggerAdd = "automation.trigger.add" ActionAutomationTriggerUpdate = "automation.trigger.update" ActionAutomationTriggerDelete = "automation.trigger.delete" diff --git a/apps/web/app/actions/agents.ts b/apps/web/app/actions/agents.ts index 1d0378bd41..b78f6d5dc8 100644 --- a/apps/web/app/actions/agents.ts +++ b/apps/web/app/actions/agents.ts @@ -138,6 +138,7 @@ export async function updateAgentProfileAction( import type { ActiveSessionInfo, + AutomationReference, RoutingTierReference, WatcherReference, } from "@/lib/types/agent-profile-errors"; @@ -149,6 +150,7 @@ export type DeleteProfileResult = activeSessions: ActiveSessionInfo[]; watchers: WatcherReference[]; routingTiers: RoutingTierReference[]; + automations: AutomationReference[]; } | { status: "error"; message: string }; @@ -171,12 +173,16 @@ export async function deleteAgentProfileAction( // A 409 is active sessions, referencing watchers, routing tier mappings, or a mix. // Treat any non-empty list as the conflict signal — a watcher-only // conflict (the new self-heal path) must still pop the dialog. - if (response.status === 409 && (body.active_sessions || body.watchers || body.routing_tiers)) { + if ( + response.status === 409 && + (body.active_sessions || body.watchers || body.routing_tiers || body.automations) + ) { return { status: "conflict", activeSessions: body.active_sessions ?? [], watchers: body.watchers ?? [], routingTiers: body.routing_tiers ?? [], + automations: body.automations ?? [], }; } return { diff --git a/apps/web/app/office/components/agent-card.tsx b/apps/web/app/office/components/agent-card.tsx index ada1116f04..b634a2b3d2 100644 --- a/apps/web/app/office/components/agent-card.tsx +++ b/apps/web/app/office/components/agent-card.tsx @@ -7,7 +7,7 @@ import { Card } from "@kandev/ui/card"; import type { AgentSummary, SessionSummary } from "@/lib/api/domains/office-api"; import { AgentAvatar as RoleAwareAgentAvatar } from "./agent-avatar"; import { timeAgo } from "@/lib/utils/time"; -import { useNow } from "./shared/use-now"; +import { useNow } from "@/hooks/use-now"; type Props = { summary: AgentSummary }; diff --git a/apps/web/components/app-sidebar/app-sidebar-constants.ts b/apps/web/components/app-sidebar/app-sidebar-constants.ts index d3138c2222..4af389ae65 100644 --- a/apps/web/components/app-sidebar/app-sidebar-constants.ts +++ b/apps/web/components/app-sidebar/app-sidebar-constants.ts @@ -1,6 +1,7 @@ /** Section IDs used for both display and persistence keys in the AppSidebar. */ export const APP_SIDEBAR_SECTION_IDS = { tasks: "tasks", + automations: "automations", officeWork: "office-work", officeWorkspace: "office-workspace", projects: "projects", diff --git a/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx b/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx index 313c0cb921..13465e7ae0 100644 --- a/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-primary-nav.test.tsx @@ -70,6 +70,15 @@ describe("AppSidebarPrimaryNav", () => { expect(screen.queryByRole("button", { name: "Quick Chat" })).toBeNull(); }); + it("no longer carries a Runs row — automations are their own section", () => { + // The destination moved into the Automations section, whose header links to + // it and whose rows are the automations themselves. Two nav entries pointing + // at the same place is the thing that made "Runs" hard to place. + renderNav(false); + + expect(screen.queryByRole("link", { name: "Runs" })).toBeNull(); + }); + it("omits Quick Chat when the rail is collapsed but there is no workspace", () => { state.workspaces.activeId = null; renderNav(true); diff --git a/apps/web/components/app-sidebar/app-sidebar-section.test.tsx b/apps/web/components/app-sidebar/app-sidebar-section.test.tsx index a97df46eef..57508cc8c4 100644 --- a/apps/web/components/app-sidebar/app-sidebar-section.test.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-section.test.tsx @@ -128,3 +128,54 @@ describe("AppSidebarSection", () => { expect(storeState.toggleAppSidebarSection).toHaveBeenCalledWith("office-work", true); }); }); + +describe("AppSidebarSection collapsed summary", () => { + beforeEach(() => { + storeState.appSidebar.sectionExpanded = { tasks: true }; + }); + + afterEach(() => { + cleanup(); + }); + + it("shows a collapsed summary only while the accordion is shut", () => { + // A section that starts folded has to say how much it is hiding, or it + // reads as empty and nobody opens it. Once it is open the rows say it. + storeState.appSidebar.sectionExpanded = { tasks: false }; + const { rerender } = render( + + +
children
+
+
, + ); + + const summary = screen.getByTestId("sidebar-section-collapsed-summary"); + expect(summary.textContent).toBe("4"); + // Muted, so it reads as a footnote to the label rather than a second label. + expect(summary.className).toContain("text-muted-foreground"); + + storeState.appSidebar.sectionExpanded = { tasks: true }; + rerender( + + +
children
+
+
, + ); + + expect(screen.queryByTestId("sidebar-section-collapsed-summary")).toBeNull(); + }); +}); diff --git a/apps/web/components/app-sidebar/app-sidebar-section.tsx b/apps/web/components/app-sidebar/app-sidebar-section.tsx index ef624ad806..eeea0b98e8 100644 --- a/apps/web/components/app-sidebar/app-sidebar-section.tsx +++ b/apps/web/components/app-sidebar/app-sidebar-section.tsx @@ -15,6 +15,11 @@ type AppSidebarSectionProps = { children: React.ReactNode; /** Optional control rendered between the label and the collapse chevron. */ headerAction?: React.ReactNode; + /** Rendered beside the label only while the accordion is shut. A section that + * starts folded still has to say how much it is hiding, or it reads as empty + * and nobody opens it. Muted by the header, so callers pass content, not + * colour. */ + collapsedSummary?: React.ReactNode; /** By default header actions render only while the section accordion is open. * "always" keeps them visible while the accordion is closed, but has no * effect when the sidebar itself is in collapsed/rail mode. */ @@ -30,6 +35,7 @@ type SectionHeaderProps = { expanded: boolean; headerAction?: React.ReactNode; headerActionVisibility: "expanded" | "always"; + collapsedSummary?: React.ReactNode; onToggle: () => void; }; @@ -38,6 +44,7 @@ function SectionHeader({ expanded, headerAction, headerActionVisibility, + collapsedSummary, onToggle, }: SectionHeaderProps) { const showHeaderAction = !!headerAction && (expanded || headerActionVisibility === "always"); @@ -47,10 +54,18 @@ function SectionHeader({ {showHeaderAction &&
{headerAction}
} + {/* Above the table on purpose: it explains why the table's automations + stopped showing up where the reader last saw them, so it has to be + read before the table, not after it. */} + {loading && items.length === 0 ? (
{t("automations:loadingAutomations")} diff --git a/apps/web/components/automations/automations-table.test.tsx b/apps/web/components/automations/automations-table.test.tsx index e2e544d4d9..2ab88c7385 100644 --- a/apps/web/components/automations/automations-table.test.tsx +++ b/apps/web/components/automations/automations-table.test.tsx @@ -12,7 +12,6 @@ const automation = { id: "automation-1", name: "Daily check", enabled: false, - execution_mode: "task", triggers: [], last_triggered_at: null, } as unknown as Automation; diff --git a/apps/web/components/automations/automations-table.tsx b/apps/web/components/automations/automations-table.tsx index 98dcafc6b4..9effa5e668 100644 --- a/apps/web/components/automations/automations-table.tsx +++ b/apps/web/components/automations/automations-table.tsx @@ -120,7 +120,6 @@ export function AutomationsTable({ {t("automations:columnName")} - {t("automations:columnMode")} {t("automations:columnTriggers")} {t("automations:columnEnabled")} {t("automations:columnLastTriggered")} @@ -131,7 +130,7 @@ export function AutomationsTable({ {automations.length === 0 ? ( @@ -151,13 +150,6 @@ export function AutomationsTable({ } > {a.name} - - - {a.execution_mode === "run" - ? t("automations:executionModeRunShort") - : t("automations:executionModeTaskShort")} - - diff --git a/apps/web/components/automations/board-move-notice.tsx b/apps/web/components/automations/board-move-notice.tsx new file mode 100644 index 0000000000..9a82a45d4d --- /dev/null +++ b/apps/web/components/automations/board-move-notice.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useMemo, useState } from "react"; +import { useTranslation } from "react-i18next"; +import { Alert, AlertDescription, AlertTitle } from "@kandev/ui/alert"; +import { Button } from "@kandev/ui/button"; +import { IconInfoCircle } from "@tabler/icons-react"; +import type { Automation } from "@/lib/types/automation"; +import { + dismissBoardMoveNotice, + isBoardMoveNoticeDismissed, +} from "@/lib/automation-board-notice-storage"; + +/** + * Tells a workspace, once, that its automations stopped putting cards on the + * kanban. + * + * Withdrawing execution modes made every firing land in one hidden place. That + * is the right shape, but `task` was the stored DEFAULT, so on upgrade a + * working setup simply stops producing the cards someone built their day + * around — with nothing on screen to say the work moved rather than broke. The + * runs are not lost, and this is where that gets said. + * + * It reads a per-automation flag the server derives from a retained column, + * and shows nothing at all for a workspace whose automations never ran in that + * mode: they lost nothing and have no news to read. + */ +export function AutomationBoardMoveNotice({ + workspaceId, + automations, +}: { + workspaceId: string; + automations: Automation[]; +}) { + const { t } = useTranslation(); + // Which workspace was dismissed in *this* session, rather than a plain + // boolean: the settings page keeps this component mounted across a workspace + // switch, and a bare flag would carry one workspace's dismissal onto the + // next workspace's first visit. + const [dismissedWorkspace, setDismissedWorkspace] = useState(null); + const alreadyDismissed = useMemo(() => isBoardMoveNoticeDismissed(workspaceId), [workspaceId]); + + const affected = automations.some((automation) => automation.legacy_board_card === true); + if (!affected || alreadyDismissed || dismissedWorkspace === workspaceId) return null; + + const handleDismiss = () => { + dismissBoardMoveNotice(workspaceId); + setDismissedWorkspace(workspaceId); + }; + + return ( + + + ); +} diff --git a/apps/web/components/automations/config-section.test.tsx b/apps/web/components/automations/config-section.test.tsx index 9d9b11d055..6b827c4706 100644 --- a/apps/web/components/automations/config-section.test.tsx +++ b/apps/web/components/automations/config-section.test.tsx @@ -1,13 +1,29 @@ -import { cleanup, render, screen, within } from "@testing-library/react"; +import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; import type { ComponentProps } from "react"; -import { afterEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; + +type MockWorkflow = { id: string; name: string; workspaceId: string }; + +const WORKFLOW_SELECTOR = "workflow-selector"; +const FEATURE_DEV = "Feature Dev"; +const WORKSPACE_1 = "workspace-1"; +const FETCH_FAILED = "Failed to fetch"; +const LOCAL_WORKFLOW_ID = "local-1"; + +// Real workflows always carry a workspace_id; the store maps it through as +// workspaceId. Omitting it here would let a workspace-scoping bug pass. +const LOCAL_WORKFLOW: MockWorkflow = { + id: "workflow-1", + name: "Build", + workspaceId: WORKSPACE_1, +}; const REPOSITORY_SELECTOR_TEST_ID = "repository-selector"; const REPOSITORY_ROWS_TEST_ID = "repository-rows"; const mockState = { workflows: { - items: [{ id: "workflow-1", name: "Build" }], + items: [LOCAL_WORKFLOW] as MockWorkflow[], }, agentProfiles: { items: [], @@ -68,65 +84,267 @@ vi.mock("@/lib/api/domains/workflow-api", () => ({ listWorkflowSteps: vi.fn().mockResolvedValue({ steps: [] }), })); +// The editor fetches its own workflows rather than reading the shared store +// slot, so the seam under test is this call — including which workspace it +// asks for. +const mockListWorkflows = vi.fn(); +vi.mock("@/lib/api", async (importOriginal) => ({ + ...(await importOriginal>()), + listWorkflows: (...args: unknown[]) => mockListWorkflows(...args), +})); + import { ConfigSection, getExecutorItemDisabledReason } from "./config-section"; -function renderConfigSection(overrides: Partial> = {}) { - return render( +// Every render triggers the fetch, so it needs a resolvable default or the +// effect throws before the test gets to its own assertion. +beforeEach(() => { + mockListWorkflows.mockResolvedValue({ workflows: [] }); +}); + +function configSection(overrides: Partial> = {}) { + return ( {}} onStepChange={() => {}} onAgentProfileChange={() => {}} onExecutorProfileChange={() => {}} onRepositoriesChange={() => {}} - onExecutionModeChange={() => {}} {...overrides} - />, + /> ); } -describe("ConfigSection", () => { - afterEach(cleanup); +function renderConfigSection(overrides: Partial> = {}) { + return render(configSection(overrides)); +} - it("marks task workflow fields as required and explains missing selections", () => { +describe("ConfigSection", () => { + afterEach(() => { + cleanup(); + mockState.workflows.items = [LOCAL_WORKFLOW]; + }); + + it("offers the workflow fields without demanding either of them", () => { renderConfigSection(); screen.getByText("Workflow"); screen.getByText("Workflow Step"); - expect(screen.getAllByText("required")).toHaveLength(2); - screen.getByText("Select a workflow to enable saving."); - screen.getByText("Select a workflow before choosing a step."); - expect(screen.getByTestId("workflow-selector").getAttribute("aria-describedby")).toBe( - "workflow-selector-help", - ); + // Both are optional now: an automation that only reports has no place on a + // board, so nothing here may claim it blocks saving. + expect(screen.queryAllByText("required")).toHaveLength(0); + expect(screen.getByTestId(WORKFLOW_SELECTOR).getAttribute("aria-invalid")).toBeNull(); + expect(screen.getByTestId("workflow-step-selector").getAttribute("aria-invalid")).toBeNull(); + expect(screen.getByTestId(WORKFLOW_SELECTOR).textContent).toContain("optional"); }); - it("changes step help text once a workflow is selected", () => { - renderConfigSection({ workflowId: "workflow-1" }); + it("keeps the step field disabled and explained until a workflow is picked", () => { + renderConfigSection(); - expect(screen.queryByText("Select a workflow to enable saving.")).toBeNull(); - expect(screen.queryByText("Select a workflow before choosing a step.")).toBeNull(); - screen.getByText("Select a workflow step to enable saving."); + screen.getByText("Select a workflow before choosing a step."); expect(screen.getByTestId("workflow-step-selector").getAttribute("aria-describedby")).toBe( "workflow-step-selector-help", ); }); - it("hides workflow required markers in run mode", () => { - renderConfigSection({ executionMode: "run" }); + it("drops the step hint once a workflow is selected", () => { + renderConfigSection({ workflowId: "workflow-1" }); - expect(screen.queryByText("Workflow")).toBeNull(); - expect(screen.queryByText("Workflow Step")).toBeNull(); - expect(screen.queryAllByText("required")).toHaveLength(0); + expect(screen.queryByText("Select a workflow before choosing a step.")).toBeNull(); + expect( + screen.getByTestId("workflow-step-selector").getAttribute("aria-describedby"), + ).toBeNull(); }); + it("no longer offers an execution mode to choose", () => { + renderConfigSection(); + + expect(screen.queryByTestId("execution-mode-selector")).toBeNull(); + expect(screen.queryByText("Execution Mode")).toBeNull(); + }); +}); + +describe("ConfigSection workflow scoping", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + }); + + it("asks for the workflows of the workspace being edited", async () => { + mockListWorkflows.mockResolvedValue({ workflows: [] }); + + renderConfigSection({ workspaceId: WORKSPACE_1 }); + + await waitFor(() => expect(mockListWorkflows).toHaveBeenCalled()); + expect(mockListWorkflows.mock.calls[0][0]).toBe(WORKSPACE_1); + }); + + it("offers the workflows that workspace returned", async () => { + mockListWorkflows.mockResolvedValue({ + workflows: [{ id: LOCAL_WORKFLOW_ID, name: FEATURE_DEV, workspace_id: WORKSPACE_1 }], + }); + + renderConfigSection({ workspaceId: WORKSPACE_1, workflowId: LOCAL_WORKFLOW_ID }); + + await waitFor(() => + expect(screen.getByTestId(WORKFLOW_SELECTOR).textContent).toContain(FEATURE_DEV), + ); + }); + + it("never shows a workflow the shared store happens to hold for another workspace", async () => { + // The store slot is global and races between the active workspace and this + // page. Reading it here once offered another workspace's workflows, and a + // name present in both makes the wrong one look right — so an automation + // could be saved against a workflow its workspace does not own. + mockState.workflows.items = [ + { id: "foreign-1", name: FEATURE_DEV, workspaceId: "workspace-other" }, + ]; + mockListWorkflows.mockResolvedValue({ workflows: [] }); + + renderConfigSection({ workspaceId: WORKSPACE_1, workflowId: "foreign-1" }); + + await waitFor(() => expect(mockListWorkflows).toHaveBeenCalled()); + expect(screen.getByTestId(WORKFLOW_SELECTOR).textContent).not.toContain(FEATURE_DEV); + }); + + it("refetches when the workspace changes", async () => { + mockListWorkflows.mockResolvedValue({ workflows: [] }); + + const { rerender } = renderConfigSection({ workspaceId: WORKSPACE_1 }); + await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledTimes(1)); + + rerender(configSection({ workspaceId: "workspace-2" })); + + await waitFor(() => expect(mockListWorkflows).toHaveBeenCalledTimes(2)); + expect(mockListWorkflows.mock.calls[1][0]).toBe("workspace-2"); + }); +}); + +describe("ConfigSection workflow load failures", () => { + afterEach(() => { + cleanup(); + vi.clearAllMocks(); + vi.useRealTimers(); + }); + + it("retries a failed fetch rather than leaving the field empty forever", async () => { + // A page opened while the backend is restarting gets "Failed to fetch". + // One attempt would strand the field for the rest of the session. + mockListWorkflows + .mockRejectedValueOnce(new TypeError(FETCH_FAILED)) + .mockResolvedValue({ workflows: [{ id: LOCAL_WORKFLOW_ID, name: FEATURE_DEV }] }); + + renderConfigSection({ workspaceId: WORKSPACE_1, workflowId: LOCAL_WORKFLOW_ID }); + + await waitFor( + () => expect(screen.getByTestId(WORKFLOW_SELECTOR).textContent).toContain(FEATURE_DEV), + { timeout: 3000 }, + ); + expect(mockListWorkflows.mock.calls.length).toBeGreaterThan(1); + }); + + it("says so and offers a retry once the attempts are exhausted", async () => { + // Fake timers so the test doesn't sit through the real backoff. + vi.useFakeTimers(); + mockListWorkflows.mockRejectedValue(new TypeError(FETCH_FAILED)); + + renderConfigSection({ workspaceId: WORKSPACE_1 }); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + + const error = screen.getByTestId("workflow-load-error"); + expect(error.textContent).toContain("Couldn't load workflows"); + expect(screen.getByTestId("workflow-retry")).toBeTruthy(); + }); + + it("refetches when the retry is used", async () => { + vi.useFakeTimers(); + mockListWorkflows.mockRejectedValue(new TypeError(FETCH_FAILED)); + + renderConfigSection({ workspaceId: WORKSPACE_1 }); + await act(async () => { + await vi.advanceTimersByTimeAsync(10_000); + }); + screen.getByTestId("workflow-retry"); + + const before = mockListWorkflows.mock.calls.length; + mockListWorkflows.mockResolvedValue({ + workflows: [{ id: LOCAL_WORKFLOW_ID, name: FEATURE_DEV }], + }); + fireEvent.click(screen.getByTestId("workflow-retry")); + await act(async () => { + await vi.advanceTimersByTimeAsync(100); + }); + + expect(mockListWorkflows.mock.calls.length).toBeGreaterThan(before); + expect(screen.queryByTestId("workflow-load-error")).toBeNull(); + }); + + it("keeps a list it already had when a later fetch fails", async () => { + mockListWorkflows.mockResolvedValue({ + workflows: [{ id: LOCAL_WORKFLOW_ID, name: FEATURE_DEV }], + }); + + const { rerender } = renderConfigSection({ + workspaceId: WORKSPACE_1, + workflowId: LOCAL_WORKFLOW_ID, + }); + await waitFor(() => + expect(screen.getByTestId(WORKFLOW_SELECTOR).textContent).toContain(FEATURE_DEV), + ); + + // Blanking a usable field on a flake is worse than showing stale options. + mockListWorkflows.mockRejectedValue(new TypeError(FETCH_FAILED)); + rerender(configSection({ workspaceId: WORKSPACE_1, workflowId: LOCAL_WORKFLOW_ID })); + + expect(screen.getByTestId(WORKFLOW_SELECTOR).textContent).toContain(FEATURE_DEV); + }); +}); + +// Workflow and step are optional now, and optional has to be reversible: an +// automation upgraded from the era when a workflow was mandatory can only be +// freed of one if the picker offers a way to say "none". +describe("ConfigSection clearing the workflow", () => { + afterEach(cleanup); + + it("offers a None entry in both the workflow and step pickers", async () => { + mockListWorkflows.mockResolvedValue({ + workflows: [{ id: LOCAL_WORKFLOW_ID, name: FEATURE_DEV, workspace_id: WORKSPACE_1 }], + }); + renderConfigSection({ workflowId: LOCAL_WORKFLOW_ID }); + + await waitFor(() => expect(mockListWorkflows).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId(WORKFLOW_SELECTOR)); + await screen.findByRole("option", { name: "No workflow" }); + }); + + it("reports a cleared workflow as an empty id, not the sentinel", async () => { + mockListWorkflows.mockResolvedValue({ + workflows: [{ id: LOCAL_WORKFLOW_ID, name: FEATURE_DEV, workspace_id: WORKSPACE_1 }], + }); + const onWorkflowChange = vi.fn(); + renderConfigSection({ workflowId: LOCAL_WORKFLOW_ID, onWorkflowChange }); + + await waitFor(() => expect(mockListWorkflows).toHaveBeenCalled()); + fireEvent.click(screen.getByTestId(WORKFLOW_SELECTOR)); + fireEvent.click(await screen.findByRole("option", { name: "No workflow" })); + + // The sentinel exists only because Radix refuses an empty option value; it + // must never reach the form. + expect(onWorkflowChange).toHaveBeenCalledWith(""); + }); +}); + +describe("ConfigSection repository and executor pickers", () => { + afterEach(cleanup); + it("renders a single dropdown when no executor profile is selected", () => { renderConfigSection(); diff --git a/apps/web/components/automations/config-section.tsx b/apps/web/components/automations/config-section.tsx index 2384f13086..04ce1db1e3 100644 --- a/apps/web/components/automations/config-section.tsx +++ b/apps/web/components/automations/config-section.tsx @@ -1,19 +1,18 @@ "use client"; -import { useState, useEffect, useMemo } from "react"; import { useTranslation } from "react-i18next"; +import { useState, useEffect, useMemo, useCallback } from "react"; import { Label } from "@kandev/ui/label"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { useAppStore } from "@/components/state-provider"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; -import { useWorkflows } from "@/hooks/use-workflows"; import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; import { discoverRepositoriesAction } from "@/app/actions/workspaces"; +import { listWorkflows } from "@/lib/api"; import { listWorkflowSteps } from "@/lib/api/domains/workflow-api"; import type { ExecutorProfile, LocalRepository, Repository } from "@/lib/types/http"; -import type { ExecutionMode, TriggerType } from "@/lib/types/automation"; +import type { TriggerType } from "@/lib/types/automation"; import { getMultiRepoExecutorDisabledReason } from "@/components/task-create-dialog-multi-repo-guard"; -import { RequiredFieldLabel } from "./required-field-label"; import { AutomationRepositoryRows } from "./automation-repository-rows"; import { buildRepositoryItems, @@ -46,10 +45,8 @@ type ConfigSectionProps = { agentProfileId: string; executorProfileId: string; repositorySelections: RepositorySelection[]; - executionMode: ExecutionMode; conditionType: TriggerType | null; dirtyFields?: { - executionMode: boolean; workflowId: boolean; workflowStepId: boolean; agentProfileId: boolean; @@ -61,11 +58,16 @@ type ConfigSectionProps = { onAgentProfileChange: (id: string) => void; onExecutorProfileChange: (id: string) => void; onRepositoriesChange: (selections: RepositorySelection[]) => void; - onExecutionModeChange: (mode: ExecutionMode) => void; }; +/** + * Stands in for "no selection" in the workflow and step pickers. Radix refuses + * an empty SelectItem value, so clearing a field needs an id of its own; it is + * mapped back to "" before it reaches the form. + */ +const NONE_OPTION_ID = "__none__"; + const CLEAN_FIELDS = { - executionMode: false, workflowId: false, workflowStepId: false, agentProfileId: false, @@ -73,13 +75,78 @@ const CLEAN_FIELDS = { repositorySelections: false, }; -// `id` is the persisted ExecutionMode the backend stores and replays; only the -// catalog key is copy. Resolved at render — a module-scope t() would freeze at -// the boot locale. -const EXECUTION_MODE_ITEMS = [ - { id: "task", labelKey: "automations:executionModeTask" }, - { id: "run", labelKey: "automations:executionModeRun" }, -]; +type WorkflowOption = { id: string; name: string }; + +// A page opened while the backend is restarting gets a bare "Failed to fetch". +// A single attempt would leave the field empty for the rest of the session, so +// back off and try again before giving up. +const WORKFLOW_RETRY_DELAYS_MS = [500, 1500, 4000]; + +/** + * Workflows for the workspace being edited, fetched into local state. + * + * Deliberately not the `workflows` store slot. That slot is global and both the + * active workspace and this settings page write to it, so when the two differ + * they race — the network log shows the two workspaces' requests alternating — + * and whichever lands last defines the list. That produced two failures: the + * editor offered another workspace's workflows (a name present in both, like + * "Feature Dev", makes the wrong one look right, so an automation could be + * saved against a workflow its workspace does not own), and filtering the + * shared slot by workspace instead left the list empty whenever the foreign + * fetch won. + * + * Owning the request here makes the list depend only on the workspace this + * editor is for. + */ +function useWorkspaceWorkflows(workspaceId: string) { + const [workflows, setWorkflows] = useState([]); + const [failed, setFailed] = useState(false); + const [retryNonce, setRetryNonce] = useState(0); + + useEffect(() => { + if (!workspaceId) { + setWorkflows([]); + setFailed(false); + return; + } + let cancelled = false; + let timer: ReturnType | undefined; + + const attempt = (index: number) => { + listWorkflows(workspaceId, { cache: "no-store", includeHidden: true }) + .then((response) => { + if (cancelled) return; + setWorkflows(response.workflows.map((w) => ({ id: w.id, name: w.name }))); + setFailed(false); + }) + .catch(() => { + if (cancelled) return; + if (index < WORKFLOW_RETRY_DELAYS_MS.length) { + timer = setTimeout(() => attempt(index + 1), WORKFLOW_RETRY_DELAYS_MS[index]); + return; + } + // Keep whatever was already loaded rather than blanking a usable + // field on a flake, and say so rather than just looking empty. + setFailed(true); + }); + }; + + // Clear before fetching, not after. Keeping the previous list across a + // workspace change is the exact bug this hook exists to prevent: if the new + // workspace's fetch then fails, the old workspace's workflows stay + // selectable — and savable — under the new workspace's name. + setWorkflows([]); + setFailed(false); + attempt(0); + return () => { + cancelled = true; + if (timer) clearTimeout(timer); + }; + }, [workspaceId, retryNonce]); + + const retry = useCallback(() => setRetryNonce((nonce) => nonce + 1), []); + return { workflows, failed, retry }; +} function useDiscoveredRepositories(workspaceId: string) { const [items, setItems] = useState([]); @@ -103,16 +170,11 @@ function useDiscoveredRepositories(workspaceId: string) { type StepOption = { id: string; name: string }; -// A plain function returning copy is invisible to `i18next/no-literal-string` -// (it only inspects literals in JSX), so `t` is threaded in explicitly rather -// than imported at module scope. function getWorkflowStepHelpText( workflowId: string, - workflowStepId: string, t: (key: string) => string, ): string | undefined { if (!workflowId) return t("automations:workflowStepHelpNoWorkflow"); - if (!workflowStepId) return t("automations:workflowStepHelp"); return undefined; } @@ -153,7 +215,6 @@ function useConfigSectionComputed({ repositorySelections, repositories, discoveredRepos, - t, }: { agentProfiles: AgentProfileLike[]; executors: ExecutorLike[]; @@ -161,8 +222,8 @@ function useConfigSectionComputed({ repositorySelections: RepositorySelection[]; repositories: Repository[]; discoveredRepos: LocalRepository[]; - t: (key: string) => string; }) { + const { t } = useTranslation(); const filteredAgentProfiles = agentProfiles.filter((profile) => !profile.cli_passthrough); // Profiles returned by the executors list/boot payload don't always carry // their own executor_type/executor_name (only the settings > Executors @@ -203,7 +264,6 @@ export function ConfigSection({ agentProfileId, executorProfileId, repositorySelections, - executionMode, conditionType, dirtyFields = CLEAN_FIELDS, onWorkflowChange, @@ -211,21 +271,18 @@ export function ConfigSection({ onAgentProfileChange, onExecutorProfileChange, onRepositoriesChange, - onExecutionModeChange, }: ConfigSectionProps) { const { t } = useTranslation(); useSettingsData(true); - useWorkflows(workspaceId, true); const { repositories } = useRepositories(workspaceId, true); const discoveredRepos = useDiscoveredRepositories(workspaceId); - const workflows = useAppStore((state) => state.workflows.items); + const workflowState = useWorkspaceWorkflows(workspaceId); + const workflows = workflowState.workflows; const agentProfiles = useAppStore((state) => state.agentProfiles.items); const executors = useAppStore((state) => state.executors.items); const steps = useWorkflowSteps(workflowId); const isPRTrigger = conditionType === "github_pr"; - const isRunMode = executionMode === "run"; - const { filteredAgentProfiles, executorItems, supportsMultiRepo, singleRepositoryItems } = useConfigSectionComputed({ agentProfiles, @@ -234,7 +291,6 @@ export function ConfigSection({ repositorySelections, repositories, discoveredRepos, - t, }); return ( @@ -243,27 +299,18 @@ export function ConfigSection({ {t("automations:configurationLabel")}
- onExecutionModeChange(v as ExecutionMode)} - placeholder={t("automations:executionModePlaceholder")} - items={EXECUTION_MODE_ITEMS.map((item) => ({ id: item.id, label: t(item.labelKey) }))} + - {!isRunMode && ( - - )} ; + workflowsFailed: boolean; + onRetryWorkflows: () => void; steps: StepOption[]; workflowDirty: boolean; workflowStepDirty: boolean; onWorkflowChange: (id: string) => void; onStepChange: (id: string) => void; }) { - const { t } = useTranslation(); // The step list is empty until a workflow is picked. Showing an empty // dropdown next to the workflow select invites users to click it first // and bounce off — keep the field in the DOM (so its testid is stable // for tooling) but disable it and surface a hint until a workflow is // chosen. + const { t } = useTranslation(); const hasWorkflow = !!workflowId; + // Both fields are optional: an automation that only reports has no place on a + // board, and demanding a workflow before it can be saved made every such + // automation pick one at random. Nothing here blocks saving, so nothing here + // says it does. + // + // Optional also has to mean reversible. A select listing only real workflows + // can be set but never unset, which would strand every automation upgraded + // from the era when a workflow was mandatory. An explicit None entry is the + // way back; picking it clears the step too, since a step without its workflow + // is not a selection anyone can act on. return ( <> ({ id: w.id, label: w.name }))} - helpText={!hasWorkflow ? t("automations:workflowHelp") : undefined} + onChange={(value) => onWorkflowChange(value === NONE_OPTION_ID ? "" : value)} + placeholder={ + workflowsFailed + ? t("automations:couldNotLoadWorkflows") + : t("automations:selectWorkflowOptional") + } + items={[ + { id: NONE_OPTION_ID, label: t("automations:noWorkflow") }, + ...workflows.map((w) => ({ id: w.id, label: w.name })), + ]} /> + {workflowsFailed && ( +

+ {t("automations:workflowLoadError")}{" "} + +

+ )} onStepChange(value === NONE_OPTION_ID ? "" : value)} placeholder={ - hasWorkflow - ? t("automations:workflowStepPlaceholder") - : t("automations:workflowStepPlaceholderNoWorkflow") + hasWorkflow ? t("automations:selectStepOptional") : t("automations:pickAWorkflowFirst") } - items={steps.map((s) => ({ id: s.id, label: s.name }))} + items={[ + { id: NONE_OPTION_ID, label: t("automations:noStep") }, + ...steps.map((s) => ({ id: s.id, label: s.name })), + ]} disabled={!hasWorkflow} - helpText={getWorkflowStepHelpText(workflowId, workflowStepId, t)} + helpText={getWorkflowStepHelpText(workflowId, t)} /> ); @@ -421,7 +500,6 @@ function SelectField({ items, disabled, helpText, - required, isDirty = false, }: { testId?: string; @@ -432,24 +510,17 @@ function SelectField({ items: Array<{ id: string; label: string; disabled?: boolean; disabledReason?: string }>; disabled?: boolean; helpText?: string; - required?: boolean; isDirty?: boolean; }) { - const invalid = required && !value; const helpId = testId && helpText ? `${testId}-help` : undefined; return (
- {required ? ( - {label} - ) : ( - - )} + { - setCustomInput(e.target.value); - if (error) setError(null); +
+ {t("automations:run")} + + + + emit(next, timezone)} + onCustomDraftChange={(value) => { + setCustomDraft(value); + if (customError) setCustomError(null); }} - onBlur={handleCustomBlur} - data-testid="schedule-custom-input" - data-settings-dirty={isDirty} - // Cron syntax the user types verbatim, not copy — same value the - // help text below interpolates. - placeholder={CRON_EVERY_2H30M} - className={`font-mono text-sm max-w-xs ${error ? "border-destructive" : ""}`} + onCustomBlur={handleCustomBlur} /> - {error &&

{error}

} -

- - - - - - - - -

+ + {wallClock && ( + emit(currentSpec(), zone)} /> + )} + +
+ + {customError && ( +

+ {customError} +

+ )} + + emit(currentSpec(), browserTimeZone())} + />
); } + +function FrequencySelect({ + value, + onChange, +}: { + value: Frequency; + onChange: (value: Frequency) => void; +}) { + return ( + + ); +} + +function FrequencyDetail({ + frequency, + spec, + isDirty, + customDraft, + hasCustomError, + onSpecChange, + onCustomDraftChange, + onCustomBlur, +}: { + frequency: Frequency; + spec: ScheduleSpec; + isDirty: boolean; + customDraft: string; + hasCustomError: boolean; + onSpecChange: (spec: ScheduleSpec) => void; + onCustomDraftChange: (value: string) => void; + onCustomBlur: () => void; +}) { + const { t } = useTranslation(); + + if (frequency === "custom") { + return ( + onCustomDraftChange(event.target.value)} + onBlur={onCustomBlur} + data-testid="schedule-custom-input" + data-settings-dirty={isDirty} + placeholder="0 9 * * 1-5" + className={`font-mono h-8 w-[170px] ${hasCustomError ? "border-destructive" : ""}`} + /> + ); + } + + if (frequency === "hourly") { + return ( + <> + {t("automations:atMinute")} + { + const minute = Number(event.target.value); + if (!Number.isFinite(minute)) return; + onSpecChange({ ...spec, minute: Math.min(59, Math.max(0, minute)) }); + }} + data-testid="schedule-minute" + data-settings-dirty={isDirty} + className="w-[75px] h-8" + /> + + ); + } + + if (frequency !== "daily" && frequency !== "weekly") return null; + + return ( + <> + {frequency === "weekly" && ( + <> + on + + + )} + at + { + const [hour, minute] = event.target.value.split(":").map(Number); + if (!Number.isFinite(hour) || !Number.isFinite(minute)) return; + onSpecChange({ ...spec, hour, minute }); + }} + data-testid="schedule-time" + data-settings-dirty={isDirty} + className="w-[110px] h-8" + /> + + ); +} + +function NextRun({ + expression, + timezone, + showTimezonePrompt, + onAdoptBrowserTimezone, +}: { + expression: string; + timezone: string; + showTimezonePrompt: boolean; + onAdoptBrowserTimezone: () => void; +}) { + const { t } = useTranslation(); + if (!expression.trim()) { + return ( +

+ {t("automations:noSchedule")} +

+ ); + } + + // An interval is anchored server-side to the last run, so there is no instant + // to promise here. The sentence above already says everything there is. + if (!usesTimezone(expression)) return null; + + const zone = timezone || "UTC"; + const next = nextRun(expression, zone); + if (!next) return null; + + const local = formatInZone(next, zone); + const utc = formatInZone(next, "UTC"); + + return ( +

+ {t("automations:nextRun")} {local}{" "} + {timeZoneAbbreviation(zone, next)} + {local !== utc && · {utc} UTC} + {showTimezonePrompt && ( + <> + {" — "} + + + )} +

+ ); +} + +function TimezoneField({ value, onChange }: { value: string; onChange: (zone: string) => void }) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const zones = useMemo(() => timeZoneOptions(), []); + + return ( + + + + + + + + + {t("automations:noTimezoneFound")} + {zones.map((zone) => ( + { + onChange(zone); + setOpen(false); + }} + > + {zone} + + ))} + + + + + ); +} diff --git a/apps/web/components/automations/trigger-card.tsx b/apps/web/components/automations/trigger-card.tsx index 4713dee5db..dafe7cbc15 100644 --- a/apps/web/components/automations/trigger-card.tsx +++ b/apps/web/components/automations/trigger-card.tsx @@ -20,6 +20,7 @@ import { GitHubPRConfig } from "./trigger-configs/github-pr-config"; import { GitHubPushConfig } from "./trigger-configs/github-push-config"; import { GitHubCIConfig } from "./trigger-configs/github-ci-config"; import { WebhookConfig } from "./trigger-configs/webhook-config"; +import { describeSchedule } from "./schedule-expression"; type TriggerCardProps = { trigger: AutomationTrigger; @@ -88,10 +89,12 @@ function getTriggerSummary( const expr = (cfg.cron_expression as string) ?? ""; const presetKey = CRON_PRESET_KEYS[expr]; if (presetKey) return t(presetKey); - // The expression itself is syntax and travels as an interpolated value. - return expr - ? t("automations:summaryCron", { expression: expr }) - : t("automations:summaryCustomSchedule"); + if (!expr) return t("automations:summaryCustomSchedule"); + // Off the preset list, describeSchedule reads the expression properly — + // "Every Monday at 09:00 GMT" rather than echoing the cron syntax at the + // user. It is not translated yet, so the preset path above is checked + // first and keeps its catalog copy for the cases most people hit. + return describeSchedule(expr, (cfg.timezone as string) ?? ""); } if (trigger.type === "github_pr") { // Event names are the persisted GitHub event ids, not copy. diff --git a/apps/web/components/automations/triggers-section.test.tsx b/apps/web/components/automations/triggers-section.test.tsx new file mode 100644 index 0000000000..b498d3745f --- /dev/null +++ b/apps/web/components/automations/triggers-section.test.tsx @@ -0,0 +1,89 @@ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import type { ComponentProps } from "react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { TooltipProvider } from "@kandev/ui/tooltip"; + +import { TriggersSection } from "./triggers-section"; +import type { AutomationTrigger } from "@/lib/types/automation"; + +function scheduledTrigger(config: Record): AutomationTrigger { + return { + id: "trigger-1", + automation_id: "automation-1", + type: "scheduled", + config, + enabled: true, + created_at: "2026-07-30T00:00:00Z", + updated_at: "2026-07-30T00:00:00Z", + } as AutomationTrigger; +} + +function renderTriggersSection(overrides: Partial> = {}) { + const props = { + triggers: [], + savedTriggers: [], + automationId: "automation-1", + workspaceId: "workspace-1", + triggerTypes: [], + onAddTrigger: vi.fn(), + onUpdateTrigger: vi.fn(), + onToggleTrigger: vi.fn(), + onDeleteTrigger: vi.fn(), + ...overrides, + } satisfies ComponentProps; + + render( + + + , + ); + return props; +} + +afterEach(cleanup); + +describe("TriggersSection schedule config", () => { + it("keeps the timezone when only the time changes", () => { + const trigger = scheduledTrigger({ + cron_expression: "0 9 * * *", + timezone: "Asia/Singapore", + }); + const props = renderTriggersSection({ triggers: [trigger], savedTriggers: [trigger] }); + + fireEvent.change(screen.getByTestId("schedule-time"), { target: { value: "10:30" } }); + + expect(props.onUpdateTrigger).toHaveBeenCalledWith("trigger-1", { + cron_expression: "30 10 * * *", + timezone: "Asia/Singapore", + }); + }); + + it("preserves config keys the schedule control does not own", () => { + // onUpdateTrigger swaps the config wholesale, so anything the schedule + // control does not emit has to survive by being merged in. + const trigger = scheduledTrigger({ + cron_expression: "0 9 * * *", + timezone: "Asia/Singapore", + some_future_key: "keep me", + }); + const props = renderTriggersSection({ triggers: [trigger], savedTriggers: [trigger] }); + + fireEvent.change(screen.getByTestId("schedule-time"), { target: { value: "10:30" } }); + + expect(props.onUpdateTrigger).toHaveBeenCalledWith( + "trigger-1", + expect.objectContaining({ some_future_key: "keep me" }), + ); + }); + + it("creates a scheduled trigger when none exists yet", () => { + const props = renderTriggersSection(); + + fireEvent.change(screen.getByTestId("schedule-time"), { target: { value: "07:15" } }); + + expect(props.onAddTrigger).toHaveBeenCalledWith( + "scheduled", + expect.objectContaining({ cron_expression: "15 7 * * *" }), + ); + }); +}); diff --git a/apps/web/components/automations/triggers-section.tsx b/apps/web/components/automations/triggers-section.tsx index 61917f3fdf..c9f00f7213 100644 --- a/apps/web/components/automations/triggers-section.tsx +++ b/apps/web/components/automations/triggers-section.tsx @@ -56,7 +56,13 @@ export function TriggersSection({ const handleScheduleChange = (config: Record) => { if (scheduleTrigger) { - onUpdateTrigger(scheduleTrigger.id, config); + // Merge rather than replace. onUpdateTrigger swaps the config object + // wholesale (see updateDraft), so emitting a partial config here silently + // drops sibling keys — a preset click that sends only `cron_expression` + // would discard a `timezone` set elsewhere, moving the schedule to UTC + // with nothing on screen to show it. A key is still removable by sending + // it as undefined: it survives the spread and JSON.stringify omits it. + onUpdateTrigger(scheduleTrigger.id, { ...scheduleTrigger.config, ...config }); } else { onAddTrigger("scheduled", config); } diff --git a/apps/web/components/runs/automation-detail-page.test.tsx b/apps/web/components/runs/automation-detail-page.test.tsx new file mode 100644 index 0000000000..8807c2e588 --- /dev/null +++ b/apps/web/components/runs/automation-detail-page.test.tsx @@ -0,0 +1,464 @@ +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import type { Automation, AutomationRun } from "@/lib/types/automation"; + +const mockIsMobile = vi.hoisted(() => ({ value: false })); +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile: mockIsMobile.value }), +})); + +const AUTOMATION_ID = "automation-1"; +const WORKSPACE_ID = "workspace-1"; +const ACTIVITY = "automation-activity"; +const TRANSCRIPT = "run-transcript"; +const SESSION_ATTR = "data-session-id"; +const RUN_NOW = "automation-run-now"; +const NEXT_RUN = "automation-next-run"; +const DETAIL_TOGGLE = "run-detail-toggle"; +const PROMPT = "automation-prompt"; +const SESSION_OLDER = "session-older"; +const SESSION_NEWEST = "session-newest"; +const OLDER_AT = "2026-07-29T00:00:00Z"; +const NEWEST_AT = "2026-07-30T16:00:00Z"; + +const mocks = vi.hoisted(() => ({ + getAutomation: vi.fn(), + getAutomationSummary: vi.fn(), + listAutomationRuns: vi.fn(), + triggerAutomation: vi.fn(), + push: vi.fn(), + // The page follows the ACTIVE workspace: an automation belonging to another + // one must not stay on screen after a switch. + activeWorkspaceId: { current: undefined as string | undefined }, +})); + +vi.mock("@/components/state-provider", () => ({ + useAppStore: (selector: (state: { workspaces: { activeId: string | undefined } }) => unknown) => + selector({ workspaces: { activeId: mocks.activeWorkspaceId.current } }), +})); + +vi.mock("@/lib/api/domains/automation-api", () => ({ + getAutomation: mocks.getAutomation, + getAutomationSummary: mocks.getAutomationSummary, + listAutomationRuns: mocks.listAutomationRuns, + triggerAutomation: mocks.triggerAutomation, +})); + +vi.mock("@/lib/routing/client-router", () => ({ + useRouter: () => ({ push: mocks.push }), + usePathname: () => `/automations/${AUTOMATION_ID}`, +})); + +vi.mock("sonner", () => ({ + toast: { info: vi.fn(), success: vi.fn(), error: vi.fn() }, +})); + +// The editor is a settings-shell component with its own data fetching; this +// page's contract is that it mounts on the Configure tab and nowhere else. +vi.mock("@/components/automations/automation-editor", () => ({ + AutomationEditor: () =>
, +})); + +// The transcript is the task chat stack. This suite is about which run the page +// puts in the pane, so it asserts on the session the embed is handed. +vi.mock("./run-transcript", () => ({ + RunTranscript: ({ sessionId }: { sessionId: string }) => ( +
+ ), +})); + +vi.mock("@/components/settings/settings-save-provider", () => ({ + SettingsSaveProvider: ({ children }: { children: React.ReactNode }) =>
{children}
, +})); + +import { AutomationDetailPage } from "./automation-detail-page"; + +const AUTOMATION: Automation = { + id: AUTOMATION_ID, + workspace_id: WORKSPACE_ID, + name: "Nightly drift", + prompt: "Run /ksdd:drift --all and report what moved.", + description: "", + enabled: true, + workflow_id: "", + workflow_step_id: "", + agent_profile_id: "", + executor_profile_id: "", + repository_id: "", + max_concurrent_runs: 1, + triggers: [ + { + id: "trigger-1", + automation_id: AUTOMATION_ID, + trigger_type: "scheduled", + enabled: true, + config: { expression: "0 0 * * *", timezone: "Asia/Singapore" }, + }, + ], + created_at: "2026-07-30T00:00:00Z", + updated_at: "2026-07-30T00:00:00Z", +} as unknown as Automation; + +function run(overrides: Partial = {}): AutomationRun { + return { + id: "run-1", + automation_id: AUTOMATION_ID, + trigger_id: "trigger-1", + trigger_type: "scheduled", + task_id: "task-1", + session_id: "session-1", + status: "succeeded", + dedup_key: "", + trigger_data: {}, + error_message: "", + summary: "Found 3 drifted specs.", + created_at: "2026-07-30T16:00:00Z", + ...overrides, + } as AutomationRun; +} + +beforeEach(() => { + mocks.activeWorkspaceId.current = WORKSPACE_ID; + mocks.getAutomation.mockResolvedValue(AUTOMATION); + mocks.getAutomationSummary.mockResolvedValue({ + automation_id: AUTOMATION_ID, + open_runs: 0, + }); + mocks.listAutomationRuns.mockResolvedValue([run()]); + mocks.triggerAutomation.mockResolvedValue({ triggered: true }); +}); + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); + mockIsMobile.value = false; +}); + +describe("AutomationDetailPage", () => { + it("opens on the newest run's conversation, not on a list of runs", async () => { + // The automation is a thread that recurs, so landing here lands you in what + // it last said — zero clicks to the thing you came for. + mocks.listAutomationRuns.mockResolvedValue([ + run({ id: "older", session_id: SESSION_OLDER, created_at: OLDER_AT }), + run({ id: "newest", session_id: SESSION_NEWEST, created_at: NEWEST_AT }), + ]); + + render(); + + const transcript = await screen.findByTestId(TRANSCRIPT); + expect(transcript.getAttribute(SESSION_ATTR)).toBe(SESSION_NEWEST); + // Configuration is behind Details, never the landing view. + expect(screen.queryByTestId("automation-editor")).toBeNull(); + }); + + // The rail is a permanent column resized by dragging its edge — a mouse + // gesture, and a large share of a phone viewport. On mobile the same runs + // move behind a control so the transcript and composer, which are the point + // of the page, get the screen. + it("puts the runs in a drawer on mobile, and a rail everywhere else", async () => { + render(); + await screen.findByTestId(TRANSCRIPT); + expect(screen.getByTestId("runs-rail")).toBeTruthy(); + expect(screen.queryByTestId("runs-drawer-trigger")).toBeNull(); + + cleanup(); + mockIsMobile.value = true; + + render(); + await screen.findByTestId(TRANSCRIPT); + expect(screen.getByTestId("runs-drawer-trigger")).toBeTruthy(); + expect(screen.queryByTestId("runs-rail")).toBeNull(); + }); + + it("puts nothing above the transcript — the conversation is what the page is for", async () => { + // The standing instruction is the same text on every run and it is long, so + // pinning it here pushed what the agent actually said down the page on + // every visit. + render(); + + await screen.findByTestId(TRANSCRIPT); + expect(screen.queryByTestId(PROMPT)).toBeNull(); + }); + + it("keeps the standing instruction one click away in the rail", async () => { + render(); + + const toggle = await screen.findByTestId(DETAIL_TOGGLE); + expect(screen.queryByTestId("run-detail-panel")).toBeNull(); + + fireEvent.click(toggle); + + expect(screen.getByTestId(PROMPT).textContent).toContain("/ksdd:drift"); + }); + + it("offers the same detail from the drawer on mobile", async () => { + mockIsMobile.value = true; + + render(); + await screen.findByTestId(TRANSCRIPT); + + fireEvent.click(screen.getByTestId("runs-drawer-trigger")); + + fireEvent.click(await screen.findByTestId(DETAIL_TOGGLE)); + expect(screen.getByTestId(PROMPT)).toBeTruthy(); + }); + + it("honours an explicitly requested run", async () => { + // "The run that failed overnight" is a thing people link each other to. + mocks.listAutomationRuns.mockResolvedValue([ + run({ id: "older", session_id: SESSION_OLDER, created_at: OLDER_AT }), + run({ id: "newest", session_id: SESSION_NEWEST, created_at: NEWEST_AT }), + ]); + + render(); + + const transcript = await screen.findByTestId(TRANSCRIPT); + expect(transcript.getAttribute(SESSION_ATTR)).toBe(SESSION_OLDER); + }); + + it("falls back to the newest run when the requested one is not in the window", async () => { + render(); + + const transcript = await screen.findByTestId(TRANSCRIPT); + expect(transcript.getAttribute(SESSION_ATTR)).toBe("session-1"); + }); + + it("skips runs that never produced a conversation", async () => { + // A skipped firing has nothing to read; selecting it would render an empty + // pane that looks like a broken link. + mocks.listAutomationRuns.mockResolvedValue([ + run({ id: "skipped", session_id: "", status: "skipped", created_at: "2026-07-31T00:00:00Z" }), + run({ id: "real", session_id: "session-real", created_at: "2026-07-30T00:00:00Z" }), + ]); + + render(); + + const transcript = await screen.findByTestId(TRANSCRIPT); + expect(transcript.getAttribute(SESSION_ATTR)).toBe("session-real"); + }); + + it("mounts the editor only when Details asks for it", async () => { + render(); + + await waitFor(() => screen.getByTestId("automation-editor")); + expect(screen.queryByTestId(ACTIVITY)).toBeNull(); + }); + + it("keeps the rail alongside configuration, so runs stay one click away", async () => { + render(); + + await waitFor(() => screen.getByTestId("runs-rail")); + }); + + it("fires the automation on demand and refreshes so the run appears", async () => { + // Waiting until tomorrow to learn whether a schedule works is the whole + // reason this button is on the reading surface. + render(); + await waitFor(() => expect(mocks.listAutomationRuns).toHaveBeenCalledTimes(1)); + + fireEvent.click(screen.getByTestId(RUN_NOW)); + + await waitFor(() => expect(mocks.triggerAutomation).toHaveBeenCalledWith(AUTOMATION_ID)); + await waitFor(() => expect(mocks.listAutomationRuns).toHaveBeenCalledTimes(2)); + }); + + it("keeps Run now inert until the automation it would fire has loaded", () => { + render(); + + expect(screen.getByTestId(RUN_NOW).hasAttribute("disabled")).toBe(true); + }); +}); + +describe("AutomationDetailPage run switching", () => { + it("switches runs from the rail, putting the selection in the URL", async () => { + mocks.listAutomationRuns.mockResolvedValue([ + run({ id: "older", session_id: SESSION_OLDER, created_at: OLDER_AT }), + run({ id: "newest", session_id: SESSION_NEWEST, created_at: NEWEST_AT }), + ]); + + render(); + await waitFor(() => screen.getByTestId("run-row-older")); + + fireEvent.click(screen.getByTestId("run-row-older")); + + expect(mocks.push).toHaveBeenCalledWith(`/automations/${AUTOMATION_ID}?run=older`); + }); + + it("lets the rail be resized, the way the sidebar edge can be", async () => { + // Run rows carry a timestamp and a status word; how much room the reader + // wants depends on whether they are scanning or reading beside it. + render(); + + const handle = await screen.findByTestId("runs-rail-resize"); + expect(handle.getAttribute("aria-label")).toBe("Resize runs"); + }); + + it("offers configuration from the rail rather than as a tab beside the transcript", async () => { + render(); + + const details = await screen.findByTestId("automation-details-link"); + expect(details.getAttribute("href")).toBe(`/automations/${AUTOMATION_ID}?tab=configure`); + }); + + it("leaves for the list when the active workspace no longer owns this automation", async () => { + // Switching workspaces with this page open would otherwise leave the + // previous workspace's automation on screen under a sidebar that says the + // user is somewhere else. + mocks.activeWorkspaceId.current = "workspace-other"; + + render(); + + await waitFor(() => expect(mocks.push).toHaveBeenCalledWith("/automations")); + expect(screen.queryByTestId(ACTIVITY)).toBeNull(); + expect(screen.queryByTestId("automation-schedule")).toBeNull(); + }); + + it("keeps polling for an open run older than its own history window", async () => { + // The window is capped, so counting open runs from it would report nothing + // in flight once enough newer entries pile up in front of the open one. + vi.useFakeTimers(); + mocks.getAutomationSummary.mockResolvedValue({ + automation_id: AUTOMATION_ID, + open_runs: 1, + }); + mocks.listAutomationRuns.mockResolvedValue([run({ id: "run-done", status: "succeeded" })]); + + render(); + // Wait for the load to land, not just for the request: polling is gated on + // the applied open count. + await vi.waitFor(() => screen.getByTestId(TRANSCRIPT)); + const before = mocks.getAutomationSummary.mock.calls.length; + + await vi.advanceTimersByTimeAsync(10_000); + + expect(mocks.getAutomationSummary.mock.calls.length).toBeGreaterThan(before); + vi.useRealTimers(); + }); + + it("makes no repeat requests once nothing is open", async () => { + vi.useFakeTimers(); + + render(); + await vi.waitFor(() => screen.getByTestId(TRANSCRIPT)); + + await vi.advanceTimersByTimeAsync(60_000); + + expect(mocks.getAutomationSummary).toHaveBeenCalledTimes(1); + vi.useRealTimers(); + }); + + it("says so and offers a retry when the automation cannot be loaded", async () => { + mocks.getAutomation.mockRejectedValue(new Error("automation not found")); + + render(); + + await waitFor(() => screen.getByTestId("automation-error")); + expect(screen.getByTestId("automation-error").textContent).toContain("automation not found"); + // A blank Activity list under an error would read as "it has never run". + expect(screen.queryByTestId(ACTIVITY)).toBeNull(); + }); +}); + +/** + * The page has to keep itself current across a manual fire — the run row is + * written after the fire returns, so anything it renders from a pre-trigger + * snapshot is stale the moment the run exists. + */ +describe("AutomationDetailPage freshness", () => { + it("clears the paused note once the triggered run settles", async () => { + // The reported bug: the amber note appeared when a run was fired and stayed + // there. It has to come down on its own, from the page's own polling, with + // nobody pressing Refresh. + vi.useFakeTimers(); + mocks.getAutomation.mockResolvedValue({ + ...AUTOMATION, + max_concurrent_runs: 2, + // A real scheduled trigger: without one the note reads "No schedule", + // which would pass a "not Paused" assertion for the wrong reason. + triggers: [ + { + id: "trigger-1", + automation_id: AUTOMATION_ID, + type: "scheduled", + enabled: true, + config: { cron_expression: "0 0 * * *", timezone: "Asia/Singapore" }, + }, + ], + }); + mocks.getAutomationSummary.mockResolvedValue({ automation_id: AUTOMATION_ID, open_runs: 2 }); + + render(); + await vi.waitFor(() => expect(screen.getByTestId(NEXT_RUN).textContent).toContain("Paused")); + + mocks.getAutomationSummary.mockResolvedValue({ automation_id: AUTOMATION_ID, open_runs: 0 }); + await vi.advanceTimersByTimeAsync(10_000); + + await vi.waitFor(() => + expect(screen.getByTestId(NEXT_RUN).textContent).not.toContain("Paused"), + ); + vi.useRealTimers(); + }); + + it("keeps re-asking after a manual fire even before the run row exists", async () => { + // The run row is written on the orchestrator's own goroutine after the fire + // returns. A page that only polls while it can already see an open run + // would stop asking here, and then render its pre-trigger snapshot — and + // everything derived from it — until someone reloaded. + vi.useFakeTimers(); + + render(); + await vi.waitFor(() => screen.getByTestId(TRANSCRIPT)); + + fireEvent.click(screen.getByTestId(RUN_NOW)); + await vi.advanceTimersByTimeAsync(4_000); + const afterRetryBurst = mocks.getAutomationSummary.mock.calls.length; + + await vi.advanceTimersByTimeAsync(20_000); + + expect(mocks.getAutomationSummary.mock.calls.length).toBeGreaterThan(afterRetryBurst); + vi.useRealTimers(); + }); + + it("stops re-asking once the settling window closes with nothing open", async () => { + // The window is a bounded hedge against a slow write, not a licence to poll + // an idle automation forever. + vi.useFakeTimers(); + + render(); + await vi.waitFor(() => screen.getByTestId(TRANSCRIPT)); + + fireEvent.click(screen.getByTestId(RUN_NOW)); + await vi.advanceTimersByTimeAsync(70_000); + const afterWindow = mocks.getAutomationSummary.mock.calls.length; + + await vi.advanceTimersByTimeAsync(60_000); + + expect(mocks.getAutomationSummary.mock.calls.length).toBe(afterWindow); + vi.useRealTimers(); + }); +}); + +describe("AutomationDetailPage visible run freshness", () => { + it("keeps polling when a visible running row has no open summary count", async () => { + vi.useFakeTimers(); + mocks.listAutomationRuns + .mockResolvedValueOnce([run({ status: "task_created" })]) + .mockResolvedValue([run({ status: "succeeded" })]); + mocks.getAutomationSummary.mockResolvedValue({ + automation_id: AUTOMATION_ID, + open_runs: 0, + }); + + try { + render(); + await vi.waitFor(() => expect(screen.getByTestId("run-group-running")).toBeTruthy()); + + await vi.advanceTimersByTimeAsync(10_000); + + await vi.waitFor(() => expect(screen.getByTestId("run-group-completed")).toBeTruthy()); + expect(screen.queryByTestId("run-group-running")).toBeNull(); + } finally { + vi.useRealTimers(); + } + }); +}); diff --git a/apps/web/components/runs/automation-detail-page.tsx b/apps/web/components/runs/automation-detail-page.tsx new file mode 100644 index 0000000000..7c0ef384ef --- /dev/null +++ b/apps/web/components/runs/automation-detail-page.tsx @@ -0,0 +1,345 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { useCallback, useEffect } from "react"; +import { IconBolt, IconPlayerPlay, IconRefresh } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { PageTopbar } from "@/components/page-topbar"; +import { AutomationEditor } from "@/components/automations/automation-editor"; +import { SettingsSaveProvider } from "@/components/settings/settings-save-provider"; +import { useAppStore } from "@/components/state-provider"; +import { useRouter } from "@/lib/routing/client-router"; +import { cn } from "@/lib/utils"; +import type { Automation, AutomationRun } from "@/lib/types/automation"; +import { nextFiring } from "./automation-rows"; +import { RunsDrawer } from "./runs-drawer"; +import { RunsRail } from "./runs-rail"; +import { RunTranscript } from "./run-transcript"; +import { isOpenRun } from "./run-status"; +import { AUTOMATIONS_HREF, runHref, type AutomationDetailTab } from "./runs-view"; +import { useAutomationActivity } from "./use-automation-activity"; +import { useLiveRefresh } from "./use-live-refresh"; +import { useRailWidth } from "./use-rail-width"; +import { useManualTrigger } from "./use-manual-trigger"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; + +const MUTED_NOTE = "py-16 text-center text-sm text-muted-foreground"; + +/** + * An automation belongs to one workspace. Switching workspaces with this page + * open would otherwise leave the previous workspace's automation on screen + * indefinitely, under a sidebar that says the user is somewhere else. + */ +function useWorkspaceGuard(automation: Automation | null, onLeave: () => void): boolean { + const activeWorkspaceId = useAppStore((state) => state.workspaces.activeId); + const foreign = Boolean( + automation && activeWorkspaceId && automation.workspace_id !== activeWorkspaceId, + ); + useEffect(() => { + if (foreign) onLeave(); + }, [foreign, onLeave]); + return foreign; +} + +/** + * The run the pane is showing. An explicit `?run=` wins; otherwise the newest, + * because "what did it say last night" is the question this page exists for. + * A requested run that is not in the window falls back rather than rendering an + * empty pane that reads as a broken link. + */ +function selectRun(runs: AutomationRun[], requestedId: string | undefined): AutomationRun | null { + const openable = runs.filter((run) => Boolean(run.session_id)); + if (openable.length === 0) return null; + const requested = requestedId ? openable.find((run) => run.id === requestedId) : undefined; + if (requested) return requested; + return openable.reduce((newest, run) => + Date.parse(run.created_at) > Date.parse(newest.created_at) ? run : newest, + ); +} + +/** + * The page's own topbar rather than a hand-rolled header: PageTopbar is `h-10`, + * which is what the sidebar header is, and the two sit side by side. A bespoke + * header drifts out of alignment the moment either one changes. + */ +function DetailHeader({ + automation, + openRuns, + runNow, + triggering, + refresh, + loading, + disabled, +}: { + automation: Automation | null; + openRuns: number; + runNow: () => void; + triggering: boolean; + refresh: () => void; + loading: boolean; + disabled: boolean; +}) { + const { t } = useTranslation(); + const next = automation ? nextFiring(automation, openRuns) : null; + return ( + } + backHref={AUTOMATIONS_HREF} + backLabel={t("automations:automations")} + leftActions={ + next ? ( + + ) : undefined + } + actions={ + <> + {/* Waiting until tomorrow to find out whether a schedule works is the + reason this sits on the reading surface and not only in settings. */} + + + + } + /> + ); +} + +function LoadError({ message, onRetry }: { message: string; onRetry: () => void }) { + const { t } = useTranslation(); + return ( +
+

{message}

+ +
+ ); +} + +/** + * Configuration, reached from the rail's Details link rather than a tab beside + * the transcript. A tab pair claims the two are done about equally; an + * automation is configured once and read continuously. + */ +function ConfigureView({ automation }: { automation: Automation }) { + return ( +
+
+ + + +
+
+ ); +} + +/** + * The conversation, and nothing above it. + * + * The standing instruction used to be pinned here as a header block. It is the + * same text on every run and it is long, so it pushed what the agent actually + * said down the page on every visit. It now lives behind the run-detail button + * in the rail (or the drawer, on a phone) — asked for once, rather than paid + * for every time. + */ +function ActivityView({ + selected, + hasRuns, + loading, +}: { + selected: AutomationRun | null; + hasRuns: boolean; + loading: boolean; +}) { + const { t } = useTranslation(); + if (!selected) { + if (loading && !hasRuns) + return

{t("automations:loadingActivity")}

; + return ( +

+ {hasRuns ? t("automations:noConversationForRun") : t("automations:hasNotRunYet")} +

+ ); + } + return ( +
+ {/* Keyed on the session so switching runs remounts the conversation + rather than letting one run's messages animate into another's. */} + +
+ ); +} + +/** + * The conversation and its run switcher. + * + * Split out so the page component stays inside the complexity budget once the + * responsive branch is added, and so the two compositions sit side by side + * where a reader can compare them. + */ +function DetailBody({ + automation, + tab, + runs, + selected, + openRuns, + loading, + isMobile, + rail, + onSelectRun, +}: { + automation: Automation; + tab: AutomationDetailTab; + runs: AutomationRun[]; + selected: AutomationRun | null; + openRuns: number; + loading: boolean; + isMobile: boolean; + rail: ReturnType; + onSelectRun: (run: AutomationRun) => void; +}) { + const switcher = isMobile ? ( + + ) : ( + + ); + + return ( +
+ {isMobile && switcher} + {/* min-w-0 or the transcript's wide code blocks push the rail off screen + instead of scrolling inside their own container. */} +
+ {tab === "configure" ? ( + + ) : ( + 0} loading={loading} /> + )} +
+ {!isMobile && switcher} +
+ ); +} + +export function AutomationDetailPage({ + automationId, + tab, + runId, +}: { + automationId: string; + tab: AutomationDetailTab; + runId?: string; +}) { + const router = useRouter(); + const { automation, runs, openRuns, loading, error, refresh } = + useAutomationActivity(automationId); + const { runNow, triggering, settling } = useManualTrigger(automationId, refresh); + const leaveForList = useCallback(() => router.push(AUTOMATIONS_HREF), [router]); + const foreign = useWorkspaceGuard(automation, leaveForList); + // `settling` as well as `openRuns`: the run row is written after the fire + // returns, so gating only on what the page can already see leaves a window + // where a just-fired run appears and finishes without the page ever asking + // again — and every note derived from that snapshot, including the amber one + // explaining why the automation will not fire next, goes stale with it. + // A visible open row is a second live signal: the summary deliberately + // excludes some task/session states, but the rail still truthfully says + // Running until the run query returns a terminal status. + const hasVisibleOpenRun = runs.some((run) => isOpenRun(run.status)); + useLiveRefresh(!foreign && (openRuns > 0 || hasVisibleOpenRun || settling), refresh); + const rail = useRailWidth(); + const { isMobile } = useResponsiveBreakpoint(); + + const selected = selectRun(runs, runId); + const show = !error && !foreign && automation; + + return ( +
+ void runNow()} + triggering={triggering} + refresh={refresh} + loading={loading} + disabled={triggering || !automation || foreign} + /> + {error && } + {show && ( + router.push(runHref(automationId, run.id))} + /> + )} +
+ ); +} diff --git a/apps/web/components/runs/automation-prompt-card.tsx b/apps/web/components/runs/automation-prompt-card.tsx new file mode 100644 index 0000000000..ba4c0abae4 --- /dev/null +++ b/apps/web/components/runs/automation-prompt-card.tsx @@ -0,0 +1,49 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { useState } from "react"; +import { cn } from "@/lib/utils"; + +/** + * What this automation is told to do, pinned above its transcript. + * + * The instruction is the same on every run, so it belongs to the automation + * rather than to any one of them — it reads as the standing brief the agent + * works from, which is what makes the turns underneath legible. Collapsed by + * default because these prompts are long and the reader came for the answer, + * not the question. + */ +const COLLAPSED_CLASS = "max-h-[9.5rem] overflow-hidden"; + +export function AutomationPromptCard({ prompt }: { prompt: string }) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + if (!prompt.trim()) return null; + + return ( +
+
+

{prompt}

+ {!expanded && ( + // Fades the clipped edge rather than cutting mid-line, so it reads as + // "there is more" instead of as a rendering fault. + + +
+ ); +} diff --git a/apps/web/components/runs/automation-rows.test.ts b/apps/web/components/runs/automation-rows.test.ts new file mode 100644 index 0000000000..382be85195 --- /dev/null +++ b/apps/web/components/runs/automation-rows.test.ts @@ -0,0 +1,275 @@ +import { describe, expect, it } from "vitest"; +import type { + Automation, + AutomationTrigger, + AutomationRun, + AutomationSummary, +} from "@/lib/types/automation"; +import { + AUTOMATION_OFF_REASON, + automationState, + buildAutomationRows, + concurrencyReason, + NO_SCHEDULE_REASON, + nextFiring, + SCHEDULE_OFF_REASON, +} from "./automation-rows"; + +const SINGAPORE = "Asia/Singapore"; +const MIDNIGHT_DAILY = "0 0 * * *"; +const NOW = new Date("2026-07-31T10:00:00Z"); +const CREATED_AT = "2026-07-01T00:00:00Z"; +const AUTO_1 = "auto-1"; + +function mkTrigger(overrides: Partial = {}): AutomationTrigger { + return { + id: "trig-1", + automation_id: AUTO_1, + type: "scheduled", + config: { cron_expression: MIDNIGHT_DAILY, timezone: SINGAPORE }, + enabled: true, + last_evaluated_at: null, + created_at: CREATED_AT, + updated_at: CREATED_AT, + ...overrides, + }; +} + +function mkAutomation(overrides: Partial = {}): Automation { + return { + id: AUTO_1, + workspace_id: "ws-1", + name: "Nightly drift sweep", + description: "", + workflow_id: "", + workflow_step_id: "", + agent_profile_id: "", + executor_profile_id: "", + repository_ids: [], + prompt: "", + task_title_template: "", + enabled: true, + max_concurrent_runs: 1, + last_triggered_at: null, + created_at: CREATED_AT, + updated_at: CREATED_AT, + triggers: [mkTrigger()], + ...overrides, + }; +} + +function mkRun(overrides: Partial = {}): AutomationRun { + return { + id: "run-1", + automation_id: AUTO_1, + trigger_id: "trig-1", + trigger_type: "scheduled", + task_id: "task-1", + status: "succeeded", + dedup_key: "", + trigger_data: {}, + error_message: "", + created_at: "2026-07-30T16:00:00Z", + ...overrides, + }; +} + +describe("nextFiring", () => { + it("resolves the firing when nothing is in the way", () => { + const next = nextFiring(mkAutomation(), 0, NOW); + + expect(next.kind).toBe("time"); + expect(next.text).toBe("~00:00 tomorrow · GMT+8"); + }); + + it("explains a disabled automation instead of promising a time", () => { + const next = nextFiring(mkAutomation({ enabled: false }), 0, NOW); + + expect(next).toEqual({ kind: "reason", text: AUTOMATION_OFF_REASON }); + }); + + it("explains an automation with no schedule at all", () => { + expect(nextFiring(mkAutomation({ triggers: [] }), 0, NOW)).toEqual({ + kind: "reason", + text: NO_SCHEDULE_REASON, + }); + }); + + it("explains a schedule trigger that is switched off", () => { + const off = mkAutomation({ triggers: [mkTrigger({ enabled: false })] }); + + expect(nextFiring(off, 0, NOW)).toEqual({ kind: "reason", text: SCHEDULE_OFF_REASON }); + }); + + it("explains a firing blocked by the concurrency cap, naming the cap", () => { + const next = nextFiring(mkAutomation({ max_concurrent_runs: 2 }), 2, NOW); + + expect(next.kind).toBe("reason"); + expect(next.text).toBe(concurrencyReason(2)); + expect(next.text).toContain("max 2 at a time"); + }); + + it("says nothing about the cap when one run is open and one slot is configured", () => { + // The default shape. Every run a single-slot automation ever does is "at + // the cap" the moment it starts, so reporting that as Paused — in amber, + // beside a dot already reading `running` — made the normal case look + // broken. The cap is only news once it is actually queueing something. + const next = nextFiring(mkAutomation({ max_concurrent_runs: 1 }), 1, NOW); + + expect(next.kind).toBe("time"); + expect(next.text).not.toContain("Paused"); + }); + + it("still reports a single-slot automation that has somehow gone past its slot", () => { + // Two open runs against a cap of one is a real jam, not the steady state. + expect(nextFiring(mkAutomation({ max_concurrent_runs: 1 }), 2, NOW)).toEqual({ + kind: "reason", + text: concurrencyReason(1), + }); + }); + + it("still resolves a time while below the cap", () => { + expect(nextFiring(mkAutomation({ max_concurrent_runs: 3 }), 2, NOW).kind).toBe("time"); + }); + + it("prefers the disabling fact over the schedule that is now irrelevant", () => { + // A switched-off automation with an open run must not report the cap: the + // cap is not why it will not fire. + const off = mkAutomation({ enabled: false }); + + expect(nextFiring(off, 1, NOW).text).toBe(AUTOMATION_OFF_REASON); + }); + + it("states the rule for an interval it cannot resolve to an instant", () => { + const every = mkAutomation({ + triggers: [mkTrigger({ config: { cron_expression: "@every 15m" } })], + }); + + expect(nextFiring(every, 0, NOW)).toEqual({ kind: "time", text: "Every 15 minutes" }); + }); +}); + +describe("automationState", () => { + it("is running while a run is open, even at the concurrency cap", () => { + expect(automationState(mkAutomation(), 1)).toBe("running"); + }); + + it("is paused when the automation is switched off", () => { + expect(automationState(mkAutomation({ enabled: false }), 0)).toBe("paused"); + }); + + it("is paused when the schedule trigger is switched off", () => { + expect(automationState(mkAutomation({ triggers: [mkTrigger({ enabled: false })] }), 0)).toBe( + "paused", + ); + }); + + it("is idle when it is simply waiting for its next firing", () => { + expect(automationState(mkAutomation(), 0)).toBe("idle"); + }); + + it("is idle, not paused, when it has no schedule to be stopped", () => { + // Nothing is holding it back — it just only runs on demand. + expect(automationState(mkAutomation({ triggers: [] }), 0)).toBe("idle"); + }); +}); + +function mkSummary(overrides: Partial = {}): AutomationSummary { + return { automation_id: AUTO_1, open_runs: 0, ...overrides }; +} + +describe("buildAutomationRows", () => { + const OTHER = mkAutomation({ id: "auto-2", name: "Dependency audit", triggers: [] }); + + it("gives every automation a row, including ones that have never run", () => { + const rows = buildAutomationRows([mkAutomation(), OTHER], [], NOW); + + expect(rows.map((row) => row.automation.id).sort()).toEqual([AUTO_1, "auto-2"]); + expect(rows.every((row) => row.lastRun === null)).toBe(true); + }); + + it("orders by most recent activity", () => { + const rows = buildAutomationRows( + [mkAutomation(), OTHER], + [ + mkSummary({ last_run: mkRun({ id: "old", created_at: "2026-07-20T00:00:00Z" }) }), + mkSummary({ + automation_id: "auto-2", + last_run: mkRun({ + id: "new", + automation_id: "auto-2", + created_at: "2026-07-30T00:00:00Z", + }), + }), + ], + NOW, + ); + + expect(rows.map((row) => row.automation.id)).toEqual(["auto-2", AUTO_1]); + }); + + it("takes each automation's last run from its own summary", () => { + const rows = buildAutomationRows( + [mkAutomation()], + [mkSummary({ last_run: mkRun({ id: "newest", created_at: "2026-07-30T00:00:00Z" }) })], + NOW, + ); + + expect(rows[0].lastRun?.id).toBe("newest"); + }); + + it("does not attribute another automation's summary", () => { + const rows = buildAutomationRows( + [mkAutomation()], + [mkSummary({ automation_id: "auto-9", open_runs: 3, last_run: mkRun({ id: "foreign" }) })], + NOW, + ); + + expect(rows[0].lastRun).toBeNull(); + expect(rows[0].state).toBe("idle"); + }); + + it("reports a jammed automation as running with the cap as its reason", () => { + const rows = buildAutomationRows( + [mkAutomation({ max_concurrent_runs: 2 })], + [mkSummary({ open_runs: 2, last_run: mkRun({ id: "open", status: "task_created" }) })], + NOW, + ); + + expect(rows[0].state).toBe("running"); + expect(rows[0].next).toEqual({ kind: "reason", text: concurrencyReason(2) }); + }); + + it("reports a single-slot automation with one run open as running, and says nothing else", () => { + // The everyday case. `running` is the whole story; there is no second + // amber sentence claiming the automation is Paused as well. + const rows = buildAutomationRows( + [mkAutomation({ max_concurrent_runs: 1 })], + [mkSummary({ open_runs: 1, last_run: mkRun({ id: "open", status: "task_created" }) })], + NOW, + ); + + expect(rows[0].state).toBe("running"); + expect(rows[0].next.kind).toBe("time"); + }); + + it("trusts the server's open count rather than re-deriving it from statuses", () => { + // The count is what the concurrency cap itself uses. A finished run left in + // the summary must not make the row claim something is still going. + const rows = buildAutomationRows( + [mkAutomation({ max_concurrent_runs: 1 })], + [mkSummary({ open_runs: 0, last_run: mkRun({ id: "done", status: "succeeded" }) })], + NOW, + ); + + expect(rows[0].state).toBe("idle"); + expect(rows[0].next.kind).toBe("time"); + }); + + it("reads a missing summary as an automation that has never run", () => { + const rows = buildAutomationRows([mkAutomation()], [], NOW); + + expect(rows[0].lastRun).toBeNull(); + expect(rows[0].state).toBe("idle"); + }); +}); diff --git a/apps/web/components/runs/automation-rows.ts b/apps/web/components/runs/automation-rows.ts new file mode 100644 index 0000000000..073b37a158 --- /dev/null +++ b/apps/web/components/runs/automation-rows.ts @@ -0,0 +1,205 @@ +import { describeSchedule } from "@/components/automations/schedule-expression"; +import type { Automation, AutomationRun, AutomationSummary } from "@/lib/types/automation"; +import { formatNextFiring, nextFiringInstant, scheduleBinding } from "./automation-schedule"; + +/** + * What the row's dot says about health. Deliberately three words, not the run + * status vocabulary: the question a list row answers is "is this thing okay", + * which a per-run status cannot answer on its own. + */ +export type AutomationActivityState = "running" | "idle" | "paused"; + +/** + * The next-run cell is one of two things and never both: a resolved time, or + * the reason there will not be one. Modelling it as a union stops a caller + * printing a time that will not happen next to an explanation of why it won't. + */ +export type NextFiring = { kind: "time"; text: string } | { kind: "reason"; text: string }; + +export const AUTOMATION_OFF_REASON = "Switched off — it will not fire"; +export const NO_SCHEDULE_REASON = "No schedule — only runs when you trigger it"; +export const SCHEDULE_OFF_REASON = "Schedule trigger is switched off"; + +/** + * The concurrency cap states its own number. "Paused" alone reads as something + * the user did; naming the cap points at the setting that would change it. + */ +export function concurrencyReason(maxConcurrentRuns: number): string { + return `Paused — a run is still open (max ${Math.max(1, maxConcurrentRuns)} at a time)`; +} + +export const STATE_DOT_CLASS: Record = { + running: "bg-blue-500", + idle: "bg-emerald-500", + paused: "bg-muted-foreground/50", +}; + +/** + * Catalog keys rather than copy: this is a plain module, so the label has to be + * resolved by whoever renders it and has a `t`. + */ +export const STATE_LABEL_KEY: Record = { + running: "automations:stateRunning", + idle: "automations:stateIdle", + paused: "automations:statePaused", +}; + +/** + * Whether the cap is genuinely holding a firing back in a way worth saying. + * + * A single-slot automation with one run open is the ordinary steady state of + * every run it will ever do — the moment it starts, it is "at the cap". Calling + * that Paused made the normal case look broken, and it said it in amber right + * next to a dot already reporting `running`. The cap is only news once it is + * queueing something: more than one slot configured, and every slot taken. + * + * Known and deliberate: `max=2/open=2` is just as much "at capacity" as + * `max=1/open=1`, so the rule is not symmetric, and `openRuns` is not evidence + * that anything is actually queued behind the cap. The honest version warns + * only when a firing was really turned away — those runs already exist, with a + * reason, as `skipped` rows — but the summary this derivation reads carries no + * skip count, so saying so would take a new backend field. Until then this + * errs toward silence in the one case that is always benign, rather than + * toward an amber note on every healthy single-slot automation. + */ +function atConcurrencyCap(automation: Automation, openRuns: number): boolean { + const max = Math.max(1, automation.max_concurrent_runs); + if (max === 1 && openRuns <= 1) return false; + return openRuns >= max; +} + +/** A time when one can be resolved, otherwise the schedule described in words. */ +function scheduleWindow(automation: Automation, now: Date): NextFiring { + const { expression, timeZone } = scheduleBinding(automation); + const at = nextFiringInstant(automation, now); + // An `@every` interval is anchored server-side and an unrecognised expression + // cannot be resolved at all, so state the rule rather than invent an instant. + if (!at) return { kind: "time", text: describeSchedule(expression, timeZone) }; + return { kind: "time", text: formatNextFiring(at, timeZone, now) }; +} + +/** + * When it fires next, or why it will not. Ordered by which fact makes the + * others moot: a disabled automation's schedule is irrelevant, and a schedule + * that does not exist cannot be blocked by concurrency. + */ +export function nextFiring( + automation: Automation, + openRuns: number, + now: Date = new Date(), +): NextFiring { + if (!automation.enabled) return { kind: "reason", text: AUTOMATION_OFF_REASON }; + const { trigger, expression } = scheduleBinding(automation); + if (!trigger || !expression) return { kind: "reason", text: NO_SCHEDULE_REASON }; + if (!trigger.enabled) return { kind: "reason", text: SCHEDULE_OFF_REASON }; + if (atConcurrencyCap(automation, openRuns)) { + return { kind: "reason", text: concurrencyReason(automation.max_concurrent_runs) }; + } + return scheduleWindow(automation, now); +} + +/** Whether anything is standing between this automation and its next firing. */ +function firesAgain(automation: Automation, openRuns: number): boolean { + return nextFiring(automation, openRuns).kind === "time"; +} + +/** + * Health, which is not the same question as "will it fire". An automation held + * back by its own concurrency cap is `running` — something IS happening — while + * its next-run cell explains why nothing new will start. + */ +export function automationState(automation: Automation, openRuns: number): AutomationActivityState { + if (openRuns > 0) return "running"; + if (!automation.enabled) return "paused"; + const { trigger, expression } = scheduleBinding(automation); + if (trigger && expression && !trigger.enabled) return "paused"; + return "idle"; +} + +export type AutomationRow = { + automation: Automation; + state: AutomationActivityState; + next: NextFiring; + lastRun: AutomationRun | null; + /** Epoch ms of the most recent thing that happened, used only for ordering. */ + lastActivity: number; + /** + * Epoch ms of the next firing, or null when there will not be one. Separate + * from `next.text` because an agenda has to sort by the instant, and a + * formatted string sorts alphabetically — "~09:00 tomorrow" before "~23:00 + * today". + */ + nextAt: number | null; +}; + +function timestamp(value: string | null | undefined): number { + if (!value) return 0; + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; +} + +function lastActivityOf(automation: Automation, lastRun: AutomationRun | null): number { + // An automation that has never run still has to sort somewhere. Its creation + // time puts a brand-new one near the top, where the user just left it. + return Math.max( + timestamp(lastRun?.created_at), + timestamp(automation.last_triggered_at), + timestamp(automation.created_at), + ); +} + +/** + * One row per automation, most recently active first. + * + * Health comes from the server's per-automation summary, not from scanning the + * workspace feed: the feed is capped, so a quiet automation behind a noisy one + * would report "No runs yet" and idle — the two claims a health row must never + * get wrong. An automation with no summary has genuinely never run. + */ +export function buildAutomationRows( + automations: Automation[], + summaries: AutomationSummary[], + now: Date = new Date(), +): AutomationRow[] { + const byAutomation = new Map(summaries.map((summary) => [summary.automation_id, summary])); + const rows = automations.map((automation) => { + const summary = byAutomation.get(automation.id); + const openRuns = summary?.open_runs ?? 0; + const lastRun = summary?.last_run ?? null; + return { + automation, + state: automationState(automation, openRuns), + next: nextFiring(automation, openRuns, now), + lastRun, + lastActivity: lastActivityOf(automation, lastRun), + nextAt: firesAgain(automation, openRuns) + ? (nextFiringInstant(automation, now)?.getTime() ?? null) + : null, + }; + }); + return rows.sort( + (a, b) => b.lastActivity - a.lastActivity || a.automation.name.localeCompare(b.automation.name), + ); +} + +/** + * The forward agenda: what fires next, soonest first. + * + * This is the one question neither the sidebar nor an automation's own page can + * answer — the sidebar has no room for a time, and a detail page knows only + * about itself. Automations that will not fire sort last, holding their reason + * rather than being dropped: "nothing is scheduled" is an answer the reader + * needs, and hiding them would make the agenda look complete when it is not. + */ +export function buildAgenda( + automations: Automation[], + summaries: AutomationSummary[], + now: Date = new Date(), +): AutomationRow[] { + return buildAutomationRows(automations, summaries, now).sort((a, b) => { + if (a.nextAt !== null && b.nextAt !== null) return a.nextAt - b.nextAt; + if (a.nextAt !== null) return -1; + if (b.nextAt !== null) return 1; + return a.automation.name.localeCompare(b.automation.name); + }); +} diff --git a/apps/web/components/runs/automation-schedule.test.ts b/apps/web/components/runs/automation-schedule.test.ts new file mode 100644 index 0000000000..bd53e0f420 --- /dev/null +++ b/apps/web/components/runs/automation-schedule.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import type { Automation, AutomationTrigger } from "@/lib/types/automation"; +import { + describeAutomationSchedule, + formatNextFiring, + nextFiringInstant, + scheduleBinding, +} from "./automation-schedule"; + +const SINGAPORE = "Asia/Singapore"; +const MIDNIGHT_DAILY = "0 0 * * *"; +const CREATED_AT = "2026-07-01T00:00:00Z"; +/** 18:00 in Singapore, so "tomorrow" and "today" differ by zone from here. */ +const NOW = new Date("2026-07-31T10:00:00Z"); + +function mkTrigger(overrides: Partial = {}): AutomationTrigger { + return { + id: "trig-1", + automation_id: "auto-1", + type: "scheduled", + config: { cron_expression: MIDNIGHT_DAILY, timezone: SINGAPORE }, + enabled: true, + last_evaluated_at: null, + created_at: CREATED_AT, + updated_at: CREATED_AT, + ...overrides, + }; +} + +function mkAutomation(triggers: AutomationTrigger[]): Automation { + return { + id: "auto-1", + workspace_id: "ws-1", + name: "Nightly drift sweep", + description: "", + workflow_id: "", + workflow_step_id: "", + agent_profile_id: "", + executor_profile_id: "", + repository_ids: [], + prompt: "", + task_title_template: "", + enabled: true, + max_concurrent_runs: 1, + last_triggered_at: null, + created_at: CREATED_AT, + updated_at: CREATED_AT, + triggers, + }; +} + +describe("scheduleBinding", () => { + it("reads the scheduled trigger's expression and zone", () => { + const binding = scheduleBinding(mkAutomation([mkTrigger()])); + + expect(binding.expression).toBe(MIDNIGHT_DAILY); + expect(binding.timeZone).toBe(SINGAPORE); + expect(binding.trigger?.id).toBe("trig-1"); + }); + + it("ignores non-scheduled triggers", () => { + const binding = scheduleBinding( + mkAutomation([mkTrigger({ type: "webhook", config: { filter_expression: "" } })]), + ); + + expect(binding.trigger).toBeNull(); + expect(binding.expression).toBe(""); + }); + + it("treats a config with no usable cron as no schedule rather than throwing", () => { + // config is an untyped bag on the wire; a hand-edited or legacy trigger can + // carry anything, and the whole list must survive it. + const binding = scheduleBinding(mkAutomation([mkTrigger({ config: { cron_expression: 42 } })])); + + expect(binding.expression).toBe(""); + expect(binding.timeZone).toBe("UTC"); + }); + + it("defaults an absent timezone to UTC, matching the backend", () => { + const binding = scheduleBinding( + mkAutomation([mkTrigger({ config: { cron_expression: MIDNIGHT_DAILY } })]), + ); + + expect(binding.timeZone).toBe("UTC"); + }); +}); + +describe("describeAutomationSchedule", () => { + it("states the rule in words including the time", () => { + expect(describeAutomationSchedule(mkAutomation([mkTrigger()]))).toBe( + "Every day at 00:00 GMT+8", + ); + }); + + it("says there is no schedule when none is set", () => { + expect(describeAutomationSchedule(mkAutomation([]))).toBe("No schedule"); + }); +}); + +describe("nextFiringInstant", () => { + it("resolves the next wall-clock firing in the schedule's own zone", () => { + // 2026-07-31T10:00Z is 18:00 in Singapore, so midnight lands on 1 Aug local + // = 2026-07-31T16:00Z. + const at = nextFiringInstant(mkAutomation([mkTrigger()]), NOW); + + expect(at?.toISOString()).toBe("2026-07-31T16:00:00.000Z"); + }); + + it("has nothing to resolve for a server-anchored interval", () => { + const every = mkAutomation([mkTrigger({ config: { cron_expression: "@every 15m" } })]); + + expect(nextFiringInstant(every, NOW)).toBeNull(); + }); + + it("has nothing to resolve without a schedule", () => { + expect(nextFiringInstant(mkAutomation([]))).toBeNull(); + }); +}); + +describe("formatNextFiring", () => { + it("marks the time as approximate and names the zone", () => { + const at = new Date("2026-07-31T16:00:00Z"); // 00:00 on 1 Aug in Singapore + + expect(formatNextFiring(at, SINGAPORE, NOW)).toBe("~00:00 tomorrow · GMT+8"); + }); + + it("says today when the firing is still ahead on the same local day", () => { + const at = new Date("2026-07-31T14:00:00Z"); // 22:00 same day in Singapore + + expect(formatNextFiring(at, SINGAPORE, NOW)).toBe("~22:00 today · GMT+8"); + }); + + it("names the weekday within the week", () => { + const at = new Date("2026-08-02T01:00:00Z"); // 09:00 Sunday in Singapore + + expect(formatNextFiring(at, SINGAPORE, NOW)).toBe("~09:00 Sunday · GMT+8"); + }); + + it("falls back to a date once a weekday name stops being unambiguous", () => { + const at = new Date("2026-08-12T01:00:00Z"); // 09:00, twelve days out + + expect(formatNextFiring(at, SINGAPORE, NOW)).toBe("~09:00 12 Aug · GMT+8"); + }); + + it("counts days in the schedule's zone, not the reader's", () => { + // 2026-07-31T16:00Z is 1 Aug in Singapore but still 31 Jul in UTC. Reading + // the day boundary in the wrong zone turns "tomorrow" into "today". + const at = new Date("2026-07-31T16:00:00Z"); + + expect(formatNextFiring(at, "UTC", NOW)).toContain("today"); + expect(formatNextFiring(at, SINGAPORE, NOW)).toContain("tomorrow"); + }); +}); diff --git a/apps/web/components/runs/automation-schedule.ts b/apps/web/components/runs/automation-schedule.ts new file mode 100644 index 0000000000..a8b63dbae6 --- /dev/null +++ b/apps/web/components/runs/automation-schedule.ts @@ -0,0 +1,98 @@ +import { + describeSchedule, + nextRun, + timeZoneAbbreviation, +} from "@/components/automations/schedule-expression"; +import type { Automation, AutomationTrigger } from "@/lib/types/automation"; + +const UTC = "UTC"; + +export type ScheduleBinding = { + /** The scheduled trigger, or null when the automation has none. */ + trigger: AutomationTrigger | null; + /** Trimmed cron/interval expression. Empty when no schedule is set. */ + expression: string; + timeZone: string; +}; + +/** + * The scheduled trigger's expression and zone, read defensively. + * + * `config` is an untyped bag on the wire, so a trigger saved before the + * composer existed — or one whose config was hand-edited — can carry anything. + * Reading it as "no schedule" is right: the row then says the automation will + * not fire on its own, which is true, rather than crashing the whole list. + */ +export function scheduleBinding(automation: Automation): ScheduleBinding { + const trigger = (automation.triggers ?? []).find((entry) => entry.type === "scheduled") ?? null; + const config = (trigger?.config ?? {}) as { cron_expression?: unknown; timezone?: unknown }; + const expression = + typeof config.cron_expression === "string" ? config.cron_expression.trim() : ""; + const timeZone = typeof config.timezone === "string" && config.timezone ? config.timezone : UTC; + return { trigger, expression, timeZone }; +} + +/** The schedule in plain words, e.g. "Every day at 09:00 GMT+8". */ +export function describeAutomationSchedule(automation: Automation): string { + const { expression, timeZone } = scheduleBinding(automation); + return describeSchedule(expression, timeZone); +} + +/** + * The next instant this automation's schedule fires, or null when that cannot + * be stated (no schedule, an `@every` interval anchored server-side, or an + * expression the parser does not recognise). + */ +export function nextFiringInstant(automation: Automation, from: Date = new Date()): Date | null { + const { expression, timeZone } = scheduleBinding(automation); + if (!expression) return null; + return nextRun(expression, timeZone, from); +} + +const MS_PER_DAY = 86_400_000; + +/** Calendar day of an instant as seen in a zone, as a day number for differencing. */ +function zonedDayNumber(instant: Date, timeZone: string): number { + const parts = new Intl.DateTimeFormat("en-US", { + timeZone, + year: "numeric", + month: "2-digit", + day: "2-digit", + }).formatToParts(instant); + const read = (type: string) => Number(parts.find((part) => part.type === type)?.value ?? "0"); + return Date.UTC(read("year"), read("month") - 1, read("day")) / MS_PER_DAY; +} + +/** + * How the reader names the day. Within the week a weekday name is unambiguous + * and shorter than a date; past that it stops being one, so fall back to a date. + */ +function dayWord(at: Date, timeZone: string, dayDelta: number): string { + if (dayDelta <= 0) return "today"; + if (dayDelta === 1) return "tomorrow"; + if (dayDelta < 7) { + return new Intl.DateTimeFormat("en-GB", { timeZone, weekday: "long" }).format(at); + } + return new Intl.DateTimeFormat("en-GB", { timeZone, day: "numeric", month: "short" }).format(at); +} + +/** + * The resolved firing, e.g. `~00:00 tomorrow · GMT+8`. + * + * The tilde is load-bearing. The scheduler wakes on an interval and fires the + * first tick at or after the cron instant, so the real firing lands a little + * after this time. Printing a bare `00:00` would claim a precision the system + * does not have, and a user who sees the run land at 00:00:47 would read the + * difference as a bug rather than as how it works. + */ +export function formatNextFiring(at: Date, timeZone: string, now: Date = new Date()): string { + const zone = timeZone || UTC; + const time = new Intl.DateTimeFormat("en-GB", { + timeZone: zone, + hour: "2-digit", + minute: "2-digit", + hourCycle: "h23", + }).format(at); + const delta = zonedDayNumber(at, zone) - zonedDayNumber(now, zone); + return `~${time} ${dayWord(at, zone, delta)} · ${timeZoneAbbreviation(zone, at)}`; +} diff --git a/apps/web/components/runs/run-detail-disclosure.tsx b/apps/web/components/runs/run-detail-disclosure.tsx new file mode 100644 index 0000000000..9604ecb19e --- /dev/null +++ b/apps/web/components/runs/run-detail-disclosure.tsx @@ -0,0 +1,76 @@ +"use client"; + +import { useState } from "react"; +import { useTranslation } from "react-i18next"; +import { IconInfoCircle } from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { cn } from "@/lib/utils"; +import type { Automation } from "@/lib/types/automation"; +import { AutomationPromptCard } from "./automation-prompt-card"; +import { nextFiring } from "./automation-rows"; + +/** + * The detail behind a button, rather than a block above the conversation. + * + * The standing instruction used to sit pinned at the top of the run view. It is + * the same text on every run and it is long, so it pushed the thing the reader + * came for — what the agent actually said — down the page on every visit, on + * every run. It has not been deleted: it moved beside the transcript into the + * runs rail, where the reader asks for it once and it stays out of the way the + * rest of the time. + * + * The next-firing line rides along because the topbar deliberately hides it on + * a phone for want of width, and the drawer is where that note said it would + * be. Reusing `AutomationPromptCard` rather than restating the markup keeps the + * expand/clamp behaviour identical to what it replaced. + * + * Shared by the rail and the mobile drawer for the same reason `RunGroup` is: + * two copies of "what a run's detail looks like" would drift. + */ +export function RunDetailDisclosure({ + automation, + openRuns, + className, +}: { + automation: Automation; + /** Feeds the next-firing line, which is a fact about the automation's queue. */ + openRuns: number; + className?: string; +}) { + const { t } = useTranslation(); + const [open, setOpen] = useState(false); + const next = nextFiring(automation, openRuns); + + return ( +
+ + {open && ( +
+

+ {next.text} +

+ +
+ )} +
+ ); +} diff --git a/apps/web/components/runs/run-feed-item.tsx b/apps/web/components/runs/run-feed-item.tsx new file mode 100644 index 0000000000..07abce3cab --- /dev/null +++ b/apps/web/components/runs/run-feed-item.tsx @@ -0,0 +1,103 @@ +"use client"; + +import { useTranslation } from "react-i18next"; +import { cn } from "@/lib/utils"; +import { formatRelativeTime } from "@/lib/utils"; +import type { AutomationRun } from "@/lib/types/automation"; +import { isOpenRun, statusDotClass, statusLabelKey } from "./run-status"; + +/** + * What the run actually said, in priority order. An error outranks the summary + * — a failed run's summary is usually the last thing the agent managed to say + * before it went wrong, which reads as success if shown on its own. + */ +export function outcomeText(run: AutomationRun): string { + if (run.error_message) return run.error_message; + if (run.summary) return run.summary; + if (isOpenRun(run.status)) return "Still running — no report yet."; + return "No report recorded."; +} + +type RunFeedItemProps = { + run: AutomationRun; + /** + * Only the cross-automation lens needs this. Inside one automation's own + * history, repeating its name on every entry is noise the reader already + * knows — the status takes the line instead. + */ + automationName?: string; + onOpen: (taskId: string) => void; +}; + +function RunFeedItemBody({ run, automationName }: { run: AutomationRun; automationName?: string }) { + const { t } = useTranslation(); + const outcome = outcomeText(run); + // A skipped firing stores its reason in error_message, but a skip is the + // concurrency cap working, not a failure. Rendering it red made a correctly + // throttled automation look broken at a glance. + const isError = Boolean(run.error_message) && run.status !== "skipped"; + return ( + <> +