diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager.go b/apps/backend/internal/agent/runtime/lifecycle/manager.go index 2c637e209f..decf628298 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager.go @@ -411,6 +411,13 @@ func (m *Manager) SetMCPIdentityScoper(scoper MCPIdentityScoper) { m.streamManager.mcpIdentityScoper = scoper } +// SetMCPPrincipalScoper installs the trusted in-session MCP principal resolver. +// The resolver derives automation identity and workspace boundaries from the +// execution's own task and session, never from the agent request payload. +func (m *Manager) SetMCPPrincipalScoper(scoper MCPPrincipalScoper) { + m.streamManager.mcpPrincipalScoper = scoper +} + // SetSessionAccessChecker installs the per-user session visibility check used // by GetOrEnsureExecution and EnsurePassthroughExecution. The checker must // return nil for contexts without a request identity (internal callers). Set diff --git a/apps/backend/internal/agent/runtime/lifecycle/mcp_identity.go b/apps/backend/internal/agent/runtime/lifecycle/mcp_identity.go index 08ad6f95d6..4c55cdeb97 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/mcp_identity.go +++ b/apps/backend/internal/agent/runtime/lifecycle/mcp_identity.go @@ -18,6 +18,12 @@ import ( // it fails closed rather than falling back to full access. type MCPIdentityScoper func(ctx context.Context, taskID string) (context.Context, error) +// MCPPrincipalScoper attaches the server-derived task/session MCP principal. +// It runs after the owner identity scoper and is independent of auth mode: +// automation self/workspace boundaries are required even on single-user +// installations. +type MCPPrincipalScoper func(ctx context.Context, taskID, sessionID string) (context.Context, error) + // taskScopedMCPHandler scopes every MCP request on one agent stream to the // owner of that stream's task. // @@ -28,6 +34,7 @@ type MCPIdentityScoper func(ctx context.Context, taskID string) (context.Context type taskScopedMCPHandler struct { inner agentctl.MCPHandler scope MCPIdentityScoper + principal MCPPrincipalScoper executionID string taskID string sessionID string @@ -40,19 +47,33 @@ func (h *taskScopedMCPHandler) Dispatch(ctx context.Context, msg *ws.Message) (* TaskID: h.taskID, SessionID: h.sessionID, }) - if h.scope == nil { - return h.inner.Dispatch(ctx, msg) + scoped := ctx + if h.scope != nil { + var err error + scoped, err = h.scope(ctx, h.taskID) + if err != nil { + h.logger.Error("denying in-session MCP request: cannot resolve the task owner", + zap.String("task_id", h.taskID), + zap.String("action", msg.Action), + zap.Error(err)) + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, + "failed to resolve the session owner", nil) + } } - scoped, err := h.scope(ctx, h.taskID) + if h.principal == nil { + return h.inner.Dispatch(scoped, msg) + } + principalScoped, err := h.principal(scoped, h.taskID, h.sessionID) if err != nil { - h.logger.Error("denying in-session MCP request: cannot resolve the task owner", + h.logger.Error("denying in-session MCP request: cannot resolve the caller principal", zap.String("task_id", h.taskID), + zap.String("session_id", h.sessionID), zap.String("action", msg.Action), zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, - "failed to resolve the session owner", nil) + "failed to resolve the session principal", nil) } - return h.inner.Dispatch(scoped, msg) + return h.inner.Dispatch(principalScoped, msg) } // mcpHandlerFor returns the MCP handler for one execution's stream. The @@ -65,6 +86,7 @@ func (sm *StreamManager) mcpHandlerFor(execution *AgentExecution) agentctl.MCPHa return &taskScopedMCPHandler{ inner: sm.mcpHandler, scope: sm.mcpIdentityScoper, + principal: sm.mcpPrincipalScoper, executionID: execution.ID, taskID: execution.TaskID, sessionID: execution.SessionID, diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_history.go b/apps/backend/internal/agent/runtime/lifecycle/session_history.go index 164bbd0605..97dd1343a8 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session_history.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session_history.go @@ -20,7 +20,8 @@ import ( // History entry type constants. const ( - historyEntryTypeToolCall = "tool_call" + historyEntryTypeToolCall = "tool_call" + resumeConversationMessageLimit = 50 ) // SessionHistoryManager stores and retrieves session history for context injection. @@ -210,18 +211,21 @@ func (m *SessionHistoryManager) GenerateResumeContext(sessionID, newPrompt strin return newPrompt, nil // No history to inject } - // Format history as conversation context + conversation := selectResumeConversation(entries, resumeConversationMessageLimit) + if len(conversation) == 0 { + return newPrompt, nil + } + + // Format history as conversation context. Tool activity and lifecycle + // events are deliberately filtered before the bounded window is selected; + // they must not displace conversation messages from fallback context. var historyBuilder strings.Builder - for _, entry := range entries { + for _, entry := range conversation { switch entry.Type { case "user_message": fmt.Fprintf(&historyBuilder, "\n[USER]: %s\n", truncateForContext(entry.Content, 2000)) case "agent_message": fmt.Fprintf(&historyBuilder, "\n[ASSISTANT]: %s\n", truncateForContext(entry.Content, 2000)) - case historyEntryTypeToolCall: - fmt.Fprintf(&historyBuilder, "\n[TOOL CALL: %s]\n", entry.ToolName) - case "tool_result": - fmt.Fprintf(&historyBuilder, "\n[TOOL RESULT: %s] %s\n", entry.ToolName, truncateForContext(entry.Content, 500)) } } @@ -254,6 +258,23 @@ Do not repeat work that was already completed. Build on the existing progress. return resumePrompt, nil } +func selectResumeConversation(entries []HistoryEntry, limit int) []HistoryEntry { + if limit <= 0 { + return nil + } + conversation := make([]HistoryEntry, 0, min(limit, len(entries))) + for _, entry := range entries { + if (entry.Type != "user_message" && entry.Type != "agent_message") || strings.TrimSpace(entry.Content) == "" { + continue + } + conversation = append(conversation, entry) + if len(conversation) > limit { + conversation = conversation[1:] + } + } + return conversation +} + // HasHistory checks if a session has any stored history. func (m *SessionHistoryManager) HasHistory(sessionID string) bool { if sessionID == "" { diff --git a/apps/backend/internal/agent/runtime/lifecycle/session_history_resume_test.go b/apps/backend/internal/agent/runtime/lifecycle/session_history_resume_test.go index 5158f28441..5e453d5425 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/session_history_resume_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/session_history_resume_test.go @@ -3,6 +3,7 @@ package lifecycle import ( "os" "path/filepath" + "strconv" "strings" "testing" "time" @@ -18,18 +19,119 @@ func newSessionHistoryManagerForTest(t *testing.T) (*SessionHistoryManager, stri return mgr, dir } -// TestGenerateResumeContextRendersEveryEntryKind pins the fork-session prompt: -// each recorded entry kind gets its own labelled section, and the user's new -// request is appended after the history so the agent knows what to do next. -func TestGenerateResumeContextRendersEveryEntryKind(t *testing.T) { +func TestGenerateResumeContextUsesNewest50ConversationMessages(t *testing.T) { mgr, _ := newSessionHistoryManagerForTest(t) now := time.Now() + for i := 1; i <= 51; i++ { + typ := "user_message" + if i%2 == 0 { + typ = "agent_message" + } + require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{ + Timestamp: now.Add(time.Duration(i) * time.Second), + Type: typ, + Content: "message-" + strconv.Itoa(i), + })) + } + + prompt, err := mgr.GenerateResumeContext("session-1", "now add tests") + + require.NoError(t, err) + require.Equal(t, 50, strings.Count(prompt, "[USER]:")+strings.Count(prompt, "[ASSISTANT]:")) + require.NotContains(t, prompt, ": message-1\n") + require.Contains(t, prompt, "message-2") + require.Contains(t, prompt, "message-51") + require.Less(t, strings.Index(prompt, "message-2"), strings.Index(prompt, "message-51"), + "the retained window must stay in chronological order") + require.Contains(t, prompt, "=== CURRENT REQUEST ===\nnow add tests") + require.Less(t, strings.Index(prompt, "message-51"), strings.Index(prompt, "now add tests"), + "the current request must follow the retained history") +} + +func TestGenerateResumeContextExcludesNonConversationEntriesBeforeWindowing(t *testing.T) { + mgr, _ := newSessionHistoryManagerForTest(t) + now := time.Now() + for i := 1; i <= 50; i++ { + require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{ + Timestamp: now.Add(time.Duration(i) * time.Second), + Type: "user_message", + Content: "conversation-" + strconv.Itoa(i), + })) + } + for i := 0; i < 150; i++ { + require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{ + Timestamp: now.Add(time.Duration(100+i) * time.Second), + Type: historyEntryTypeToolCall, + ToolName: "read_file", + })) + require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{ + Timestamp: now.Add(time.Duration(250+i) * time.Second), + Type: "tool_result", + ToolName: "read_file", + Content: "tool output", + })) + } + require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{ + Timestamp: now.Add(500 * time.Second), Type: "session_started", Content: "status event", + })) + require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{ + Timestamp: now.Add(501 * time.Second), Type: "unknown", Content: "unknown event", + })) + + prompt, err := mgr.GenerateResumeContext("session-1", "continue") + + require.NoError(t, err) + require.Equal(t, 50, strings.Count(prompt, "[USER]:")+strings.Count(prompt, "[ASSISTANT]:")) + require.Contains(t, prompt, "conversation-1") + require.Contains(t, prompt, "conversation-50") + require.NotContains(t, prompt, "TOOL CALL") + require.NotContains(t, prompt, "TOOL RESULT") + require.NotContains(t, prompt, "status event") + require.NotContains(t, prompt, "unknown event") +} + +func TestGenerateResumeContextExcludesEmptyConversationMessages(t *testing.T) { + mgr, _ := newSessionHistoryManagerForTest(t) + require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{Type: "user_message"})) + require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{ + Type: "agent_message", Content: "kept", + })) + + prompt, err := mgr.GenerateResumeContext("session-1", "continue") + + require.NoError(t, err) + require.Equal(t, 1, strings.Count(prompt, "[ASSISTANT]:")) + require.NotContains(t, prompt, "[USER]:") + require.Contains(t, prompt, "kept") +} + +func TestGenerateResumeContextPreservesHistoryFile(t *testing.T) { + mgr, dir := newSessionHistoryManagerForTest(t) + require.NoError(t, mgr.AppendEntry("session-1", HistoryEntry{ + Type: "user_message", Content: "keep this file", + })) + path := filepath.Join(dir, "session-1.jsonl") + before, err := os.ReadFile(path) + require.NoError(t, err) + + _, err = mgr.GenerateResumeContext("session-1", "continue") + + require.NoError(t, err) + after, err := os.ReadFile(path) + require.NoError(t, err) + require.Equal(t, before, after) +} + +// TestGenerateResumeContextPlacesCurrentRequestAfterHistory pins the fork +// prompt framing while the history selection remains independently bounded. +func TestGenerateResumeContextPlacesCurrentRequestAfterHistory(t *testing.T) { + mgr, _ := newSessionHistoryManagerForTest(t) for _, entry := range []HistoryEntry{ - {Timestamp: now, Type: "user_message", Content: "add retries"}, - {Timestamp: now, Type: "agent_message", Content: "added them"}, - {Timestamp: now, Type: historyEntryTypeToolCall, ToolName: "edit_file"}, - {Timestamp: now, Type: "tool_result", ToolName: "edit_file", Content: "3 lines changed"}, - {Timestamp: now, Type: "unrecognised_kind", Content: "should be ignored"}, + {Timestamp: time.Now(), Type: "user_message", Content: "add retries"}, + {Timestamp: time.Now(), Type: "agent_message", Content: "added them"}, + {Timestamp: time.Now(), Type: historyEntryTypeToolCall, ToolName: "edit_file"}, + {Timestamp: time.Now(), Type: "tool_result", ToolName: "edit_file", Content: "3 lines changed"}, + {Timestamp: time.Now(), Type: "unrecognised_kind", Content: "should be ignored"}, } { require.NoError(t, mgr.AppendEntry("session-1", entry)) } @@ -39,8 +141,8 @@ func TestGenerateResumeContextRendersEveryEntryKind(t *testing.T) { require.NoError(t, err) require.Contains(t, prompt, "[USER]: add retries") require.Contains(t, prompt, "[ASSISTANT]: added them") - require.Contains(t, prompt, "[TOOL CALL: edit_file]") - require.Contains(t, prompt, "[TOOL RESULT: edit_file] 3 lines changed") + require.NotContains(t, prompt, "[TOOL CALL: edit_file]") + require.NotContains(t, prompt, "[TOOL RESULT: edit_file] 3 lines changed") require.NotContains(t, prompt, "should be ignored", "an unknown entry kind must be skipped rather than rendered raw") require.Contains(t, prompt, "=== CURRENT REQUEST ===\nnow add tests") @@ -98,11 +200,10 @@ func TestGenerateResumeContextTruncatesLongEntries(t *testing.T) { prompt, err := mgr.GenerateResumeContext("session-1", "continue") require.NoError(t, err) - require.Equal(t, 2, strings.Count(prompt, "... [truncated]")) + require.Equal(t, 1, strings.Count(prompt, "... [truncated]")) require.NotContains(t, prompt, strings.Repeat("a", 2001), "user messages are capped at 2000 characters") - require.NotContains(t, prompt, strings.Repeat("b", 501), - "tool results are capped at 500 characters — they are the bulkiest entries") + require.NotContains(t, prompt, "TOOL RESULT") } func TestTruncateForContext(t *testing.T) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/streams.go b/apps/backend/internal/agent/runtime/lifecycle/streams.go index b7d06d5ba0..174a715f83 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/streams.go +++ b/apps/backend/internal/agent/runtime/lifecycle/streams.go @@ -39,6 +39,8 @@ type StreamManager struct { // stream's task. Nil leaves dispatch unscoped (single-user instances and // isolated tests); set via Manager.SetMCPIdentityScoper. mcpIdentityScoper MCPIdentityScoper + // mcpPrincipalScoper attaches the trusted task/session/surface principal. + mcpPrincipalScoper MCPPrincipalScoper // stopCh is the Manager-owned shutdown signal. The retry/backoff and // connected `<-ws.Done() / <-stop>` select read from it so they drain on // Manager.Stop. May be nil when isolated tests don't care about external diff --git a/apps/backend/internal/automation/continuation_contract_test.go b/apps/backend/internal/automation/continuation_contract_test.go new file mode 100644 index 0000000000..898ad30611 --- /dev/null +++ b/apps/backend/internal/automation/continuation_contract_test.go @@ -0,0 +1,353 @@ +package automation + +import ( + "context" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestAutomationSchemaIncludesContinuationAndRunBindingColumns(t *testing.T) { + store := setupTestStore(t) + + for _, column := range []string{"continuation_policy", "continuation_task_id"} { + var count int + require.NoError(t, store.db.Get(&count, + `SELECT COUNT(*) FROM pragma_table_info('automations') WHERE name = ?`, column)) + require.Equal(t, 1, count, "automations.%s must be persisted", column) + } + for _, column := range []string{"session_id", "turn_id", "thread_action", "thread_reason", "display_title"} { + var count int + require.NoError(t, store.db.Get(&count, + `SELECT COUNT(*) FROM pragma_table_info('automation_runs') WHERE name = ?`, column)) + require.Equal(t, 1, count, "automation_runs.%s must be persisted", column) + } +} + +func TestAutomationSchemaIncludesTargetModeColumns(t *testing.T) { + store := setupTestStore(t) + + for _, column := range []string{"task_mode", "repository_mode"} { + var count int + require.NoError(t, store.db.Get(&count, + `SELECT COUNT(*) FROM pragma_table_info('automations') WHERE name = ?`, column)) + require.Equal(t, 1, count, "automations.%s must be persisted", column) + } +} + +func TestCountActiveRunsIncludesAdmittedTriggeredRuns(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + a := &Automation{WorkspaceID: "ws-1", Name: "A", Enabled: true} + require.NoError(t, store.CreateAutomation(ctx, a)) + require.NoError(t, store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTriggered, + TriggerData: json.RawMessage(`{}`), + })) + + count, err := store.CountActiveRuns(ctx, a.ID) + + require.NoError(t, err) + require.Equal(t, 1, count) +} + +func TestAutomationContinuationPolicyDefaultsAndRoundTrips(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + + defaulted := &Automation{WorkspaceID: "ws-1", Name: "default", Enabled: true} + require.NoError(t, store.CreateAutomation(ctx, defaulted)) + got, err := store.GetAutomation(ctx, defaulted.ID) + require.NoError(t, err) + require.Equal(t, ContinuationPolicyNewTask, got.ContinuationPolicy) + + reused := &Automation{ + WorkspaceID: "ws-1", Name: "reused", Enabled: true, + MaxConcurrentRuns: 1, ContinuationPolicy: ContinuationPolicyReuseThread, + } + require.NoError(t, store.CreateAutomation(ctx, reused)) + got, err = store.GetAutomation(ctx, reused.ID) + require.NoError(t, err) + require.Equal(t, ContinuationPolicyReuseThread, got.ContinuationPolicy) + + reused.ContinuationTaskID = "task-1" + _, err = store.db.Exec(`UPDATE automations SET continuation_task_id = ? WHERE id = ?`, + reused.ContinuationTaskID, reused.ID) + require.NoError(t, err) + got, err = store.GetAutomation(ctx, reused.ID) + require.NoError(t, err) + require.Equal(t, "task-1", got.ContinuationTaskID) + + encoded, err := json.Marshal(got) + require.NoError(t, err) + require.NotContains(t, string(encoded), "continuation_task_id") +} + +func TestAutomationTargetModesPersistAndValidate(t *testing.T) { + svc := newTestService(t) + svc.SetRepositoryLookup(&fakeRepositoryLookup{repos: map[string]string{"repo-1": "ws-1"}}) + ctx := context.Background() + + hidden, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", + Name: "scratch", + TaskMode: TaskModeAutomationRun, + RepositoryMode: RepositoryModeNone, + }) + require.NoError(t, err) + require.Equal(t, TaskModeAutomationRun, hidden.TaskMode) + require.Equal(t, RepositoryModeNone, hidden.RepositoryMode) + + normal, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", + Name: "visible", + TaskMode: TaskModeNormalTask, + WorkflowID: "workflow-1", + RepositoryMode: RepositoryModeNone, + }) + require.NoError(t, err) + require.Equal(t, TaskModeNormalTask, normal.TaskMode) + require.Equal(t, RepositoryModeNone, normal.RepositoryMode) + + _, err = svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", + Name: "missing workflow", + TaskMode: TaskModeNormalTask, + }) + require.ErrorIs(t, err, ErrWorkflowRequired) + + got, err := svc.GetAutomation(ctx, normal.ID) + require.NoError(t, err) + require.Equal(t, TaskModeNormalTask, got.TaskMode) + require.Equal(t, RepositoryModeNone, got.RepositoryMode) + + selected, err := svc.CreateAutomation(ctx, &CreateAutomationRequest{ + WorkspaceID: "ws-1", + Name: "selected", + RepositoryMode: RepositoryModeSelected, + RepositoryIDs: []string{"repo-1"}, + }) + require.NoError(t, err) + mode := RepositoryModeNone + updated, err := svc.UpdateAutomation(ctx, selected.ID, &UpdateAutomationRequest{RepositoryMode: &mode}) + require.NoError(t, err) + require.Equal(t, RepositoryModeNone, updated.RepositoryMode) + require.Empty(t, updated.RepositoryIDs) +} + +func TestRepositoryFreeTargetAcceptsWorktreeExecutor(t *testing.T) { + svc := newTestService(t) + + created, err := svc.CreateAutomation(context.Background(), &CreateAutomationRequest{ + WorkspaceID: "ws-1", + Name: "worktree scratch", + ExecutorProfileID: "worktree-profile", + RepositoryMode: RepositoryModeNone, + }) + require.NoError(t, err) + require.Equal(t, RepositoryModeNone, created.RepositoryMode) +} + +func TestRepositoryFreeTargetDoesNotSelectWorkspaceRepositoryForTrigger(t *testing.T) { + svc := newTestService(t) + _, err := svc.CreateAutomation(context.Background(), &CreateAutomationRequest{ + WorkspaceID: "ws-1", + Name: "pr trigger", + RepositoryMode: RepositoryModeNone, + Triggers: []CreateTriggerSpec{{Type: TriggerTypeGitHubPR, Config: json.RawMessage(`{}`)}}, + }) + require.NoError(t, err) + + _, err = svc.CreateAutomation(context.Background(), &CreateAutomationRequest{ + WorkspaceID: "ws-1", + Name: "scheduled scratch", + RepositoryMode: RepositoryModeNone, + Triggers: []CreateTriggerSpec{{Type: TriggerTypeScheduled, Config: json.RawMessage(`{}`)}}, + }) + require.NoError(t, err) +} + +func TestAutomationRunPersistsExactBindingAndThreadMetadata(t *testing.T) { + store := setupTestStore(t) + run := &AutomationRun{ + AutomationID: "automation-1", TriggerID: "trigger-1", TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, TaskID: "task-1", SessionID: "session-1", TurnID: "turn-1", + ThreadAction: ThreadActionResumed, ThreadReason: "", DisplayTitle: "Nightly report", + TriggerData: json.RawMessage(`{"ok":true}`), + } + require.NoError(t, store.CreateRun(context.Background(), run)) + got, err := store.GetRun(context.Background(), run.ID) + require.NoError(t, err) + require.Equal(t, run.SessionID, got.SessionID) + require.Equal(t, run.TurnID, got.TurnID) + require.Equal(t, run.ThreadAction, got.ThreadAction) + require.Equal(t, run.DisplayTitle, got.DisplayTitle) +} + +func TestAutomationRunBindsAndSettlesExactTurn(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + a := &Automation{WorkspaceID: "ws-1", Name: "exact", MaxConcurrentRuns: 1} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + run := &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTriggered, + TriggerData: json.RawMessage(`{}`), + } + if err := store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + if err := store.BindRunTask(ctx, run.ID, "task-shared"); err != nil { + t.Fatal(err) + } + if err := store.BindRun(ctx, run.ID, "task-shared", "session-shared", "turn-1", ThreadActionResumed, "reused continuation"); err != nil { + t.Fatal(err) + } + if err := store.MarkRunTerminalByBinding(ctx, "task-shared", "session-shared", "turn-1", RunStatusSucceeded, ""); err != nil { + t.Fatal(err) + } + + got, err := store.GetRun(ctx, run.ID) + if err != nil { + t.Fatal(err) + } + if got.Status != RunStatusSucceeded || got.TaskID != "task-shared" || got.SessionID != "session-shared" || got.TurnID != "turn-1" { + t.Fatalf("settled exact run = %+v", got) + } + if got.ThreadAction != ThreadActionResumed || got.ThreadReason != "reused continuation" { + t.Fatalf("thread metadata = action %q reason %q", got.ThreadAction, got.ThreadReason) + } +} + +func TestAutomationRunTerminalBindingDoesNotSettleAnotherTurn(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + a := &Automation{WorkspaceID: "ws-1", Name: "exact", MaxConcurrentRuns: 1} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + for _, turnID := range []string{"turn-1", "turn-2"} { + run := &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusTaskCreated, + TaskID: "task-shared", + SessionID: "session-shared", + TurnID: turnID, + TriggerData: json.RawMessage(`{}`), + } + if err := store.CreateRun(ctx, run); err != nil { + t.Fatal(err) + } + } + if err := store.MarkRunTerminalByBinding(ctx, "task-shared", "session-shared", "turn-1", RunStatusFailed, "first failed"); err != nil { + t.Fatal(err) + } + runs, err := store.ListRuns(ctx, a.ID, 10) + if err != nil { + t.Fatal(err) + } + for _, run := range runs { + if run.TurnID == "turn-1" && run.Status != RunStatusFailed { + t.Errorf("turn-1 status = %q, want failed", run.Status) + } + if run.TurnID == "turn-2" && run.Status != RunStatusTaskCreated { + t.Errorf("turn-2 status = %q, want task_created", run.Status) + } + } +} + +func TestReuseThreadRequiresSingleConcurrencySlot(t *testing.T) { + svc := newTestService(t) + + _, err := svc.CreateAutomation(context.Background(), &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "invalid", ContinuationPolicy: ContinuationPolicyReuseThread, + MaxConcurrentRuns: 2, + }) + require.ErrorContains(t, err, "reuse_thread requires max_concurrent_runs = 1") + + a, err := svc.CreateAutomation(context.Background(), &CreateAutomationRequest{ + WorkspaceID: "ws-1", Name: "valid", ContinuationPolicy: ContinuationPolicyReuseThread, + MaxConcurrentRuns: 1, + }) + require.NoError(t, err) + tooMany := 2 + _, err = svc.UpdateAutomation(context.Background(), a.ID, &UpdateAutomationRequest{ + MaxConcurrentRuns: &tooMany, + }) + require.ErrorContains(t, err, "reuse_thread requires max_concurrent_runs = 1") +} + +func TestReuseThreadNormalizesNonPositiveConcurrencyOnUpdate(t *testing.T) { + svc := newTestService(t) + a, err := svc.CreateAutomation(context.Background(), &CreateAutomationRequest{ + WorkspaceID: "ws-1", + Name: "normalize", + MaxConcurrentRuns: 2, + }) + require.NoError(t, err) + + policy := ContinuationPolicyReuseThread + zero := 0 + updated, err := svc.UpdateAutomation(context.Background(), a.ID, &UpdateAutomationRequest{ + ContinuationPolicy: &policy, + MaxConcurrentRuns: &zero, + }) + require.NoError(t, err) + require.Equal(t, ContinuationPolicyReuseThread, updated.ContinuationPolicy) + require.Equal(t, 1, updated.MaxConcurrentRuns) +} + +func TestStoreUpdateNormalizesNonPositiveReuseConcurrency(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + a := &Automation{WorkspaceID: "ws-1", Name: "normalize-store", Enabled: true, MaxConcurrentRuns: 2} + require.NoError(t, store.CreateAutomation(ctx, a)) + + policy := ContinuationPolicyReuseThread + zero := 0 + require.NoError(t, store.UpdateAutomation(ctx, a.ID, &UpdateAutomationRequest{ + ContinuationPolicy: &policy, + MaxConcurrentRuns: &zero, + })) + got, err := store.GetAutomation(ctx, a.ID) + require.NoError(t, err) + require.Equal(t, 1, got.MaxConcurrentRuns) +} + +func TestDispatchRunBindsExactIdentityAndRejectsStoppedAdmission(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + a := &Automation{WorkspaceID: "ws-1", Name: "dispatch", Enabled: true, MaxConcurrentRuns: 1} + require.NoError(t, svc.store.CreateAutomation(ctx, a)) + + run := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTriggered} + require.NoError(t, svc.store.CreateRun(ctx, run)) + called := false + require.NoError(t, svc.DispatchRun(ctx, run.ID, ThreadActionCreated, "created", func() (RunDispatch, error) { + called = true + return RunDispatch{TaskID: "task-1", SessionID: "session-1", TurnID: "turn-1"}, nil + })) + got, err := svc.store.GetRun(ctx, run.ID) + require.NoError(t, err) + require.True(t, called) + require.Equal(t, RunStatusTaskCreated, got.Status) + require.Equal(t, "turn-1", got.TurnID) + + stopped := &AutomationRun{AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTriggered} + require.NoError(t, svc.store.CreateRun(ctx, stopped)) + require.NoError(t, svc.store.MarkRunTerminal(ctx, stopped.ID, "", "", RunStatusFailed, "stopped")) + called = false + err = svc.DispatchRun(ctx, stopped.ID, ThreadActionCreated, "created", func() (RunDispatch, error) { + called = true + return RunDispatch{TaskID: "task-2", SessionID: "session-2", TurnID: "turn-2"}, nil + }) + require.ErrorIs(t, err, ErrAutomationRunNotDispatchable) + require.False(t, called) +} diff --git a/apps/backend/internal/automation/export.go b/apps/backend/internal/automation/export.go index e971d910da..3697f39e0d 100644 --- a/apps/backend/internal/automation/export.go +++ b/apps/backend/internal/automation/export.go @@ -20,7 +20,8 @@ type exportDocument struct { } // exportAutomation is a single automation's exported form. Key order is pinned by -// AC-40: name, description, enabled, max_concurrent_runs, task_title_template, prompt, +// AC-40: name, description, enabled, max_concurrent_runs, continuation_policy, +// task_mode, repository_mode, task_title_template, prompt, // agent_profile, executor_profile, workflow, repositories, triggers. // // Prompt and every trigger's Config are *yaml.Node rather than string/json.RawMessage @@ -28,17 +29,20 @@ type exportDocument struct { // cannot express: Prompt per the prompt-fidelity rules (AC-15/16/17/46/47/49), Config // per the per-JSON-type node table (AC-8, AC-41). type exportAutomation struct { - Name string `yaml:"name"` - Description string `yaml:"description,omitempty"` - Enabled bool `yaml:"enabled"` - MaxConcurrentRuns int `yaml:"max_concurrent_runs"` - TaskTitleTemplate string `yaml:"task_title_template,omitempty"` - Prompt *yaml.Node `yaml:"prompt,omitempty"` - AgentProfile *exportAgentProfile `yaml:"agent_profile,omitempty"` - ExecutorProfile *exportExecutorProfile `yaml:"executor_profile,omitempty"` - Workflow *exportWorkflow `yaml:"workflow,omitempty"` - Repositories []string `yaml:"repositories,omitempty"` - Triggers []exportTrigger `yaml:"triggers"` + Name string `yaml:"name"` + Description string `yaml:"description,omitempty"` + Enabled bool `yaml:"enabled"` + MaxConcurrentRuns int `yaml:"max_concurrent_runs"` + ContinuationPolicy ContinuationPolicy `yaml:"continuation_policy"` + TaskMode TaskMode `yaml:"task_mode"` + RepositoryMode RepositoryMode `yaml:"repository_mode"` + TaskTitleTemplate string `yaml:"task_title_template,omitempty"` + Prompt *yaml.Node `yaml:"prompt,omitempty"` + AgentProfile *exportAgentProfile `yaml:"agent_profile,omitempty"` + ExecutorProfile *exportExecutorProfile `yaml:"executor_profile,omitempty"` + Workflow *exportWorkflow `yaml:"workflow,omitempty"` + Repositories []string `yaml:"repositories,omitempty"` + Triggers []exportTrigger `yaml:"triggers"` } // exportAgentProfile is the portable {agent_name, model, mode} descriptor resolved diff --git a/apps/backend/internal/automation/export_ac22_test.go b/apps/backend/internal/automation/export_ac22_test.go index b6014ae8bc..44f6029655 100644 --- a/apps/backend/internal/automation/export_ac22_test.go +++ b/apps/backend/internal/automation/export_ac22_test.go @@ -20,18 +20,23 @@ type fieldDisposition struct { } var automationFieldDispositions = map[string]fieldDisposition{ - "Name": {exported: true, yamlKey: "name"}, - "Description": {exported: true, yamlKey: "description"}, - "Prompt": {exported: true, yamlKey: "prompt"}, - "TaskTitleTemplate": {exported: true, yamlKey: "task_title_template"}, - "Enabled": {exported: true, yamlKey: "enabled"}, - "MaxConcurrentRuns": {exported: true, yamlKey: "max_concurrent_runs"}, - "Triggers": {exported: true, yamlKey: "triggers"}, - "WorkflowID": {exported: true, yamlKey: "workflow.name"}, - "WorkflowStepID": {exported: true, yamlKey: "workflow.step"}, - "AgentProfileID": {exported: true, yamlKey: "agent_profile"}, - "ExecutorProfileID": {exported: true, yamlKey: "executor_profile"}, - "RepositoryIDs": {exported: true, yamlKey: "repositories"}, + "Name": {exported: true, yamlKey: "name"}, + "Description": {exported: true, yamlKey: "description"}, + "Prompt": {exported: true, yamlKey: "prompt"}, + "TaskTitleTemplate": {exported: true, yamlKey: "task_title_template"}, + "Enabled": {exported: true, yamlKey: "enabled"}, + "MaxConcurrentRuns": {exported: true, yamlKey: "max_concurrent_runs"}, + "ContinuationPolicy": {exported: true, yamlKey: "continuation_policy"}, + "TaskMode": {exported: true, yamlKey: "task_mode"}, + "RepositoryMode": {exported: true, yamlKey: "repository_mode"}, + "Triggers": {exported: true, yamlKey: "triggers"}, + "WorkflowID": {exported: true, yamlKey: "workflow.name"}, + "WorkflowStepID": {exported: true, yamlKey: "workflow.step"}, + "AgentProfileID": {exported: true, yamlKey: "agent_profile"}, + "ExecutorProfileID": {exported: true, yamlKey: "executor_profile"}, + "RepositoryIDs": {exported: true, yamlKey: "repositories"}, + "Repositories": {exported: false}, + "ContinuationTaskID": {exported: false}, // runtime pointer to the reused task "WebhookSecret": {exported: false}, // secret "ID": {exported: false}, // instance identity diff --git a/apps/backend/internal/automation/export_document_test.go b/apps/backend/internal/automation/export_document_test.go index 25050fb330..1b855af340 100644 --- a/apps/backend/internal/automation/export_document_test.go +++ b/apps/backend/internal/automation/export_document_test.go @@ -15,16 +15,19 @@ func fullyPopulatedExportAutomation(t *testing.T) exportAutomation { } promptNode := &yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: "Do the thing"} return exportAutomation{ - Name: "Daily Review", - Description: "Runs every morning", - Enabled: true, - MaxConcurrentRuns: 1, - TaskTitleTemplate: "Daily Review ({{trigger.timestamp}})", - Prompt: promptNode, - AgentProfile: &exportAgentProfile{AgentName: "Claude Code", Model: "opus[1m]", Mode: "auto"}, - ExecutorProfile: &exportExecutorProfile{Executor: "exec-worktree", Name: "Worktree"}, - Workflow: &exportWorkflow{Name: "Kanban", Step: "In Progress"}, - Repositories: []string{"kegmil-offline-first"}, + Name: "Daily Review", + Description: "Runs every morning", + Enabled: true, + MaxConcurrentRuns: 1, + ContinuationPolicy: ContinuationPolicyReuseThread, + TaskMode: TaskModeNormalTask, + RepositoryMode: RepositoryModeSelected, + TaskTitleTemplate: "Daily Review ({{trigger.timestamp}})", + Prompt: promptNode, + AgentProfile: &exportAgentProfile{AgentName: "Claude Code", Model: "opus[1m]", Mode: "auto"}, + ExecutorProfile: &exportExecutorProfile{Executor: "exec-worktree", Name: "Worktree"}, + Workflow: &exportWorkflow{Name: "Kanban", Step: "In Progress"}, + Repositories: []string{"kegmil-offline-first"}, Triggers: []exportTrigger{ {Type: "scheduled", Enabled: true, Config: configNode}, }, @@ -33,7 +36,8 @@ func fullyPopulatedExportAutomation(t *testing.T) exportAutomation { // AC-40: top-level keys in fixed order version, type, automations, warnings (warnings // omitted here since empty); automation keys in fixed order name, description, -// enabled, max_concurrent_runs, task_title_template, prompt, agent_profile, +// enabled, max_concurrent_runs, continuation_policy, task_mode, repository_mode, +// task_title_template, prompt, agent_profile, // executor_profile, workflow, repositories, triggers; trigger keys in fixed order // type, enabled, config. func TestMarshalExportDocument_AC40KeyOrder(t *testing.T) { @@ -51,6 +55,9 @@ func TestMarshalExportDocument_AC40KeyOrder(t *testing.T) { "description:", "enabled:", "max_concurrent_runs:", + "continuation_policy:", + "task_mode:", + "repository_mode:", "task_title_template:", "prompt:", "agent_profile:", @@ -83,10 +90,11 @@ func TestMarshalExportDocument_WarningsKeyPresentWhenNonEmpty(t *testing.T) { // AC-4: keys omitted when empty are skipped without disturbing the order of the rest. func TestMarshalExportDocument_OmitsEmptyOptionalFields(t *testing.T) { doc := newExportDocument([]exportAutomation{{ - Name: "Minimal", - Enabled: false, - MaxConcurrentRuns: 1, - Triggers: []exportTrigger{}, + Name: "Minimal", + Enabled: false, + MaxConcurrentRuns: 1, + ContinuationPolicy: ContinuationPolicyNewTask, + Triggers: []exportTrigger{}, }}, nil) out, err := marshalExportDocument(doc) if err != nil { @@ -98,7 +106,7 @@ func TestMarshalExportDocument_OmitsEmptyOptionalFields(t *testing.T) { t.Errorf("expected %q omitted for empty field, got:\n%s", absent, s) } } - assertKeysAppearInOrder(t, s, []string{"name:", "enabled:", "max_concurrent_runs:", "triggers:"}) + assertKeysAppearInOrder(t, s, []string{"name:", "enabled:", "max_concurrent_runs:", "continuation_policy:", "task_mode:", "repository_mode:", "triggers:"}) } // AC-12: indentation is 2 spaces per nesting level, not yaml.v3's package-level diff --git a/apps/backend/internal/automation/export_resolve.go b/apps/backend/internal/automation/export_resolve.go index 38cf9e749d..cc67bf7b97 100644 --- a/apps/backend/internal/automation/export_resolve.go +++ b/apps/backend/internal/automation/export_resolve.go @@ -183,7 +183,7 @@ func (s *Service) resolveWorkflow(ctx context.Context, tx *sqlx.Tx, a *Automatio // member warns by its 0-based position, since the export carries no UUID to // name it by (AC-18). func (s *Service) resolveRepositories(ctx context.Context, tx *sqlx.Tx, a *Automation) ([]string, []string, error) { - if len(a.RepositoryIDs) == 0 { + if a.RepositoryMode == RepositoryModeNone || len(a.RepositoryIDs) == 0 { return nil, nil, nil } if s.exportRepositoryLookup == nil { diff --git a/apps/backend/internal/automation/export_resolve_test.go b/apps/backend/internal/automation/export_resolve_test.go index c2ce07a5af..040097702a 100644 --- a/apps/backend/internal/automation/export_resolve_test.go +++ b/apps/backend/internal/automation/export_resolve_test.go @@ -410,6 +410,22 @@ func TestResolveDescriptors_RepositoriesAllUnresolved_EmptyList(t *testing.T) { } } +func TestResolveDescriptors_RepositoryFreeModeOmitsRepositoryReferences(t *testing.T) { + svc := newTestService(t) + tx := beginTestReadTx(t, svc) + + got, warnings, err := svc.resolveDescriptors(context.Background(), tx, &Automation{ + ID: "a1", Name: "scratch", RepositoryMode: RepositoryModeNone, + RepositoryIDs: []string{"stale-repository-id"}, + }) + if err != nil { + t.Fatalf("resolveDescriptors: %v", err) + } + if len(got.Repositories) != 0 || len(warnings) != 0 { + t.Fatalf("repository-free export resolved references: repositories=%v warnings=%v", got.Repositories, warnings) + } +} + func TestResolveDescriptors_LookupNotWired_FailsClosed(t *testing.T) { svc := newTestService(t) tx := beginTestReadTx(t, svc) diff --git a/apps/backend/internal/automation/export_service.go b/apps/backend/internal/automation/export_service.go index c7c26f1b3d..2d6d16843f 100644 --- a/apps/backend/internal/automation/export_service.go +++ b/apps/backend/internal/automation/export_service.go @@ -137,6 +137,27 @@ func (s *Service) buildExportAutomation(ctx context.Context, tx *sqlx.Tx, a *Aut Description: a.Description, Enabled: a.Enabled, MaxConcurrentRuns: a.MaxConcurrentRuns, + ContinuationPolicy: func() ContinuationPolicy { + if a.ContinuationPolicy == "" { + return ContinuationPolicyNewTask + } + return a.ContinuationPolicy + }(), + TaskMode: func() TaskMode { + if a.TaskMode == "" { + return TaskModeAutomationRun + } + return a.TaskMode + }(), + RepositoryMode: func() RepositoryMode { + if a.RepositoryMode == "" { + if len(a.RepositoryIDs) > 0 { + return RepositoryModeSelected + } + return RepositoryModeNone + } + return a.RepositoryMode + }(), TaskTitleTemplate: a.TaskTitleTemplate, Prompt: promptNode, AgentProfile: resolved.AgentProfile, diff --git a/apps/backend/internal/automation/fire_trigger_publish_test.go b/apps/backend/internal/automation/fire_trigger_publish_test.go new file mode 100644 index 0000000000..531c6b688f --- /dev/null +++ b/apps/backend/internal/automation/fire_trigger_publish_test.go @@ -0,0 +1,139 @@ +package automation + +import ( + "context" + "encoding/json" + "errors" + "strings" + "testing" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events/bus" +) + +type toggledPublishBus struct { + delegate *bus.MemoryEventBus + publishErr error +} + +func (b *toggledPublishBus) Publish(ctx context.Context, subject string, event *bus.Event) error { + if b.publishErr != nil { + return b.publishErr + } + return b.delegate.Publish(ctx, subject, event) +} + +func (b *toggledPublishBus) Subscribe(subject string, handler bus.EventHandler) (bus.Subscription, error) { + return b.delegate.Subscribe(subject, handler) +} + +func (b *toggledPublishBus) QueueSubscribe(subject, queue string, handler bus.EventHandler) (bus.Subscription, error) { + return b.delegate.QueueSubscribe(subject, queue, handler) +} + +func (b *toggledPublishBus) Request(ctx context.Context, subject string, event *bus.Event, timeout time.Duration) (*bus.Event, error) { + return b.delegate.Request(ctx, subject, event, timeout) +} + +func (b *toggledPublishBus) Close() { + b.delegate.Close() +} + +func (b *toggledPublishBus) IsConnected() bool { + return b.delegate.IsConnected() +} + +func TestFireTrigger_PublishFailureFailsRunAndReleasesSlot(t *testing.T) { + ctx := context.Background() + store := setupTestStore(t) + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatal(err) + } + delegate := bus.NewMemoryEventBus(log) + publishErr := errors.New("publish unavailable") + eventBus := &toggledPublishBus{delegate: delegate, publishErr: publishErr} + service := NewService(store, eventBus, log) + + automation := &Automation{ + WorkspaceID: "ws-1", + Name: "Reusable automation", + Enabled: true, + MaxConcurrentRuns: 1, + ContinuationPolicy: ContinuationPolicyReuseThread, + } + if err := store.CreateAutomation(ctx, automation); err != nil { + t.Fatal(err) + } + trigger := &AutomationTrigger{ + AutomationID: automation.ID, + Type: TriggerTypeScheduled, + Config: json.RawMessage(`{"expression":"* * * * *"}`), + Enabled: true, + } + if err := store.CreateTrigger(ctx, trigger); err != nil { + t.Fatal(err) + } + + if _, err := service.FireTrigger(ctx, automation.ID, trigger.ID, trigger.Type, json.RawMessage(`{}`), "first"); err == nil { + t.Fatal("expected publish failure") + } else if !strings.Contains(err.Error(), publishErr.Error()) { + t.Fatalf("publish error = %q, want %q", err, publishErr) + } + + runs, err := store.ListRuns(ctx, automation.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(runs) != 1 { + t.Fatalf("got %d runs after failed publish, want 1", len(runs)) + } + if runs[0].Status != RunStatusFailed { + t.Fatalf("failed publish run status = %q, want failed", runs[0].Status) + } + if !strings.Contains(runs[0].ErrorMessage, publishErr.Error()) { + t.Fatalf("failed publish run error = %q, want %q", runs[0].ErrorMessage, publishErr) + } + current, err := store.GetAutomation(ctx, automation.ID) + if err != nil { + t.Fatal(err) + } + if current.LastTriggeredAt != nil { + t.Fatalf("last_triggered_at = %v after failed publish, want nil", current.LastTriggeredAt) + } + + eventBus.publishErr = nil + second, err := service.FireTrigger(ctx, automation.ID, trigger.ID, trigger.Type, json.RawMessage(`{}`), "second") + if err != nil { + t.Fatalf("second firing: %v", err) + } + if second.Skipped { + t.Fatalf("second firing was skipped: %s", second.Reason) + } + if second.RunID == "" { + t.Fatal("second firing did not return a run ID") + } + current, err = store.GetAutomation(ctx, automation.ID) + if err != nil { + t.Fatal(err) + } + if current.LastTriggeredAt == nil { + t.Fatal("last_triggered_at is nil after successful publish") + } + + runs, err = store.ListRuns(ctx, automation.ID, 10) + if err != nil { + t.Fatal(err) + } + if len(runs) != 2 { + t.Fatalf("got %d runs after retry, want 2", len(runs)) + } + for _, run := range runs { + if run.ID == second.RunID && run.Status != RunStatusTriggered { + t.Fatalf("retry run status = %q, want triggered", run.Status) + } + } +} diff --git a/apps/backend/internal/automation/handlers.go b/apps/backend/internal/automation/handlers.go index 444a63ead4..fc31890b51 100644 --- a/apps/backend/internal/automation/handlers.go +++ b/apps/backend/internal/automation/handlers.go @@ -3,6 +3,7 @@ package automation import ( "context" "encoding/json" + "errors" "github.com/gin-gonic/gin" @@ -35,6 +36,7 @@ func registerWSHandlers(dispatcher *ws.Dispatcher, svc *Service, log *logger.Log dispatcher.RegisterFunc(ws.ActionAutomationTriggerTypes, wsTriggerTypes()) dispatcher.RegisterFunc(ws.ActionAutomationWebhookRevealSecret, wsRevealWebhookSecret(svc, log)) dispatcher.RegisterFunc(ws.ActionAutomationRunDelete, wsDeleteRun(svc, log)) + dispatcher.RegisterFunc(ws.ActionAutomationRunStop, wsStopRun(svc, log)) dispatcher.RegisterFunc(ws.ActionAutomationRunsDeleteAll, wsDeleteAllRuns(svc, log)) } @@ -413,6 +415,28 @@ func wsDeleteRun(svc *Service, _ *logger.Logger) func(ctx context.Context, msg * } } +func wsStopRun(svc *Service, _ *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) + runID, _ := payload["run_id"].(string) + if automationID == "" || runID == "" { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "automation_id and run_id required", nil) + } + run, err := svc.StopRun(ctx, automationID, runID) + if err != nil { + if errors.Is(err, ErrAutomationNotFound) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "automation run not found", nil) + } + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]any{ + "run_id": run.ID, + "status": run.Status, + }) + } +} + func wsDeleteAllRuns(svc *Service, _ *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) diff --git a/apps/backend/internal/automation/models.go b/apps/backend/internal/automation/models.go index f1a2e4ec1a..23827f33f6 100644 --- a/apps/backend/internal/automation/models.go +++ b/apps/backend/internal/automation/models.go @@ -70,26 +70,78 @@ const ( RunStatusCancelled RunStatus = "cancelled" ) +// ContinuationPolicy controls whether a firing receives an isolated task or +// continues the automation's durable thread. +type ContinuationPolicy string + +const ( + ContinuationPolicyNewTask ContinuationPolicy = "new_task" + ContinuationPolicyReuseThread ContinuationPolicy = "reuse_thread" +) + +// TaskMode controls whether an automation firing owns a coordinator-only +// task or a normal user-visible task. +type TaskMode string + +const ( + TaskModeAutomationRun TaskMode = "automation_run" + TaskModeNormalTask TaskMode = "normal_task" +) + +// RepositoryMode controls how a firing chooses its repository environment. +type RepositoryMode string + +const ( + RepositoryModeWorkspaceDefault RepositoryMode = "workspace_default" + RepositoryModeSelected RepositoryMode = "selected" + RepositoryModeNone RepositoryMode = "none" +) + +// AutomationRepository is one exact repository environment selected for an +// automation. BaseBranch is persisted with the repository so dispatch never +// depends on workspace repository ordering or a later default-branch change. +type AutomationRepository struct { + RepositoryID string `json:"repository_id" db:"repository_id"` + BaseBranch string `json:"base_branch" db:"base_branch"` +} + +// ThreadAction describes how a dispatched run reached its task/session. +type ThreadAction string + +const ( + ThreadActionCreated ThreadAction = "created" + ThreadActionResumed ThreadAction = "resumed" + ThreadActionReplaced ThreadAction = "replaced" +) + // 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 / 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"` + // TaskModeAutomationRun is coordinator-only and may omit a workflow. A + // TaskModeNormalTask must name a workflow so the generated task enters the + // normal task lifecycle and appears in the Kanban/sidebar. + TaskMode TaskMode `json:"task_mode" db:"task_mode"` + RepositoryMode RepositoryMode `json:"repository_mode" db:"repository_mode"` + 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"` + ContinuationPolicy ContinuationPolicy `json:"continuation_policy" db:"continuation_policy"` + // ContinuationTaskID is runtime state. It is intentionally omitted from + // the public automation JSON because the saved task is not portable + // configuration and may be deleted or replaced by the server. + ContinuationTaskID string `json:"-" db:"continuation_task_id"` + 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 @@ -107,12 +159,11 @@ type Automation struct { // Hydrated separately, not stored as columns on this table. Triggers []AutomationTrigger `json:"triggers" db:"-"` - // RepositoryIDs is the ordered list of repositories to use for trigger - // firings, backed by the automation_repositories join table. Empty - // falls back to the workspace's first repository (for scheduled / - // webhook triggers) or the PR's repository (for github_pr triggers, - // which always override this field — see resolveAutomationRepository). - RepositoryIDs []string `json:"repository_ids" db:"-"` + // Repositories is the canonical ordered repository/base-branch selection. + // An empty list means task-owned scratch execution. RepositoryIDs remains a + // response compatibility projection for older clients. + Repositories []AutomationRepository `json:"repositories" db:"-"` + RepositoryIDs []string `json:"repository_ids" db:"-"` } // AutomationTrigger is a single trigger attached to an automation. @@ -143,15 +194,19 @@ type AutomationRun struct { 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. + // read at list time and truncated for display. Hidden automation-run tasks + // stay out of the board, while normal-task automation tasks follow the + // ordinary task lists. The summary keeps the run row useful in either mode + // when the task is not open. 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"` + SessionID string `json:"session_id,omitempty" db:"session_id"` + TurnID string `json:"turn_id,omitempty" db:"turn_id"` + ThreadAction ThreadAction `json:"thread_action,omitempty" db:"thread_action"` + ThreadReason string `json:"thread_reason,omitempty" db:"thread_reason"` + DisplayTitle string `json:"display_title,omitempty" db:"display_title"` } // WorkspaceAutomationRun is a run carrying just enough of its owning @@ -224,10 +279,12 @@ type WebhookTriggerConfig struct { FilterExpression string `json:"filter_expression,omitempty"` } -// TaskOriginLookup answers the two facts a merged-PR event needs about the -// task its PR was linked to. ok=false means the task could not be resolved -// (absent or query error — the adapter collapses both into ok=false and logs -// at the appropriate level). +// TaskOriginLookup answers the task workspace and whether it is hidden +// automation-run work. The merged-PR subscriber uses the same facts to avoid +// loops, while run cleanup uses them to leave visible automation-created tasks +// in the normal task lifecycle. ok=false means the task could not be resolved +// (absent or query error; the adapter collapses both into ok=false and logs at +// the appropriate level). type TaskOriginLookup interface { TaskWorkspaceAndAutomationOrigin(ctx context.Context, taskID string) ( workspaceID string, isAutomationRun bool, ok bool, @@ -238,18 +295,22 @@ type TaskOriginLookup interface { // CreateAutomationRequest is the payload for creating an automation. type CreateAutomationRequest struct { - WorkspaceID string `json:"workspace_id"` - Name string `json:"name"` - Description string `json:"description"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - AgentProfileID string `json:"agent_profile_id"` - ExecutorProfileID string `json:"executor_profile_id"` - RepositoryIDs []string `json:"repository_ids"` - Prompt string `json:"prompt"` - TaskTitleTemplate string `json:"task_title_template"` - MaxConcurrentRuns int `json:"max_concurrent_runs"` - Triggers []CreateTriggerSpec `json:"triggers"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + Description string `json:"description"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + AgentProfileID string `json:"agent_profile_id"` + ExecutorProfileID string `json:"executor_profile_id"` + Repositories []AutomationRepository `json:"repositories,omitempty"` + RepositoryIDs []string `json:"repository_ids"` + Prompt string `json:"prompt"` + TaskTitleTemplate string `json:"task_title_template"` + MaxConcurrentRuns int `json:"max_concurrent_runs"` + ContinuationPolicy ContinuationPolicy `json:"continuation_policy,omitempty"` + TaskMode TaskMode `json:"task_mode,omitempty"` + RepositoryMode RepositoryMode `json:"repository_mode,omitempty"` + Triggers []CreateTriggerSpec `json:"triggers"` } // CreateTriggerSpec defines a trigger to add during automation creation. @@ -267,13 +328,18 @@ type UpdateAutomationRequest struct { WorkflowStepID *string `json:"workflow_step_id,omitempty"` AgentProfileID *string `json:"agent_profile_id,omitempty"` ExecutorProfileID *string `json:"executor_profile_id,omitempty"` + // Repositories replaces the exact repository/base-branch list when non-nil. + Repositories []AutomationRepository `json:"repositories,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"` - 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"` + ContinuationPolicy *ContinuationPolicy `json:"continuation_policy,omitempty"` + TaskMode *TaskMode `json:"task_mode,omitempty"` + RepositoryMode *RepositoryMode `json:"repository_mode,omitempty"` } // AddTriggerRequest adds a trigger to an existing automation. @@ -307,6 +373,7 @@ type RevealWebhookSecretResponse struct { // AutomationTriggeredEvent is published when a trigger fires. type AutomationTriggeredEvent struct { + RunID string `json:"run_id"` AutomationID string `json:"automation_id"` TriggerID string `json:"trigger_id"` TriggerType TriggerType `json:"trigger_type"` diff --git a/apps/backend/internal/automation/provider.go b/apps/backend/internal/automation/provider.go index c922ea2d77..90a6f89907 100644 --- a/apps/backend/internal/automation/provider.go +++ b/apps/backend/internal/automation/provider.go @@ -4,6 +4,7 @@ import ( "context" "github.com/jmoiron/sqlx" + "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events/bus" @@ -21,6 +22,12 @@ type Components struct { // Start begins background processing (scheduler + GitHub polling + webhook subscriber + merged-PR subscriber). func (c *Components) Start(ctx context.Context) { + if err := c.Service.ReconcileOpenRuns(ctx); err != nil { + c.Service.logger.Warn("automation open-run reconciliation failed", zap.Error(err)) + } + if err := c.Service.ReconcileCleanupJobs(ctx); err != nil { + c.Service.logger.Warn("automation cleanup-job reconciliation failed", zap.Error(err)) + } c.Scheduler.Start(ctx) c.Evaluator.Start(ctx) c.WebhookSubscriber.Start(ctx) diff --git a/apps/backend/internal/automation/repository_bindings_test.go b/apps/backend/internal/automation/repository_bindings_test.go new file mode 100644 index 0000000000..8eda05a395 --- /dev/null +++ b/apps/backend/internal/automation/repository_bindings_test.go @@ -0,0 +1,92 @@ +package automation + +import ( + "context" + "testing" +) + +// @covers AC-OFFICE-AUTOMATION-TARGETS-001.9 +func TestCreateAutomationDefaultsEmptyRepositorySelectionToNone(t *testing.T) { + store := setupTestStore(t) + a := &Automation{WorkspaceID: "ws-1", Name: "Scratch", Enabled: true} + + if err := store.CreateAutomation(context.Background(), a); err != nil { + t.Fatal(err) + } + if a.RepositoryMode != RepositoryModeNone { + t.Fatalf("repository mode = %q, want %q", a.RepositoryMode, RepositoryModeNone) + } +} + +// @covers AC-OFFICE-AUTOMATION-TARGETS-001.9 +func TestValidateAutomationTargetRejectsWorkspaceDefault(t *testing.T) { + err := validateAutomationTarget(TaskModeAutomationRun, RepositoryModeWorkspaceDefault, "", nil) + if err == nil { + t.Fatal("expected workspace_default to be rejected") + } +} + +// @covers AC-OFFICE-AUTOMATION-TARGETS-001.10 +func TestCreateAutomationPersistsRepositoryBaseBranches(t *testing.T) { + store := setupTestStore(t) + a := &Automation{ + WorkspaceID: "ws-1", + Name: "Branches", + Enabled: true, + Repositories: []AutomationRepository{ + {RepositoryID: "repo-b", BaseBranch: "release/2"}, + {RepositoryID: "repo-a", BaseBranch: "main"}, + }, + } + + if err := store.CreateAutomation(context.Background(), a); err != nil { + t.Fatal(err) + } + got, err := store.GetAutomation(context.Background(), a.ID) + if err != nil { + t.Fatal(err) + } + want := []AutomationRepository{ + {RepositoryID: "repo-b", BaseBranch: "release/2"}, + {RepositoryID: "repo-a", BaseBranch: "main"}, + } + if len(got.Repositories) != len(want) { + t.Fatalf("repositories = %#v, want %#v", got.Repositories, want) + } + for i := range want { + if got.Repositories[i] != want[i] { + t.Fatalf("repositories[%d] = %#v, want %#v", i, got.Repositories[i], want[i]) + } + } +} + +// @covers AC-OFFICE-AUTOMATION-TARGETS-001.10 +func TestUpdateAutomationReplacesRepositoryBaseBranches(t *testing.T) { + store := setupTestStore(t) + a := &Automation{ + WorkspaceID: "ws-1", + Name: "Branches", + Enabled: true, + Repositories: []AutomationRepository{ + {RepositoryID: "repo-a", BaseBranch: "main"}, + }, + } + if err := store.CreateAutomation(context.Background(), a); err != nil { + t.Fatal(err) + } + + repositories := []AutomationRepository{{RepositoryID: "repo-a", BaseBranch: "release/3"}} + if err := store.UpdateAutomation(context.Background(), a.ID, &UpdateAutomationRequest{ + Repositories: repositories, + }); err != nil { + t.Fatal(err) + } + + got, err := store.GetAutomation(context.Background(), a.ID) + if err != nil { + t.Fatal(err) + } + if len(got.Repositories) != 1 || got.Repositories[0] != repositories[0] { + t.Fatalf("repositories = %#v, want %#v", got.Repositories, repositories) + } +} diff --git a/apps/backend/internal/automation/run_retention.go b/apps/backend/internal/automation/run_retention.go index c41d4b9b3a..423b870cb7 100644 --- a/apps/backend/internal/automation/run_retention.go +++ b/apps/backend/internal/automation/run_retention.go @@ -2,6 +2,7 @@ package automation import ( "context" + "strings" taskmodels "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/worktree" @@ -109,20 +110,35 @@ func (s *Store) PrunableRunTaskIDs(ctx context.Context, finalizedTaskID string, args = append(args, worktree.StatusDeleted, runWorktreeSweepWindow, keep) var taskIDs []string + // A reusable automation can have many terminal run rows for one task. The + // retention unit is the distinct task checkout, not each run row. Keep the + // newest run ordering for each task, and protect the current continuation + // even when it has already parked after a successful turn. 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 + WITH terminal_tasks AS ( + SELECT ar.automation_id, ar.task_id, + MAX(ar.created_at) AS latest_created_at, + MAX(ar.id) AS latest_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 (?, ?) + GROUP BY ar.automation_id, ar.task_id + ) + SELECT tt.task_id FROM terminal_tasks tt + WHERE NOT EXISTS ( + SELECT 1 FROM automations a + WHERE a.id = tt.automation_id AND a.continuation_task_id = tt.task_id ) - 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 (?, ?) + WHERE ts.task_id = tt.task_id AND ts.state IN (?, ?) ) - AND`+runHasLiveWorktreeSQL+` - ORDER BY `+runOrderSQL("ar")+` + AND`+strings.ReplaceAll(runHasLiveWorktreeSQL, "ar.task_id", "tt.task_id")+` + ORDER BY tt.latest_created_at DESC, tt.latest_id DESC LIMIT ? OFFSET ?`, args...) return taskIDs, err } diff --git a/apps/backend/internal/automation/scheduler_test.go b/apps/backend/internal/automation/scheduler_test.go index fdbe44d3f7..f6f24384a8 100644 --- a/apps/backend/internal/automation/scheduler_test.go +++ b/apps/backend/internal/automation/scheduler_test.go @@ -8,6 +8,8 @@ import ( "time" "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/events/bus" ) // TestFireTrigger_SkippedForConcurrencyCap_UpdatesLastEvaluatedAt guards @@ -94,6 +96,63 @@ func TestFireTrigger_SkippedForConcurrencyCap_UpdatesLastEvaluatedAt(t *testing. } } +func TestFireTriggerAdmitsRunBeforePublishing(t *testing.T) { + svc := newTestService(t) + ctx := context.Background() + a := &Automation{ + WorkspaceID: "ws-1", + Name: "Daily report", + TaskTitleTemplate: "Report: {{trigger.type}}", + WorkflowID: "wf-1", + WorkflowStepID: "s-1", + Enabled: true, + MaxConcurrentRuns: 1, + } + if err := svc.store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + trig := &AutomationTrigger{AutomationID: a.ID, Type: TriggerTypeScheduled, Config: json.RawMessage(`{}`), Enabled: true} + if err := svc.store.CreateTrigger(ctx, trig); err != nil { + t.Fatal(err) + } + + var publishedRun *AutomationRun + if _, err := svc.eventBus.Subscribe(events.AutomationTriggered, func(ctx context.Context, event *bus.Event) error { + evt, ok := event.Data.(*AutomationTriggeredEvent) + if !ok { + t.Fatalf("event data = %T, want *AutomationTriggeredEvent", event.Data) + } + if evt.RunID == "" { + t.Fatal("published automation event has no admitted run ID") + } + var err error + publishedRun, err = svc.store.GetRun(ctx, evt.RunID) + if err != nil { + t.Fatalf("load admitted run during publish: %v", err) + } + return nil + }); err != nil { + t.Fatal(err) + } + + result, err := svc.FireTrigger(ctx, a.ID, trig.ID, TriggerTypeScheduled, json.RawMessage(`{}`), "scheduled:1") + if err != nil { + t.Fatal(err) + } + if result.Skipped || result.RunID == "" { + t.Fatalf("FireTrigger result = %+v, want an admitted run", result) + } + if publishedRun == nil { + t.Fatal("event was not observed") + } + if publishedRun.Status != RunStatusTriggered { + t.Fatalf("published run status = %q, want triggered", publishedRun.Status) + } + if publishedRun.DisplayTitle != "Report: scheduled" { + t.Fatalf("display title = %q, want rendered trigger title", publishedRun.DisplayTitle) + } +} + func TestFireTrigger_MergedPRConcurrencySkipLeavesDedupKeyEmpty(t *testing.T) { svc := newTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/automation/service.go b/apps/backend/internal/automation/service.go index abab1d05a7..c6a9fbe174 100644 --- a/apps/backend/internal/automation/service.go +++ b/apps/backend/internal/automation/service.go @@ -14,6 +14,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + taskservice "github.com/kandev/kandev/internal/task/service" ) // ErrTaskNotFound is the sentinel that run-cleanup paths check to distinguish @@ -54,6 +55,66 @@ var ErrAutomationNotFound = errors.New("automation: not found") // pointed at a profile that isn't there, failing quietly on a schedule. var ErrAgentProfileNotFound = errors.New("automation: agent profile not found") +var ErrInvalidContinuationPolicy = errors.New("automation: invalid continuation policy") + +// ErrAutomationRunNotDispatchable means the run was stopped or otherwise +// settled before its agent turn could be started. The event handler must not +// launch work for this run. +var ErrAutomationRunNotDispatchable = errors.New("automation: run is not dispatchable") + +// ErrAutomationRunNotLive is returned by the orchestrator when an exact run +// binding is no longer the live turn. The service maps that outcome to the +// same not-found result as a missing or terminal run, so a stale stop request +// cannot affect a newer turn in a shared session. +var ErrAutomationRunNotLive = errors.New("automation: run is not live") + +// RunStopper cancels one exact task/session/turn binding. The bool is false +// when the binding is already terminal or stale; that is not an internal +// failure and must not cancel a successor turn. +type RunStopper interface { + StopAutomationRun(ctx context.Context, taskID, sessionID, turnID string) (bool, error) +} + +// RunLivenessChecker answers whether an exact bound turn is still live or +// blocked. Errors fail closed and leave the run open for a later retry. +type RunLivenessChecker interface { + AutomationRunLive(ctx context.Context, taskID, sessionID, turnID string) (bool, error) +} + +// RunDispatch is the exact identity returned after an agent turn is accepted. +// The service binds it while holding the automation run lock, so stopping or +// deleting an automation cannot race the task/session/turn admission. +type RunDispatch struct { + TaskID string + SessionID string + TurnID string +} + +// RunDispatcher serializes accepted-turn dispatch with exact-run stop and +// run-history deletion. +type RunDispatcher interface { + DispatchRun( + ctx context.Context, + runID string, + action ThreadAction, + reason string, + dispatch func() (RunDispatch, error), + ) error +} + +func validateContinuationSettings(policy ContinuationPolicy, maxRuns int) error { + if policy == "" { + policy = ContinuationPolicyNewTask + } + if policy != ContinuationPolicyNewTask && policy != ContinuationPolicyReuseThread { + return fmt.Errorf("%w: %q", ErrInvalidContinuationPolicy, policy) + } + if policy == ContinuationPolicyReuseThread && maxRuns != 1 { + return fmt.Errorf("reuse_thread requires max_concurrent_runs = 1") + } + return nil +} + // 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 @@ -105,6 +166,8 @@ type Service struct { eventBus bus.EventBus logger *logger.Logger taskDeleter TaskDeleter // optional; nil-safe + runStopper RunStopper // optional; wired by the orchestrator composition + runLiveness RunLivenessChecker // workflowLocator gates workflow ownership. Optional: when nil (isolated // tests) ownership is not enforced. workflowLocator WorkflowLocator @@ -117,8 +180,9 @@ type Service struct { // = validation skipped (not yet wired at startup, or an isolated test). repoLookup RepositoryLookup - // taskOriginLookup resolves a task's workspace and whether it is an - // automation run, used by the merged-PR subscriber's state machine. + // taskOriginLookup resolves a task's workspace and whether it is a hidden + // automation run. It is used by the merged-PR subscriber and by cleanup so + // visible automation-created tasks are never treated as disposable runs. // Nil = not wired; the github_pr_merged trigger type then never fires. taskOriginLookup TaskOriginLookup @@ -181,6 +245,18 @@ func (s *Service) SetTaskDeleter(d TaskDeleter) { s.taskDeleter = d } +// SetRunStopper wires exact automation-turn cancellation. It is kept as a +// narrow interface so the automation package does not depend on the +// orchestrator implementation. +func (s *Service) SetRunStopper(stopper RunStopper) { + s.runStopper = stopper +} + +// SetRunLivenessChecker wires restart reconciliation for exact bound turns. +func (s *Service) SetRunLivenessChecker(checker RunLivenessChecker) { + s.runLiveness = checker +} + // SetWorkflowLocator wires the workflow ownership check. func (s *Service) SetWorkflowLocator(l WorkflowLocator) { s.workflowLocator = l @@ -281,8 +357,8 @@ func (s *Service) SetWorkspaceAuthorizer(fn func(ctx context.Context, workspaceI } // SetTaskOriginLookup wires the task workspace/origin resolver used by the -// github_pr_merged trigger subscriber. Must be called before Start, following -// the SetWorkflowLocator precedent. +// github_pr_merged trigger subscriber and run cleanup. Must be called before +// Start, following the SetWorkflowLocator precedent. func (s *Service) SetTaskOriginLookup(l TaskOriginLookup) { s.taskOriginLookup = l } @@ -293,10 +369,10 @@ func (s *Service) TaskOriginLookup() TaskOriginLookup { } // SetRepositoryLookup wires the repository ownership validator for -// repository_ids on create/update. This is a security control (prevents a +// repository bindings on create/update. This is a security control (prevents a // crafted request attaching another workspace's repository), so an unset -// lookup fails closed for any non-empty repository_ids list rather than -// silently skipping validation — see validateRepositoryIDs. +// lookup fails closed for any non-empty repository list rather than silently +// skipping validation. func (s *Service) SetRepositoryLookup(lookup RepositoryLookup) { s.repoLookup = lookup } @@ -383,10 +459,36 @@ func (s *Service) CreateAutomation(ctx context.Context, req *CreateAutomationReq if maxRuns <= 0 { maxRuns = 1 } + continuationPolicy := req.ContinuationPolicy + if continuationPolicy == "" { + continuationPolicy = ContinuationPolicyNewTask + } + if err := validateContinuationSettings(continuationPolicy, maxRuns); err != nil { + return nil, err + } - // Workflow + step are optional for every automation: no automation run is - // placed on a board, so no automation needs a starting column. When a step - // is supplied, it must belong to the selected workflow. + taskMode := req.TaskMode + if taskMode == "" { + taskMode = TaskModeAutomationRun + } + repositories, err := s.resolveAutomationRepositories(ctx, req.WorkspaceID, req.Repositories, req.RepositoryIDs) + if err != nil { + return nil, err + } + repositoryMode := RepositoryModeNone + if len(repositories) > 0 { + repositoryMode = RepositoryModeSelected + } + if req.RepositoryMode != "" && req.RepositoryMode != repositoryMode { + return nil, fmt.Errorf("%w: %q", ErrInvalidRepositoryMode, req.RepositoryMode) + } + repositoryIDs := repositoryIDs(repositories) + if err := validateAutomationTarget(taskMode, repositoryMode, req.WorkflowID, repositoryIDs); err != nil { + return nil, err + } + // Hidden automation runs may omit a workflow. Visible normal tasks require + // one, and when a workflow is supplied its ownership and optional starting + // step are still checked. if err := s.authorizeWorkflowOwnership(ctx, req.WorkspaceID, req.WorkflowID); err != nil { return nil, err } @@ -394,21 +496,22 @@ func (s *Service) CreateAutomation(ctx context.Context, req *CreateAutomationReq return nil, err } a := &Automation{ - WorkspaceID: req.WorkspaceID, - Name: req.Name, - Description: req.Description, - WorkflowID: req.WorkflowID, - WorkflowStepID: req.WorkflowStepID, - AgentProfileID: req.AgentProfileID, - ExecutorProfileID: req.ExecutorProfileID, - RepositoryIDs: req.RepositoryIDs, - Prompt: req.Prompt, - TaskTitleTemplate: req.TaskTitleTemplate, - Enabled: true, - MaxConcurrentRuns: maxRuns, - } - if err := s.validateRepositoryIDs(ctx, req.WorkspaceID, req.RepositoryIDs); err != nil { - return nil, err + WorkspaceID: req.WorkspaceID, + Name: req.Name, + Description: req.Description, + WorkflowID: req.WorkflowID, + WorkflowStepID: req.WorkflowStepID, + AgentProfileID: req.AgentProfileID, + ExecutorProfileID: req.ExecutorProfileID, + TaskMode: taskMode, + RepositoryMode: repositoryMode, + Repositories: repositories, + RepositoryIDs: repositoryIDs, + Prompt: req.Prompt, + TaskTitleTemplate: req.TaskTitleTemplate, + Enabled: true, + MaxConcurrentRuns: maxRuns, + ContinuationPolicy: continuationPolicy, } if err := s.validateAgentProfileID(ctx, req.AgentProfileID); err != nil { return nil, err @@ -477,7 +580,82 @@ func (s *Service) UpdateAutomation(ctx context.Context, id string, req *UpdateAu if err := s.authorizeUpdatedReferences(ctx, id, req); err != nil { return nil, err } - if err := s.store.UpdateAutomation(ctx, id, req); err != nil { + existing, err := s.store.GetAutomation(ctx, id) + if err != nil { + return nil, err + } + if existing == nil { + return nil, ErrAutomationNotFound + } + taskMode := existing.TaskMode + if taskMode == "" { + taskMode = TaskModeAutomationRun + } + if req.TaskMode != nil { + taskMode = *req.TaskMode + } + repositories := existing.Repositories + if req.Repositories != nil || req.RepositoryIDs != nil { + repositories, err = s.resolveAutomationRepositories( + ctx, existing.WorkspaceID, req.Repositories, req.RepositoryIDs, + ) + if err != nil { + return nil, err + } + } + if req.RepositoryMode != nil { + switch *req.RepositoryMode { + case RepositoryModeNone: + repositories = nil + case RepositoryModeWorkspaceDefault: + return nil, fmt.Errorf("%w: %q", ErrInvalidRepositoryMode, *req.RepositoryMode) + } + } + repositoryMode := RepositoryModeNone + if len(repositories) > 0 { + repositoryMode = RepositoryModeSelected + } + if req.RepositoryMode != nil && *req.RepositoryMode != repositoryMode { + return nil, fmt.Errorf("%w: %q", ErrInvalidRepositoryMode, *req.RepositoryMode) + } + workflowID := existing.WorkflowID + if req.WorkflowID != nil { + workflowID = *req.WorkflowID + } + repositoryIDs := repositoryIDs(repositories) + if err := validateAutomationTarget(taskMode, repositoryMode, workflowID, repositoryIDs); err != nil { + return nil, err + } + policy := existing.ContinuationPolicy + if policy == "" { + policy = ContinuationPolicyNewTask + } + maxRuns := existing.MaxConcurrentRuns + if req.ContinuationPolicy != nil { + policy = *req.ContinuationPolicy + } + if req.MaxConcurrentRuns != nil { + maxRuns = *req.MaxConcurrentRuns + } + storeReq := req + if policy == ContinuationPolicyReuseThread && maxRuns <= 0 { + maxRuns = 1 + normalized := 1 + clone := *req + clone.MaxConcurrentRuns = &normalized + storeReq = &clone + } + if err := validateContinuationSettings(policy, maxRuns); err != nil { + return nil, err + } + if req.Repositories != nil || req.RepositoryIDs != nil || req.RepositoryMode != nil { + clone := *storeReq + clone.Repositories = repositories + clone.RepositoryIDs = nil + clone.RepositoryMode = &repositoryMode + storeReq = &clone + } + if err := s.store.UpdateAutomation(ctx, id, storeReq); err != nil { return nil, err } return s.store.GetAutomation(ctx, id) @@ -488,7 +666,7 @@ func (s *Service) UpdateAutomation(ctx context.Context, id string, req *UpdateAu // step. All three 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 && req.WorkflowStepID == nil { + if req.Repositories == nil && req.RepositoryIDs == nil && req.WorkflowID == nil && req.WorkflowStepID == nil { return nil } existing, err := s.store.GetAutomation(ctx, id) @@ -498,8 +676,10 @@ func (s *Service) authorizeUpdatedReferences(ctx context.Context, id string, req 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 { + if req.Repositories != nil || req.RepositoryIDs != nil { + if _, err := s.resolveAutomationRepositories( + ctx, existing.WorkspaceID, req.Repositories, req.RepositoryIDs, + ); err != nil { return err } } @@ -520,31 +700,41 @@ func (s *Service) authorizeUpdatedReferences(ctx context.Context, id string, req return s.authorizeWorkflowStepOwnership(ctx, existing.WorkspaceID, workflowID, stepID) } -// 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 -// (ErrRepositoryLookupUnavailable) rather than silently skipping the check — -// this validates cross-workspace access, so "unconfigured" must not mean -// "unchecked". -func (s *Service) validateRepositoryIDs(ctx context.Context, workspaceID string, repositoryIDs []string) error { - if len(repositoryIDs) == 0 { - return nil +func (s *Service) resolveAutomationRepositories( + ctx context.Context, + workspaceID string, + repositories []AutomationRepository, + legacyIDs []string, +) ([]AutomationRepository, error) { + if repositories == nil { + repositories = repositoriesFromIDs(legacyIDs) + } + if len(repositories) == 0 { + return []AutomationRepository{}, nil } if s.repoLookup == nil { - return ErrRepositoryLookupUnavailable + return nil, ErrRepositoryLookupUnavailable } - seen := make(map[string]bool, len(repositoryIDs)) - for _, id := range repositoryIDs { - if seen[id] { - return fmt.Errorf("%w: %s", ErrDuplicateRepositoryID, id) + result := make([]AutomationRepository, 0, len(repositories)) + seen := make(map[string]bool, len(repositories)) + for _, repository := range repositories { + if seen[repository.RepositoryID] { + return nil, fmt.Errorf("%w: %s", ErrDuplicateRepositoryID, repository.RepositoryID) } - seen[id] = true - repoWorkspaceID, _, ok := s.repoLookup.GetRepository(ctx, id) + seen[repository.RepositoryID] = true + repoWorkspaceID, defaultBranch, ok := s.repoLookup.GetRepository(ctx, repository.RepositoryID) if !ok || repoWorkspaceID != workspaceID { - return fmt.Errorf("%w: %s", ErrRepositoryNotInWorkspace, id) + return nil, fmt.Errorf("%w: %s", ErrRepositoryNotInWorkspace, repository.RepositoryID) } + if strings.TrimSpace(repository.BaseBranch) == "" { + repository.BaseBranch = defaultBranch + } + if strings.TrimSpace(repository.BaseBranch) == "" { + return nil, fmt.Errorf("%w: repository %s requires a base branch", ErrInvalidRepositoryMode, repository.RepositoryID) + } + result = append(result, repository) } - return nil + return result, nil } // DeleteAutomation removes an automation. @@ -552,7 +742,194 @@ func (s *Service) DeleteAutomation(ctx context.Context, id string) error { if err := s.authorizeAutomation(ctx, id); err != nil { return err } - return s.store.DeleteAutomation(ctx, id) + unlock := s.automationRunLock(id) + defer unlock() + if err := s.stopOpenAutomationRuns(ctx, id); err != nil { + return err + } + cleanupTaskIDs, err := s.hiddenAutomationTaskIDs(ctx, id) + if err != nil { + return err + } + if _, err := s.store.DeleteAutomationWithCleanup(ctx, id, cleanupTaskIDs); err != nil { + return err + } + _ = s.ReconcileCleanupJobs(ctx) + return nil +} + +func (s *Service) hiddenAutomationTaskIDs(ctx context.Context, automationID string) ([]string, error) { + taskIDs, err := s.store.ListAutomationTaskIDs(ctx, automationID) + if err != nil { + return nil, err + } + if s.taskOriginLookup == nil { + return taskIDs, nil + } + hidden := make([]string, 0, len(taskIDs)) + seen := make(map[string]struct{}, len(taskIDs)) + for _, taskID := range taskIDs { + if taskID == "" { + continue + } + if _, ok := seen[taskID]; ok { + continue + } + seen[taskID] = struct{}{} + _, isAutomationRun, ok := s.taskOriginLookup.TaskWorkspaceAndAutomationOrigin(ctx, taskID) + if ok && isAutomationRun { + hidden = append(hidden, taskID) + } + } + return hidden, nil +} + +// DeleteAutomationsByWorkspace applies the same ownership and cleanup path as +// an individual automation deletion. Workspace reset must not bypass hidden +// task capture and leave reusable worktrees orphaned. +func (s *Service) DeleteAutomationsByWorkspace(ctx context.Context, workspaceID string) (int, error) { + automations, err := s.store.ListAutomations(ctx, workspaceID) + if err != nil { + return 0, err + } + deleted := 0 + for _, a := range automations { + if a == nil { + continue + } + if err := s.DeleteAutomation(ctx, a.ID); err != nil { + return deleted, err + } + deleted++ + } + return deleted, nil +} + +// stopOpenAutomationRuns quiesces live bound turns before their automation +// references are removed. An admitted row without a binding has no runtime +// to stop and is safely removed by the deletion transaction. +func (s *Service) stopOpenAutomationRuns(ctx context.Context, automationID string) error { + runs, err := s.store.ListOpenRuns(ctx, automationID) + if err != nil { + return fmt.Errorf("list open automation runs: %w", err) + } + for _, run := range runs { + if run == nil || run.TaskID == "" || run.SessionID == "" || run.TurnID == "" || s.runStopper == nil { + continue + } + stopped, stopErr := s.runStopper.StopAutomationRun(ctx, run.TaskID, run.SessionID, run.TurnID) + if stopErr != nil { + return fmt.Errorf("stop automation run %s: %w", run.ID, stopErr) + } + if stopped { + if markErr := s.store.MarkRunTerminal(ctx, run.ID, run.SessionID, run.TurnID, RunStatusFailed, "automation deleted"); markErr != nil { + return fmt.Errorf("mark automation run %s failed: %w", run.ID, markErr) + } + } + } + return nil +} + +// ReconcileOpenRuns settles rows left open by a process stop. Admission rows +// without a binding are never guessed into a task and fail immediately. Bound +// rows are settled only when the orchestrator confirms that their exact turn +// is no longer live or blocked. +func (s *Service) ReconcileOpenRuns(ctx context.Context) error { + runs, err := s.store.ListAllOpenRuns(ctx) + if err != nil { + return err + } + for _, run := range runs { + if run == nil { + continue + } + if run.TaskID == "" || run.SessionID == "" || run.TurnID == "" { + if err := s.store.MarkRunTerminal(ctx, run.ID, "", "", RunStatusFailed, "backend stopped before the automation turn was bound"); err != nil { + s.logger.Warn("failed to reconcile unbound automation run", zap.String("run_id", run.ID), zap.Error(err)) + } + continue + } + if s.runLiveness == nil { + continue + } + live, checkErr := s.runLiveness.AutomationRunLive(ctx, run.TaskID, run.SessionID, run.TurnID) + if checkErr != nil { + s.logger.Warn("failed to inspect automation run liveness", zap.String("run_id", run.ID), zap.Error(checkErr)) + continue + } + if !live { + if err := s.store.MarkRunTerminal(ctx, run.ID, run.SessionID, run.TurnID, RunStatusFailed, "automation turn was stale after backend recovery"); err != nil { + s.logger.Warn("failed to reconcile stale automation run", zap.String("run_id", run.ID), zap.Error(err)) + } + } + } + return nil +} + +// ReconcileCleanupJobs retries hidden-task cleanup left after automation +// deletion. A missing task is already clean; every other failure remains +// durable with a safe error for the next startup/reconciliation pass. +func (s *Service) ReconcileCleanupJobs(ctx context.Context) error { + jobs, err := s.store.ListCleanupJobs(ctx) + if err != nil { + return err + } + if s.taskDeleter == nil { + return nil + } + for _, job := range jobs { + if job == nil { + continue + } + delErr := s.taskDeleter.DeleteTask(ctx, job.TaskID) + if delErr == nil || errors.Is(delErr, ErrTaskNotFound) { + if err := s.store.DeleteCleanupJob(ctx, job.TaskID); err != nil { + s.logger.Warn("failed to remove completed automation cleanup job", zap.String("task_id", job.TaskID), zap.Error(err)) + } + continue + } + if err := s.store.UpdateCleanupJobError(ctx, job.TaskID, delErr.Error()); err != nil { + s.logger.Warn("failed to persist automation cleanup error", zap.String("task_id", job.TaskID), zap.Error(err)) + } + } + return nil +} + +// StopRun cancels one open automation run. The stored binding is authoritative; +// callers never provide task, session, or turn identities themselves. +func (s *Service) StopRun(ctx context.Context, automationID, runID string) (*AutomationRun, error) { + if err := s.authorizeAutomation(ctx, automationID); err != nil { + return nil, err + } + unlock := s.automationRunLock(automationID) + defer unlock() + + run, err := s.store.GetRun(ctx, runID) + if err != nil { + return nil, fmt.Errorf("get run: %w", err) + } + if run == nil || run.AutomationID != automationID || + (run.Status != RunStatusTriggered && run.Status != RunStatusTaskCreated) { + return nil, ErrAutomationNotFound + } + if run.TaskID != "" && run.SessionID != "" && run.TurnID != "" { + if s.runStopper == nil { + return nil, errors.New("automation run stopper is not configured") + } + stopped, stopErr := s.runStopper.StopAutomationRun(ctx, run.TaskID, run.SessionID, run.TurnID) + if stopErr != nil { + return nil, stopErr + } + if !stopped { + return nil, ErrAutomationNotFound + } + } + if err := s.store.MarkRunTerminal(ctx, run.ID, run.SessionID, run.TurnID, RunStatusFailed, "stopped by user"); err != nil { + return nil, err + } + run.Status = RunStatusFailed + run.ErrorMessage = "stopped by user" + return run, nil } // EnableAutomation sets enabled = true. @@ -629,16 +1006,17 @@ func (s *Service) UpdateTrigger(ctx context.Context, id string, req *UpdateTrigg if err := s.authorizeTrigger(ctx, id); err != nil { return err } + existing, err := s.store.GetTrigger(ctx, id) + if err != nil { + return err + } + if existing == nil { + return fmt.Errorf("trigger not found: %s", id) + } if req.Config != nil { - existing, err := s.store.GetTrigger(ctx, id) - if err != nil { + if err := validateScheduledConfig(existing.Type, *req.Config); 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) } @@ -727,6 +1105,73 @@ func (s *Service) automationRunLock(automationID string) func() { return mu.Unlock } +// DispatchRun serializes the fallible agent dispatch with exact-run stop and +// deletion. The callback is invoked only while the admitted run is still +// open; its exact task/session/turn identity is bound before the lock is +// released, so a stop can never settle a different firing. +func (s *Service) DispatchRun( + ctx context.Context, + runID string, + action ThreadAction, + reason string, + dispatch func() (RunDispatch, error), +) error { + if runID == "" || dispatch == nil { + return ErrAutomationRunNotDispatchable + } + run, err := s.store.GetRun(ctx, runID) + if err != nil { + return err + } + if run == nil { + return ErrAutomationRunNotDispatchable + } + unlock := s.automationRunLock(run.AutomationID) + defer unlock() + + run, err = s.store.GetRun(ctx, runID) + if err != nil { + return err + } + if run == nil || run.Status != RunStatusTriggered { + return ErrAutomationRunNotDispatchable + } + + dispatchResult, err := dispatch() + if err != nil { + return s.markDispatchFailed(ctx, runID, err) + } + if dispatchResult.TaskID == "" || dispatchResult.SessionID == "" || dispatchResult.TurnID == "" { + return s.markDispatchFailed(ctx, runID, errors.New("automation dispatch returned no exact identity")) + } + if err := s.store.BindRun(ctx, runID, dispatchResult.TaskID, dispatchResult.SessionID, dispatchResult.TurnID, action, reason); err != nil { + return s.markDispatchFailed(ctx, runID, err) + } + return nil +} + +func (s *Service) markDispatchFailed(ctx context.Context, runID string, dispatchErr error) error { + if err := s.store.MarkRunTerminal(ctx, runID, "", "", RunStatusFailed, dispatchErr.Error()); err != nil { + return fmt.Errorf("%w (mark run failed: %v)", dispatchErr, err) + } + return dispatchErr +} + +// lockRun returns the per-automation lock for a persisted run. Binding uses +// the same lock as DispatchRun and StopRun, while the store's status guard +// remains the final protection against a row deleted between the lookup and +// the update. +func (s *Service) lockRun(ctx context.Context, runID string) (func(), error) { + run, err := s.store.GetRun(ctx, runID) + if err != nil { + return nil, err + } + if run == nil { + return nil, ErrAutomationRunNotDispatchable + } + return s.automationRunLock(run.AutomationID), nil +} + // createRunLocked persists a run row while holding the per-automation lock // that DeleteAllRuns also acquires. Without this, a run created between // DeleteAllRuns' task-id snapshot and its final row purge would be deleted @@ -744,51 +1189,125 @@ func (s *Service) DeleteRun(ctx context.Context, runID string) error { if err != nil { return fmt.Errorf("get run: %w", err) } + if run == nil { + return s.store.DeleteRun(ctx, runID) + } // 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 err := s.authorizeAutomation(ctx, run.AutomationID); err != nil { + return err } - if run != nil && run.TaskID != "" && s.taskDeleter != nil { - if delErr := s.taskDeleter.DeleteTask(ctx, run.TaskID); delErr != nil { - if !errors.Is(delErr, ErrTaskNotFound) { - return fmt.Errorf("delete task: %w", delErr) - } - s.logger.Debug("run task already gone, continuing delete", - zap.String("run_id", runID), - zap.String("task_id", run.TaskID)) - } + unlock := s.automationRunLock(run.AutomationID) + defer unlock() + if err := s.deleteRunTaskIfUnreferenced(ctx, run); err != nil { + return err } return s.store.DeleteRun(ctx, runID) } +func (s *Service) deleteRunTaskIfUnreferenced(ctx context.Context, run *AutomationRun) error { + if run.TaskID == "" || s.taskDeleter == nil { + return nil + } + if s.taskOriginLookup != nil { + _, isAutomationRun, ok := s.taskOriginLookup.TaskWorkspaceAndAutomationOrigin(ctx, run.TaskID) + if ok && !isAutomationRun { + return nil + } + } + referenced, err := s.runTaskHasReferences(ctx, run) + if err != nil { + return err + } + if referenced { + return nil + } + if err := s.taskDeleter.DeleteTask(ctx, run.TaskID); err != nil { + if !errors.Is(err, ErrTaskNotFound) { + return fmt.Errorf("delete task: %w", err) + } + s.logger.Debug("run task already gone, continuing delete", + zap.String("run_id", run.ID), zap.String("task_id", run.TaskID)) + } + return nil +} + +func (s *Service) runTaskHasReferences(ctx context.Context, run *AutomationRun) (bool, error) { + otherRun, err := s.store.IsTaskReferencedByRun(ctx, run.AutomationID, run.ID, run.TaskID) + if err != nil { + return false, fmt.Errorf("check run task references: %w", err) + } + if otherRun { + return true, nil + } + continuation, err := s.store.IsContinuationTask(ctx, run.AutomationID, run.TaskID) + if err != nil { + return false, fmt.Errorf("check continuation task reference: %w", err) + } + if continuation { + return true, nil + } + foreign, err := s.store.IsTaskReferencedByOtherAutomation(ctx, run.AutomationID, run.TaskID) + if err != nil { + return false, fmt.Errorf("check foreign task references: %w", err) + } + return foreign, nil +} + // DeleteAllRuns removes every run for an automation, deleting each associated -// task first. Task deletion is best-effort: not-found errors are ignored. +// hidden task first. Visible automation-created tasks belong to the normal +// task lifecycle and remain available after their run history is cleared. +// 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) - if err != nil { - return fmt.Errorf("list run task ids: %w", err) + unlock := s.automationRunLock(automationID) + defer unlock() + taskIDs, err := s.store.ListRunTaskIDs(ctx, automationID) + if err != nil { + return fmt.Errorf("list run task ids: %w", err) + } + a, err := s.store.GetAutomation(ctx, automationID) + if err != nil { + return err + } + if a != nil && a.ContinuationTaskID != "" { + taskIDs = append(taskIDs, a.ContinuationTaskID) + } + seen := make(map[string]struct{}, len(taskIDs)) + for _, taskID := range taskIDs { + if taskID == "" { + continue } - for _, taskID := range taskIDs { - if delErr := s.taskDeleter.DeleteTask(ctx, taskID); delErr != nil { - if !errors.Is(delErr, ErrTaskNotFound) { - return fmt.Errorf("delete task %s: %w", taskID, delErr) - } - s.logger.Debug("run task already gone, skipping", - zap.String("automation_id", automationID), - zap.String("task_id", taskID)) + if _, ok := seen[taskID]; ok { + continue + } + seen[taskID] = struct{}{} + if s.taskOriginLookup != nil { + _, isAutomationRun, ok := s.taskOriginLookup.TaskWorkspaceAndAutomationOrigin(ctx, taskID) + if ok && !isAutomationRun { + continue } } + foreign, refErr := s.store.IsTaskReferencedByOtherAutomation(ctx, automationID, taskID) + if refErr != nil { + return fmt.Errorf("check task %s references: %w", taskID, refErr) + } + if foreign || s.taskDeleter == nil { + continue + } + if delErr := s.taskDeleter.DeleteTask(ctx, taskID); delErr != nil { + if !errors.Is(delErr, ErrTaskNotFound) { + return fmt.Errorf("delete task %s: %w", taskID, delErr) + } + s.logger.Debug("run task already gone, skipping", + zap.String("automation_id", automationID), + zap.String("task_id", taskID)) + } } - return s.store.DeleteAllRuns(ctx, automationID) + return s.store.ClearContinuationAndDeleteAllRuns(ctx, automationID) } // --- Trigger firing --- @@ -799,10 +1318,32 @@ func (s *Service) DeleteAllRuns(ctx context.Context, automationID string) error // indistinguishable from a fire that happened. type FireResult struct { Skipped bool + // RunID identifies the admitted run when the trigger was accepted. + RunID string // Reason is human-readable and set only when Skipped. Reason string } +// RenderRunDisplayTitle resolves the title snapshot stored with a firing. It +// is evaluated before admission so later edits to the automation cannot change +// the title shown for an already-admitted run. +func RenderRunDisplayTitle(a *Automation, triggerType TriggerType, triggerData json.RawMessage) string { + if a == nil { + return "" + } + if a.TaskTitleTemplate != "" { + if title := InterpolatePrompt(a.TaskTitleTemplate, triggerType, triggerData); title != "" { + return taskservice.TruncateTaskTitle(title) + } + } + if info := GetTriggerTypeInfo(triggerType); info != nil && info.DefaultTaskTitle != "" { + if title := InterpolatePrompt(info.DefaultTaskTitle, triggerType, triggerData); title != "" { + return taskservice.TruncateTaskTitle(title) + } + } + return taskservice.TruncateTaskTitle(fmt.Sprintf("[Auto] %s", a.Name)) +} + // 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) (FireResult, error) { @@ -822,27 +1363,18 @@ func (s *Service) FireTrigger(ctx context.Context, automationID, triggerID strin 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 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 FireResult{Skipped: true, Reason: "this trigger has already fired"}, nil - } + // Serialize deduplication, the concurrency admission check, and the run + // insert. Otherwise two scheduler/webhook callers can both observe a free + // slot and publish two fires, or DeleteAllRuns can remove a row after its + // task snapshot but before the row is inserted. + admittedRun, capReason, duplicate, admissionErr := s.admitTrigger( + ctx, a, triggerID, triggerType, triggerData, dedupKey, + ) + if admissionErr != nil { + return FireResult{}, admissionErr } - - // 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. - capReason, capErr := s.maybeSkipForConcurrencyCap(ctx, a, triggerID, triggerType, triggerData, dedupKey) - if capErr != nil { - return FireResult{}, capErr + if duplicate { + return FireResult{Skipped: true, Reason: "this trigger has already fired"}, nil } // Record that the trigger was evaluated now that the cap check itself @@ -867,74 +1399,181 @@ func (s *Service) FireTrigger(ctx context.Context, automationID, triggerID strin evt := &AutomationTriggeredEvent{ AutomationID: automationID, + RunID: admittedRun.ID, TriggerID: triggerID, TriggerType: triggerType, TriggerData: triggerData, DedupKey: dedupKey, } + event := bus.NewEvent(events.AutomationTriggered, "automation_service", evt) + if err := s.eventBus.Publish(ctx, events.AutomationTriggered, event); err != nil { + if markErr := s.store.MarkRunTerminal(ctx, admittedRun.ID, "", "", RunStatusFailed, err.Error()); markErr != nil { + return FireResult{}, fmt.Errorf("publish automation triggered: %w; mark admitted run failed: %v", err, markErr) + } + return FireResult{}, fmt.Errorf("publish automation triggered: %w", err) + } if updateErr := s.store.UpdateLastTriggered(ctx, automationID, now); updateErr != nil { s.logger.Warn("failed to update last_triggered_at", zap.String("automation_id", automationID), zap.Error(updateErr)) } - event := bus.NewEvent(events.AutomationTriggered, "automation_service", evt) - if err := s.eventBus.Publish(ctx, events.AutomationTriggered, event); err != nil { - 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 FireResult{}, nil + return FireResult{RunID: admittedRun.ID}, nil } -// RecordRun records a trigger run outcome. -func (s *Service) RecordRun(ctx context.Context, run *AutomationRun) error { - return s.createRunLocked(ctx, run) +func (s *Service) admitTrigger( + ctx context.Context, + a *Automation, + triggerID string, + triggerType TriggerType, + triggerData json.RawMessage, + dedupKey string, +) (*AutomationRun, string, bool, error) { + unlock := s.automationRunLock(a.ID) + defer unlock() + return s.admitTriggerLocked(ctx, a, triggerID, triggerType, triggerData, dedupKey) } -// 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 "", nil +func (s *Service) admitTriggerLocked( + ctx context.Context, + a *Automation, + triggerID string, + triggerType TriggerType, + triggerData json.RawMessage, + dedupKey string, +) (*AutomationRun, string, bool, error) { + if dedupKey != "" { + exists, err := s.store.HasRunWithDedupKey(ctx, a.ID, dedupKey) + if err != nil { + return nil, "", false, fmt.Errorf("check dedup: %w", err) + } + if exists { + s.logger.Debug("skipping duplicate trigger", + zap.String("automation_id", a.ID), zap.String("dedup_key", dedupKey)) + return nil, "", true, nil + } } - automationID := a.ID - active, err := s.store.CountActiveRuns(ctx, automationID) + + capReason, active, full, err := s.automationCapacity(ctx, a) if err != nil { - return "", fmt.Errorf("count active runs: %w", err) + return nil, "", false, err + } + if full { + s.recordSkippedTrigger(ctx, a, triggerID, triggerType, triggerData, dedupKey, capReason, active) + return nil, capReason, false, nil + } + + run := &AutomationRun{ + AutomationID: a.ID, + TriggerID: triggerID, + TriggerType: triggerType, + Status: RunStatusTriggered, + DedupKey: dedupKey, + TriggerData: triggerData, + DisplayTitle: RenderRunDisplayTitle(a, triggerType, triggerData), + } + if err := s.store.CreateRun(ctx, run); err != nil { + return nil, "", false, fmt.Errorf("record admitted run: %w", err) + } + return run, "", false, nil +} + +func (s *Service) automationCapacity(ctx context.Context, a *Automation) (string, int, bool, error) { + if a.MaxConcurrentRuns <= 0 { + return "", 0, false, nil + } + active, err := s.store.CountActiveRuns(ctx, a.ID) + if err != nil { + return "", 0, false, fmt.Errorf("count active runs: %w", err) } if active < a.MaxConcurrentRuns { - return "", nil + return "", active, false, nil } - reason := fmt.Sprintf("max_concurrent_runs=%d reached", a.MaxConcurrentRuns) - // github_pr_merged cap-skip rows must NOT consume the dedup key so that a - // later event for the same PR can retry. Write an empty key for this type. + return fmt.Sprintf("max_concurrent_runs=%d reached", a.MaxConcurrentRuns), active, true, nil +} + +func (s *Service) recordSkippedTrigger( + ctx context.Context, + a *Automation, + triggerID string, + triggerType TriggerType, + triggerData json.RawMessage, + dedupKey, reason string, + active int, +) { skipDedupKey := dedupKey if triggerType == TriggerTypeGitHubPRMerged { skipDedupKey = "" } skipRun := &AutomationRun{ - AutomationID: automationID, + AutomationID: a.ID, TriggerID: triggerID, TriggerType: triggerType, Status: RunStatusSkipped, DedupKey: skipDedupKey, TriggerData: triggerData, ErrorMessage: reason, + DisplayTitle: RenderRunDisplayTitle(a, triggerType, triggerData), } - if recErr := s.createRunLocked(ctx, skipRun); recErr != nil { - s.logger.Warn("failed to record skipped run", zap.Error(recErr)) + if err := s.store.CreateRun(ctx, skipRun); err != nil { + s.logger.Warn("failed to record skipped run", zap.Error(err)) } s.logger.Info("automation trigger skipped: concurrency cap reached", - zap.String("automation_id", automationID), - zap.Int("active", active), - zap.Int("max", a.MaxConcurrentRuns)) - return reason, nil + zap.String("automation_id", a.ID), zap.Int("active", active), zap.Int("max", a.MaxConcurrentRuns)) +} + +// RecordRun records a trigger run outcome. +func (s *Service) RecordRun(ctx context.Context, run *AutomationRun) error { + if run == nil { + return fmt.Errorf("automation run is required") + } + unlock := s.automationRunLock(run.AutomationID) + defer unlock() + if run.TriggerDataJSON == "" { + run.TriggerDataJSON = string(run.TriggerData) + } + adopted, err := s.store.AdoptTriggeredRun(ctx, run) + if err != nil { + return err + } + if adopted { + return nil + } + return s.store.CreateRun(ctx, run) +} + +func (s *Service) BindRunTask(ctx context.Context, runID, taskID string) error { + unlock, err := s.lockRun(ctx, runID) + if err != nil { + return err + } + defer unlock() + return s.store.BindRunTask(ctx, runID, taskID) +} + +func (s *Service) SetContinuationTaskID(ctx context.Context, automationID, taskID string) error { + return s.store.SetContinuationTaskID(ctx, automationID, taskID) +} + +func (s *Service) BindRun(ctx context.Context, runID, taskID, sessionID, turnID string, action ThreadAction, reason string) error { + unlock, err := s.lockRun(ctx, runID) + if err != nil { + return err + } + defer unlock() + return s.store.BindRun(ctx, runID, taskID, sessionID, turnID, action, reason) +} + +func (s *Service) MarkRunTerminal(ctx context.Context, runID, sessionID, turnID string, status RunStatus, errMsg string) error { + return s.store.MarkRunTerminal(ctx, runID, sessionID, turnID, status, errMsg) +} + +func (s *Service) MarkRunTerminalByBinding(ctx context.Context, taskID, sessionID, turnID string, status RunStatus, errMsg string) error { + return s.store.MarkRunTerminalByBinding(ctx, taskID, sessionID, turnID, status, errMsg) } // MarkRunFailedByTaskID transitions a still-pending run (task_created) into diff --git a/apps/backend/internal/automation/service_test.go b/apps/backend/internal/automation/service_test.go index 0916b22fc8..7d1a28d94c 100644 --- a/apps/backend/internal/automation/service_test.go +++ b/apps/backend/internal/automation/service_test.go @@ -10,6 +10,7 @@ import ( "time" "github.com/jmoiron/sqlx" + "github.com/stretchr/testify/require" "go.uber.org/zap" "github.com/kandev/kandev/internal/common/logger" @@ -123,6 +124,25 @@ func TestCreateAutomation_AcceptsValidRepositoryIDs(t *testing.T) { if len(a.RepositoryIDs) != 2 || a.RepositoryIDs[0] != "repo-a" || a.RepositoryIDs[1] != "repo-b" { t.Fatalf("expected repository_ids [repo-a repo-b], got %v", a.RepositoryIDs) } + if len(a.Repositories) != 2 || a.Repositories[0].BaseBranch != "main" || a.Repositories[1].BaseBranch != "main" { + t.Fatalf("expected legacy IDs to resolve repository default branches, got %#v", a.Repositories) + } +} + +func TestCreateAutomation_PreservesExplicitRepositoryBaseBranch(t *testing.T) { + svc := newTestService(t) + svc.SetRepositoryLookup(&fakeRepositoryLookup{repos: map[string]string{"repo-a": "ws-a"}}) + + a, err := svc.CreateAutomation(context.Background(), &CreateAutomationRequest{ + Name: "x", WorkspaceID: "ws-a", + Repositories: []AutomationRepository{{RepositoryID: "repo-a", BaseBranch: "release/2"}}, + }) + if err != nil { + t.Fatal(err) + } + if got := a.Repositories[0].BaseBranch; got != "release/2" { + t.Fatalf("base branch = %q, want release/2", got) + } } func TestUpdateAutomation_RejectsForeignRepositoryID(t *testing.T) { @@ -226,6 +246,50 @@ func TestService_DeleteRun_TaskNotFound_StillDeletesRun(t *testing.T) { } } +func TestService_DeleteRun_PreservesVisibleAutomationTask(t *testing.T) { + svc := newTestService(t) + deleter := &fakeTaskDeleter{} + svc.SetTaskDeleter(deleter) + svc.SetTaskOriginLookup(&fakeTaskOriginLookup{results: map[string]fakeOriginResult{ + "visible-task": {workspaceID: "ws-1", isAutomationRun: false, ok: true}, + }}) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "visible", Enabled: true} + require.NoError(t, svc.store.CreateAutomation(ctx, a)) + run := &AutomationRun{ + AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusTaskCreated, + TaskID: "visible-task", TriggerData: json.RawMessage(`{}`), + } + require.NoError(t, svc.store.CreateRun(ctx, run)) + + require.NoError(t, svc.DeleteRun(ctx, run.ID)) + require.Empty(t, deleter.deleted) +} + +func TestService_DeleteAutomation_PreservesVisibleAutomationTasks(t *testing.T) { + svc := newTestService(t) + deleter := &fakeTaskDeleter{} + svc.SetTaskDeleter(deleter) + svc.SetTaskOriginLookup(&fakeTaskOriginLookup{results: map[string]fakeOriginResult{ + "visible-task": {workspaceID: "ws-1", isAutomationRun: false, ok: true}, + }}) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "visible", Enabled: true} + require.NoError(t, svc.store.CreateAutomation(ctx, a)) + require.NoError(t, svc.store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, TriggerType: TriggerTypeScheduled, Status: RunStatusSucceeded, + TaskID: "visible-task", TriggerData: json.RawMessage(`{}`), + })) + + require.NoError(t, svc.DeleteAutomation(ctx, a.ID)) + require.Empty(t, deleter.deleted) + jobs, err := svc.store.ListCleanupJobs(ctx) + require.NoError(t, err) + require.Empty(t, jobs) +} + func TestService_DeleteAllRuns_CallsTaskDeleterForEach(t *testing.T) { svc := newTestService(t) deleter := &fakeTaskDeleter{} @@ -273,6 +337,32 @@ func TestService_DeleteAllRuns_CallsTaskDeleterForEach(t *testing.T) { } } +func TestService_DeleteAllRuns_PreservesVisibleAutomationTasks(t *testing.T) { + svc := newTestService(t) + deleter := &fakeTaskDeleter{} + svc.SetTaskDeleter(deleter) + svc.SetTaskOriginLookup(&fakeTaskOriginLookup{results: map[string]fakeOriginResult{ + "hidden-task": {workspaceID: "ws-1", isAutomationRun: true, ok: true}, + "visible-task": {workspaceID: "ws-1", isAutomationRun: false, ok: true}, + }}) + ctx := context.Background() + + a := &Automation{WorkspaceID: "ws-1", Name: "visible delete-all", Enabled: true} + require.NoError(t, svc.store.CreateAutomation(ctx, a)) + for _, taskID := range []string{"hidden-task", "visible-task"} { + require.NoError(t, svc.store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSucceeded, + TaskID: taskID, + TriggerData: json.RawMessage(`{}`), + })) + } + + require.NoError(t, svc.DeleteAllRuns(ctx, a.ID)) + require.Equal(t, []string{"hidden-task"}, deleter.deleted) +} + func TestService_DeleteAllRuns_TaskNotFound_StillClearsRuns(t *testing.T) { svc := newTestService(t) deleter := &fakeTaskDeleter{ diff --git a/apps/backend/internal/automation/store.go b/apps/backend/internal/automation/store.go index 90bec2af97..7f7a7410b1 100644 --- a/apps/backend/internal/automation/store.go +++ b/apps/backend/internal/automation/store.go @@ -43,6 +43,8 @@ const createTablesSQL = ` workflow_step_id TEXT NOT NULL, agent_profile_id TEXT NOT NULL, executor_profile_id TEXT NOT NULL, + task_mode TEXT NOT NULL DEFAULT 'automation_run', + repository_mode TEXT NOT NULL DEFAULT 'none', repository_id TEXT NOT NULL DEFAULT '', prompt TEXT DEFAULT '', task_title_template TEXT DEFAULT '', @@ -55,6 +57,8 @@ const createTablesSQL = ` execution_mode TEXT NOT NULL DEFAULT 'task', enabled BOOLEAN DEFAULT 1, max_concurrent_runs INTEGER DEFAULT 1, + continuation_policy TEXT NOT NULL DEFAULT 'new_task', + continuation_task_id TEXT DEFAULT '', webhook_secret TEXT DEFAULT '', last_triggered_at DATETIME, created_at DATETIME NOT NULL, @@ -84,6 +88,11 @@ const createTablesSQL = ` dedup_key TEXT DEFAULT '', trigger_data TEXT NOT NULL DEFAULT '{}', error_message TEXT DEFAULT '', + session_id TEXT DEFAULT '', + turn_id TEXT DEFAULT '', + thread_action TEXT DEFAULT '', + thread_reason TEXT DEFAULT '', + display_title TEXT DEFAULT '', created_at DATETIME NOT NULL ); @@ -102,12 +111,25 @@ const createTablesSQL = ` id TEXT PRIMARY KEY, automation_id TEXT NOT NULL REFERENCES automations(id) ON DELETE CASCADE, repository_id TEXT NOT NULL, + base_branch TEXT NOT NULL DEFAULT '', position INTEGER NOT NULL DEFAULT 0, created_at DATETIME NOT NULL, UNIQUE(automation_id, repository_id) ); CREATE INDEX IF NOT EXISTS idx_automation_repositories_automation ON automation_repositories(automation_id); + + CREATE TABLE IF NOT EXISTS automation_task_cleanup_jobs ( + task_id TEXT PRIMARY KEY, + workspace_id TEXT NOT NULL, + automation_id TEXT NOT NULL, + last_error TEXT DEFAULT '', + created_at DATETIME NOT NULL, + updated_at DATETIME NOT NULL + ); + + CREATE INDEX IF NOT EXISTS idx_automation_task_cleanup_jobs_due + ON automation_task_cleanup_jobs(updated_at, created_at); ` // In-branch column additions. The canonical CREATE TABLE covers fresh @@ -128,9 +150,19 @@ const createTablesSQL = ` // 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 ''` + 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 ''` + migrateContinuationPolicySQL = `ALTER TABLE automations ADD COLUMN continuation_policy TEXT NOT NULL DEFAULT 'new_task'` + migrateContinuationTaskSQL = `ALTER TABLE automations ADD COLUMN continuation_task_id TEXT DEFAULT ''` + migrateTaskModeSQL = `ALTER TABLE automations ADD COLUMN task_mode TEXT NOT NULL DEFAULT 'automation_run'` + migrateRepositoryModeSQL = `ALTER TABLE automations ADD COLUMN repository_mode TEXT NOT NULL DEFAULT 'none'` + migrateRepositoryBranchSQL = `ALTER TABLE automation_repositories ADD COLUMN base_branch TEXT NOT NULL DEFAULT ''` + migrateRunSessionSQL = `ALTER TABLE automation_runs ADD COLUMN session_id TEXT DEFAULT ''` + migrateRunTurnSQL = `ALTER TABLE automation_runs ADD COLUMN turn_id TEXT DEFAULT ''` + migrateRunThreadActionSQL = `ALTER TABLE automation_runs ADD COLUMN thread_action TEXT DEFAULT ''` + migrateRunThreadReasonSQL = `ALTER TABLE automation_runs ADD COLUMN thread_reason TEXT DEFAULT ''` + migrateRunDisplayTitleSQL = `ALTER TABLE automation_runs ADD COLUMN display_title TEXT DEFAULT ''` ) // automationColumns is the explicit column list for every query that scans a @@ -149,18 +181,32 @@ const ( // mode to branch on, and the whole point of withdrawing it is that no firing // path has one. See docs/specs/office/requirements/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, - enabled, max_concurrent_runs, webhook_secret, last_triggered_at, created_at, updated_at, + agent_profile_id, executor_profile_id, task_mode, repository_mode, prompt, task_title_template, + enabled, max_concurrent_runs, continuation_policy, continuation_task_id, 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 { return err } - s.db.Exec(migrateTaskTitleSQL) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(migrateExecutionModeSQL) //nolint:errcheck // duplicate-column on existing DBs - s.db.Exec(migrateRepositoryIDSQL) //nolint:errcheck // duplicate-column on existing DBs - return s.backfillLegacyRepositoryIDs() + s.db.Exec(migrateTaskTitleSQL) //nolint:errcheck // duplicate-column on existing DBs + s.db.Exec(migrateExecutionModeSQL) //nolint:errcheck // duplicate-column on existing DBs + s.db.Exec(migrateRepositoryIDSQL) //nolint:errcheck // duplicate-column on existing DBs + s.db.Exec(migrateContinuationPolicySQL) //nolint:errcheck // duplicate-column on existing DBs + s.db.Exec(migrateContinuationTaskSQL) //nolint:errcheck // duplicate-column on existing DBs + s.db.Exec(migrateTaskModeSQL) //nolint:errcheck // duplicate-column on existing DBs + s.db.Exec(migrateRepositoryModeSQL) //nolint:errcheck // duplicate-column on existing DBs + s.db.Exec(migrateRepositoryBranchSQL) //nolint:errcheck // duplicate-column on existing DBs + s.db.Exec(migrateRunSessionSQL) //nolint:errcheck // duplicate-column on existing DBs + s.db.Exec(migrateRunTurnSQL) //nolint:errcheck // duplicate-column on existing DBs + s.db.Exec(migrateRunThreadActionSQL) //nolint:errcheck // duplicate-column on existing DBs + s.db.Exec(migrateRunThreadReasonSQL) //nolint:errcheck // duplicate-column on existing DBs + s.db.Exec(migrateRunDisplayTitleSQL) //nolint:errcheck // duplicate-column on existing DBs + if err := s.backfillLegacyRepositoryIDs(); err != nil { + return err + } + return s.backfillRepositoryModes() } // backfillLegacyRepositoryIDs copies every non-empty legacy @@ -209,6 +255,24 @@ func (s *Store) backfillLegacyRepositoryIDs() error { return tx.Commit() } +// backfillRepositoryModes derives the mode from the explicit list. Empty +// legacy workspace-default rows become repository-free; Kandev no longer +// attaches the first workspace repository implicitly. +func (s *Store) backfillRepositoryModes() error { + if _, err := s.db.Exec(` + UPDATE automations + SET repository_mode = 'selected' + WHERE repository_mode = 'workspace_default' + AND EXISTS ( + SELECT 1 FROM automation_repositories + WHERE automation_repositories.automation_id = automations.id + )`); err != nil { + return err + } + _, err := s.db.Exec(`UPDATE automations SET repository_mode = 'none' WHERE repository_mode = 'workspace_default'`) + return err +} + // --- Automation CRUD --- // CreateAutomation persists a new automation and its repository_ids. @@ -222,6 +286,26 @@ func (s *Store) CreateAutomation(ctx context.Context, a *Automation) error { now := time.Now().UTC() a.CreatedAt = now a.UpdatedAt = now + if a.ContinuationPolicy == "" { + a.ContinuationPolicy = ContinuationPolicyNewTask + } + if a.TaskMode == "" { + a.TaskMode = TaskModeAutomationRun + } + normalizeAutomationRepositories(a) + if a.RepositoryMode == "" { + if len(a.Repositories) > 0 { + a.RepositoryMode = RepositoryModeSelected + } else { + a.RepositoryMode = RepositoryModeNone + } + } + if err := validateAutomationTarget(a.TaskMode, a.RepositoryMode, a.WorkflowID, a.RepositoryIDs); err != nil { + return err + } + if err := validateContinuationSettings(a.ContinuationPolicy, a.MaxConcurrentRuns); err != nil { + return err + } tx, err := s.db.BeginTxx(ctx, nil) if err != nil { return err @@ -239,19 +323,20 @@ func (s *Store) CreateAutomation(ctx context.Context, a *Automation) error { _, 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, + task_mode, repository_mode, prompt, task_title_template, execution_mode, + enabled, max_concurrent_runs, continuation_policy, continuation_task_id, 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, + string(a.TaskMode), string(a.RepositoryMode), a.Prompt, a.TaskTitleTemplate, - a.Enabled, a.MaxConcurrentRuns, + a.Enabled, a.MaxConcurrentRuns, a.ContinuationPolicy, a.ContinuationTaskID, a.WebhookSecret, a.LastTriggeredAt, a.CreatedAt, a.UpdatedAt) if err != nil { return err } - if err := insertAutomationRepositories(ctx, tx, a.ID, a.RepositoryIDs); err != nil { + if err := insertAutomationRepositories(ctx, tx, a.ID, a.Repositories); err != nil { return err } return tx.Commit() @@ -260,13 +345,13 @@ func (s *Store) CreateAutomation(ctx context.Context, a *Automation) error { // insertAutomationRepositories inserts one automation_repositories row per // ID, preserving slice order via the position column. No-op for an empty // slice. Shared by CreateAutomation and UpdateAutomation's replace path. -func insertAutomationRepositories(ctx context.Context, tx *sqlx.Tx, automationID string, repositoryIDs []string) error { +func insertAutomationRepositories(ctx context.Context, tx *sqlx.Tx, automationID string, repositories []AutomationRepository) error { now := time.Now().UTC() - for i, repositoryID := range repositoryIDs { + for i, repository := range repositories { _, err := tx.ExecContext(ctx, ` - INSERT INTO automation_repositories (id, automation_id, repository_id, position, created_at) - VALUES (?, ?, ?, ?, ?)`, - uuid.New().String(), automationID, repositoryID, i, now) + INSERT INTO automation_repositories (id, automation_id, repository_id, base_branch, position, created_at) + VALUES (?, ?, ?, ?, ?, ?)`, + uuid.New().String(), automationID, repository.RepositoryID, repository.BaseBranch, i, now) if err != nil { return fmt.Errorf("insert automation_repositories: %w", err) } @@ -290,22 +375,23 @@ func (s *Store) GetAutomation(ctx context.Context, id string) (*Automation, erro return nil, fmt.Errorf("hydrate triggers: %w", err) } a.Triggers = triggers - repoIDs, err := s.listRepositoryIDsForAutomations(ctx, []string{id}) + repositories, err := s.listRepositoriesForAutomations(ctx, []string{id}) if err != nil { return nil, fmt.Errorf("hydrate repository_ids: %w", err) } - a.RepositoryIDs = repoIDs[id] + a.Repositories = repositories[id] + a.RepositoryIDs = repositoryIDs(a.Repositories) return &a, nil } // listRepositoryIDsForAutomations batch-loads ordered repository_ids for // several automations at once, mirroring listTriggersForAutomations. -func (s *Store) listRepositoryIDsForAutomations(ctx context.Context, automationIDs []string) (map[string][]string, error) { +func (s *Store) listRepositoriesForAutomations(ctx context.Context, automationIDs []string) (map[string][]AutomationRepository, error) { if len(automationIDs) == 0 { - return make(map[string][]string), nil + return make(map[string][]AutomationRepository), nil } query, args, err := sqlx.In( - `SELECT automation_id, repository_id FROM automation_repositories + `SELECT automation_id, repository_id, base_branch FROM automation_repositories WHERE automation_id IN (?) ORDER BY automation_id, position`, automationIDs) if err != nil { return nil, err @@ -314,17 +400,21 @@ func (s *Store) listRepositoryIDsForAutomations(ctx context.Context, automationI type row struct { AutomationID string `db:"automation_id"` RepositoryID string `db:"repository_id"` + BaseBranch string `db:"base_branch"` } var rows []row if err := s.ro.SelectContext(ctx, &rows, query, args...); err != nil { return nil, err } - result := make(map[string][]string, len(automationIDs)) + result := make(map[string][]AutomationRepository, len(automationIDs)) for _, id := range automationIDs { - result[id] = []string{} + result[id] = []AutomationRepository{} } for _, r := range rows { - result[r.AutomationID] = append(result[r.AutomationID], r.RepositoryID) + result[r.AutomationID] = append(result[r.AutomationID], AutomationRepository{ + RepositoryID: r.RepositoryID, + BaseBranch: r.BaseBranch, + }) } return result, nil } @@ -423,21 +513,22 @@ func (s *Store) hydrateAutomations(ctx context.Context, automations []*Automatio if err != nil { return fmt.Errorf("hydrate triggers: %w", err) } - repoIDsByAutomation, err := s.listRepositoryIDsForAutomations(ctx, ids) + repositoriesByAutomation, err := s.listRepositoriesForAutomations(ctx, ids) if err != nil { return fmt.Errorf("hydrate repository_ids: %w", err) } for _, a := range automations { a.Triggers = triggersByAutomation[a.ID] - a.RepositoryIDs = repoIDsByAutomation[a.ID] + a.Repositories = repositoriesByAutomation[a.ID] + a.RepositoryIDs = repositoryIDs(a.Repositories) } return nil } -// UpdateAutomation applies partial updates to an automation. When -// req.RepositoryIDs is non-nil, it atomically replaces the automation's -// automation_repositories rows (nil means "leave unchanged"; an explicit -// empty slice clears the list). +// UpdateAutomation applies partial updates to an automation. When repository +// bindings or legacy repository IDs are non-nil, it atomically replaces the +// automation_repositories rows. An explicit non-selected repository mode also +// clears the rows when a partial client omits repository data. func (s *Store) UpdateAutomation(ctx context.Context, id string, req *UpdateAutomationRequest) error { a, err := s.GetAutomation(ctx, id) if err != nil { @@ -447,6 +538,29 @@ func (s *Store) UpdateAutomation(ctx context.Context, id string, req *UpdateAuto return fmt.Errorf("automation not found: %s", id) } applyAutomationUpdate(a, req) + if a.ContinuationPolicy == "" { + a.ContinuationPolicy = ContinuationPolicyNewTask + } + if a.TaskMode == "" { + a.TaskMode = TaskModeAutomationRun + } + normalizeAutomationRepositories(a) + if a.RepositoryMode == "" { + if len(a.Repositories) > 0 { + a.RepositoryMode = RepositoryModeSelected + } else { + a.RepositoryMode = RepositoryModeNone + } + } + if err := validateAutomationTarget(a.TaskMode, a.RepositoryMode, a.WorkflowID, a.RepositoryIDs); err != nil { + return err + } + if a.ContinuationPolicy == ContinuationPolicyReuseThread && a.MaxConcurrentRuns <= 0 { + a.MaxConcurrentRuns = 1 + } + if err := validateContinuationSettings(a.ContinuationPolicy, a.MaxConcurrentRuns); err != nil { + return err + } a.UpdatedAt = time.Now().UTC() tx, err := s.db.BeginTxx(ctx, nil) @@ -458,23 +572,28 @@ func (s *Store) UpdateAutomation(ctx context.Context, id string, req *UpdateAuto _, err = tx.ExecContext(ctx, ` UPDATE automations SET name = ?, description = ?, workflow_id = ?, workflow_step_id = ?, agent_profile_id = ?, executor_profile_id = ?, - prompt = ?, task_title_template = ?, - enabled = ?, max_concurrent_runs = ?, updated_at = ? + task_mode = ?, repository_mode = ?, prompt = ?, task_title_template = ?, + enabled = ?, max_concurrent_runs = ?, continuation_policy = ?, updated_at = ? WHERE id = ?`, a.Name, a.Description, a.WorkflowID, a.WorkflowStepID, a.AgentProfileID, a.ExecutorProfileID, + string(a.TaskMode), string(a.RepositoryMode), a.Prompt, a.TaskTitleTemplate, - a.Enabled, a.MaxConcurrentRuns, a.UpdatedAt, id) + a.Enabled, a.MaxConcurrentRuns, a.ContinuationPolicy, a.UpdatedAt, id) if err != nil { return err } - if req.RepositoryIDs != nil { + if req.Repositories != nil || req.RepositoryIDs != nil { if _, err := tx.ExecContext(ctx, `DELETE FROM automation_repositories WHERE automation_id = ?`, id); err != nil { return fmt.Errorf("clear automation_repositories: %w", err) } - if err := insertAutomationRepositories(ctx, tx, id, req.RepositoryIDs); err != nil { + if err := insertAutomationRepositories(ctx, tx, id, a.Repositories); err != nil { return err } + } else if req.RepositoryMode != nil && *req.RepositoryMode != RepositoryModeSelected { + if _, err := tx.ExecContext(ctx, `DELETE FROM automation_repositories WHERE automation_id = ?`, id); err != nil { + return fmt.Errorf("clear automation_repositories for repository mode: %w", err) + } } return tx.Commit() } @@ -498,6 +617,38 @@ func applyAutomationUpdate(a *Automation, req *UpdateAutomationRequest) { if req.ExecutorProfileID != nil { a.ExecutorProfileID = *req.ExecutorProfileID } + if req.TaskMode != nil { + a.TaskMode = *req.TaskMode + } + if req.RepositoryMode != nil { + a.RepositoryMode = *req.RepositoryMode + if a.RepositoryMode != RepositoryModeSelected && req.RepositoryIDs == nil && req.Repositories == nil { + a.Repositories = nil + a.RepositoryIDs = nil + } + } + if req.Repositories != nil { + a.Repositories = append([]AutomationRepository(nil), req.Repositories...) + a.RepositoryIDs = repositoryIDs(a.Repositories) + if req.RepositoryMode == nil { + if len(a.Repositories) > 0 { + a.RepositoryMode = RepositoryModeSelected + } else { + a.RepositoryMode = RepositoryModeNone + } + } + } + if req.RepositoryIDs != nil { + a.RepositoryIDs = append([]string(nil), req.RepositoryIDs...) + a.Repositories = repositoriesFromIDs(req.RepositoryIDs) + if req.RepositoryMode == nil { + if len(req.RepositoryIDs) > 0 { + a.RepositoryMode = RepositoryModeSelected + } else { + a.RepositoryMode = RepositoryModeNone + } + } + } if req.Prompt != nil { a.Prompt = *req.Prompt } @@ -510,6 +661,32 @@ func applyAutomationUpdate(a *Automation, req *UpdateAutomationRequest) { if req.TaskTitleTemplate != nil { a.TaskTitleTemplate = *req.TaskTitleTemplate } + if req.ContinuationPolicy != nil { + a.ContinuationPolicy = *req.ContinuationPolicy + } +} + +func normalizeAutomationRepositories(a *Automation) { + if len(a.Repositories) == 0 && len(a.RepositoryIDs) > 0 { + a.Repositories = repositoriesFromIDs(a.RepositoryIDs) + } + a.RepositoryIDs = repositoryIDs(a.Repositories) +} + +func repositoriesFromIDs(ids []string) []AutomationRepository { + result := make([]AutomationRepository, 0, len(ids)) + for _, id := range ids { + result = append(result, AutomationRepository{RepositoryID: id}) + } + return result +} + +func repositoryIDs(repositories []AutomationRepository) []string { + result := make([]string, 0, len(repositories)) + for _, repository := range repositories { + result = append(result, repository.RepositoryID) + } + return result } // DeleteAutomation removes an automation and its triggers/runs (CASCADE). @@ -518,6 +695,263 @@ func (s *Store) DeleteAutomation(ctx context.Context, id string) error { return err } +// AutomationTaskCleanupJob is durable ownership for a hidden task whose +// automation references have already been removed. The job deliberately has +// no foreign key to the deleted automation: it must survive that deletion. +type AutomationTaskCleanupJob struct { + TaskID string `db:"task_id" json:"task_id"` + WorkspaceID string `db:"workspace_id" json:"workspace_id"` + AutomationID string `db:"automation_id" json:"automation_id"` + LastError string `db:"last_error" json:"last_error,omitempty"` + CreatedAt time.Time `db:"created_at" json:"created_at"` + UpdatedAt time.Time `db:"updated_at" json:"updated_at"` +} + +// ListOpenRuns returns all admitted runs that still occupy an automation +// concurrency slot. It is intentionally unbounded and server-only: cleanup +// and startup reconciliation must not miss an old open run because a UI list +// limit was applied. +func (s *Store) ListOpenRuns(ctx context.Context, automationID string) ([]*AutomationRun, error) { + var runs []*AutomationRun + err := s.ro.SelectContext(ctx, &runs, ` + SELECT * FROM automation_runs + WHERE automation_id = ? AND status IN (?, ?) + ORDER BY created_at ASC, id ASC`, + automationID, string(RunStatusTriggered), string(RunStatusTaskCreated)) + for _, run := range runs { + run.TriggerData = json.RawMessage(run.TriggerDataJSON) + } + return runs, err +} + +// ListAllOpenRuns is the startup-reconciliation view. It includes disabled +// automations because disabling a rule must not make an already-admitted run +// disappear from recovery. +func (s *Store) ListAllOpenRuns(ctx context.Context) ([]*AutomationRun, error) { + var runs []*AutomationRun + err := s.ro.SelectContext(ctx, &runs, ` + SELECT * FROM automation_runs + WHERE status IN (?, ?) + ORDER BY automation_id, created_at ASC, id ASC`, + string(RunStatusTriggered), string(RunStatusTaskCreated)) + for _, run := range runs { + run.TriggerData = json.RawMessage(run.TriggerDataJSON) + } + return runs, err +} + +// DeleteAutomationWithCleanup captures every task owned only by the +// automation, inserts one durable cleanup job per task, and removes the +// automation and its cascading references in one transaction. The caller must +// stop live sessions before invoking this method. +type automationCleanupOwner struct { + WorkspaceID string `db:"workspace_id"` + ContinuationTaskID string `db:"continuation_task_id"` +} + +func (s *Store) DeleteAutomationWithCleanup(ctx context.Context, id string, cleanupTaskIDs ...[]string) ([]string, error) { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return nil, err + } + defer func() { _ = tx.Rollback() }() + + owner, found, err := loadAutomationCleanupOwner(ctx, tx, id) + if err != nil { + return nil, err + } + if !found { + return nil, nil + } + var taskIDs []string + if len(cleanupTaskIDs) > 0 { + taskIDs = cleanupTaskIDs[0] + } else { + taskIDs, err = listAutomationCleanupTaskIDs(ctx, tx, id, owner.ContinuationTaskID) + } + if err != nil { + return nil, err + } + jobs, err := insertAutomationCleanupJobs(ctx, tx, id, owner, taskIDs) + if err != nil { + return nil, err + } + if _, err := tx.ExecContext(ctx, `DELETE FROM automations WHERE id = ?`, id); err != nil { + return nil, err + } + if err := tx.Commit(); err != nil { + return nil, err + } + return jobs, nil +} + +// ListAutomationTaskIDs returns the distinct task IDs currently owned by an +// automation's runs plus its continuation pointer. The service uses the task +// origin to keep visible normal tasks out of hidden-task cleanup jobs. +func (s *Store) ListAutomationTaskIDs(ctx context.Context, id string) ([]string, error) { + var taskIDs []string + if err := s.ro.SelectContext(ctx, &taskIDs, + `SELECT DISTINCT task_id FROM automation_runs WHERE automation_id = ? AND task_id != ''`, id); err != nil { + return nil, err + } + var continuationTaskID string + if err := s.ro.GetContext(ctx, &continuationTaskID, + `SELECT continuation_task_id FROM automations WHERE id = ?`, id); err == nil && continuationTaskID != "" { + taskIDs = append(taskIDs, continuationTaskID) + } else if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, err + } + return taskIDs, nil +} + +func loadAutomationCleanupOwner(ctx context.Context, tx *sqlx.Tx, id string) (automationCleanupOwner, bool, error) { + var owner automationCleanupOwner + err := tx.GetContext(ctx, &owner, + `SELECT workspace_id, continuation_task_id FROM automations WHERE id = ?`, id) + if errors.Is(err, sql.ErrNoRows) { + return owner, false, nil + } + return owner, err == nil, err +} + +func listAutomationCleanupTaskIDs(ctx context.Context, tx *sqlx.Tx, id, continuationTaskID string) ([]string, error) { + var taskIDs []string + if err := tx.SelectContext(ctx, &taskIDs, + `SELECT DISTINCT task_id FROM automation_runs WHERE automation_id = ? AND task_id != ''`, id); err != nil { + return nil, err + } + if continuationTaskID != "" { + taskIDs = append(taskIDs, continuationTaskID) + } + return taskIDs, nil +} + +func insertAutomationCleanupJobs( + ctx context.Context, + tx *sqlx.Tx, + automationID string, + owner automationCleanupOwner, + taskIDs []string, +) ([]string, error) { + seen := make(map[string]struct{}, len(taskIDs)) + jobs := make([]string, 0, len(taskIDs)) + now := time.Now().UTC() + for _, taskID := range taskIDs { + if taskID == "" { + continue + } + if _, ok := seen[taskID]; ok { + continue + } + seen[taskID] = struct{}{} + owned, err := automationCleanupTaskIsUnreferenced(ctx, tx, automationID, taskID) + if err != nil { + return nil, err + } + if !owned { + continue + } + if _, err := tx.ExecContext(ctx, ` + INSERT INTO automation_task_cleanup_jobs + (task_id, workspace_id, automation_id, last_error, created_at, updated_at) + VALUES (?, ?, ?, '', ?, ?) + ON CONFLICT(task_id) DO UPDATE SET + workspace_id = excluded.workspace_id, + automation_id = excluded.automation_id, + updated_at = excluded.updated_at`, + taskID, owner.WorkspaceID, automationID, now, now); err != nil { + return nil, err + } + jobs = append(jobs, taskID) + } + return jobs, nil +} + +func automationCleanupTaskIsUnreferenced(ctx context.Context, tx *sqlx.Tx, automationID, taskID string) (bool, error) { + var refs int + if err := tx.GetContext(ctx, &refs, ` + SELECT COUNT(*) FROM automation_runs + WHERE task_id = ? AND automation_id != ?`, taskID, automationID); err != nil { + return false, err + } + if refs > 0 { + return false, nil + } + var pointers int + if err := tx.GetContext(ctx, &pointers, ` + SELECT COUNT(*) FROM automations + WHERE continuation_task_id = ? AND id != ?`, taskID, automationID); err != nil { + return false, err + } + return pointers == 0, nil +} + +// IsTaskReferencedByOtherAutomation reports whether a task is still owned by +// another run or continuation pointer. The caller holds the relevant +// automation admission lock; this protects run/delete-all races within one +// automation while the query protects cross-automation sharing. +func (s *Store) IsTaskReferencedByOtherAutomation(ctx context.Context, automationID, taskID string) (bool, error) { + if taskID == "" { + return false, nil + } + var refs int + err := s.ro.GetContext(ctx, &refs, ` + SELECT COUNT(*) FROM automation_runs + WHERE task_id = ? AND automation_id != ?`, taskID, automationID) + if err != nil { + return false, err + } + if refs > 0 { + return true, nil + } + var pointers int + err = s.ro.GetContext(ctx, &pointers, ` + SELECT COUNT(*) FROM automations + WHERE continuation_task_id = ? AND id != ?`, taskID, automationID) + return pointers > 0, err +} + +// IsTaskReferencedByRun reports whether another run in the same automation +// owns the task. It is used when deleting one run from a shared thread. +func (s *Store) IsTaskReferencedByRun(ctx context.Context, automationID, runID, taskID string) (bool, error) { + if taskID == "" { + return false, nil + } + var count int + err := s.ro.GetContext(ctx, &count, ` + SELECT COUNT(*) FROM automation_runs + WHERE automation_id = ? AND id != ? AND task_id = ?`, automationID, runID, taskID) + return count > 0, err +} + +// IsContinuationTask reports whether the automation currently protects the +// task from run-row deletion. +func (s *Store) IsContinuationTask(ctx context.Context, automationID, taskID string) (bool, error) { + var count int + err := s.ro.GetContext(ctx, &count, + `SELECT COUNT(*) FROM automations WHERE id = ? AND continuation_task_id = ?`, automationID, taskID) + return count > 0, err +} + +func (s *Store) ListCleanupJobs(ctx context.Context) ([]*AutomationTaskCleanupJob, error) { + var jobs []*AutomationTaskCleanupJob + err := s.ro.SelectContext(ctx, &jobs, + `SELECT * FROM automation_task_cleanup_jobs ORDER BY created_at ASC, task_id ASC`) + return jobs, err +} + +func (s *Store) DeleteCleanupJob(ctx context.Context, taskID string) error { + _, err := s.db.ExecContext(ctx, `DELETE FROM automation_task_cleanup_jobs WHERE task_id = ?`, taskID) + return err +} + +func (s *Store) UpdateCleanupJobError(ctx context.Context, taskID, message string) error { + _, err := s.db.ExecContext(ctx, ` + UPDATE automation_task_cleanup_jobs SET last_error = ?, updated_at = ? WHERE task_id = ?`, + message, time.Now().UTC(), taskID) + return err +} + // UpdateLastTriggered updates the last_triggered_at timestamp. func (s *Store) UpdateLastTriggered(ctx context.Context, id string, t time.Time) error { _, err := s.db.ExecContext(ctx, @@ -665,13 +1099,127 @@ func (s *Store) CreateRun(ctx context.Context, r *AutomationRun) error { r.TriggerDataJSON = string(r.TriggerData) _, err := s.db.ExecContext(ctx, ` INSERT INTO automation_runs (id, automation_id, trigger_id, trigger_type, task_id, status, - dedup_key, trigger_data, error_message, created_at) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + dedup_key, trigger_data, error_message, session_id, turn_id, thread_action, thread_reason, + display_title, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, r.ID, r.AutomationID, r.TriggerID, r.TriggerType, r.TaskID, r.Status, - r.DedupKey, r.TriggerDataJSON, r.ErrorMessage, r.CreatedAt) + r.DedupKey, r.TriggerDataJSON, r.ErrorMessage, r.SessionID, r.TurnID, + r.ThreadAction, r.ThreadReason, r.DisplayTitle, r.CreatedAt) return err } +// AdoptTriggeredRun upgrades the admission row for callers that still report +// an outcome through RecordRun without carrying the new run ID. It is keyed by +// the dedup key and only touches an unbound triggered row, so it cannot rewrite +// a different firing or a terminal history row. +func (s *Store) AdoptTriggeredRun(ctx context.Context, r *AutomationRun) (bool, error) { + if r == nil || r.DedupKey == "" { + return false, nil + } + res, err := s.db.ExecContext(ctx, ` + UPDATE automation_runs SET task_id = ?, status = ?, trigger_data = ?, error_message = ?, + session_id = ?, turn_id = ?, thread_action = ?, thread_reason = ?, display_title = ? + WHERE id = ( + SELECT id FROM automation_runs + WHERE automation_id = ? AND dedup_key = ? AND status = ? + ORDER BY created_at DESC, id DESC LIMIT 1 + ) AND status = ?`, + r.TaskID, r.Status, r.TriggerDataJSON, r.ErrorMessage, r.SessionID, r.TurnID, + r.ThreadAction, r.ThreadReason, r.DisplayTitle, + r.AutomationID, r.DedupKey, string(RunStatusTriggered), string(RunStatusTriggered)) + if err != nil { + return false, err + } + affected, _ := res.RowsAffected() + return affected > 0, nil +} + +// BindRunTask records the task created for an admitted run while leaving the +// run in triggered state. This closes the task ownership gap before the agent +// session is ready, so a failed launch can still settle the exact run. +func (s *Store) BindRunTask(ctx context.Context, runID, taskID string) error { + res, err := s.db.ExecContext(ctx, ` + UPDATE automation_runs SET task_id = ? + WHERE id = ? AND status = ?`, taskID, runID, string(RunStatusTriggered)) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return fmt.Errorf("automation run %s is not an admitted triggered run", runID) + } + return nil +} + +func (s *Store) SetContinuationTaskID(ctx context.Context, automationID, taskID string) error { + _, err := s.db.ExecContext(ctx, + `UPDATE automations SET continuation_task_id = ?, updated_at = ? WHERE id = ?`, + taskID, time.Now().UTC(), automationID) + return err +} + +// BindRun attaches the exact session and turn after dispatch and makes the run +// task_created. The identity is kept on the row so shared-task completion can +// never settle a different firing. +func (s *Store) BindRun(ctx context.Context, runID, taskID, sessionID, turnID string, action ThreadAction, reason string) error { + res, err := s.db.ExecContext(ctx, ` + UPDATE automation_runs + SET task_id = ?, session_id = ?, turn_id = ?, thread_action = ?, thread_reason = ?, status = ? + WHERE id = ? AND status IN (?, ?)`, + taskID, sessionID, turnID, action, reason, string(RunStatusTaskCreated), + runID, string(RunStatusTriggered), string(RunStatusTaskCreated)) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return fmt.Errorf("automation run %s is not bindable", runID) + } + return nil +} + +// MarkRunTerminal settles one exact run. Empty session/turn arguments are +// allowed only for a pre-dispatch failure; once both are known they must match +// the persisted binding. +func (s *Store) MarkRunTerminal(ctx context.Context, runID, sessionID, turnID string, status RunStatus, errMsg string) error { + query := `UPDATE automation_runs SET status = ?, error_message = ? WHERE id = ? AND status IN (?, ?)` + args := []any{string(status), errMsg, runID, string(RunStatusTriggered), string(RunStatusTaskCreated)} + if sessionID != "" { + query += ` AND session_id = ?` + args = append(args, sessionID) + } + if turnID != "" { + query += ` AND turn_id = ?` + args = append(args, turnID) + } + res, err := s.db.ExecContext(ctx, query, args...) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return fmt.Errorf("automation run %s does not match the requested terminal binding", runID) + } + return nil +} + +// MarkRunTerminalByBinding settles a run using the exact task/session/turn +// triple observed by lifecycle completion. It is used by shared tasks where +// several runs intentionally point at one task and session. +func (s *Store) MarkRunTerminalByBinding(ctx context.Context, taskID, sessionID, turnID string, status RunStatus, errMsg string) error { + if taskID == "" || sessionID == "" || turnID == "" { + return fmt.Errorf("exact automation run binding is required") + } + res, err := s.db.ExecContext(ctx, ` + UPDATE automation_runs SET status = ?, error_message = ? + WHERE task_id = ? AND session_id = ? AND turn_id = ? AND status = ?`, + string(status), errMsg, taskID, sessionID, turnID, string(RunStatusTaskCreated)) + if err != nil { + return err + } + if affected, _ := res.RowsAffected(); affected == 0 { + return fmt.Errorf("no open automation run matches task/session/turn binding") + } + return nil +} + // 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 an automation run can't answer) makes the run effectively @@ -715,6 +1263,9 @@ func (s *Store) ListRuns(ctx context.Context, automationID string, limit int) ([ if limit <= 0 { limit = 50 } + if limit > maxRunsLimit { + limit = maxRunsLimit + } runs, err := s.listRunsWithTaskState(ctx, automationID, limit) if db.IsMissingTableError(err) { runs, err = s.listRunsRaw(ctx, automationID, limit) @@ -725,9 +1276,84 @@ func (s *Store) ListRuns(ctx context.Context, automationID string, limit int) ([ for _, r := range runs { r.TriggerData = json.RawMessage(r.TriggerDataJSON) } + if err := s.hydrateRunSummaries(ctx, runs); err != nil { + return nil, err + } return runs, nil } +// maxRunsLimit caps the per-automation run history for the same reason the +// workspace feed is capped: a client should not be able to pull unbounded +// historical transcript metadata in one request. +const maxRunsLimit = 200 + +// runSummaryRow is the newest agent message for one exact turn. +type runSummaryRow struct { + TurnID string `db:"turn_id"` + Summary sql.NullString `db:"summary"` +} + +// hydrateRunSummaries replaces the task-level fallback summary with the +// message from the exact turn bound to a run. Reused tasks can contain several +// turns, so looking only at task_id can show a later firing's result for an +// earlier run. Runs without a turn binding retain the legacy task-level query. +// Bound runs are hydrated in one set-based query because these lists are also +// used by live polling and can contain hundreds of runs. +func (s *Store) hydrateRunSummaries(ctx context.Context, runs []*AutomationRun) error { + turnIDs := make([]string, 0, len(runs)) + for _, run := range runs { + if run.TurnID != "" { + turnIDs = append(turnIDs, run.TurnID) + } + } + if len(turnIDs) == 0 { + return nil + } + + placeholders := strings.TrimRight(strings.Repeat("?,", len(turnIDs)), ",") + args := make([]any, len(turnIDs)) + for i, turnID := range turnIDs { + args[i] = turnID + } + var summaries []runSummaryRow + err := s.ro.SelectContext(ctx, &summaries, ` + SELECT m.turn_id, substr(m.content, 1, 280) AS summary + FROM task_session_messages m + WHERE m.turn_id IN (`+placeholders+`) + AND m.author_type = 'agent' AND m.type = 'message' + AND NOT EXISTS ( + SELECT 1 FROM task_session_messages newer + WHERE newer.turn_id = m.turn_id + AND newer.author_type = 'agent' AND newer.type = 'message' + AND (newer.created_at > m.created_at OR + (newer.created_at = m.created_at AND newer.id > m.id)) + )`, args...) + if db.IsMissingTableError(err) { + return nil + } + if err != nil { + return err + } + + for _, run := range runs { + if run.TurnID != "" { + run.Summary = "" + } + } + byTurn := make(map[string]string, len(summaries)) + for _, summary := range summaries { + if summary.Summary.Valid { + byTurn[summary.TurnID] = summary.Summary.String + } + } + for _, run := range runs { + if summary, ok := byTurn[run.TurnID]; ok { + run.Summary = summary + } + } + return nil +} + // 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 @@ -778,7 +1404,9 @@ const runTaskStateColumnsSQL = ` ) THEN ? ELSE ar.status END AS status, - ar.dedup_key, ar.trigger_data, ar.error_message, ar.created_at, + ar.dedup_key, ar.trigger_data, ar.error_message, + ar.session_id, ar.turn_id, ar.thread_action, ar.thread_reason, ar.display_title, + ar.created_at, COALESCE(( SELECT substr(m.content, 1, 280) FROM task_session_messages m WHERE m.task_id = ar.task_id @@ -790,11 +1418,11 @@ const runTaskStateColumnsSQL = ` -- 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(( + COALESCE(NULLIF(ar.session_id, ''), ( SELECT ts.id FROM task_sessions ts WHERE ts.task_id = ar.task_id AND ts.is_primary = 1 LIMIT 1 - ), '') AS session_id` + ), ar.session_id, '') 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 @@ -857,9 +1485,20 @@ func (s *Store) ListWorkspaceRuns(ctx context.Context, workspaceID string, limit for _, r := range runs { r.TriggerData = json.RawMessage(r.TriggerDataJSON) } + if err := s.hydrateWorkspaceRunSummaries(ctx, runs); err != nil { + return nil, err + } return runs, nil } +func (s *Store) hydrateWorkspaceRunSummaries(ctx context.Context, runs []*WorkspaceAutomationRun) error { + plain := make([]*AutomationRun, len(runs)) + for i := range runs { + plain[i] = &runs[i].AutomationRun + } + return s.hydrateRunSummaries(ctx, plain) +} + 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 @@ -918,6 +1557,13 @@ func (s *Store) listSummaries(ctx context.Context, scope string, arg any) ([]*Au if err != nil { return nil, err } + boundRuns := make([]*AutomationRun, len(rows)) + for i := range rows { + boundRuns[i] = &rows[i].AutomationRun + } + if err := s.hydrateRunSummaries(ctx, boundRuns); err != nil { + return nil, err + } summaries := make([]*AutomationSummary, 0, len(rows)) for _, row := range rows { run := row.AutomationRun @@ -952,8 +1598,8 @@ var openRunsSubquerySQL = `( // 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 + 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) { @@ -980,13 +1626,13 @@ func (s *Store) selectSummariesRaw(ctx context.Context, scope string, arg any) ( 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 = ? + SELECT COUNT(*) FROM automation_runs aro + WHERE aro.automation_id = ar.automation_id AND aro.status IN (?, ?) ) AS open_runs FROM automation_runs ar JOIN automations a ON a.id = ar.automation_id WHERE `+scope+` AND `+latestRunPerAutomationSQL, - string(RunStatusTaskCreated), arg) + string(RunStatusTriggered), string(RunStatusTaskCreated), arg) return rows, err } @@ -1042,12 +1688,15 @@ func (s *Store) CountActiveRuns(ctx context.Context, automationID string) (int, // 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 ( + +const openRunPredicateSQL = `( + ar.status = ? + OR (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. @@ -1068,7 +1717,10 @@ func runOrderSQL(alias string) string { // openRunArgs binds openRunPredicateSQL's placeholders, in order. func openRunArgs() []any { - return []any{string(RunStatusTaskCreated), string(taskmodels.TaskSessionStateCancelled)} + return []any{ + string(RunStatusTriggered), string(RunStatusTaskCreated), + string(taskmodels.TaskSessionStateCancelled), + } } func (s *Store) countActiveRunsWithTaskState(ctx context.Context, automationID string) (int, error) { @@ -1084,8 +1736,8 @@ func (s *Store) countActiveRunsWithTaskState(ctx context.Context, automationID s func (s *Store) countActiveRunsRaw(ctx context.Context, automationID string) (int, error) { var count int err := s.ro.GetContext(ctx, &count, - `SELECT COUNT(*) FROM automation_runs WHERE automation_id = ? AND status = ?`, - automationID, string(RunStatusTaskCreated)) + `SELECT COUNT(*) FROM automation_runs WHERE automation_id = ? AND status IN (?, ?)`, + automationID, string(RunStatusTriggered), string(RunStatusTaskCreated)) return count, err } @@ -1115,7 +1767,7 @@ func (s *Store) DeleteRun(ctx context.Context, id string) error { func (s *Store) ListRunTaskIDs(ctx context.Context, automationID string) ([]string, error) { var ids []string err := s.ro.SelectContext(ctx, &ids, - `SELECT task_id FROM automation_runs WHERE automation_id = ? AND task_id != ''`, + `SELECT DISTINCT task_id FROM automation_runs WHERE automation_id = ? AND task_id != ''`, automationID) return ids, err } @@ -1126,6 +1778,26 @@ func (s *Store) DeleteAllRuns(ctx context.Context, automationID string) error { return err } +// ClearContinuationAndDeleteAllRuns atomically releases the reusable task +// pointer with the run rows it protects. The service performs task cleanup +// before calling this method, while holding the automation admission lock. +func (s *Store) ClearContinuationAndDeleteAllRuns(ctx context.Context, automationID string) error { + tx, err := s.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + defer func() { _ = tx.Rollback() }() + if _, err := tx.ExecContext(ctx, + `UPDATE automations SET continuation_task_id = '', updated_at = ? WHERE id = ?`, time.Now().UTC(), automationID); err != nil { + return err + } + if _, err := tx.ExecContext(ctx, + `DELETE FROM automation_runs WHERE automation_id = ?`, automationID); err != nil { + return err + } + return tx.Commit() +} + // DeleteAutomationsByWorkspace removes all automations (and their triggers/runs) for a workspace. // Used by e2e reset. func (s *Store) DeleteAutomationsByWorkspace(ctx context.Context, workspaceID string) (int, error) { diff --git a/apps/backend/internal/automation/store_runs_test.go b/apps/backend/internal/automation/store_runs_test.go index 29894b67ad..33565eb357 100644 --- a/apps/backend/internal/automation/store_runs_test.go +++ b/apps/backend/internal/automation/store_runs_test.go @@ -21,7 +21,7 @@ func createTasksTable(t *testing.T, store *Store) { // 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 { + if _, err := store.db.Exec(`CREATE TABLE task_session_messages (id TEXT PRIMARY KEY, task_id TEXT NOT NULL DEFAULT '', turn_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 @@ -320,6 +320,51 @@ func TestListRuns_CarriesTheAgentsLastMessageAsSummary(t *testing.T) { } } +func TestListRunsSummaryUsesTheRunTurn(t *testing.T) { + store := setupTestStore(t) + ctx := context.Background() + createTasksTable(t, store) + + a := &Automation{WorkspaceID: "ws-1", Name: "turn-aware", WorkflowID: "wf-1", WorkflowStepID: "s-1"} + if err := store.CreateAutomation(ctx, a); err != nil { + t.Fatal(err) + } + insertTask(t, store, "task-turns", false) + if err := store.CreateRun(ctx, &AutomationRun{ + AutomationID: a.ID, + TriggerType: TriggerTypeScheduled, + Status: RunStatusSucceeded, + TaskID: "task-turns", + SessionID: "session-1", + TurnID: "turn-1", + TriggerData: json.RawMessage(`{}`), + }); err != nil { + t.Fatal(err) + } + seed := func(id, turnID, content string, at time.Time) { + t.Helper() + if _, err := store.db.Exec( + `INSERT INTO task_session_messages (id, task_id, turn_id, author_type, content, type, created_at) VALUES (?,?,?,?,?,?,?)`, + id, "task-turns", turnID, "agent", content, "message", at); err != nil { + t.Fatal(err) + } + } + base := time.Now().UTC() + seed("turn-1-message", "turn-1", "Summary for the first run", base) + seed("turn-2-message", "turn-2", "A newer summary from a different run", base.Add(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 != "Summary for the first run" { + t.Fatalf("summary = %q, want the exact bound turn's message", runs[0].Summary) + } +} + func TestListRuns_SummaryIsEmptyWhenTheAgentNeverSpoke(t *testing.T) { store := setupTestStore(t) ctx := context.Background() diff --git a/apps/backend/internal/automation/target_mode.go b/apps/backend/internal/automation/target_mode.go new file mode 100644 index 0000000000..bce377e751 --- /dev/null +++ b/apps/backend/internal/automation/target_mode.go @@ -0,0 +1,50 @@ +package automation + +import ( + "errors" + "fmt" + "strings" +) + +var ( + ErrInvalidTaskMode = errors.New("automation: invalid task mode") + ErrInvalidRepositoryMode = errors.New("automation: invalid repository mode") + ErrWorkflowRequired = errors.New("automation: normal task mode requires a workflow") +) + +// validateAutomationTarget enforces the persisted target contract shared by +// create, update, and direct store callers. Repository selection is kept +// separate from task visibility so a normal task can still use the local +// scratch executor when it has no repository. +func validateAutomationTarget(taskMode TaskMode, repositoryMode RepositoryMode, workflowID string, repositoryIDs []string) error { + if taskMode == "" { + taskMode = TaskModeAutomationRun + } + switch taskMode { + case TaskModeAutomationRun: + case TaskModeNormalTask: + if strings.TrimSpace(workflowID) == "" { + return ErrWorkflowRequired + } + default: + return fmt.Errorf("%w: %q", ErrInvalidTaskMode, taskMode) + } + + if repositoryMode == "" { + repositoryMode = RepositoryModeNone + } + switch repositoryMode { + case RepositoryModeSelected: + if len(repositoryIDs) == 0 { + return fmt.Errorf("%w: selected requires repository_ids", ErrInvalidRepositoryMode) + } + case RepositoryModeNone: + if len(repositoryIDs) > 0 { + return fmt.Errorf("%w: none cannot include repository_ids", ErrInvalidRepositoryMode) + } + default: + return fmt.Errorf("%w: %q", ErrInvalidRepositoryMode, repositoryMode) + } + + return nil +} diff --git a/apps/backend/internal/backendapp/agents.go b/apps/backend/internal/backendapp/agents.go index 74979ced8a..a7280e094e 100644 --- a/apps/backend/internal/backendapp/agents.go +++ b/apps/backend/internal/backendapp/agents.go @@ -34,6 +34,8 @@ func provideLifecycleManager( baseBranchProvider lifecycle.BaseBranchProvider, comparisonTargetProvider lifecycle.ComparisonTargetProvider, managedRuntimeSelections managedruntime.SelectionReader, + mcpIdentityScoper lifecycle.MCPIdentityScoper, + mcpPrincipalScoper lifecycle.MCPPrincipalScoper, ) (*lifecycle.Manager, error) { log.Info("Initializing Agent Manager...") @@ -147,6 +149,16 @@ func provideLifecycleManager( // Wire the comparison-target provider before Start so recovered executions // hydrate the exact provider-qualified ref before their first status poll. lifecycleMgr.SetComparisonTargetProvider(comparisonTargetProvider) + // Recovered executions can dispatch MCP calls as soon as Start resumes + // their streams. Install both trusted scopes before Start, not after route + // registration, so the first recovered call has the same authority boundary + // as every later call. + if mcpIdentityScoper != nil { + lifecycleMgr.SetMCPIdentityScoper(mcpIdentityScoper) + } + if mcpPrincipalScoper != nil { + lifecycleMgr.SetMCPPrincipalScoper(mcpPrincipalScoper) + } if err := lifecycleMgr.Start(ctx); err != nil { return nil, err diff --git a/apps/backend/internal/backendapp/e2e_reset.go b/apps/backend/internal/backendapp/e2e_reset.go index b1b878f169..c638559c4b 100644 --- a/apps/backend/internal/backendapp/e2e_reset.go +++ b/apps/backend/internal/backendapp/e2e_reset.go @@ -59,7 +59,7 @@ func registerE2EResetRoutes( // through the WS API (works on any Node version). if automationSvc != nil { api.POST("/automations", handleE2ECreateAutomation(automationSvc, repo, log)) - api.POST("/automation-runs", handleE2ECreateAutomationRun(automationSvc, log)) + api.POST("/automation-runs", handleE2ECreateAutomationRun(automationSvc, repo, log)) api.POST("/automation-triggers", handleE2EAddTrigger(automationSvc, log)) // Fire a fake github_pr_merged event into the in-process event bus so // E2E tests can exercise the automation subscriber without real GitHub @@ -321,7 +321,7 @@ func deleteAutomationsForReset( if automationSvc == nil { return 0, nil } - return automationSvc.Store().DeleteAutomationsByWorkspace(ctx, workspaceID) + return automationSvc.DeleteAutomationsByWorkspace(ctx, workspaceID) } type e2eHiddenWorkflowRequest struct { @@ -356,10 +356,14 @@ func handleE2ECreateHiddenWorkflow(taskSvc *taskservice.Service, log *logger.Log } type e2eCreateAutomationRequest struct { - WorkspaceID string `json:"workspace_id"` - Name string `json:"name"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` + WorkspaceID string `json:"workspace_id"` + Name string `json:"name"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + TaskMode automation.TaskMode `json:"task_mode"` + RepositoryMode automation.RepositoryMode `json:"repository_mode"` + RepositoryIDs []string `json:"repository_ids"` + Repositories []automation.AutomationRepository `json:"repositories"` // 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. @@ -402,6 +406,10 @@ func handleE2ECreateAutomation( Name: body.Name, WorkflowID: body.WorkflowID, WorkflowStepID: body.WorkflowStepID, + TaskMode: body.TaskMode, + RepositoryMode: body.RepositoryMode, + RepositoryIDs: body.RepositoryIDs, + Repositories: body.Repositories, Prompt: body.Prompt, AgentProfileID: body.AgentProfileID, ExecutorProfileID: body.ExecutorProfileID, @@ -433,10 +441,14 @@ type e2eCreateAutomationRunRequest struct { // exercise task-lifecycle interactions (e.g. archiving the task and // asserting the run's displayed status/concurrency accounting reacts). TaskID string `json:"task_id"` + // When a task is supplied, the endpoint derives the current session and + // open turn so E2E tests can exercise exact-run actions such as stop. + SessionID string `json:"session_id,omitempty"` + TurnID string `json:"turn_id,omitempty"` } // handleE2ECreateAutomationRun seeds an automation run row for E2E tests. -func handleE2ECreateAutomationRun(svc *automation.Service, log *logger.Logger) gin.HandlerFunc { +func handleE2ECreateAutomationRun(svc *automation.Service, repo *sqliterepo.Repository, log *logger.Logger) gin.HandlerFunc { return func(c *gin.Context) { var body e2eCreateAutomationRunRequest if err := c.ShouldBindJSON(&body); err != nil || body.AutomationID == "" { @@ -452,8 +464,18 @@ func handleE2ECreateAutomationRun(svc *automation.Service, log *logger.Logger) g TriggerType: automation.TriggerTypeScheduled, Status: status, TaskID: body.TaskID, + SessionID: body.SessionID, + TurnID: body.TurnID, TriggerData: []byte(`{}`), } + if run.TaskID != "" && (run.SessionID == "" || run.TurnID == "") { + if session, sessionErr := repo.GetActiveTaskSessionByTaskID(c.Request.Context(), run.TaskID); sessionErr == nil && session != nil { + run.SessionID = session.ID + if turn, turnErr := repo.GetActiveTurnBySessionID(c.Request.Context(), session.ID); turnErr == nil && turn != nil { + run.TurnID = turn.ID + } + } + } if err := svc.RecordRun(c.Request.Context(), run); err != nil { log.Error("e2e: failed to create automation run", zap.Error(err)) c.JSON(http.StatusInternalServerError, gin.H{errKey: err.Error()}) @@ -461,6 +483,7 @@ func handleE2ECreateAutomationRun(svc *automation.Service, log *logger.Logger) g } c.JSON(http.StatusCreated, gin.H{ "id": run.ID, "automation_id": run.AutomationID, statusKey: run.Status, taskIDPayloadKey: run.TaskID, + "session_id": run.SessionID, "turn_id": run.TurnID, }) } } @@ -710,8 +733,10 @@ func handleE2EAutomationManualTrigger(svc *automation.Service, log *logger.Logge } } -// e2ePollNewRun blocks until a new run (with a different id than beforeID) appears -// for automationID, or until 5 seconds elapse. Returns the new run's TaskID. +// e2ePollNewRun blocks until a new run (with a different id than beforeID) has +// a task binding for automationID, or until 5 seconds elapse. The trigger row +// is admitted before the event handler creates/binds its task, so observing the +// row alone can return an empty task ID to an E2E caller. func e2ePollNewRun(ctx context.Context, svc *automation.Service, automationID, beforeID string) (string, error) { deadline := time.Now().Add(5 * time.Second) for time.Now().Before(deadline) { @@ -720,7 +745,7 @@ func e2ePollNewRun(ctx context.Context, svc *automation.Service, automationID, b if err != nil || len(runs) == 0 { continue } - if runs[0].ID != beforeID { + if runs[0].ID != beforeID && runs[0].TaskID != "" { return runs[0].TaskID, nil } } diff --git a/apps/backend/internal/backendapp/helpers.go b/apps/backend/internal/backendapp/helpers.go index 67d54c16ab..f3adcddfe9 100644 --- a/apps/backend/internal/backendapp/helpers.go +++ b/apps/backend/internal/backendapp/helpers.go @@ -1696,16 +1696,20 @@ func registerMCPAndDebugRoutes( p.log.Debug("MCP handler configured for agent lifecycle manager") // In-session MCP calls reach this same dispatcher over the agent's own WS - // stream, which carries no credential — so scope them to the user who owns - // the stream's task. Without this the handlers run with no identity, which - // the task service treats as an internal caller and serves unscoped. + // stream, which carries no credential. Always attach the server-derived + // task/session principal so automation self/workspace boundaries remain in + // force even when authentication is disabled or unavailable. Owner identity + // scoping is conditional because single-user installs intentionally retain + // their existing unscoped behavior. + mcpScopeResolver := mcpscope.NewResolver( + p.taskRepo, + p.authSvc, + func() bool { return p.authSvc != nil && p.authSvc.Mode() != auth.ModeDisabled }, + p.log, + ) + p.lifecycleMgr.SetMCPPrincipalScoper(mcpScopeResolver.ScopePrincipal) if p.authSvc != nil { - p.lifecycleMgr.SetMCPIdentityScoper(mcpscope.NewResolver( - p.taskRepo, - p.authSvc, - func() bool { return p.authSvc.Mode() != auth.ModeDisabled }, - p.log, - ).Scope) + p.lifecycleMgr.SetMCPIdentityScoper(mcpScopeResolver.Scope) p.log.Debug("In-session MCP dispatch scoped to task owner") } diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index bd77343b8b..622996eb7a 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -20,6 +20,7 @@ import ( "time" "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/auth" authhttpmw "github.com/kandev/kandev/internal/auth/httpmw" "github.com/kandev/kandev/internal/common/httpmw" "github.com/kandev/kandev/internal/entityrefs" @@ -58,6 +59,7 @@ import ( agentsettingscontroller "github.com/kandev/kandev/internal/agent/settings/controller" settingsstore "github.com/kandev/kandev/internal/agent/settings/store" agentctltracing "github.com/kandev/kandev/internal/agentctl/tracing" + mcpscope "github.com/kandev/kandev/internal/mcp/scope" "github.com/kandev/kandev/internal/utility/profilebinding" // WebSocket gateway @@ -493,6 +495,12 @@ func startAgentInfrastructure( restoreCleanups = append(restoreCleanups, stop) } userSecretStore := secrets.NewUserVisibleStore(repos.Secrets) + mcpScopeResolver := mcpscope.NewResolver( + repos.Task, + services.Auth, + func() bool { return services.Auth != nil && services.Auth.Mode() != auth.ModeDisabled }, + log, + ) // ============================================ // AGENT MANAGER // ============================================ @@ -507,6 +515,8 @@ func startAgentInfrastructure( services.Task.TaskBaseBranches, services.Task.TaskComparisonTargets, services.ManagedRuntimeSelections, + mcpScopeResolver.Scope, + mcpScopeResolver.ScopePrincipal, ) if err != nil { log.Error("Failed to initialize agent manager", zap.Error(err)) @@ -648,6 +658,8 @@ func startAgentInfrastructure( // tasks via the task service directly. if services.Automation != nil { orchestratorSvc.SetAutomationService(services.Automation.Service) + services.Automation.Service.SetRunStopper(orchestratorSvc) + services.Automation.Service.SetRunLivenessChecker(orchestratorSvc) } // Wire GitHub service into orchestrator for PR auto-detection on push diff --git a/apps/backend/internal/mcp/handlers/agent_permissions.go b/apps/backend/internal/mcp/handlers/agent_permissions.go index 824b8aa4f2..22ce0390f8 100644 --- a/apps/backend/internal/mcp/handlers/agent_permissions.go +++ b/apps/backend/internal/mcp/handlers/agent_permissions.go @@ -6,6 +6,7 @@ import ( "errors" mcporigin "github.com/kandev/kandev/internal/mcp/origin" + mcpscope "github.com/kandev/kandev/internal/mcp/scope" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/task/models" ws "github.com/kandev/kandev/pkg/websocket" @@ -65,15 +66,21 @@ func (h *Handlers) handleResolveAgentPermission(ctx context.Context, msg *ws.Mes return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, required.field+" is required", nil) } } - if !mcporigin.IsTrustedExternalTransport(ctx) { + principal, hasPrincipal := mcpscope.PrincipalFromContext(ctx) + isAutomation := hasPrincipal && principal.IsAutomation() + if !mcporigin.IsTrustedExternalTransport(ctx) && !isAutomation { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeForbidden, "Agent permission resolution requires external MCP", nil) } if h.agentPermissionSvc == nil { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Agent permission service is not available", nil) } + source := models.PermissionSourceExternalMCP + if isAutomation && principal.IsAutomation() { + source = models.PermissionSourceAutomationMCP + } result, err := h.agentPermissionSvc.ResolveAgentPermission(ctx, orchestrator.ResolveAgentPermissionRequest{ TaskID: request.TaskID, SessionID: request.SessionID, RequestID: request.RequestID, - PendingID: request.PendingID, OptionID: request.OptionID, Source: models.PermissionSourceExternalMCP, + PendingID: request.PendingID, OptionID: request.OptionID, Source: source, }) if err != nil { return h.agentPermissionError(msg, err) diff --git a/apps/backend/internal/mcp/handlers/agent_permissions_test.go b/apps/backend/internal/mcp/handlers/agent_permissions_test.go index 8c23a56aff..f7db4fab0b 100644 --- a/apps/backend/internal/mcp/handlers/agent_permissions_test.go +++ b/apps/backend/internal/mcp/handlers/agent_permissions_test.go @@ -9,6 +9,8 @@ import ( "github.com/kandev/kandev/internal/agentctl/types/streams" mcporigin "github.com/kandev/kandev/internal/mcp/origin" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" + mcpscope "github.com/kandev/kandev/internal/mcp/scope" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/task/models" ws "github.com/kandev/kandev/pkg/websocket" @@ -102,6 +104,24 @@ func TestHandleResolveAgentPermissionRejectsUntrustedTransport(t *testing.T) { assert.Empty(t, stub.resolved.TaskID, "untrusted dispatch must not reach the permission service") } +func TestHandleResolveAgentPermissionDoesNotTreatNormalTaskPrincipalAsAutomation(t *testing.T) { + stub := &permissionServiceStub{resolve: &orchestrator.ResolveAgentPermissionResult{Status: "resolved"}} + h := &Handlers{agentPermissionSvc: stub, logger: testLogger(t).WithFields()} + ctx := mcpscope.WithPrincipal(context.Background(), mcpscope.Principal{ + CallerTaskID: "task-1", CallerSessionID: "session-1", WorkspaceID: "workspace-1", + Surface: mcpprofile.SurfaceKanbanTask, + }) + + resp, err := h.handleResolveAgentPermission(ctx, makeWSMessage(t, ws.ActionMCPResolveAgentPermission, map[string]any{ + "task_id": "task-1", "session_id": "session-1", "request_id": "request-1", + "pending_id": "pending-1", "option_id": "allow-once", + })) + require.NoError(t, err) + assert.Equal(t, ws.MessageTypeError, resp.Type) + assert.Contains(t, string(resp.Payload), ws.ErrorCodeForbidden) + assert.Empty(t, stub.resolved.TaskID, "a normal task principal must not authorize permission resolution") +} + func TestAgentPermissionHandlersValidateRequiredIdentity(t *testing.T) { h := &Handlers{agentPermissionSvc: &permissionServiceStub{}, logger: testLogger(t).WithFields()} diff --git a/apps/backend/internal/mcp/handlers/automation_authorization.go b/apps/backend/internal/mcp/handlers/automation_authorization.go new file mode 100644 index 0000000000..fcc60ed259 --- /dev/null +++ b/apps/backend/internal/mcp/handlers/automation_authorization.go @@ -0,0 +1,288 @@ +package handlers + +import ( + "context" + "encoding/json" + + mcpscope "github.com/kandev/kandev/internal/mcp/scope" + ws "github.com/kandev/kandev/pkg/websocket" +) + +// mcpActionRegistrar is the small registration surface shared by the normal +// dispatcher and the automation-aware wrapper. +type mcpActionRegistrar interface { + RegisterFunc(string, ws.HandlerFunc) +} + +type guardedMCPDispatcher struct { + *ws.Dispatcher + handlers *Handlers +} + +func (d *guardedMCPDispatcher) RegisterFunc(action string, handler ws.HandlerFunc) { + d.Dispatcher.RegisterFunc(action, func(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + guarded, replacement, err := d.handlers.authorizeAutomationRequest(ctx, msg) + if guarded != nil { + return guarded, err + } + return handler(ctx, replacement) + }) +} + +// automationSurfaceActions is the execution-time mirror of the fixed +// SurfaceAutomation catalog. Discovery alone is not an authorization boundary +// because an agent can still send a raw WebSocket action. +var automationSurfaceActions = map[string]struct{}{ + ws.ActionMCPListWorkspaces: {}, + ws.ActionMCPListWorkflows: {}, + ws.ActionMCPListWorkflowSteps: {}, + ws.ActionMCPListRepositories: {}, + ws.ActionMCPListTasks: {}, + ws.ActionMCPListAgents: {}, + ws.ActionMCPListExecutors: {}, + ws.ActionMCPListExecutorProfiles: {}, + ws.ActionMCPListRelatedTasks: {}, + ws.ActionMCPGetTaskConversation: {}, + ws.ActionMCPListTaskSessions: {}, + ws.ActionMCPCreateTask: {}, + ws.ActionMCPUpdateTask: {}, + ws.ActionMCPMoveTask: {}, + ws.ActionMCPArchiveTask: {}, + ws.ActionMCPAddTaskDependency: {}, + ws.ActionMCPRemoveTaskDependency: {}, + ws.ActionMCPMessageTask: {}, + ws.ActionMCPStopTask: {}, + ws.ActionMCPSpawnSession: {}, + ws.ActionMCPListPendingQuestions: {}, + ws.ActionMCPAnswerQuestion: {}, + ws.ActionMCPListPendingAgentPermissions: {}, + ws.ActionMCPResolveAgentPermission: {}, +} + +var automationSelfDeniedActions = map[string]struct{}{ + ws.ActionMCPCreateTask: {}, + ws.ActionMCPUpdateTask: {}, + ws.ActionMCPMoveTask: {}, + ws.ActionMCPArchiveTask: {}, + ws.ActionMCPAddTaskDependency: {}, + ws.ActionMCPRemoveTaskDependency: {}, + ws.ActionMCPMessageTask: {}, + ws.ActionMCPStopTask: {}, + ws.ActionMCPSpawnSession: {}, + ws.ActionMCPListPendingQuestions: {}, + ws.ActionMCPAnswerQuestion: {}, + ws.ActionMCPListPendingAgentPermissions: {}, + ws.ActionMCPResolveAgentPermission: {}, +} + +// authorizeAutomationRequest is the one execution-time boundary for the +// fixed automation surface. It rejects raw actions outside the catalog, +// verifies supplied resources belong to the principal workspace, rejects +// self-targets for coordination operations, and replaces caller attribution +// fields with the trusted stream identity. +func (h *Handlers) authorizeAutomationRequest(ctx context.Context, msg *ws.Message) (*ws.Message, *ws.Message, error) { + principal, ok := mcpscope.PrincipalFromContext(ctx) + if !ok || !principal.IsAutomation() { + return nil, msg, nil + } + if _, allowed := automationSurfaceActions[msg.Action]; !allowed { + response, err := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeUnknownAction, + "tool is not available on the automation MCP surface", nil) + return response, nil, err + } + if h.taskSvc == nil || principal.WorkspaceID == "" { + return automationNotFound(msg) + } + + fields, err := automationPayloadFields(msg.Payload) + if err != nil { + response, responseErr := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, + "Invalid payload: "+err.Error(), nil) + return response, nil, responseErr + } + if !h.authorizeAutomationScalarFields(ctx, principal, msg.Action, fields) || + !h.authorizeAutomationReferenceFields(ctx, principal, msg.Action, fields) { + return automationNotFound(msg) + } + changed := rewriteAutomationPayload(principal, msg.Action, fields) + if !changed { + return nil, msg, nil + } + payload, err := json.Marshal(fields) + if err != nil { + return automationNotFound(msg) + } + replacement := *msg + replacement.Payload = payload + return nil, &replacement, nil +} + +func automationPayloadFields(payload json.RawMessage) (map[string]json.RawMessage, error) { + fields := make(map[string]json.RawMessage) + if len(payload) == 0 { + return fields, nil + } + if err := json.Unmarshal(payload, &fields); err != nil { + return nil, err + } + if fields == nil { + fields = make(map[string]json.RawMessage) + } + return fields, nil +} + +func (h *Handlers) authorizeAutomationScalarFields( + ctx context.Context, + principal mcpscope.Principal, + action string, + fields map[string]json.RawMessage, +) bool { + workspaceID := jsonStringField(fields, "workspace_id") + if workspaceID != "" && workspaceID != principal.WorkspaceID { + return false + } + for _, field := range []string{"source_task_id", "caller_task_id"} { + value := jsonStringField(fields, field) + if value != "" && value != principal.CallerTaskID { + return false + } + } + parentID := jsonStringField(fields, "parent_id") + if parentID != "" && (parentID == principal.CallerTaskID || !h.authorizeAutomationTask(ctx, principal, parentID, action, false)) { + return false + } + taskID := jsonStringField(fields, "task_id") + return taskID == "" || h.authorizeAutomationTask(ctx, principal, taskID, action, true) +} + +func (h *Handlers) authorizeAutomationReferenceFields( + ctx context.Context, + principal mcpscope.Principal, + action string, + fields map[string]json.RawMessage, +) bool { + if workflowID := jsonStringField(fields, "workflow_id"); workflowID != "" { + workflow, err := h.taskSvc.GetWorkflow(ctx, workflowID) + if err != nil || workflow == nil || workflow.WorkspaceID != principal.WorkspaceID { + return false + } + } + if sessionID := jsonStringField(fields, "session_id"); sessionID != "" { + session, err := h.taskSvc.GetTaskSession(ctx, sessionID) + if err != nil || session == nil || !h.authorizeAutomationTask(ctx, principal, session.TaskID, action, true) { + return false + } + } + if dependencyID := jsonStringField(fields, "depends_on_task_id"); dependencyID != "" && + !h.authorizeAutomationTask(ctx, principal, dependencyID, action, true) { + return false + } + for _, blockedByID := range jsonStringSliceField(fields, "blocked_by") { + if !h.authorizeAutomationTask(ctx, principal, blockedByID, action, true) { + return false + } + } + return h.authorizeAutomationRepositoryFields(ctx, principal, fields) +} + +func (h *Handlers) authorizeAutomationRepositoryFields( + ctx context.Context, + principal mcpscope.Principal, + fields map[string]json.RawMessage, +) bool { + if repositoryID := jsonStringField(fields, "repository_id"); repositoryID != "" && + !h.authorizeAutomationRepository(ctx, principal, repositoryID) { + return false + } + for _, repositoryID := range jsonRepositoryIDs(fields, "repositories") { + if !h.authorizeAutomationRepository(ctx, principal, repositoryID) { + return false + } + } + return true +} + +func (h *Handlers) authorizeAutomationRepository(ctx context.Context, principal mcpscope.Principal, repositoryID string) bool { + repository, err := h.taskSvc.GetRepository(ctx, repositoryID) + return err == nil && repository != nil && repository.WorkspaceID == principal.WorkspaceID +} + +func rewriteAutomationPayload(principal mcpscope.Principal, action string, fields map[string]json.RawMessage) bool { + changed := false + if action == ws.ActionMCPListPendingQuestions || action == ws.ActionMCPListRepositories { + fields["workspace_id"], _ = json.Marshal(principal.WorkspaceID) + changed = true + } + for key, value := range map[string]string{ + "sender_task_id": principal.CallerTaskID, + "sender_session_id": principal.CallerSessionID, + "caller_task_id": principal.CallerTaskID, + "source_task_id": principal.CallerTaskID, + "source_session_id": principal.CallerSessionID, + } { + fields[key], _ = json.Marshal(value) + changed = true + } + if action == ws.ActionMCPCreateTask && jsonStringField(fields, "workspace_id") == "" { + fields["workspace_id"], _ = json.Marshal(principal.WorkspaceID) + changed = true + } + return changed +} + +func (h *Handlers) authorizeAutomationTask( + ctx context.Context, + principal mcpscope.Principal, + taskID, action string, + denySelf bool, +) bool { + task, err := h.taskSvc.GetTask(ctx, taskID) + if err != nil || task == nil || task.WorkspaceID != principal.WorkspaceID { + return false + } + if denySelf && task.ID == principal.CallerTaskID { + if _, denied := automationSelfDeniedActions[action]; denied { + return false + } + } + return true +} + +func jsonStringField(fields map[string]json.RawMessage, key string) string { + var value string + if raw, ok := fields[key]; ok { + _ = json.Unmarshal(raw, &value) + } + return value +} + +func jsonStringSliceField(fields map[string]json.RawMessage, key string) []string { + var values []string + if raw, ok := fields[key]; ok { + _ = json.Unmarshal(raw, &values) + } + return values +} + +func jsonRepositoryIDs(fields map[string]json.RawMessage, key string) []string { + var inputs []struct { + RepositoryID string `json:"repository_id"` + } + if raw, ok := fields[key]; ok { + _ = json.Unmarshal(raw, &inputs) + } + ids := make([]string, 0, len(inputs)) + for _, input := range inputs { + if input.RepositoryID != "" { + ids = append(ids, input.RepositoryID) + } + } + return ids +} + +func automationNotFound(msg *ws.Message) (*ws.Message, *ws.Message, error) { + response, err := ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "target not found", nil) + return response, nil, err +} + +var _ mcpActionRegistrar = (*guardedMCPDispatcher)(nil) diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 9c3f6745b1..193feab6c7 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -20,6 +20,7 @@ import ( "github.com/kandev/kandev/internal/common/logger" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + mcpscope "github.com/kandev/kandev/internal/mcp/scope" "github.com/kandev/kandev/internal/office/dashboard" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/orchestrator/executor" @@ -401,10 +402,25 @@ func (h *Handlers) SetPluginService(svc *plugins.Service) { } // RegisterHandlers registers all MCP handlers with the dispatcher. -func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { +func (h *Handlers) RegisterHandlers(dispatcher *ws.Dispatcher) { + d := &guardedMCPDispatcher{Dispatcher: dispatcher, handlers: h} before := d.HandlerCount() + h.registerTaskModeHandlers(d) + h.registerConfigModeHandlers(d) - // Task-mode handlers (always registered) + after := d.HandlerCount() + h.logger.Info("registered MCP handlers", zap.Int("count", after-before)) +} + +func (h *Handlers) registerTaskModeHandlers(d *guardedMCPDispatcher) { + h.registerTaskReadHandlers(d) + h.registerTaskMutationHandlers(d) + h.registerTaskPlanHandlers(d) + h.registerTaskQuestionHandlers(d) + h.registerReviewHandlers(d) +} + +func (h *Handlers) registerTaskReadHandlers(d *guardedMCPDispatcher) { d.RegisterFunc(ws.ActionMCPListWorkspaces, h.handleListWorkspaces) if h.pluginSvc != nil { d.RegisterFunc(ws.ActionMCPListPluginTools, h.handleListPluginTools) @@ -414,13 +430,20 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPListWorkflowSteps, h.handleListWorkflowSteps) d.RegisterFunc(ws.ActionMCPListRepositories, h.handleListRepositories) d.RegisterFunc(ws.ActionMCPListTasks, h.handleListTasks) - d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask) - d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask) - d.RegisterFunc(ws.ActionMCPSetTaskTitle, h.handleSetTaskTitle) d.RegisterFunc(ws.ActionMCPGetTaskPRAutomation, h.handleGetTaskPRAutomation) d.RegisterFunc(ws.ActionMCPUpdateTaskPRAutomation, h.handleUpdateTaskPRAutomation) d.RegisterFunc(ws.ActionMCPGetTaskMRAutomation, h.handleGetTaskMRAutomation) d.RegisterFunc(ws.ActionMCPUpdateTaskMRAutomation, h.handleUpdateTaskMRAutomation) + d.RegisterFunc(ws.ActionMCPGetTaskConversation, h.handleGetTaskConversation) + d.RegisterFunc(ws.ActionMCPListTaskSessions, h.handleListTaskSessions) + d.RegisterFunc(ws.ActionMCPListPendingAgentPermissions, h.handleListPendingAgentPermissions) + d.RegisterFunc(ws.ActionMCPResolveAgentPermission, h.handleResolveAgentPermission) +} + +func (h *Handlers) registerTaskMutationHandlers(d *guardedMCPDispatcher) { + d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask) + d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask) + d.RegisterFunc(ws.ActionMCPSetTaskTitle, h.handleSetTaskTitle) d.RegisterFunc(ws.ActionMCPAddTaskDependency, h.handleAddTaskDependency) d.RegisterFunc(ws.ActionMCPRemoveTaskDependency, h.handleRemoveTaskDependency) d.RegisterFunc(ws.ActionMCPAddBranchToTask, h.handleAddBranchToTask) @@ -430,12 +453,9 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPMessageTask, h.handleMessageTask) d.RegisterFunc(ws.ActionMCPStopTask, h.handleStopTask) d.RegisterFunc(ws.ActionMCPSpawnSession, h.handleSpawnSession) - d.RegisterFunc(ws.ActionMCPGetTaskConversation, h.handleGetTaskConversation) - d.RegisterFunc(ws.ActionMCPListTaskSessions, h.handleListTaskSessions) - d.RegisterFunc(ws.ActionMCPListPendingAgentPermissions, h.handleListPendingAgentPermissions) - d.RegisterFunc(ws.ActionMCPResolveAgentPermission, h.handleResolveAgentPermission) - d.RegisterFunc(ws.ActionMCPAskUserQuestion, h.handleAskUserQuestion) - d.RegisterFunc(ws.ActionMCPAskParentQuestion, h.handleAskParentQuestion) +} + +func (h *Handlers) registerTaskPlanHandlers(d *guardedMCPDispatcher) { d.RegisterFunc(ws.ActionMCPCreateTaskPlan, h.handleCreateTaskPlan) d.RegisterFunc(ws.ActionMCPGetTaskPlan, h.handleGetTaskPlan) d.RegisterFunc(ws.ActionMCPUpdateTaskPlan, h.handleUpdateTaskPlan) @@ -443,11 +463,15 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPShowWalkthrough, h.handleShowWalkthrough) d.RegisterFunc(ws.ActionMCPGetWalkthrough, h.handleGetWalkthrough) d.RegisterFunc(ws.ActionMCPDeleteWalkthrough, h.handleDeleteWalkthrough) - // Plain (non-MCP) action so the web UI can backfill the current walkthrough - // on mount — live task.walkthrough.created events can fire before the page's - // WS subscription is established. Reuses the same read handler. + // Plain actions let the web UI backfill or remove the current walkthrough + // when its subscription was established after the creation event. d.RegisterFunc(ws.ActionTaskWalkthroughGet, h.handleGetWalkthrough) d.RegisterFunc(ws.ActionTaskWalkthroughDelete, h.handleDeleteWalkthrough) +} + +func (h *Handlers) registerTaskQuestionHandlers(d *guardedMCPDispatcher) { + d.RegisterFunc(ws.ActionMCPAskUserQuestion, h.handleAskUserQuestion) + d.RegisterFunc(ws.ActionMCPAskParentQuestion, h.handleAskParentQuestion) d.RegisterFunc(ws.ActionMCPClarificationTimeout, h.handleClarificationTimeout) if h.diagnosticBundles != nil && h.diagnosticMaterializer != nil { d.RegisterFunc(ws.ActionMCPGetDiagnosticBundle, h.handleGetDiagnosticBundle) @@ -456,29 +480,17 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPListPendingQuestions, h.handleListPendingQuestions) d.RegisterFunc(ws.ActionMCPAnswerQuestion, h.handleAnswerQuestion) } - h.registerReviewHandlers(d) +} - // Config-mode handlers (registered when config deps are set) +func (h *Handlers) registerConfigModeHandlers(d *guardedMCPDispatcher) { if h.workflowSvc != nil { - d.RegisterFunc(ws.ActionMCPCreateWorkflow, h.handleCreateWorkflow) - d.RegisterFunc(ws.ActionMCPUpdateWorkflow, h.handleUpdateWorkflow) - d.RegisterFunc(ws.ActionMCPDeleteWorkflow, h.handleDeleteWorkflow) - d.RegisterFunc(ws.ActionMCPImportWorkflow, h.handleImportWorkflow) - d.RegisterFunc(ws.ActionMCPExportWorkflow, h.handleExportWorkflow) - d.RegisterFunc(ws.ActionMCPCreateWorkflowStep, h.handleCreateWorkflowStep) - d.RegisterFunc(ws.ActionMCPUpdateWorkflowStep, h.handleUpdateWorkflowStep) - d.RegisterFunc(ws.ActionMCPDeleteWorkflowStep, h.handleDeleteWorkflowStep) - d.RegisterFunc(ws.ActionMCPReorderWorkflowStep, h.handleReorderWorkflowSteps) + h.registerWorkflowHandlers(d) } if h.agentSettingsCtrl != nil { - d.RegisterFunc(ws.ActionMCPListAgents, h.handleListAgents) - d.RegisterFunc(ws.ActionMCPUpdateAgent, h.handleUpdateAgent) - d.RegisterFunc(ws.ActionMCPListAgentProfiles, h.handleListAgentProfiles) - d.RegisterFunc(ws.ActionMCPCreateAgentProfile, h.handleCreateAgentProfile) - d.RegisterFunc(ws.ActionMCPUpdateAgentProfile, h.handleUpdateAgentProfile) - d.RegisterFunc(ws.ActionMCPDeleteAgentProfile, h.handleDeleteAgentProfile) + h.registerAgentHandlers(d) } - // Executor discovery/profile listing is always available (read-only, used in task mode for create_task) + // Executor discovery/profile listing is always available for task-mode + // create_task, while executor mutations remain config-mode only. if h.taskSvc != nil { d.RegisterFunc(ws.ActionMCPListExecutors, h.handleListExecutors) d.RegisterFunc(ws.ActionMCPListExecutorProfiles, h.handleListExecutorProfiles) @@ -497,21 +509,41 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPRecordStepDecision, h.handleRecordStepDecision) } if h.taskSvc != nil { - d.RegisterFunc(ws.ActionMCPMoveTask, h.handleMoveTask) - d.RegisterFunc(ws.ActionMCPDeleteTask, h.handleDeleteTask) - d.RegisterFunc(ws.ActionMCPArchiveTask, h.handleArchiveTask) - d.RegisterFunc(ws.ActionMCPUpdateTaskState, h.handleUpdateTaskState) - - // Executor mutation handlers (config-mode only) - if h.workflowSvc != nil { - d.RegisterFunc(ws.ActionMCPCreateExecutorProfile, h.handleCreateExecutorProfile) - d.RegisterFunc(ws.ActionMCPUpdateExecutorProfile, h.handleUpdateExecutorProfile) - d.RegisterFunc(ws.ActionMCPDeleteExecutorProfile, h.handleDeleteExecutorProfile) - } + h.registerTaskConfigMutationHandlers(d) } +} - after := d.HandlerCount() - h.logger.Info("registered MCP handlers", zap.Int("count", after-before)) +func (h *Handlers) registerWorkflowHandlers(d *guardedMCPDispatcher) { + d.RegisterFunc(ws.ActionMCPCreateWorkflow, h.handleCreateWorkflow) + d.RegisterFunc(ws.ActionMCPUpdateWorkflow, h.handleUpdateWorkflow) + d.RegisterFunc(ws.ActionMCPDeleteWorkflow, h.handleDeleteWorkflow) + d.RegisterFunc(ws.ActionMCPImportWorkflow, h.handleImportWorkflow) + d.RegisterFunc(ws.ActionMCPExportWorkflow, h.handleExportWorkflow) + d.RegisterFunc(ws.ActionMCPCreateWorkflowStep, h.handleCreateWorkflowStep) + d.RegisterFunc(ws.ActionMCPUpdateWorkflowStep, h.handleUpdateWorkflowStep) + d.RegisterFunc(ws.ActionMCPDeleteWorkflowStep, h.handleDeleteWorkflowStep) + d.RegisterFunc(ws.ActionMCPReorderWorkflowStep, h.handleReorderWorkflowSteps) +} + +func (h *Handlers) registerAgentHandlers(d *guardedMCPDispatcher) { + d.RegisterFunc(ws.ActionMCPListAgents, h.handleListAgents) + d.RegisterFunc(ws.ActionMCPUpdateAgent, h.handleUpdateAgent) + d.RegisterFunc(ws.ActionMCPListAgentProfiles, h.handleListAgentProfiles) + d.RegisterFunc(ws.ActionMCPCreateAgentProfile, h.handleCreateAgentProfile) + d.RegisterFunc(ws.ActionMCPUpdateAgentProfile, h.handleUpdateAgentProfile) + d.RegisterFunc(ws.ActionMCPDeleteAgentProfile, h.handleDeleteAgentProfile) +} + +func (h *Handlers) registerTaskConfigMutationHandlers(d *guardedMCPDispatcher) { + d.RegisterFunc(ws.ActionMCPMoveTask, h.handleMoveTask) + d.RegisterFunc(ws.ActionMCPDeleteTask, h.handleDeleteTask) + d.RegisterFunc(ws.ActionMCPArchiveTask, h.handleArchiveTask) + d.RegisterFunc(ws.ActionMCPUpdateTaskState, h.handleUpdateTaskState) + if h.workflowSvc != nil { + d.RegisterFunc(ws.ActionMCPCreateExecutorProfile, h.handleCreateExecutorProfile) + d.RegisterFunc(ws.ActionMCPUpdateExecutorProfile, h.handleUpdateExecutorProfile) + d.RegisterFunc(ws.ActionMCPDeleteExecutorProfile, h.handleDeleteExecutorProfile) + } } // handleListWorkspaces lists all workspaces. @@ -521,6 +553,15 @@ func (h *Handlers) handleListWorkspaces(ctx context.Context, msg *ws.Message) (* h.logger.Error("failed to list workspaces", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list workspaces", nil) } + if principal, ok := mcpscope.PrincipalFromContext(ctx); ok && principal.IsAutomation() { + filtered := make([]*models.Workspace, 0, 1) + for _, workspace := range workspaces { + if workspace != nil && workspace.ID == principal.WorkspaceID { + filtered = append(filtered, workspace) + } + } + workspaces = filtered + } dtos := make([]dto.WorkspaceDTO, 0, len(workspaces)) for _, w := range workspaces { dtos = append(dtos, dto.FromWorkspace(w)) diff --git a/apps/backend/internal/mcp/handlers/handlers_test.go b/apps/backend/internal/mcp/handlers/handlers_test.go index d94e1ca7cd..8379969802 100644 --- a/apps/backend/internal/mcp/handlers/handlers_test.go +++ b/apps/backend/internal/mcp/handlers/handlers_test.go @@ -17,9 +17,12 @@ import ( "github.com/kandev/kandev/internal/db" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" + mcpscope "github.com/kandev/kandev/internal/mcp/scope" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/orchestrator/messagequeue" + "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" @@ -142,6 +145,42 @@ func newTestTaskServiceWithWorkflow(t *testing.T) (*service.Service, *sqliterepo return svc, repo, workflowcontroller.NewController(workflowSvc), workflowRepo } +func TestHandleListWorkspacesAutomationIsScopedToPrincipalWorkspace(t *testing.T) { + svc, repo := newTestTaskService(t) + ctx := context.Background() + now := time.Now().UTC() + require.NoError(t, repo.CreateWorkspace(ctx, &models.Workspace{ + ID: "ws-state-event", + Name: "State Event", + CreatedAt: now, + UpdatedAt: now, + })) + foreignWorkspace := &models.Workspace{ + ID: "ws-foreign", + Name: "Foreign", + CreatedAt: now, + UpdatedAt: now, + } + require.NoError(t, repo.CreateWorkspace(ctx, foreignWorkspace)) + + h := NewHandlers(svc, nil, nil, nil, nil, repo, repo, nil, nil, nil, nil, nil, testLogger(t)) + ctx = mcpscope.WithPrincipal(ctx, mcpscope.Principal{ + AutomationID: "automation-1", + WorkspaceID: "ws-state-event", + CallerTaskID: "automation-task", + CallerSessionID: "automation-session", + Surface: mcpprofile.SurfaceAutomation, + }) + response, err := h.handleListWorkspaces(ctx, &ws.Message{ID: "1", Action: ws.ActionMCPListWorkspaces}) + require.NoError(t, err) + require.NotNil(t, response) + + var payload dto.ListWorkspacesResponse + require.NoError(t, json.Unmarshal(response.Payload, &payload)) + require.Equal(t, 1, payload.Total) + require.Equal(t, "ws-state-event", payload.Workspaces[0].ID) +} + func seedMCPHandlerSession(t *testing.T, repo *sqliterepo.Repository, taskID, sessionID string, state models.TaskSessionState) { t.Helper() ctx := context.Background() diff --git a/apps/backend/internal/mcp/handlers/question_handlers.go b/apps/backend/internal/mcp/handlers/question_handlers.go index bc98f319f9..50be8e6e9f 100644 --- a/apps/backend/internal/mcp/handlers/question_handlers.go +++ b/apps/backend/internal/mcp/handlers/question_handlers.go @@ -11,6 +11,7 @@ import ( "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/clarification" + mcpscope "github.com/kandev/kandev/internal/mcp/scope" "github.com/kandev/kandev/internal/task/models" ws "github.com/kandev/kandev/pkg/websocket" "go.uber.org/zap" @@ -107,12 +108,11 @@ func (h *Handlers) handleListPendingQuestions(ctx context.Context, msg *ws.Messa return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list pending questions", nil) } - page, err := h.clarificationBundles.ListUnresolvedClarificationBundles(ctx, opts) + page, err := h.listVisiblePendingQuestionBundles(ctx, opts) if err != nil { h.logger.Error("failed to list unresolved clarification bundles", zap.Error(err)) return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "Failed to list pending questions", nil) } - resp, err := h.buildListPendingQuestionsResponse(ctx, page) if err != nil { h.logger.Error("failed to build list_pending_questions_kandev response", zap.Error(err)) @@ -121,11 +121,69 @@ func (h *Handlers) handleListPendingQuestions(ctx context.Context, msg *ws.Messa return ws.NewResponse(msg.ID, msg.Action, resp) } +// listVisiblePendingQuestionBundles applies the automation self-filter while +// preserving the requested visible page size. The storage query is ordered +// and cursor-based, so a page filled with the caller's own bundle must be +// followed until either enough foreign bundles are found or the source is +// exhausted. Filtering one fetched page would hide later foreign bundles and +// incorrectly report that there is no next page. +func (h *Handlers) listVisiblePendingQuestionBundles( + ctx context.Context, + opts models.ListClarificationBundlesOptions, +) (*models.ClarificationBundlePage, error) { + principal, isAutomation := mcpscope.PrincipalFromContext(ctx) + if !isAutomation || !principal.IsAutomation() { + return h.clarificationBundles.ListUnresolvedClarificationBundles(ctx, opts) + } + + visible := make([]models.ClarificationBundleSummary, 0, opts.Limit) + for { + page, err := h.clarificationBundles.ListUnresolvedClarificationBundles(ctx, opts) + if err != nil { + return nil, err + } + if page == nil { + return nil, fmt.Errorf("clarification bundle lister returned a nil page") + } + + visibleBeyondLimit := false + for _, bundle := range page.Bundles { + if bundle.TaskID == principal.CallerTaskID { + continue + } + if len(visible) >= opts.Limit { + visibleBeyondLimit = true + break + } + visible = append(visible, bundle) + } + if visibleBeyondLimit { + return &models.ClarificationBundlePage{Bundles: visible, HasMore: true}, nil + } + if !page.HasMore || len(page.Bundles) == 0 { + return &models.ClarificationBundlePage{Bundles: visible}, nil + } + + last := page.Bundles[len(page.Bundles)-1] + if last.CreatedAt.Equal(opts.CursorCreatedAt) && last.PendingID == opts.CursorPendingID { + return nil, fmt.Errorf("clarification bundle lister returned a non-advancing cursor") + } + opts.CursorCreatedAt = last.CreatedAt + opts.CursorPendingID = last.PendingID + } +} + // resolveBundleVisibility implements L1a-L1c's caller-visibility resolution: // an unscoped caller (no identity, or the synthetic auth-disabled identity) // bypasses the workspace filter entirely; a scoped caller is limited to the // workspace set service.Service.ListWorkspaces already resolves for them. func (h *Handlers) resolveBundleVisibility(ctx context.Context, opts *models.ListClarificationBundlesOptions) error { + if principal, ok := mcpscope.PrincipalFromContext(ctx); ok && principal.IsAutomation() { + opts.Unscoped = false + opts.VisibleWorkspaceIDs = []string{principal.WorkspaceID} + opts.WorkspaceID = principal.WorkspaceID + return nil + } if !callerScoped(ctx) { opts.Unscoped = true return nil @@ -260,6 +318,10 @@ func (h *Handlers) handleAnswerQuestion(ctx context.Context, msg *ws.Message) (* if req.PendingID == "" { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "pending_id is required", nil) } + if principal, ok := mcpscope.PrincipalFromContext(ctx); ok && principal.IsAutomation() && + !h.automationQuestionVisible(ctx, principal, req.PendingID) { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "clarification request not found", nil) + } res, claimed, err := h.clarificationResolver.ResolveBundle(ctx, req.PendingID, clarification.Outcome{ Answers: req.Answers, @@ -287,6 +349,32 @@ func (h *Handlers) handleAnswerQuestion(ctx context.Context, msg *ws.Message) (* } } +func (h *Handlers) automationQuestionVisible( + ctx context.Context, + principal mcpscope.Principal, + pendingID string, +) bool { + messages, err := h.clarificationBundles.FindMessagesByPendingID(ctx, pendingID) + if err != nil || len(messages) == 0 { + return false + } + for _, message := range messages { + taskID := message.TaskID + if taskID == "" { + session, sessionErr := h.taskSvc.GetTaskSession(ctx, message.TaskSessionID) + if sessionErr != nil || session == nil { + return false + } + taskID = session.TaskID + } + task, taskErr := h.taskSvc.GetTask(ctx, taskID) + if taskErr != nil || task == nil || task.WorkspaceID != principal.WorkspaceID || taskID == principal.CallerTaskID { + return false + } + } + return true +} + func (h *Handlers) answerQuestionSuccessResponse(msg *ws.Message, claimed bool, res *clarification.Resolution) (*ws.Message, error) { serialized, err := clarification.SerializeResponse(res.Response) if err != nil { @@ -304,6 +392,9 @@ func (h *Handlers) answerQuestionSuccessResponse(msg *ws.Message, claimed bool, // no identity in context (internal caller) or the synthetic auth-disabled // identity. Mirrors clarification.resolvedByFromContext (unexported there). func resolvedByFromContext(ctx context.Context) string { + if principal, ok := mcpscope.PrincipalFromContext(ctx); ok && principal.IsAutomation() { + return "automation:" + principal.AutomationID + } identity, ok := authn.IdentityFromContext(ctx) if !ok || identity.Synthetic || identity.UserID == "" { return "" diff --git a/apps/backend/internal/mcp/handlers/question_handlers_test.go b/apps/backend/internal/mcp/handlers/question_handlers_test.go index 1c8a39b3ac..15478fd467 100644 --- a/apps/backend/internal/mcp/handlers/question_handlers_test.go +++ b/apps/backend/internal/mcp/handlers/question_handlers_test.go @@ -10,6 +10,8 @@ import ( "github.com/kandev/kandev/internal/auth/authn" "github.com/kandev/kandev/internal/clarification" "github.com/kandev/kandev/internal/events/bus" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" + mcpscope "github.com/kandev/kandev/internal/mcp/scope" "github.com/kandev/kandev/internal/task/models" sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" "github.com/kandev/kandev/internal/task/service" @@ -24,15 +26,25 @@ import ( type recordingBundleLister struct { gotOpts models.ListClarificationBundlesOptions page *models.ClarificationBundlePage + pages []*models.ClarificationBundlePage + calls int msgs map[string][]*models.Message listErr error } func (r *recordingBundleLister) ListUnresolvedClarificationBundles(_ context.Context, opts models.ListClarificationBundlesOptions) (*models.ClarificationBundlePage, error) { r.gotOpts = opts + r.calls++ if r.listErr != nil { return nil, r.listErr } + if len(r.pages) > 0 { + page := r.pages[r.calls-1] + if page == nil { + return &models.ClarificationBundlePage{}, nil + } + return page, nil + } if r.page == nil { return &models.ClarificationBundlePage{}, nil } @@ -184,6 +196,43 @@ func TestHandleListPendingQuestions_EmptyResult_NoErrorEmptyEnvelope(t *testing. require.Contains(t, string(resp.Payload), `"bundles":[]`) } +func TestHandleListPendingQuestions_AutomationPaginatesPastSelfBundle(t *testing.T) { + svc, _ := newTestTaskService(t) + first := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + second := first.Add(time.Minute) + lister := &recordingBundleLister{ + pages: []*models.ClarificationBundlePage{ + {Bundles: []models.ClarificationBundleSummary{{ + PendingID: "self-pending", TaskID: "automation-task", SessionID: "self-session", CreatedAt: first, + }}, HasMore: true}, + {Bundles: []models.ClarificationBundleSummary{{ + PendingID: "foreign-pending", TaskID: "foreign-task", SessionID: "foreign-session", CreatedAt: second, + }}}, + }, + msgs: map[string][]*models.Message{ + "foreign-pending": {questionMessage("foreign-pending", "q1", "pending", 0, nil)}, + }, + } + h := &Handlers{taskSvc: svc, clarificationBundles: lister, logger: testLogger(t)} + ctx := mcpscope.WithPrincipal(context.Background(), mcpscope.Principal{ + AutomationID: "automation-1", WorkspaceID: "ws-1", + CallerTaskID: "automation-task", CallerSessionID: "automation-session", + Surface: mcpprofile.SurfaceAutomation, + }) + + resp, err := h.handleListPendingQuestions(ctx, makeWSMessage(t, ws.ActionMCPListPendingQuestions, map[string]interface{}{ + "limit": 1, + })) + require.NoError(t, err) + var body listPendingQuestionsResponse + require.NoError(t, json.Unmarshal(resp.Payload, &body)) + require.Equal(t, 1, body.Count) + require.Equal(t, "foreign-pending", body.Bundles[0].PendingID) + require.Equal(t, 2, lister.calls) + require.Equal(t, "self-pending", lister.gotOpts.CursorPendingID) + require.Empty(t, body.NextCursor) +} + // TestHandleListPendingQuestions_L3L4ResponseShape proves the L11 envelope // and L3/L4 per-bundle/per-question fields, including L4a's never-null rule // for a question with no parseable metadata (L15). diff --git a/apps/backend/internal/mcp/handlers/review.go b/apps/backend/internal/mcp/handlers/review.go index 4a54062655..4244055ff2 100644 --- a/apps/backend/internal/mcp/handlers/review.go +++ b/apps/backend/internal/mcp/handlers/review.go @@ -36,7 +36,7 @@ func (h *Handlers) SetReviewRunner(runner ReviewRunner) { // registerReviewHandlers registers the native code-review actions. Both the // agent-facing MCP publish action and the plain UI actions are gated on the // review service being wired. -func (h *Handlers) registerReviewHandlers(d *ws.Dispatcher) int { +func (h *Handlers) registerReviewHandlers(d mcpActionRegistrar) int { if h.reviewService == nil { return 0 } diff --git a/apps/backend/internal/mcp/handlers/stop_task.go b/apps/backend/internal/mcp/handlers/stop_task.go index b0f51d4a92..5e6a0cd3f7 100644 --- a/apps/backend/internal/mcp/handlers/stop_task.go +++ b/apps/backend/internal/mcp/handlers/stop_task.go @@ -6,6 +6,7 @@ import ( "errors" "strings" + mcpscope "github.com/kandev/kandev/internal/mcp/scope" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository" @@ -33,9 +34,15 @@ func (h *Handlers) handleStopTask(ctx context.Context, msg *ws.Message) (*ws.Mes return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeInternalError, "task lookup is not configured", nil) } - sender, lookupError := h.lookupStopTask(ctx, msg, req.SenderTaskID, "sender") - if lookupError != nil { - return lookupError.response, lookupError.err + principal, hasPrincipal := mcpscope.PrincipalFromContext(ctx) + automationCaller := hasPrincipal && principal.IsAutomation() + var sender *models.Task + if !automationCaller { + var lookupError *stopTaskFailure + sender, lookupError = h.lookupStopTask(ctx, msg, req.SenderTaskID, "sender") + if lookupError != nil { + return lookupError.response, lookupError.err + } } target, lookupError := h.lookupStopTask(ctx, msg, req.TaskID, "target") @@ -43,7 +50,11 @@ func (h *Handlers) handleStopTask(ctx context.Context, msg *ws.Message) (*ws.Mes return lookupError.response, lookupError.err } - if !canStopTask(sender, target) { + if automationCaller { + if target.ID == principal.CallerTaskID || target.WorkspaceID != principal.WorkspaceID { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeNotFound, "target task not found", nil) + } + } else if !canStopTask(sender, target) { return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeForbidden, "only a task's direct parent in the same workspace can stop it", nil) } diff --git a/apps/backend/internal/mcp/handlers/stop_task_test.go b/apps/backend/internal/mcp/handlers/stop_task_test.go index 88f1638f11..ea14acd52d 100644 --- a/apps/backend/internal/mcp/handlers/stop_task_test.go +++ b/apps/backend/internal/mcp/handlers/stop_task_test.go @@ -5,6 +5,8 @@ import ( "errors" "testing" + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" + mcpscope "github.com/kandev/kandev/internal/mcp/scope" "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/task/models" taskrepo "github.com/kandev/kandev/internal/task/repository" @@ -111,6 +113,52 @@ func TestHandleStopTask_AuthorizesOnlyDirectParentInWorkspace(t *testing.T) { } } +func TestHandleStopTaskAutomationUsesTrustedCallerWithoutLookingUpSender(t *testing.T) { + tasks := map[string]*models.Task{ + "automation-target": {ID: "automation-target", WorkspaceID: "ws-1"}, + "foreign-sender": {ID: "foreign-sender", WorkspaceID: "ws-2"}, + } + for _, senderID := range []string{"foreign-sender", "missing-sender"} { + t.Run(senderID, func(t *testing.T) { + var lookups []string + stopper := &recordingTaskStopper{result: orchestrator.CoordinatorTaskStopResult{ + Status: orchestrator.CoordinatorTaskStopStatusStopped, + }} + h := &Handlers{ + taskStopper: stopper, + stopTaskGetter: func(_ context.Context, taskID string) (*models.Task, error) { + lookups = append(lookups, taskID) + task, ok := tasks[taskID] + if !ok { + return nil, taskrepo.ErrTaskNotFound + } + return task, nil + }, + logger: testLogger(t), + } + ctx := mcpscope.WithPrincipal(context.Background(), mcpscope.Principal{ + AutomationID: "automation-1", WorkspaceID: "ws-1", + CallerTaskID: "automation-caller", CallerSessionID: "session-1", + Surface: mcpprofile.SurfaceAutomation, + }) + msg := makeWSMessage(t, ws.ActionMCPStopTask, map[string]interface{}{ + "task_id": "automation-target", "sender_task_id": senderID, + }) + + resp, err := h.handleStopTask(ctx, msg) + if err != nil { + t.Fatalf("handleStopTask: %v", err) + } + if resp.Type != ws.MessageTypeResponse { + t.Fatalf("response type = %q, want response", resp.Type) + } + if len(lookups) != 1 || lookups[0] != "automation-target" { + t.Fatalf("task lookups = %v, want only target lookup", lookups) + } + }) + } +} + func TestHandleStopTask_ValidatesPayload(t *testing.T) { tests := []struct { name string diff --git a/apps/backend/internal/mcp/profile/profile.go b/apps/backend/internal/mcp/profile/profile.go index 0815c8ad6b..2d07c6d3c1 100644 --- a/apps/backend/internal/mcp/profile/profile.go +++ b/apps/backend/internal/mcp/profile/profile.go @@ -12,6 +12,7 @@ const ( SurfaceOfficeTask Surface = "office-task" SurfaceConfiguration Surface = "configuration" SurfaceExternal Surface = "external" + SurfaceAutomation Surface = "automation" ) type Capability string @@ -42,6 +43,13 @@ func New(surface Surface, capabilities []Capability, providers []string) Context return ctx } +// NewAutomation returns the fixed profile used by automation task sessions. +// Automation tasks coordinate workspace work and never receive task-local +// question capabilities. +func NewAutomation() Context { + return New(SurfaceAutomation, nil, nil) +} + func (c Context) HasCapability(capability Capability) bool { return slices.Contains(c.Capabilities, capability) } @@ -75,10 +83,12 @@ func Legacy(mode string, disableAskQuestion bool, providers []string) Context { surface = SurfaceConfiguration case "external": surface = SurfaceExternal + case "automation": + surface = SurfaceAutomation case "task-title-pending": capabilities = append(capabilities, CapabilityTaskTitle) } - if !disableAskQuestion && surface != SurfaceExternal { + if !disableAskQuestion && surface != SurfaceExternal && surface != SurfaceAutomation { capabilities = append(capabilities, CapabilityUserQuestion) } return New(surface, capabilities, providers) @@ -86,7 +96,7 @@ func Legacy(mode string, disableAskQuestion bool, providers []string) Context { func normalizeSurface(surface Surface) Surface { switch surface { - case SurfaceKanbanTask, SurfaceOfficeTask, SurfaceConfiguration, SurfaceExternal: + case SurfaceKanbanTask, SurfaceOfficeTask, SurfaceConfiguration, SurfaceExternal, SurfaceAutomation: return surface default: return SurfaceKanbanTask diff --git a/apps/backend/internal/mcp/profile/profile_test.go b/apps/backend/internal/mcp/profile/profile_test.go index b4f29533d3..0b791040ce 100644 --- a/apps/backend/internal/mcp/profile/profile_test.go +++ b/apps/backend/internal/mcp/profile/profile_test.go @@ -44,3 +44,13 @@ func TestLegacyExternalHasNoQuestionCapability(t *testing.T) { t.Fatalf("external capabilities = %#v, want no question capability", ctx.Capabilities) } } + +func TestLegacyAutomationHasNoQuestionCapability(t *testing.T) { + ctx := Legacy("automation", false, nil) + if ctx.Surface != SurfaceAutomation { + t.Fatalf("surface = %q, want %q", ctx.Surface, SurfaceAutomation) + } + if ctx.HasCapability(CapabilityUserQuestion) || ctx.HasCapability(CapabilityParentQuestion) { + t.Fatalf("automation capabilities = %#v, want no question capability", ctx.Capabilities) + } +} diff --git a/apps/backend/internal/mcp/scope/principal.go b/apps/backend/internal/mcp/scope/principal.go new file mode 100644 index 0000000000..415fea87fd --- /dev/null +++ b/apps/backend/internal/mcp/scope/principal.go @@ -0,0 +1,128 @@ +package scope + +import ( + "context" + "fmt" + + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" + "github.com/kandev/kandev/internal/task/models" +) + +// Principal is the server-derived identity of an in-session MCP caller. +// Request payloads must never be used to construct or replace these fields. +// Automation handlers use the principal as their workspace and self-target +// boundary in addition to the normal owner identity attached to the context. +type Principal struct { + AutomationID string + WorkspaceID string + CallerTaskID string + CallerSessionID string + Surface mcpprofile.Surface +} + +func (p Principal) IsAutomation() bool { + return p.AutomationID != "" && p.Surface == mcpprofile.SurfaceAutomation +} + +type principalContextKey struct{} + +// WithPrincipal attaches a trusted principal to a dispatch context. +func WithPrincipal(ctx context.Context, principal Principal) context.Context { + return context.WithValue(ctx, principalContextKey{}, principal) +} + +// PrincipalFromContext returns the server-derived caller principal. +func PrincipalFromContext(ctx context.Context) (Principal, bool) { + principal, ok := ctx.Value(principalContextKey{}).(Principal) + return principal, ok && principal.CallerTaskID != "" && principal.CallerSessionID != "" +} + +// ScopePrincipal derives the MCP principal from the execution's own task and +// session. It intentionally does not read any identity or surface fields from +// an agent payload. Non-automation tasks still receive their normal surface so +// downstream code has one consistent trusted caller shape. +func (r *Resolver) ScopePrincipal(ctx context.Context, taskID, sessionID string) (context.Context, error) { + if r == nil || taskID == "" || sessionID == "" || r.tasks == nil { + return nil, fmt.Errorf("resolve MCP principal: task and session are required") + } + task, err := r.resolvePrincipalTask(ctx, taskID) + if err != nil { + return nil, err + } + if err := r.validatePrincipalSession(ctx, taskID, sessionID); err != nil { + return nil, err + } + + workspaceID, err := r.resolvePrincipalWorkspace(ctx, task) + if err != nil { + return nil, err + } + + automationID, surface, err := principalSurface(task) + if err != nil { + return nil, fmt.Errorf("resolve MCP principal task %s: %w", taskID, err) + } + return WithPrincipal(ctx, Principal{ + AutomationID: automationID, + WorkspaceID: workspaceID, + CallerTaskID: taskID, + CallerSessionID: sessionID, + Surface: surface, + }), nil +} + +func (r *Resolver) resolvePrincipalTask(ctx context.Context, taskID string) (*models.Task, error) { + task, err := r.tasks.GetTask(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("resolve MCP principal task %s: %w", taskID, err) + } + if task == nil { + return nil, fmt.Errorf("resolve MCP principal task %s: task not found", taskID) + } + return task, nil +} + +func (r *Resolver) validatePrincipalSession(ctx context.Context, taskID, sessionID string) error { + lookup, ok := r.tasks.(interface { + GetTaskSession(context.Context, string) (*models.TaskSession, error) + }) + if !ok { + return nil + } + session, err := lookup.GetTaskSession(ctx, sessionID) + if err != nil { + return fmt.Errorf("resolve MCP principal session %s: %w", sessionID, err) + } + if session == nil || session.TaskID != taskID { + return fmt.Errorf("resolve MCP principal: session %s does not belong to task %s", sessionID, taskID) + } + return nil +} + +func (r *Resolver) resolvePrincipalWorkspace(ctx context.Context, task *models.Task) (string, error) { + if task.WorkspaceID == "" { + return "", fmt.Errorf("resolve MCP principal task %s: workspace is required", task.ID) + } + workspace, err := r.tasks.GetWorkspace(ctx, task.WorkspaceID) + if err != nil { + return "", fmt.Errorf("resolve MCP principal workspace %s: %w", task.WorkspaceID, err) + } + if workspace == nil { + return "", fmt.Errorf("resolve MCP principal workspace %s: workspace not found", task.WorkspaceID) + } + return task.WorkspaceID, nil +} + +func principalSurface(task *models.Task) (string, mcpprofile.Surface, error) { + if task.Origin != models.TaskOriginAutomationRun { + if task.IsFromOffice { + return "", mcpprofile.SurfaceOfficeTask, nil + } + return "", mcpprofile.SurfaceKanbanTask, nil + } + automationID := models.StringFromAny(task.Metadata["automation_id"]) + if automationID == "" { + return "", mcpprofile.SurfaceAutomation, fmt.Errorf("automation ID is missing") + } + return automationID, mcpprofile.SurfaceAutomation, nil +} diff --git a/apps/backend/internal/mcp/scope/principal_test.go b/apps/backend/internal/mcp/scope/principal_test.go new file mode 100644 index 0000000000..05f1c6e944 --- /dev/null +++ b/apps/backend/internal/mcp/scope/principal_test.go @@ -0,0 +1,66 @@ +package scope + +import ( + "context" + "testing" + + mcpprofile "github.com/kandev/kandev/internal/mcp/profile" + "github.com/kandev/kandev/internal/task/models" + "github.com/stretchr/testify/require" +) + +type principalLookup struct { + task *models.Task + workspace *models.Workspace + session *models.TaskSession +} + +func (l principalLookup) GetTask(context.Context, string) (*models.Task, error) { + return l.task, nil +} + +func (l principalLookup) GetWorkspace(context.Context, string) (*models.Workspace, error) { + return l.workspace, nil +} + +func (l principalLookup) GetTaskSession(context.Context, string) (*models.TaskSession, error) { + return l.session, nil +} + +func TestScopePrincipalDerivesAutomationIdentityFromExecution(t *testing.T) { + resolver := &Resolver{tasks: principalLookup{ + task: &models.Task{ + ID: "automation-task", + WorkspaceID: "workspace-1", + Origin: models.TaskOriginAutomationRun, + Metadata: map[string]interface{}{"automation_id": "automation-1"}, + }, + workspace: &models.Workspace{ID: "workspace-1"}, + session: &models.TaskSession{ID: "session-1", TaskID: "automation-task"}, + }} + + ctx, err := resolver.ScopePrincipal(context.Background(), "automation-task", "session-1") + require.NoError(t, err) + + principal, ok := PrincipalFromContext(ctx) + require.True(t, ok) + require.Equal(t, Principal{ + AutomationID: "automation-1", + WorkspaceID: "workspace-1", + CallerTaskID: "automation-task", + CallerSessionID: "session-1", + Surface: mcpprofile.SurfaceAutomation, + }, principal) + require.True(t, principal.IsAutomation()) +} + +func TestScopePrincipalRejectsSessionFromAnotherTask(t *testing.T) { + resolver := &Resolver{tasks: principalLookup{ + task: &models.Task{ID: "automation-task", WorkspaceID: "workspace-1"}, + workspace: &models.Workspace{ID: "workspace-1"}, + session: &models.TaskSession{ID: "session-1", TaskID: "other-task"}, + }} + + _, err := resolver.ScopePrincipal(context.Background(), "automation-task", "session-1") + require.Error(t, err) +} diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index 180515792d..6c7cebd3fe 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -50,6 +50,9 @@ const ( // ModeOffice registers plan and interaction tools for office agents. // Kanban tools are excluded because office agents use CLI commands instead. ModeOffice = "office" + // ModeAutomation registers the fixed workspace coordinator catalog for + // scheduled automation agents. + ModeAutomation = "automation" ) const pluginToolArgumentsKey = "arguments" @@ -85,7 +88,7 @@ func locatorCount(locators ...string) int { // normalizeMode returns a valid MCP mode, defaulting unknown values to ModeTask. func normalizeMode(mode string) string { switch mode { - case ModeConfig, ModeExternal, ModeOffice, ModeTaskTitlePending: + case ModeConfig, ModeExternal, ModeOffice, ModeAutomation, ModeTaskTitlePending: return mode default: return ModeTask @@ -596,6 +599,8 @@ func surfaceForMode(mode string) mcpprofile.Surface { return mcpprofile.SurfaceExternal case ModeOffice: return mcpprofile.SurfaceOfficeTask + case ModeAutomation: + return mcpprofile.SurfaceAutomation default: return mcpprofile.SurfaceKanbanTask } @@ -894,7 +899,9 @@ func (s *Server) profileToolGroups() []profileToolGroup { external := surfaceEnabled(mcpprofile.SurfaceExternal) office := surfaceEnabled(mcpprofile.SurfaceOfficeTask) kanban := surfaceEnabled(mcpprofile.SurfaceKanbanTask) + automation := surfaceEnabled(mcpprofile.SurfaceAutomation) return []profileToolGroup{ + {name: "automation", enabled: automation, register: func(s *Server) { s.registerAutomationTools() }}, {name: "configuration-workflows", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigWorkflowTools() }}, {name: "configuration-agents", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigAgentTools() }}, {name: "configuration-mcp", enabled: func(ctx mcpprofile.Context) bool { return config(ctx) || external(ctx) }, register: func(s *Server) { s.registerConfigMcpTools() }}, @@ -959,7 +966,9 @@ func (s *Server) registerTools() { group.register(s) } } - s.registerPluginTools() + if s.profile.Surface != mcpprofile.SurfaceAutomation { + s.registerPluginTools() + } s.logger.Info("registered MCP tools", zap.String("mode", s.mode), zap.Int("count", len(s.mcpServer.ListTools())), @@ -967,6 +976,44 @@ func (s *Server) registerTools() { s.rebuildToolArgumentValidators() } +// registerAutomationTools composes the fixed coordinator catalog from the +// existing read/task lifecycle registrations, then removes every mutation or +// task-local capability that is not part of the automation authority. Keeping +// this allowlist next to the profile registry makes accidental additions +// visible in the catalog test instead of silently expanding automation power. +func (s *Server) registerAutomationTools() { + s.registerKanbanTools() + s.registerTaskDependencyTools() + s.registerRelatedTasksTool() + s.registerQuestionAnsweringTools() + s.registerAgentPermissionTools() + s.registerConfigWorkflowTools() + s.registerConfigExecutorTools() + s.mcpServer.DeleteTools( + "delete_task_kandev", + "update_task_state_kandev", + "create_workflow_kandev", + "update_workflow_kandev", + "delete_workflow_kandev", + "import_workflow_kandev", + "export_workflow_kandev", + "create_workflow_step_kandev", + "update_workflow_step_kandev", + "delete_workflow_step_kandev", + "reorder_workflow_steps_kandev", + "update_agent_kandev", + "create_agent_profile_kandev", + "delete_agent_profile_kandev", + "list_agent_profiles_kandev", + "update_agent_profile_kandev", + "get_mcp_config_kandev", + "update_mcp_config_kandev", + "create_executor_profile_kandev", + "update_executor_profile_kandev", + "delete_executor_profile_kandev", + ) +} + func (s *Server) registerDiagnosticBundleTool() { s.mcpServer.AddTool( mcp.NewTool("get_diagnostic_bundle_kandev", diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index ce182af253..85e58b20de 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -425,7 +425,7 @@ func getRegisteredToolNames(s *Server) []string { func TestServerModeTask_RegistersCorrectTools(t *testing.T) { log := newTestLogger(t) backend := NewChannelBackendClient(log) - defer backend.Close() + t.Cleanup(backend.Close) s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask, []string{"github", "gitlab"}) require.NotNil(t, s) @@ -517,6 +517,26 @@ func TestServerProfile_AutopilotChildHasOnlyParentQuestion(t *testing.T) { assert.NotContains(t, rootTools, "ask_user_question_kandev") } +func TestServerSurfaceAutomationHasFixedCoordinatorCatalog(t *testing.T) { + log := newTestLogger(t) + backend := NewChannelBackendClient(log) + defer backend.Close() + + profile := mcpprofile.NewAutomation() + s := NewWithProfile(backend, "automation-session", "automation-task", 10005, log, "", false, profile) + want := []string{ + "list_workspaces_kandev", "list_workflows_kandev", "list_workflow_steps_kandev", + "list_repositories_kandev", "list_tasks_kandev", "list_agents_kandev", + "list_executors_kandev", "list_executor_profiles_kandev", "list_related_tasks_kandev", + "get_task_conversation_kandev", "list_task_sessions_kandev", "create_task_kandev", + "update_task_kandev", "move_task_kandev", "archive_task_kandev", + "add_task_dependency_kandev", "remove_task_dependency_kandev", "message_task_kandev", + "stop_task_kandev", "spawn_session_kandev", "list_pending_questions_kandev", + "answer_question_kandev", "list_pending_agent_permissions_kandev", "resolve_agent_permission_kandev", + } + assert.ElementsMatch(t, want, getRegisteredToolNames(s)) +} + func TestServerModeConfig_RegistersCorrectTools(t *testing.T) { log := newTestLogger(t) backend := NewChannelBackendClient(log) diff --git a/apps/backend/internal/orchestrator/agent_permissions.go b/apps/backend/internal/orchestrator/agent_permissions.go index fd5fd11418..5cfcc448e5 100644 --- a/apps/backend/internal/orchestrator/agent_permissions.go +++ b/apps/backend/internal/orchestrator/agent_permissions.go @@ -12,6 +12,7 @@ import ( "github.com/google/uuid" "github.com/kandev/kandev/internal/agentctl/types/streams" "github.com/kandev/kandev/internal/auth/authn" + mcpscope "github.com/kandev/kandev/internal/mcp/scope" "github.com/kandev/kandev/internal/orchestrator/executor" "github.com/kandev/kandev/internal/task/models" "go.uber.org/zap" @@ -346,6 +347,9 @@ func (s *Service) finalizePermissionDeliveryFailure(ctx context.Context, request } func permissionAuditActor(ctx context.Context) (string, models.PermissionResolutionActorKind) { + if principal, ok := mcpscope.PrincipalFromContext(ctx); ok && principal.IsAutomation() { + return "automation:" + principal.AutomationID, models.PermissionActorAutomation + } identity, ok := authn.IdentityFromContext(ctx) if !ok { return "", models.PermissionActorAutomation diff --git a/apps/backend/internal/orchestrator/event_handlers_automation.go b/apps/backend/internal/orchestrator/event_handlers_automation.go index 37977f6c51..e459602558 100644 --- a/apps/backend/internal/orchestrator/event_handlers_automation.go +++ b/apps/backend/internal/orchestrator/event_handlers_automation.go @@ -16,7 +16,6 @@ import ( "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/github" "github.com/kandev/kandev/internal/task/models" - taskservice "github.com/kandev/kandev/internal/task/service" "github.com/kandev/kandev/internal/worktree" ) @@ -30,6 +29,136 @@ type AutomationService interface { MarkRunSucceededByTaskID(ctx context.Context, taskID string) error } +// automationRunBinding is the exact-run extension implemented by the +// automation service. It stays separate from AutomationService so existing +// integration stubs that exercise legacy events remain source-compatible. +type automationRunBinding interface { + GetRun(ctx context.Context, id string) (*automation.AutomationRun, error) + BindRunTask(ctx context.Context, runID, taskID string) error + BindRun(ctx context.Context, runID, taskID, sessionID, turnID string, action automation.ThreadAction, reason string) error + MarkRunTerminal(ctx context.Context, runID, sessionID, turnID string, status automation.RunStatus, errMsg string) error + MarkRunTerminalByBinding(ctx context.Context, taskID, sessionID, turnID string, status automation.RunStatus, errMsg string) error +} + +type automationRunDispatcher interface { + DispatchRun( + ctx context.Context, + runID string, + action automation.ThreadAction, + reason string, + dispatch func() (automation.RunDispatch, error), + ) error +} + +type automationContinuationState interface { + SetContinuationTaskID(ctx context.Context, automationID, taskID string) error +} + +// StopAutomationRun cancels only the currently active turn identified by the +// exact task/session/turn triple. A false result means the binding is stale or +// already terminal and no successor turn was touched. +func (s *Service) StopAutomationRun(ctx context.Context, taskID, sessionID, turnID string) (bool, error) { + if taskID == "" || sessionID == "" || turnID == "" || s.turnService == nil || s.executor == nil { + return false, nil + } + valid, err := s.automationTurnMatches(ctx, taskID, sessionID, turnID, true) + if err != nil { + return false, err + } + if !valid { + return false, nil + } + return s.stopTaskSessionForCoordinator(ctx, taskID, sessionID) +} + +// AutomationRunLive is the conservative liveness check used during startup +// recovery. A session that is still starting/running, an exact open turn, or a +// live permission blocker remains open. Missing runtime state is not enough to +// keep a parked/stale binding open forever. +func (s *Service) AutomationRunLive(ctx context.Context, taskID, sessionID, turnID string) (bool, error) { + if taskID == "" || sessionID == "" || turnID == "" || s.turnService == nil { + return false, nil + } + session, valid, err := s.loadAutomationCoordinatorSession(ctx, taskID, sessionID, false) + if err != nil { + return false, err + } + if !valid { + return false, nil + } + active, err := s.coordinatorActiveTurn(ctx, sessionID) + if err != nil { + return false, err + } + if active != nil && active.ID == turnID { + return true, nil + } + if isCoordinatorLiveSessionState(session.State) { + return true, nil + } + return s.hasPendingCoordinatorPermissions(ctx, sessionID) +} + +func (s *Service) automationTurnMatches(ctx context.Context, taskID, sessionID, turnID string, requireStoppable bool) (bool, error) { + session, valid, err := s.loadAutomationCoordinatorSession(ctx, taskID, sessionID, requireStoppable) + if err != nil || !valid { + return valid, err + } + active, err := s.coordinatorActiveTurn(ctx, session.ID) + if err != nil { + return false, err + } + return active != nil && active.ID == turnID, nil +} + +func (s *Service) loadAutomationCoordinatorSession(ctx context.Context, taskID, sessionID string, requireStoppable bool) (*models.TaskSession, bool, error) { + task, err := s.repo.GetTask(ctx, taskID) + if err != nil { + return nil, false, err + } + if task == nil || !isAutomationTaskOrigin(task.Origin) { + return nil, false, nil + } + session, err := s.repo.GetTaskSession(ctx, sessionID) + if err != nil { + return nil, false, err + } + if session == nil || session.TaskID != taskID { + return nil, false, nil + } + if requireStoppable && !isCoordinatorStoppableSessionState(session.State) { + return nil, false, nil + } + return session, true, nil +} + +func isAutomationTaskOrigin(origin string) bool { + return origin == models.TaskOriginAutomationRun || origin == models.TaskOriginAutomationTask +} + +func (s *Service) coordinatorActiveTurn(ctx context.Context, sessionID string) (*models.Turn, error) { + active, err := s.turnService.GetActiveTurn(ctx, sessionID) + if isNoActiveTurnError(err) { + return nil, nil + } + return active, err +} + +func isCoordinatorLiveSessionState(state models.TaskSessionState) bool { + return state == models.TaskSessionStateStarting || state == models.TaskSessionStateRunning +} + +func (s *Service) hasPendingCoordinatorPermissions(ctx context.Context, sessionID string) (bool, error) { + if s.executor == nil { + return false, nil + } + permissions, err := s.executor.ListPendingPermissions(ctx, sessionID) + if err != nil { + return false, err + } + return len(permissions) > 0, nil +} + // taskCleaner deletes a task a firing has abandoned. // // Deliberately one method wide, and asserted at the call site rather than added @@ -124,6 +253,7 @@ func (s *Service) createAutomationTask(ctx context.Context, evt *automation.Auto if !a.Enabled { s.logger.Debug("automation disabled, skipping", zap.String("automation_id", evt.AutomationID)) + s.recordFailedRun(ctx, evt, "automation is disabled") return } @@ -134,16 +264,11 @@ func (s *Service) createAutomationTask(ctx context.Context, evt *automation.Auto } title := s.resolveAutomationTaskTitle(a, evt) - repositories := s.resolveAutomationRepository(ctx, a, evt) - if len(repositories) == 0 { - errMsg := "no repository available; add a repository to the workspace" - s.logger.Warn("automation skipped: "+errMsg, - zap.String("automation_id", a.ID), - zap.String("workspace_id", a.WorkspaceID)) - s.recordFailedRun(ctx, evt, errMsg) - return + if binding, ok := s.automationService.(automationRunBinding); ok && evt.RunID != "" { + if run, runErr := binding.GetRun(ctx, evt.RunID); runErr == nil && run != nil && run.DisplayTitle != "" { + title = run.DisplayTitle + } } - metadata := map[string]interface{}{ "automation_id": a.ID, "automation_name": a.Name, @@ -160,47 +285,33 @@ func (s *Service) createAutomationTask(ctx context.Context, evt *automation.Auto metadata[models.MetaKeyAutomationTargetTaskID] = triggerData.TaskID } - // 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: a.WorkflowID, - WorkflowStepID: a.WorkflowStepID, - Title: title, - Description: prompt, - Repositories: repositories, - Metadata: metadata, - Origin: models.TaskOriginAutomationRun, - }) + task, continuationSession, action, reason, taskErr := s.prepareAutomationTask( + ctx, a, evt, title, prompt, metadata, + ) if taskErr != nil { - s.logger.Error("failed to create automation task", - zap.String("automation_id", a.ID), zap.Error(taskErr)) s.recordFailedRun(ctx, evt, taskErr.Error()) return } - // 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. + // The run row is the record that this firing happened and carries its exact + // concurrency accounting. Hidden tasks are reachable only through that row; + // visible normal tasks also remain available through ordinary task lists. 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) + if action != automation.ThreadActionResumed { + 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 { - s.associateAutomationPR(ctx, task.ID, repositories[0].RepositoryID, evt.TriggerData) + if repositories := s.resolveAutomationRepository(ctx, a, evt); len(repositories) > 0 { + s.associateAutomationPR(ctx, task.ID, repositories[0].RepositoryID, evt.TriggerData) + } } s.logger.Info("created automation task", @@ -208,15 +319,392 @@ func (s *Service) createAutomationTask(ctx context.Context, evt *automation.Auto zap.String("automation_id", a.ID), zap.String("trigger_type", string(evt.TriggerType))) + if continuationSession != nil { + s.dispatchAutomationContinuation(ctx, a, task, continuationSession, prompt, evt.RunID, action, reason) + 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) + // auto_start_agent setting is irrelevant here because the automation + // trigger is the start signal for both hidden runs and visible normal tasks. + s.autoStartAutomationTaskForRun(ctx, a, task, task.WorkflowStepID, evt.RunID, action, reason) +} + +func (s *Service) prepareAutomationTask( + ctx context.Context, + a *automation.Automation, + evt *automation.AutomationTriggeredEvent, + title, prompt string, + metadata map[string]interface{}, +) (*models.Task, *models.TaskSession, automation.ThreadAction, string, error) { + action := automation.ThreadActionCreated + reason := "new task created for automation run" + taskMode := a.TaskMode + if taskMode == "" { + taskMode = automation.TaskModeAutomationRun + } + repositoryMode := a.RepositoryMode + if repositoryMode == "" { + if len(a.Repositories) > 0 || len(a.RepositoryIDs) > 0 { + repositoryMode = automation.RepositoryModeSelected + } else { + repositoryMode = automation.RepositoryModeNone + } + } + metadata[models.MetaKeyAutomationTaskMode] = string(taskMode) + metadata[models.MetaKeyAutomationRepositoryMode] = string(repositoryMode) + taskOrigin := models.TaskOriginAutomationRun + if taskMode == automation.TaskModeNormalTask { + taskOrigin = models.TaskOriginAutomationTask + } + var task *models.Task + var continuationSession *models.TaskSession + if a.ContinuationPolicy == automation.ContinuationPolicyReuseThread { + task, continuationSession, reason = s.findAutomationContinuation(ctx, a, evt) + if task != nil { + action = automation.ThreadActionResumed + } else if a.ContinuationTaskID != "" { + action = automation.ThreadActionReplaced + } + } + if task != nil { + return task, continuationSession, action, reason, nil + } + + repositories := s.resolveAutomationRepository(ctx, a, evt) + task, err := s.reviewTaskCreator.CreateReviewTask(ctx, &ReviewTaskRequest{ + WorkspaceID: a.WorkspaceID, + WorkflowID: a.WorkflowID, + WorkflowStepID: a.WorkflowStepID, + Title: title, + Description: prompt, + Repositories: repositories, + Metadata: metadata, + Origin: taskOrigin, + }) + if err != nil { + return nil, nil, action, reason, fmt.Errorf("create automation task: %w", err) + } + if a.ContinuationPolicy != automation.ContinuationPolicyReuseThread { + return task, nil, action, reason, nil + } + state, ok := s.automationService.(automationContinuationState) + if !ok { + s.deleteAbandonedTask(ctx, a.ID, task.ID) + return nil, nil, action, reason, fmt.Errorf("automation continuation state is unavailable") + } + if err := state.SetContinuationTaskID(ctx, a.ID, task.ID); err != nil { + s.deleteAbandonedTask(ctx, a.ID, task.ID) + return nil, nil, action, reason, err + } + if action == automation.ThreadActionReplaced { + reason = "previous continuation was unavailable; created a replacement task" + } + return task, nil, action, reason, nil +} + +func (s *Service) findAutomationContinuation(ctx context.Context, a *automation.Automation, evt *automation.AutomationTriggeredEvent) (*models.Task, *models.TaskSession, string) { + if a.ContinuationTaskID == "" { + return nil, nil, "no continuation task exists; created the first task" + } + task, err := s.repo.GetTask(ctx, a.ContinuationTaskID) + if err != nil || task == nil { + return nil, nil, "previous continuation task was not found" + } + if reason := s.continuationTaskIncompatibility(ctx, a, evt, task); reason != "" { + return nil, nil, reason + } + session, reason := s.continuationSession(ctx, task.ID) + if reason != "" { + return nil, nil, reason + } + return task, session, "continued the previous task and session" +} + +func (s *Service) continuationTaskIncompatibility(ctx context.Context, a *automation.Automation, evt *automation.AutomationTriggeredEvent, task *models.Task) string { + if task.WorkspaceID != a.WorkspaceID || task.Origin != automationTaskOrigin(a) || task.ArchivedAt != nil { + return "previous continuation task is not compatible" + } + if !continuationWorkflowMatches(a, task) { + return "previous continuation task uses a different workflow" + } + if !continuationTaskModeMatches(a, task) { + return "previous continuation task uses a different target mode" + } + if !continuationRepositoryModeMatches(a, task) { + return "previous continuation task uses a different repository mode" + } + if !continuationProfilesMatch(a, task) { + return "previous continuation task uses different runtime profiles" + } + if !s.continuationRepositoriesMatch(ctx, a, evt, task) { + return "previous continuation task uses different repositories" + } + return "" +} + +func automationTaskOrigin(a *automation.Automation) string { + if a.TaskMode == automation.TaskModeNormalTask { + return models.TaskOriginAutomationTask + } + return models.TaskOriginAutomationRun +} + +func continuationWorkflowMatches(a *automation.Automation, task *models.Task) bool { + return task.WorkflowID == a.WorkflowID && (a.WorkflowStepID == "" || task.WorkflowStepID == a.WorkflowStepID) +} + +func continuationTaskModeMatches(a *automation.Automation, task *models.Task) bool { + taskMode := a.TaskMode + if taskMode == "" { + taskMode = automation.TaskModeAutomationRun + } + previous := metadataString(task.Metadata, models.MetaKeyAutomationTaskMode) + return previous == "" || previous == string(taskMode) +} + +func continuationRepositoryModeMatches(a *automation.Automation, task *models.Task) bool { + repositoryMode := a.RepositoryMode + if repositoryMode == "" { + if len(a.Repositories) > 0 || len(a.RepositoryIDs) > 0 { + repositoryMode = automation.RepositoryModeSelected + } else { + repositoryMode = automation.RepositoryModeNone + } + } + previous := metadataString(task.Metadata, models.MetaKeyAutomationRepositoryMode) + return previous == "" || previous == string(repositoryMode) +} + +func continuationProfilesMatch(a *automation.Automation, task *models.Task) bool { + return metadataString(task.Metadata, models.MetaKeyAgentProfileID) == a.AgentProfileID && + metadataString(task.Metadata, models.MetaKeyExecutorProfileID) == a.ExecutorProfileID +} + +func (s *Service) continuationRepositoriesMatch(ctx context.Context, a *automation.Automation, evt *automation.AutomationTriggeredEvent, task *models.Task) bool { + expected := s.resolveAutomationRepository(ctx, a, evt) + if sameAutomationRepositoryIDs(task.Repositories, expected) { + return true + } + store, ok := s.repo.(interface { + ListTaskRepositories(context.Context, string) ([]*models.TaskRepository, error) + }) + if !ok { + return false + } + links, err := store.ListTaskRepositories(ctx, task.ID) + return err == nil && sameAutomationRepositoryIDs(links, expected) +} + +func (s *Service) continuationSession(ctx context.Context, taskID string) (*models.TaskSession, string) { + lookup, ok := s.repo.(interface { + GetTaskSessionByTaskID(context.Context, string) (*models.TaskSession, error) + }) + if !ok { + return nil, "task session lookup is unavailable" + } + session, err := lookup.GetTaskSessionByTaskID(ctx, taskID) + if err != nil || session == nil { + return nil, "previous continuation session was not found" + } + if session.State != models.TaskSessionStateWaitingForInput && session.State != models.TaskSessionStateIdle { + return nil, "previous continuation session is not ready" + } + return session, "" +} + +func metadataString(metadata map[string]interface{}, key string) string { + value, _ := metadata[key].(string) + return value +} + +func sameAutomationRepositoryIDs(taskRepos []*models.TaskRepository, expected []ReviewTaskRepository) bool { + if len(taskRepos) != len(expected) { + return false + } + for i, expectedRepo := range expected { + if taskRepos[i] == nil || taskRepos[i].RepositoryID != expectedRepo.RepositoryID || + taskRepos[i].BaseBranch != expectedRepo.BaseBranch { + return false + } + } + return true +} + +// dispatchAutomationRun hands an admitted run to the service-owned +// dispatcher when available. The dispatcher holds the per-automation lock +// across the provider call and exact binding, so a stop cannot race a launch. +// The bool distinguishes that path from legacy test/integration services that +// do not implement the extension yet. +func (s *Service) dispatchAutomationRun( + ctx context.Context, + automationID, taskID, sessionID, runID string, + action automation.ThreadAction, + reason, operation string, + dispatch func() (automation.RunDispatch, error), +) bool { + if runID == "" { + return false + } + dispatcher, ok := s.automationService.(automationRunDispatcher) + if !ok { + return false + } + if err := dispatcher.DispatchRun(ctx, runID, action, reason, dispatch); err == nil { + return true + } else { + s.logger.Error("failed to dispatch automation run", + zap.String("operation", operation), zap.String("automation_id", automationID), + zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(err)) + } + s.cleanupFailedAutomationTask(ctx, automationID, taskID, action) + return true +} + +func (s *Service) cleanupFailedAutomationTask(ctx context.Context, automationID, taskID string, action automation.ThreadAction) { + if action == automation.ThreadActionResumed { + return + } + s.deleteAbandonedTask(ctx, automationID, taskID) +} + +// bindAutomationRun records a dispatch result for legacy callers that do not +// use the service-owned dispatcher. It returns false after a binding failure +// because the provider turn must not continue as an untracked run. +func (s *Service) bindAutomationRun( + ctx context.Context, + runID, taskID, sessionID, turnID string, + action automation.ThreadAction, + reason, operation string, +) bool { + if runID == "" { + return true + } + binding, ok := s.automationService.(automationRunBinding) + if !ok { + return true + } + bindErr := binding.BindRun(ctx, runID, taskID, sessionID, turnID, action, reason) + if bindErr == nil { + return true + } + s.logger.Error("failed to bind automation run", + zap.String("operation", operation), zap.String("run_id", runID), + zap.String("task_id", taskID), zap.String("turn_id", turnID), zap.Error(bindErr)) + if !s.markExactAutomationRunTerminal(ctx, runID, "", "", false, bindErr.Error()) { + s.markAutomationRunTerminal(ctx, taskID, false, bindErr.Error()) + } + return false +} + +func (s *Service) dispatchAutomationContinuation(ctx context.Context, a *automation.Automation, task *models.Task, session *models.TaskSession, prompt, runID string, action automation.ThreadAction, reason string) { + if s.dispatchAutomationRun(ctx, a.ID, task.ID, session.ID, runID, action, reason, "continuation", func() (automation.RunDispatch, error) { + return s.promptAutomationContinuation(ctx, task, session, prompt) + }) { + return + } + + dispatch, err := s.promptAutomationContinuation(ctx, task, session, prompt) + if err != nil { + s.logger.Error("failed to dispatch automation continuation", + zap.String("automation_id", a.ID), zap.String("task_id", task.ID), zap.String("session_id", session.ID), zap.Error(err)) + if !s.markExactAutomationRunTerminal(ctx, runID, "", "", false, err.Error()) { + s.markAutomationRunTerminal(ctx, task.ID, false, err.Error()) + } + return + } + if !s.bindAutomationRun(ctx, runID, dispatch.TaskID, dispatch.SessionID, dispatch.TurnID, action, reason, "continuation") { + return + } +} + +func (s *Service) promptAutomationContinuation( + ctx context.Context, + task *models.Task, + session *models.TaskSession, + prompt string, +) (automation.RunDispatch, error) { + result, err := s.PromptTask(ctx, task.ID, session.ID, prompt, "", false, nil, true) + if err != nil { + return automation.RunDispatch{}, err + } + turnID := "" + if result != nil { + turnID = result.TurnID + } + if turnID == "" && s.turnService != nil { + if active, turnErr := s.turnService.GetActiveTurn(ctx, session.ID); turnErr == nil && active != nil { + turnID = active.ID + } + } + if turnID == "" { + s.cancelAutomationDispatch(ctx, task.ID, session.ID) + return automation.RunDispatch{}, errors.New("automation continuation dispatch returned no turn identity") + } + s.recordAutomationContinuationMessage(ctx, task.ID, session.ID, turnID, prompt) + return automation.RunDispatch{TaskID: task.ID, SessionID: session.ID, TurnID: turnID}, nil +} + +func (s *Service) cancelAutomationDispatch(ctx context.Context, taskID, sessionID string) { + if s.turnService == nil || s.executor == nil { + return + } + if _, err := s.stopTaskSessionForCoordinator(ctx, taskID, sessionID); err != nil { + s.logger.Warn("failed to cancel automation dispatch without a turn identity", + zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(err)) + } +} + +func (s *Service) recordAutomationContinuationMessage(ctx context.Context, taskID, sessionID, turnID, prompt string) { + if s.messageCreator == nil || prompt == "" || turnID == "" { + return + } + meta := NewUserMessageMeta().WithAutoStart(true) + if err := s.messageCreator.CreateUserMessage(ctx, taskID, prompt, sessionID, turnID, meta.ToMap()); err != nil { + s.logger.Warn("failed to record automation continuation prompt", zap.String("task_id", taskID), zap.String("turn_id", turnID), zap.Error(err)) + } } func (s *Service) autoStartAutomationTask(ctx context.Context, a *automation.Automation, task *models.Task, workflowStepID string) { - _, err := s.StartTask( + s.autoStartAutomationTaskForRun(ctx, a, task, workflowStepID, "", automation.ThreadActionCreated, "") +} + +func (s *Service) autoStartAutomationTaskForRun(ctx context.Context, a *automation.Automation, task *models.Task, workflowStepID, runID string, action automation.ThreadAction, reason string) { + if s.dispatchAutomationRun(ctx, a.ID, task.ID, "", runID, action, reason, "auto-start", func() (automation.RunDispatch, error) { + return s.startAutomationTask(ctx, a, task, workflowStepID) + }) { + return + } + + dispatch, err := s.startAutomationTask(ctx, a, task, workflowStepID) + 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. + if !s.markExactAutomationRunTerminal(ctx, runID, "", "", false, err.Error()) { + s.markAutomationRunTerminal(ctx, task.ID, false, err.Error()) + } + return + } + if !s.bindAutomationRun(ctx, runID, dispatch.TaskID, dispatch.SessionID, dispatch.TurnID, action, reason, "auto-start") { + return + } + s.logger.Info("auto-started automation task", + zap.String("task_id", task.ID), + zap.String("automation_id", a.ID)) +} + +func (s *Service) startAutomationTask( + ctx context.Context, + a *automation.Automation, + task *models.Task, + workflowStepID string, +) (automation.RunDispatch, error) { + execution, err := s.StartTask( ctx, task.ID, a.AgentProfileID, @@ -230,18 +718,12 @@ func (s *Service) autoStartAutomationTask(ctx context.Context, a *automation.Aut nil, ) 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 + return automation.RunDispatch{}, err } - s.logger.Info("auto-started automation task", - zap.String("task_id", task.ID), - zap.String("automation_id", a.ID)) + if execution == nil || execution.SessionID == "" || execution.TurnID == "" { + return automation.RunDispatch{}, errors.New("automation task start returned no session or turn identity") + } + return automation.RunDispatch{TaskID: task.ID, SessionID: execution.SessionID, TurnID: execution.TurnID}, nil } // resolveAutomationRepository determines the repositories for an @@ -249,42 +731,44 @@ func (s *Service) autoStartAutomationTask(ctx context.Context, a *automation.Aut // repo info from the trigger data — the PR's own repo is the only sensible // choice when responding to a PR event, so the automation's RepositoryIDs // are ignored. For other triggers (scheduled, webhook), it prefers the -// automation's explicit RepositoryIDs; falls back to the workspace's first -// repository if unset. +// automation's explicit repository/base-branch pairs. An empty selection is +// repository-free and never falls back to workspace ordering. func (s *Service) resolveAutomationRepository( ctx context.Context, a *automation.Automation, evt *automation.AutomationTriggeredEvent, ) []ReviewTaskRepository { if evt.TriggerType == automation.TriggerTypeGitHubPR { return s.resolveGitHubPRTriggerRepository(ctx, a.WorkspaceID, evt.TriggerData) } - if len(a.RepositoryIDs) > 0 { - return s.resolveExplicitRepositories(ctx, a.RepositoryIDs) + if len(a.Repositories) == 0 && len(a.RepositoryIDs) == 0 { + return nil } - return s.resolveWorkspaceRepository(ctx, a.WorkspaceID) + return s.resolveExplicitRepositories(ctx, configuredAutomationRepositories(a)) } -// resolveExplicitRepositories loads each repository named by the -// automation's RepositoryIDs and produces one ReviewTaskRepository entry -// per resolvable ID, in order. Each task repository gets pinned to its own -// default branch — automations have no way to specify a checkout branch -// yet. An ID that fails to load is skipped (with a warning) rather than -// aborting the whole firing, so one bad repository doesn't sink the others. +// resolveExplicitRepositories loads each configured repository and produces one +// ReviewTaskRepository entry per resolvable pair, in order. Canonical bindings +// use their saved base branch; legacy repository-ID-only rows use the repository +// default. A repository that fails to load is skipped (with a warning) rather +// than aborting the whole firing, so one bad repository does not sink the others. func (s *Service) resolveExplicitRepositories( - ctx context.Context, repositoryIDs []string, + ctx context.Context, repositories []automation.AutomationRepository, ) []ReviewTaskRepository { store, ok := s.repo.(repoStore) if !ok { return nil } - resolved := make([]ReviewTaskRepository, 0, len(repositoryIDs)) - for _, repositoryID := range repositoryIDs { - repo, err := store.GetRepository(ctx, repositoryID) + resolved := make([]ReviewTaskRepository, 0, len(repositories)) + for _, configured := range repositories { + repo, err := store.GetRepository(ctx, configured.RepositoryID) if err != nil || repo == nil { s.logger.Warn("failed to load explicit automation repository", - zap.String("repository_id", repositoryID), zap.Error(err)) + zap.String("repository_id", configured.RepositoryID), zap.Error(err)) continue } - baseBranch := repo.DefaultBranch + baseBranch := configured.BaseBranch + if baseBranch == "" { + baseBranch = repo.DefaultBranch + } if baseBranch == "" { baseBranch = automationDefaultBaseBranch } @@ -297,6 +781,17 @@ func (s *Service) resolveExplicitRepositories( return resolved } +func configuredAutomationRepositories(a *automation.Automation) []automation.AutomationRepository { + if len(a.Repositories) > 0 { + return a.Repositories + } + result := make([]automation.AutomationRepository, 0, len(a.RepositoryIDs)) + for _, repositoryID := range a.RepositoryIDs { + result = append(result, automation.AutomationRepository{RepositoryID: repositoryID}) + } + return result +} + // resolveGitHubPRTriggerRepository extracts repo owner/name from PR trigger data // and resolves it via the repository resolver. func (s *Service) resolveGitHubPRTriggerRepository( @@ -333,53 +828,9 @@ func (s *Service) resolveGitHubPRTriggerRepository( }} } -// resolveWorkspaceRepository looks up the workspace's first repository -// and returns it for task creation. This mirrors how the review watch -// auto-resolves repos — no manual selector needed. -func (s *Service) resolveWorkspaceRepository( - ctx context.Context, workspaceID string, -) []ReviewTaskRepository { - store, ok := s.repo.(repoStore) - if !ok { - return nil - } - repos, err := store.ListRepositories(ctx, workspaceID) - if err != nil { - s.logger.Warn("failed to list workspace repositories", zap.Error(err)) - return nil - } - if len(repos) == 0 { - s.logger.Warn("workspace has no repositories", - zap.String("workspace_id", workspaceID)) - return nil - } - repo := repos[0] - baseBranch := repo.DefaultBranch - if baseBranch == "" { - baseBranch = automationDefaultBaseBranch - } - return []ReviewTaskRepository{{ - RepositoryID: repo.ID, - BaseBranch: baseBranch, - }} -} - // resolveAutomationTaskTitle builds the task title from the automation's template or falls back to a default. func (s *Service) resolveAutomationTaskTitle(a *automation.Automation, evt *automation.AutomationTriggeredEvent) string { - if a.TaskTitleTemplate != "" { - title := automation.InterpolatePrompt(a.TaskTitleTemplate, evt.TriggerType, evt.TriggerData) - if title != "" { - return taskservice.TruncateTaskTitle(title) - } - } - // Fall back to trigger type default from registry. - if info := automation.GetTriggerTypeInfo(evt.TriggerType); info != nil && info.DefaultTaskTitle != "" { - title := automation.InterpolatePrompt(info.DefaultTaskTitle, evt.TriggerType, evt.TriggerData) - if title != "" { - return taskservice.TruncateTaskTitle(title) - } - } - return taskservice.TruncateTaskTitle(fmt.Sprintf("[Auto] %s", a.Name)) + return automation.RenderRunDisplayTitle(a, evt.TriggerType, evt.TriggerData) } // associateAutomationPR links a task to a GitHub PR using trigger data. @@ -431,6 +882,9 @@ func (s *Service) recordFailedRun(ctx context.Context, evt *automation.Automatio if s.automationService == nil { return } + if s.markExactAutomationRunTerminal(ctx, evt.RunID, "", "", false, errMsg) { + return + } // github_pr_merged pre-task-failure rows must NOT consume the dedup key so // that a later event for the same PR can retry. All recordFailedRun call // sites are pre-task-creation, so blanking is always correct for this type. @@ -461,6 +915,7 @@ func (s *Service) recordFailedRun(ctx context.Context, evt *automation.Automatio // 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) { + s.clearAbandonedContinuation(ctx, automationID, taskID) cleaner, ok := s.repo.(taskCleaner) if !ok { return @@ -473,12 +928,39 @@ func (s *Service) deleteAbandonedTask(ctx context.Context, automationID, taskID } } +func (s *Service) clearAbandonedContinuation(ctx context.Context, automationID, taskID string) { + if s.automationService == nil { + return + } + state, ok := s.automationService.(automationContinuationState) + if !ok { + return + } + current, err := s.automationService.GetAutomation(ctx, automationID) + if err != nil || current == nil || current.ContinuationTaskID != taskID { + return + } + if err := state.SetContinuationTaskID(ctx, automationID, ""); err != nil { + s.logger.Error("failed to clear abandoned automation continuation", + 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 } + if evt.RunID != "" { + binding, ok := s.automationService.(automationRunBinding) + if !ok { + return fmt.Errorf("automation service cannot bind admitted run %s", evt.RunID) + } + return binding.BindRunTask(ctx, evt.RunID, taskID) + } run := &automation.AutomationRun{ AutomationID: evt.AutomationID, TriggerID: evt.TriggerID, @@ -491,12 +973,38 @@ func (s *Service) recordSuccessRun( return s.automationService.RecordRun(ctx, run) } +func (s *Service) markExactAutomationRunTerminal(ctx context.Context, runID, sessionID, turnID string, success bool, errMsg string) bool { + if runID == "" || s.automationService == nil { + return false + } + binding, ok := s.automationService.(automationRunBinding) + if !ok { + return false + } + status := automation.RunStatusFailed + if success { + status = automation.RunStatusSucceeded + } + if err := binding.MarkRunTerminal(ctx, runID, sessionID, turnID, status, errMsg); err != nil { + s.logger.Warn("failed to update exact automation run terminal status", + zap.String("run_id", runID), zap.String("session_id", sessionID), zap.String("turn_id", turnID), zap.Error(err)) + return false + } + return true +} + // 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. func (s *Service) handleAutomationTurnComplete( ctx context.Context, taskID, sessionID string, session *models.TaskSession, stopReason string, isError bool, errMsg string, +) bool { + return s.handleAutomationTurnCompleteForTurn(ctx, taskID, sessionID, session, "", stopReason, isError, errMsg) +} + +func (s *Service) handleAutomationTurnCompleteForTurn( + ctx context.Context, taskID, sessionID string, session *models.TaskSession, turnID, stopReason string, isError bool, errMsg string, ) bool { if taskID == "" || sessionID == "" { return false @@ -505,15 +1013,15 @@ func (s *Service) handleAutomationTurnComplete( if err != nil || task == nil { return false } - // 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.TaskOriginAutomationTask { + return s.handleVisibleAutomationTurnComplete(ctx, taskID, sessionID, turnID, stopReason, isError, errMsg) + } if task.Origin != models.TaskOriginAutomationRun { return false } cancelled := stopReason == stopReasonCancelled - success := !isError && stopReason != "error" && !cancelled + success := !isError && stopReason != agentEventError && !cancelled if cancelled && errMsg == "" { errMsg = stopReasonCancelled } @@ -530,7 +1038,7 @@ func (s *Service) handleAutomationTurnComplete( nextState = models.TaskSessionStateFailed } s.updateTaskSessionState(ctx, taskID, sessionID, nextState, errMsg, false, session) - s.markAutomationRunTerminal(ctx, taskID, success, errMsg) + s.markAutomationRunTerminalForTurn(ctx, taskID, sessionID, turnID, success, errMsg) s.stopAutomationAgent(ctx, taskID, sessionID, session) // 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 @@ -538,11 +1046,27 @@ func (s *Service) handleAutomationTurnComplete( return true } +func (s *Service) handleVisibleAutomationTurnComplete( + ctx context.Context, taskID, sessionID, turnID, stopReason string, isError bool, errMsg string, +) bool { + if turnID != "" { + s.markAutomationRunTerminalForTurn( + ctx, + taskID, + sessionID, + turnID, + !isError && stopReason != agentEventError && stopReason != stopReasonCancelled, + errMsg, + ) + } + return false +} + // 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. +// without anyone archiving anything. Keyed on automation origin so both hidden +// coordinator tasks and visible automation-created tasks are settled while +// regular tasks remain untouched. func (s *Service) finalizeAutomationRun(ctx context.Context, taskID string, success bool, errMsg string) { if taskID == "" { return @@ -551,7 +1075,28 @@ func (s *Service) finalizeAutomationRun(ctx context.Context, taskID string, succ if err != nil || task == nil { return } - if task.Origin != models.TaskOriginAutomationRun { + if !isAutomationTaskOrigin(task.Origin) { + return + } + if task.Origin == models.TaskOriginAutomationTask { + // Visible automation tasks may share one session across multiple exact + // runs. The stream completion path settles the current bound turn; the + // process-exit fallback must never settle a different run by task ID. + lookup, ok := s.repo.(interface { + GetTaskSessionByTaskID(context.Context, string) (*models.TaskSession, error) + }) + if !ok || s.turnService == nil { + return + } + session, sessionErr := lookup.GetTaskSessionByTaskID(ctx, taskID) + if sessionErr != nil || session == nil { + return + } + turn, turnErr := s.turnService.GetActiveTurn(ctx, session.ID) + if turnErr != nil || turn == nil { + return + } + s.markAutomationRunTerminalForTurn(ctx, taskID, session.ID, turn.ID, success, errMsg) return } @@ -579,6 +1124,26 @@ func (s *Service) markAutomationRunTerminal(ctx context.Context, taskID string, s.pruneAutomationRunWorktrees(ctx, taskID) } +func (s *Service) markAutomationRunTerminalForTurn(ctx context.Context, taskID, sessionID, turnID string, success bool, errMsg string) { + if turnID == "" { + s.markAutomationRunTerminal(ctx, taskID, success, errMsg) + return + } + binding, ok := s.automationService.(automationRunBinding) + if !ok { + s.markAutomationRunTerminal(ctx, taskID, success, errMsg) + return + } + status := automation.RunStatusFailed + if success { + status = automation.RunStatusSucceeded + } + if err := binding.MarkRunTerminalByBinding(ctx, taskID, sessionID, turnID, status, errMsg); err != nil { + s.logger.Warn("failed to update automation run by exact turn binding", + zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.String("turn_id", turnID), zap.Error(err)) + } +} + // 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 @@ -607,6 +1172,9 @@ func (s *Service) pruneAutomationRunWorktrees(ctx context.Context, taskID string zap.Error(err)) } for _, agedOutTaskID := range s.automationWorkspaceSweepOrder(agedOut) { + if task, taskErr := s.repo.GetTask(ctx, agedOutTaskID); taskErr == nil && task != nil && task.Origin == models.TaskOriginAutomationTask { + continue + } s.reclaimAutomationRunWorkspace(ctx, retention, agedOutTaskID) } } diff --git a/apps/backend/internal/orchestrator/event_handlers_automation_test.go b/apps/backend/internal/orchestrator/event_handlers_automation_test.go index 97e5edb06b..45efbf2c5a 100644 --- a/apps/backend/internal/orchestrator/event_handlers_automation_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_automation_test.go @@ -92,7 +92,7 @@ func TestResolveAutomationRepository_SkipsUnloadableID(t *testing.T) { } } -func TestResolveAutomationRepository_EmptyListFallsBackToWorkspace(t *testing.T) { +func TestResolveAutomationRepository_EmptyListUsesNoRepository(t *testing.T) { repo := setupTestRepo(t) seedAutomationWorkspaceRepos(t, repo, "ws-1", []string{"repo-only"}) svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) @@ -102,11 +102,32 @@ func TestResolveAutomationRepository_EmptyListFallsBackToWorkspace(t *testing.T) resolved := svc.resolveAutomationRepository(context.Background(), a, evt) - if len(resolved) != 1 || resolved[0].RepositoryID != "repo-only" { - t.Fatalf("expected fallback to the workspace's only repository, got %+v", resolved) + if len(resolved) != 0 { + t.Fatalf("expected no repository, got %+v", resolved) } } +// @covers AC-OFFICE-AUTOMATION-TARGETS-001.10 +func TestResolveAutomationRepository_UsesSavedBaseBranches(t *testing.T) { + repo := setupTestRepo(t) + seedAutomationWorkspaceRepos(t, repo, "ws-1", []string{"repo-a"}) + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + a := &automation.Automation{ + WorkspaceID: "ws-1", + Repositories: []automation.AutomationRepository{ + {RepositoryID: "repo-a", BaseBranch: "release/2"}, + }, + } + + resolved := svc.resolveAutomationRepository(context.Background(), a, &automation.AutomationTriggeredEvent{ + TriggerType: automation.TriggerTypeScheduled, + }) + + require.Len(t, resolved, 1) + require.Equal(t, "release/2", resolved[0].BaseBranch) + require.Equal(t, "release/2", resolved[0].CheckoutBranch) +} + func TestResolveAutomationTaskTitleTruncatesRenderedTitle(t *testing.T) { svc := &Service{} longTitle := strings.Repeat("x", taskservice.TaskTitleMaxLength+20) @@ -122,6 +143,96 @@ func TestResolveAutomationTaskTitleTruncatesRenderedTitle(t *testing.T) { } } +func TestFindAutomationContinuationRejectsChangedLaunchIdentity(t *testing.T) { + repo := setupTestRepo(t) + seedAutomationWorkspaceRepos(t, repo, "ws-1", []string{"repo-a"}) + now := time.Now().UTC() + ctx := context.Background() + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "continuation-task", + WorkspaceID: "ws-1", + WorkflowID: "workflow-a", + WorkflowStepID: "step-a", + Origin: models.TaskOriginAutomationRun, + Metadata: map[string]interface{}{ + models.MetaKeyAgentProfileID: "agent-a", + models.MetaKeyExecutorProfileID: "executor-a", + }, + CreatedAt: now, + UpdatedAt: now, + })) + require.NoError(t, repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "continuation-repo", TaskID: "continuation-task", RepositoryID: "repo-a", Position: 0, + CreatedAt: now, UpdatedAt: now, + })) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "continuation-session", TaskID: "continuation-task", + State: models.TaskSessionStateWaitingForInput, StartedAt: now, UpdatedAt: now, + })) + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + a := &automation.Automation{ + ContinuationTaskID: "continuation-task", WorkspaceID: "ws-1", + WorkflowID: "workflow-b", WorkflowStepID: "step-a", + AgentProfileID: "agent-a", ExecutorProfileID: "executor-a", + RepositoryIDs: []string{"repo-a"}, + } + + task, session, reason := svc.findAutomationContinuation(ctx, a, &automation.AutomationTriggeredEvent{ + TriggerType: automation.TriggerTypeScheduled, + }) + require.Nil(t, task) + require.Nil(t, session) + require.Equal(t, "previous continuation task uses a different workflow", reason) +} + +func TestFindAutomationContinuationKeepsImplicitStepAndRotatesRepositoryMode(t *testing.T) { + repo := setupTestRepo(t) + now := time.Now().UTC() + ctx := context.Background() + require.NoError(t, repo.CreateTask(ctx, &models.Task{ + ID: "implicit-step-task", + WorkspaceID: "ws-1", + WorkflowID: "workflow-a", + WorkflowStepID: "resolved-start-step", + Origin: models.TaskOriginAutomationRun, + Metadata: map[string]interface{}{ + models.MetaKeyAgentProfileID: "agent-a", + models.MetaKeyExecutorProfileID: "executor-a", + models.MetaKeyAutomationTaskMode: string(automation.TaskModeAutomationRun), + models.MetaKeyAutomationRepositoryMode: string(automation.RepositoryModeNone), + }, + CreatedAt: now, + UpdatedAt: now, + })) + require.NoError(t, repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "implicit-step-session", TaskID: "implicit-step-task", + State: models.TaskSessionStateWaitingForInput, StartedAt: now, UpdatedAt: now, + })) + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + a := &automation.Automation{ + ContinuationTaskID: "implicit-step-task", WorkspaceID: "ws-1", + WorkflowID: "workflow-a", AgentProfileID: "agent-a", ExecutorProfileID: "executor-a", + TaskMode: automation.TaskModeAutomationRun, RepositoryMode: automation.RepositoryModeNone, + } + + task, session, reason := svc.findAutomationContinuation(ctx, a, &automation.AutomationTriggeredEvent{ + TriggerType: automation.TriggerTypeScheduled, + }) + require.Equal(t, "implicit-step-task", task.ID) + require.Equal(t, "implicit-step-session", session.ID) + require.Equal(t, "continued the previous task and session", reason) + + a.RepositoryMode = automation.RepositoryModeWorkspaceDefault + task, session, reason = svc.findAutomationContinuation(ctx, a, &automation.AutomationTriggeredEvent{ + TriggerType: automation.TriggerTypeScheduled, + }) + require.Nil(t, task) + require.Nil(t, session) + require.Equal(t, "previous continuation task uses a different repository mode", reason) +} + // TestResolveAutomationRepository_GitHubPRIgnoresConfiguredRepositoryIDs is // the regression guard for a CodeRabbit review finding on PR #2077: the // frontend disables (but does not clear) the repository picker for @@ -356,6 +467,58 @@ func TestCreateAutomationTask_WorksWithoutWorkflowOrStep(t *testing.T) { require.Equal(t, models.TaskOriginAutomationRun, creator.got.Origin) } +func TestPrepareAutomationTask_AllowsRepositoryFreeHiddenRun(t *testing.T) { + repo := setupTestRepo(t) + creator := &stubReviewTaskCreator{task: &models.Task{ID: "t-scratch"}} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.reviewTaskCreator = creator + a := &automation.Automation{ + ID: "a-scratch", WorkspaceID: "ws-1", Name: "scratch", Enabled: true, + TaskMode: automation.TaskModeAutomationRun, + RepositoryMode: automation.RepositoryModeNone, + } + + task, session, action, _, err := svc.prepareAutomationTask( + context.Background(), a, + &automation.AutomationTriggeredEvent{TriggerType: automation.TriggerTypeScheduled}, + "Scratch", "report", map[string]interface{}{}, + ) + + require.NoError(t, err) + require.NotNil(t, task) + require.Nil(t, session) + require.Equal(t, automation.ThreadActionCreated, action) + require.Empty(t, creator.got.Repositories) + require.Equal(t, models.TaskOriginAutomationRun, creator.got.Origin) +} + +func TestPrepareAutomationTaskCreatesVisibleNormalTaskWithoutRepository(t *testing.T) { + repo := setupTestRepo(t) + creator := &stubReviewTaskCreator{task: &models.Task{ID: "t-visible"}} + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.reviewTaskCreator = creator + a := &automation.Automation{ + ID: "a-visible", WorkspaceID: "ws-1", Name: "visible", Enabled: true, + TaskMode: automation.TaskModeNormalTask, + RepositoryMode: automation.RepositoryModeNone, + WorkflowID: "workflow-1", + WorkflowStepID: "step-1", + } + + task, _, _, _, err := svc.prepareAutomationTask( + context.Background(), a, + &automation.AutomationTriggeredEvent{TriggerType: automation.TriggerTypeScheduled}, + "Visible", "report", map[string]interface{}{}, + ) + + require.NoError(t, err) + require.NotNil(t, task) + require.Empty(t, creator.got.Repositories) + require.Equal(t, "automation_task", creator.got.Origin) + require.Equal(t, "workflow-1", creator.got.WorkflowID) + require.Equal(t, "step-1", creator.got.WorkflowStepID) +} + // 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 diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index 1d1e5f85a9..8f090cf966 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -2458,11 +2458,12 @@ func (s *Service) handleCompleteStreamEvent(ctx context.Context, payload *lifecy if session != nil && s.handleOfficeTurnComplete(ctx, payload.TaskID, payload.SessionID, session, stopReason) { return } - if session != nil && s.handleAutomationTurnComplete( + if session != nil && s.handleAutomationTurnCompleteForTurn( ctx, payload.TaskID, payload.SessionID, session, + completionTurnID, stopReason, extractCompleteIsError(payload), extractCompleteErrorMessage(payload), diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index a22f0b108c..12aaa2141d 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -516,6 +516,10 @@ const McpModeTaskTitlePending = "task-title-pending" // docs/specs/office/system-design/agents-03.md. const McpModeOffice = "office" +// McpModeAutomation selects the fixed coordinator MCP surface for tasks +// created by a user-configured automation. +const McpModeAutomation = "automation" + // LaunchOptions contains optional parameters for LaunchPreparedSession. type LaunchOptions struct { AgentProfileID string @@ -526,7 +530,7 @@ type LaunchOptions struct { PriorACPSession string // ACP session ID to resume for the same concrete profile WorkflowStepID string StartAgent bool - McpMode string // MCP tool mode: empty task default, McpModeTaskTitlePending, McpModeConfig, or McpModeOffice + McpMode string // MCP tool mode: empty task default, McpModeTaskTitlePending, McpModeConfig, McpModeOffice, or McpModeAutomation McpProfile *mcpprofile.Context Attachments []v1.MessageAttachment Env map[string]string @@ -605,6 +609,7 @@ type TaskExecution struct { TaskID string AgentExecutionID string AgentProfileID string + TurnID string StartedAt time.Time SessionState v1.TaskSessionState LastUpdate time.Time diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 9f2a1869dc..7a3d1d1371 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -47,6 +47,9 @@ func (e *Executor) resolveTaskSessionMCPMode(ctx context.Context, taskID string, if err != nil { return "", fmt.Errorf("load task for MCP mode: %w", err) } + if task != nil && task.Origin == models.TaskOriginAutomationRun { + return McpModeAutomation, nil + } if task != nil && task.IsFromOffice { return McpModeOffice, nil } @@ -76,6 +79,9 @@ func (e *Executor) resolveTaskSessionMCPProfile(ctx context.Context, taskID stri // the exact office/autopilot capability set. return mcpprofile.Legacy("", session != nil && session.IsPassthrough, nil), nil } + if task.Origin == models.TaskOriginAutomationRun { + return mcpprofile.NewAutomation(), nil + } surface := mcpprofile.SurfaceKanbanTask if task.IsFromOffice { surface = mcpprofile.SurfaceOfficeTask diff --git a/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go b/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go index 88168a6a29..c3f6b1d811 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go @@ -68,6 +68,14 @@ func TestResolveTaskSessionMCPProfile_SelectsSurfaceAndQuestionCapability(t *tes wantUserQuestion: true, wantParentQuestion: false, }, + { + name: "automation task", + task: &models.Task{ID: "task", Origin: models.TaskOriginAutomationRun}, + session: &models.TaskSession{ID: "session", TaskID: "task"}, + wantSurface: mcpprofile.SurfaceAutomation, + wantUserQuestion: false, + wantParentQuestion: false, + }, { name: "configuration session", task: &models.Task{ID: "task"}, diff --git a/apps/backend/internal/orchestrator/task_operations.go b/apps/backend/internal/orchestrator/task_operations.go index 68f39100c8..9aab8147aa 100644 --- a/apps/backend/internal/orchestrator/task_operations.go +++ b/apps/backend/internal/orchestrator/task_operations.go @@ -39,6 +39,7 @@ import ( type PromptResult struct { StopReason string // The reason the agent stopped (e.g., "end_turn") AgentMessage string // The agent's accumulated response message + TurnID string // The exact turn accepted for this prompt, when known. } // CoordinatorTaskStopStatus is the idempotent product result returned to a @@ -1178,6 +1179,7 @@ func (s *Service) startTask(ctx context.Context, taskID string, agentProfileID s } s.postLaunchStart(ctx, taskID, execution, effectivePrompt, planModeActive || configMode, planModeActive, autoStart, attachments) + execution.TurnID = initialTurnID s.clearTaskLaunchErrorIfStamp(ctx, taskID, launchErrorStamp) // The agent is running, so the reservation becomes a consumption. @@ -4044,7 +4046,7 @@ func (s *Service) promptTask(ctx context.Context, taskID, sessionID string, prom if publicationErr != nil { return nil, &acceptedPromptDispatchError{err: publicationErr} } - return &PromptResult{StopReason: result.StopReason, AgentMessage: result.AgentMessage}, nil + return &PromptResult{StopReason: result.StopReason, AgentMessage: result.AgentMessage, TurnID: rollback.turnID}, nil } func (s *Service) finishPromptDispatchFailure( diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index 5cadbb9dd8..8984725f52 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -94,7 +94,12 @@ const ( MetaKeyAgentProfileID = "agent_profile_id" MetaKeyExecutorID = "executor_id" MetaKeyExecutorProfileID = "executor_profile_id" - MetaKeyDeferredLaunch = "deferred_launch" + // Automation target metadata is written to continuation tasks so a + // change from hidden to visible ownership or from repository-backed to + // repository-free execution cannot silently reuse the old task. + MetaKeyAutomationTaskMode = "automation_task_mode" + MetaKeyAutomationRepositoryMode = "automation_repository_mode" + MetaKeyDeferredLaunch = "deferred_launch" // MetaKeyQueuedMoveExitPending identifies a queued manual move whose // source-step on_exit side effect is not yet complete. Its value records the // source step so recovery can resume the work after a restart. @@ -781,6 +786,9 @@ const ( TaskOriginRoutine = "routine" TaskOriginOnboarding = "onboarding" TaskOriginAutomationRun = "automation_run" + // TaskOriginAutomationTask is a normal, user-visible task created by an + // automation. Unlike automation_run, it remains in Kanban/sidebar flows. + TaskOriginAutomationTask = "automation_task" ) // Task represents a task in the database @@ -1148,6 +1156,10 @@ const ( PermissionSourceWeb PermissionResolutionSource = "web" PermissionSourceExternalMCP PermissionResolutionSource = "external_mcp" PermissionSourceAutomation PermissionResolutionSource = "automation" + // PermissionSourceAutomationMCP identifies a resolution made by the + // fixed in-session coordinator surface. It is distinct from legacy + // backend automation and from the authenticated external MCP bridge. + PermissionSourceAutomationMCP PermissionResolutionSource = "automation_mcp" ) type PermissionResolutionResult string 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 index 013a13b552..dea4718d8e 100644 --- a/apps/backend/internal/task/repository/sqlite/task_automation_origin_test.go +++ b/apps/backend/internal/task/repository/sqlite/task_automation_origin_test.go @@ -175,6 +175,51 @@ func TestTaskListQueriesExcludeAutomationOriginTasks(t *testing.T) { assertExcludesAutomation(t, "ListTaskTree", tree, f.boardTaskID) } +func TestTaskListQueriesIncludeVisibleAutomationTasks(t *testing.T) { + f := seedAutomationOriginFixture(t) + ctx := context.Background() + const visibleID = "t-auto-visible" + if err := f.repo.CreateTask(ctx, &models.Task{ + ID: visibleID, + WorkspaceID: autoOriginWorkspaceID, + WorkflowID: autoOriginWorkflowID, + WorkflowStepID: autoOriginStepID, + Title: "visible automation task", + State: "BACKLOG", + Priority: "medium", + Origin: models.TaskOriginAutomationTask, + }); err != nil { + t.Fatalf("CreateTask(%s): %v", visibleID, err) + } + + list, _, err := f.repo.ListTasksByWorkspace(ctx, autoOriginWorkspaceID, "", "", "", 1, 50, "", false, false, false, false) + if err != nil { + t.Fatalf("ListTasksByWorkspace: %v", err) + } + ids := taskIDs(list) + if len(ids) != 2 || !containsTaskID(ids, "t-board") || !containsTaskID(ids, visibleID) { + t.Fatalf("workspace task list = %v, want both t-board and %s", ids, visibleID) + } + + byWorkflow, err := f.repo.ListTasks(ctx, autoOriginWorkflowID) + if err != nil { + t.Fatalf("ListTasks: %v", err) + } + workflowIDs := taskIDs(byWorkflow) + if len(workflowIDs) != 2 || !containsTaskID(workflowIDs, "t-board") || !containsTaskID(workflowIDs, visibleID) { + t.Fatalf("workflow task list = %v, want both t-board and %s", workflowIDs, visibleID) + } +} + +func containsTaskID(ids []string, want string) bool { + for _, id := range ids { + if id == want { + return true + } + } + return false +} + // 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) { diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 4fa84c585c..d68868905b 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -669,6 +669,7 @@ const ( ActionAutomationTriggerTypes = "automation.trigger_types" ActionAutomationWebhookRevealSecret = "automation.webhook.reveal_secret" ActionAutomationRunDelete = "automation.run.delete" + ActionAutomationRunStop = "automation.run.stop" ActionAutomationRunsDeleteAll = "automation.runs.delete_all" ) diff --git a/apps/web/components/automations/automation-editor-sections.tsx b/apps/web/components/automations/automation-editor-sections.tsx index 1703b65e8e..dc066e6057 100644 --- a/apps/web/components/automations/automation-editor-sections.tsx +++ b/apps/web/components/automations/automation-editor-sections.tsx @@ -5,13 +5,9 @@ import { Input } from "@kandev/ui/input"; import { Label } from "@kandev/ui/label"; import { Separator } from "@kandev/ui/separator"; import { Switch } from "@kandev/ui/switch"; -import type { - AutomationTrigger, - PlaceholderInfo, - TriggerType, - TriggerTypeInfo, -} from "@/lib/types/automation"; -import type { CreatedWebhookDetails, FormState } from "./automation-payload"; +import { RadioGroup, RadioGroupItem } from "@kandev/ui/radio-group"; +import type { AutomationTrigger, PlaceholderInfo, TriggerTypeInfo } from "@/lib/types/automation"; +import { type CreatedWebhookDetails, type FormState } from "./automation-payload"; import { useAutomationTriggerDrafts } from "./automation-trigger-drafts"; import { ConfigSection } from "./config-section"; import { PromptSection } from "./prompt-section"; @@ -22,6 +18,9 @@ import { useTaskTitleSelectionRestore } from "@/hooks/use-task-title-selection-r type UpdateField = (key: K, value: FormState[K]) => void; +const SELECTED_CARD_CLASS_NAME = "border-primary bg-primary/5"; +const UNSELECTED_CARD_CLASS_NAME = "border-border hover:bg-muted/30"; + export function NameField({ value, isDirty, @@ -128,7 +127,6 @@ export function ThenSection({ workspaceId, placeholders, defaultTaskTitle, - conditionType, savedForm, updateField, }: { @@ -136,7 +134,6 @@ export function ThenSection({ workspaceId: string; placeholders: PlaceholderInfo[]; defaultTaskTitle: string; - conditionType: TriggerType | null; savedForm: FormState; updateField: UpdateField; }) { @@ -146,7 +143,7 @@ export function ThenSection({ "taskTitleTemplate", "prompt", "workflowId", - "workflowStepId", + "taskMode", "agentProfileId", "executorProfileId", "repositorySelections", @@ -186,14 +183,12 @@ export function ThenSection({ updateField("workflowStepId", value)} onAgentProfileChange={(value) => updateField("agentProfileId", value)} onExecutorProfileChange={(value) => updateField("executorProfileId", value)} - onRepositoriesChange={(value) => updateField("repositorySelections", value)} + onRepositoriesChange={(value) => { + updateField("repositorySelections", value); + updateField("repositoryMode", value.length > 0 ? "selected" : "none"); + }} /> ); } +function ContinuationPolicySection({ + form, + savedForm, + updateField, +}: { + form: FormState; + savedForm: FormState; + updateField: UpdateField; +}) { + const { t } = useTranslation(); + const continuationIsDirty = isAutomationFieldDirty(form, savedForm, "continuationPolicy"); + const reusesThread = form.continuationPolicy === "reuse_thread"; + const newTaskDescriptionKey = + form.taskMode === "normal_task" + ? "automations:contextBetweenRunsNewTaskNormalDescription" + : "automations:contextBetweenRunsNewTaskDescription"; + const continuationDescriptionId = "automation-continuation-description"; + + return ( +
+
+

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

+

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

+
+ { + const policy = value as FormState["continuationPolicy"]; + updateField("continuationPolicy", policy); + if (policy === "reuse_thread") updateField("maxConcurrentRuns", 1); + }} + data-settings-dirty={continuationIsDirty} + className="gap-2" + > + + + + {reusesThread && ( +

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

+ )} +
+ ); +} + +function TargetModeSection({ + form, + savedForm, + updateField, +}: { + form: FormState; + savedForm: FormState; + updateField: UpdateField; +}) { + const { t } = useTranslation(); + const targetIsDirty = isAutomationFieldDirty(form, savedForm, "taskMode"); + const normalTask = form.taskMode === "normal_task"; + const descriptionId = "automation-task-mode-description"; + return ( +
+
+

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

+

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

+
+ updateField("taskMode", value as FormState["taskMode"])} + data-settings-dirty={targetIsDirty} + className="gap-2" + > + + + +
+ ); +} + export function SettingsSection({ form, savedForm, @@ -224,10 +401,15 @@ export function SettingsSection({ const { t } = useTranslation(); const enabledIsDirty = isAutomationFieldDirty(form, savedForm, "enabled"); const maxRunsIsDirty = isAutomationFieldDirty(form, savedForm, "maxConcurrentRuns"); + const continuationIsDirty = isAutomationFieldDirty(form, savedForm, "continuationPolicy"); + const taskModeIsDirty = isAutomationFieldDirty(form, savedForm, "taskMode"); + const reusesThread = form.continuationPolicy === "reuse_thread"; return (
+ + ); } diff --git a/apps/web/components/automations/automation-editor.tsx b/apps/web/components/automations/automation-editor.tsx index 24ac4df1e9..27caeaccbd 100644 --- a/apps/web/components/automations/automation-editor.tsx +++ b/apps/web/components/automations/automation-editor.tsx @@ -27,7 +27,7 @@ import { buildCreatePayload, buildUpdatePayload, buildWebhookUrl, - resolveNormalizedRepositoryIds, + resolveRepositoryIdsForMode, } from "./automation-payload"; import { resolveExecutorType } from "./automation-repository-selection"; import { useSettingsSaveContributor } from "@/components/settings/settings-save-provider"; @@ -66,6 +66,9 @@ const defaultForm: FormState = { taskTitleTemplate: "", enabled: true, maxConcurrentRuns: 1, + continuationPolicy: "new_task", + taskMode: "automation_run", + repositoryMode: "none", }; function formFromAutomation(a: Automation): FormState { @@ -73,14 +76,24 @@ function formFromAutomation(a: Automation): FormState { name: a.name, description: a.description, workflowId: a.workflow_id, - workflowStepId: a.workflow_step_id, + workflowStepId: "", agentProfileId: a.agent_profile_id, executorProfileId: a.executor_profile_id, - repositorySelections: a.repository_ids.map((id) => ({ kind: "registered" as const, id })), + taskMode: a.task_mode ?? "automation_run", + repositoryMode: (a.repositories?.length ?? a.repository_ids.length) > 0 ? "selected" : "none", + repositorySelections: ( + a.repositories ?? a.repository_ids.map((id) => ({ repository_id: id, base_branch: "" })) + ).map((repository, index) => ({ + kind: "registered" as const, + id: repository.repository_id, + branch: repository.base_branch, + key: `automation-repository-${index}`, + })), prompt: a.prompt || DEFAULT_PROMPT, taskTitleTemplate: a.task_title_template ?? "", enabled: a.enabled, maxConcurrentRuns: a.max_concurrent_runs, + continuationPolicy: a.continuation_policy ?? "new_task", }; } @@ -112,10 +125,9 @@ type SaveHandlerOpts = { workspaceId: string; form: FormState; currentId: string | null; - // supportsMultiRepo/isPRTrigger mirror what ConfigSection's picker - // actually rendered for this save — see normalizeRepositorySelections. + // supportsMultiRepo mirrors what ConfigSection's picker rendered for this + // save. See normalizeRepositorySelections. supportsMultiRepo: boolean; - isPRTrigger: boolean; create: (payload: CreateAutomationRequest) => Promise; update: (id: string, payload: UpdateAutomationRequest) => Promise; setSaving: React.Dispatch>; @@ -135,31 +147,31 @@ type SaveHandlerOpts = { // function-length lint cap; the save flow has gotten chunky now that it // registers discovered repos before persisting the automation. function useSaveHandler(opts: SaveHandlerOpts): () => Promise { - const { isNew, workspaceId, form, currentId, create, update, supportsMultiRepo, isPRTrigger } = - opts; + const { isNew, workspaceId, form, currentId, create, update, supportsMultiRepo } = opts; const { setSaving, setCurrentId, setForm, setCreatedWebhook, triggerActions, router, onSaved } = opts; return async () => { setSaving(true); try { // The picker only ever *renders* repositorySelections[0] once the - // executor stops supporting multi-repo or a github_pr trigger is - // active (RepositoryPickerField in config-section.tsx) — it doesn't - // truncate the underlying array, so resolveNormalizedRepositoryIds - // re-enforces the same invariant right before persisting (see its - // doc comment for why a stale entry can't be skipped here). - const { ids: repositoryIds, selections: promotedSelections } = - await resolveNormalizedRepositoryIds(workspaceId, form.repositorySelections, { + // executor stops supporting multi-repo. It doesn't truncate the + // underlying array, so resolveNormalizedRepositoryIds re-enforces the + // same invariant right before persisting. + const { repositories, selections: promotedSelections } = await resolveRepositoryIdsForMode( + workspaceId, + form.repositorySelections, + form.repositorySelections.length > 0 ? "selected" : "none", + { supportsMultiRepo, - isPRTrigger, - }); + }, + ); const promoteSelections = () => { setForm((prev) => ({ ...prev, repositorySelections: promotedSelections })); }; const formWithPromotedSelections = { ...form, repositorySelections: promotedSelections }; if (isNew) { const a = await create( - buildCreatePayload(workspaceId, form, repositoryIds, triggerActions.pending), + buildCreatePayload(workspaceId, form, repositories, triggerActions.pending), ); promoteSelections(); // Webhook automations need their URL + secret communicated to the @@ -180,7 +192,7 @@ function useSaveHandler(opts: SaveHandlerOpts): () => Promise { ); } } else if (currentId) { - await update(currentId, buildUpdatePayload(form, repositoryIds)); + await update(currentId, buildUpdatePayload(form, repositories)); const persistedTriggers = await triggerActions.persistDrafts(); promoteSelections(); onSaved(formWithPromotedSelections, persistedTriggers); @@ -336,11 +348,14 @@ function useAutomationPersistence(options: AutomationPersistenceOptions) { description: error instanceof Error ? error.message : t("common:requestFailed"), }), ); - // The name is the only thing an automation cannot do without — it is how the - // reader finds it in the runs list. Workflow and step are optional: an - // automation that only reports has no place on a board, and requiring one - // made every such automation pick a step at random to get past the gate. - const canSave = options.form.name.trim().length > 0; + // The name is the only required field for hidden runs. A visible normal + // task also needs a workflow so the task has a Kanban/sidebar destination. + const canSave = + options.form.name.trim().length > 0 && + (options.form.taskMode !== "normal_task" || options.form.workflowId.trim().length > 0) && + options.form.repositorySelections.every( + (selection) => selection.kind !== "none" && Boolean(selection.branch?.trim()), + ); useAutomationSaveContributor({ isNew: options.isNew, currentId: options.currentId, @@ -383,7 +398,6 @@ export function AutomationEditor({ workspaceId, automationId }: AutomationEditor ); useAutoPromptUpdate(activeTriggerInfo, conditionType, triggerTypes, setForm); const supportsMultiRepo = useSupportsMultiRepo(form.executorProfileId); - const isPRTrigger = conditionType === "github_pr"; useLoadAutomation({ automationId, workspaceId, @@ -408,7 +422,6 @@ export function AutomationEditor({ workspaceId, automationId }: AutomationEditor form, currentId, supportsMultiRepo, - isPRTrigger, create, update, setSaving, @@ -446,7 +459,6 @@ export function AutomationEditor({ workspaceId, automationId }: AutomationEditor workspaceId={workspaceId} placeholders={placeholders} defaultTaskTitle={defaultTaskTitle} - conditionType={conditionType} savedForm={dirtyBaseline} updateField={updateField} /> diff --git a/apps/web/components/automations/automation-payload.test.ts b/apps/web/components/automations/automation-payload.test.ts index 165e62bc44..c03cff95fd 100644 --- a/apps/web/components/automations/automation-payload.test.ts +++ b/apps/web/components/automations/automation-payload.test.ts @@ -10,6 +10,7 @@ import { buildCreatePayload, buildUpdatePayload, resolveNormalizedRepositoryIds, + resolveRepositoryIdsForMode, resolveRepositoryIds, } from "./automation-payload"; import type { FormState } from "./automation-payload"; @@ -22,11 +23,14 @@ function baseForm(overrides: Partial = {}): FormState { workflowStepId: "step-1", agentProfileId: "agent-1", executorProfileId: "exec-1", + taskMode: "automation_run", + repositoryMode: "none", repositorySelections: [], prompt: "Run it", taskTitleTemplate: "", enabled: true, maxConcurrentRuns: 1, + continuationPolicy: "new_task", ...overrides, }; } @@ -40,14 +44,24 @@ describe("resolveRepositoryIds", () => { createRepositoryAction.mockResolvedValue({ id: "repo-new" }); const result = await resolveRepositoryIds("ws-1", [ - { kind: "registered", id: "repo-a" }, - { kind: "discovered", path: "/tmp/repo-b", name: "repo-b", defaultBranch: "main" }, + { kind: "registered", id: "repo-a", branch: "release/1" }, + { + kind: "discovered", + path: "/tmp/repo-b", + name: "repo-b", + defaultBranch: "main", + branch: "develop", + }, ]); expect(result.ids).toEqual(["repo-a", "repo-new"]); expect(result.selections).toEqual([ - { kind: "registered", id: "repo-a" }, - { kind: "registered", id: "repo-new" }, + { kind: "registered", id: "repo-a", branch: "release/1" }, + { kind: "registered", id: "repo-new", key: undefined, branch: "develop" }, + ]); + expect(result.repositories).toEqual([ + { repository_id: "repo-a", base_branch: "release/1" }, + { repository_id: "repo-new", base_branch: "develop" }, ]); expect(createRepositoryAction).toHaveBeenCalledTimes(1); expect(createRepositoryAction).toHaveBeenCalledWith( @@ -59,6 +73,7 @@ describe("resolveRepositoryIds", () => { const result = await resolveRepositoryIds("ws-1", []); expect(result.ids).toEqual([]); + expect(result.repositories).toEqual([]); expect(result.selections).toEqual([]); expect(createRepositoryAction).not.toHaveBeenCalled(); }); @@ -77,14 +92,13 @@ describe("resolveNormalizedRepositoryIds", () => { }); const twoRegistered = [ - { kind: "registered" as const, id: "repo-a" }, - { kind: "registered" as const, id: "repo-b" }, + { kind: "registered" as const, id: "repo-a", branch: "main" }, + { kind: "registered" as const, id: "repo-b", branch: "develop" }, ]; - it("resolves every selection when the executor supports multi-repo and it's not a PR trigger", async () => { + it("resolves every selection when the executor supports multi-repo", async () => { const result = await resolveNormalizedRepositoryIds("ws-1", twoRegistered, { supportsMultiRepo: true, - isPRTrigger: false, }); expect(result.ids).toEqual(["repo-a", "repo-b"]); }); @@ -96,15 +110,6 @@ describe("resolveNormalizedRepositoryIds", () => { // both stale repository_ids here. const result = await resolveNormalizedRepositoryIds("ws-1", twoRegistered, { supportsMultiRepo: false, - isPRTrigger: false, - }); - expect(result.ids).toEqual(["repo-a"]); - }); - - it("truncates a stale multi-repository selection to one ID for a github_pr trigger", async () => { - const result = await resolveNormalizedRepositoryIds("ws-1", twoRegistered, { - supportsMultiRepo: true, - isPRTrigger: true, }); expect(result.ids).toEqual(["repo-a"]); }); @@ -112,30 +117,58 @@ describe("resolveNormalizedRepositoryIds", () => { it("feeds the truncated ids straight into buildUpdatePayload", async () => { const { ids } = await resolveNormalizedRepositoryIds("ws-1", twoRegistered, { supportsMultiRepo: false, - isPRTrigger: false, }); - const payload = buildUpdatePayload(baseForm(), ids); + const payload = buildUpdatePayload( + baseForm(), + ids.map((repository_id) => ({ repository_id, base_branch: "main" })), + ); expect(payload.repository_ids).toEqual(["repo-a"]); }); it("feeds the truncated ids straight into buildCreatePayload", async () => { const { ids } = await resolveNormalizedRepositoryIds("ws-1", twoRegistered, { supportsMultiRepo: false, - isPRTrigger: false, }); - const payload = buildCreatePayload("ws-1", baseForm(), ids, []); + const payload = buildCreatePayload( + "ws-1", + baseForm(), + ids.map((repository_id) => ({ repository_id, base_branch: "main" })), + [], + ); expect(payload.repository_ids).toEqual(["repo-a"]); }); }); describe("buildCreatePayload / buildUpdatePayload", () => { + it("leaves workflow step resolution to normal task creation", () => { + const form = baseForm({ workflowStepId: "stale-step" }); + + expect(buildCreatePayload("ws-1", form, [], []).workflow_step_id).toBe(""); + expect(buildUpdatePayload(form, []).workflow_step_id).toBe(""); + }); + it("sends repository_ids in row order on create", () => { - const payload = buildCreatePayload("ws-1", baseForm(), ["repo-a", "repo-b"], []); + const payload = buildCreatePayload( + "ws-1", + baseForm(), + [ + { repository_id: "repo-a", base_branch: "main" }, + { repository_id: "repo-b", base_branch: "develop" }, + ], + [], + ); expect(payload.repository_ids).toEqual(["repo-a", "repo-b"]); + expect(payload.repositories).toEqual([ + { repository_id: "repo-a", base_branch: "main" }, + { repository_id: "repo-b", base_branch: "develop" }, + ]); }); it("sends repository_ids in row order on update", () => { - const payload = buildUpdatePayload(baseForm(), ["repo-b", "repo-a"]); + const payload = buildUpdatePayload(baseForm(), [ + { repository_id: "repo-b", base_branch: "develop" }, + { repository_id: "repo-a", base_branch: "main" }, + ]); expect(payload.repository_ids).toEqual(["repo-b", "repo-a"]); }); @@ -143,4 +176,37 @@ describe("buildCreatePayload / buildUpdatePayload", () => { const payload = buildCreatePayload("ws-1", baseForm(), [], []); expect(payload.repository_ids).toEqual([]); }); + + it("sends the selected continuation policy on create and update", () => { + const form = baseForm({ continuationPolicy: "reuse_thread" }); + + expect(buildCreatePayload("ws-1", form, [], []).continuation_policy).toBe("reuse_thread"); + expect(buildUpdatePayload(form, []).continuation_policy).toBe("reuse_thread"); + }); + + it("sends the target and repository modes on create and update", () => { + const form = baseForm({ taskMode: "normal_task", repositoryMode: "selected" }); + + const repositories = [{ repository_id: "repo-a", base_branch: "main" }]; + expect(buildCreatePayload("ws-1", form, repositories, [])).toMatchObject({ + task_mode: "normal_task", + repository_mode: "selected", + }); + expect(buildUpdatePayload(form, repositories)).toMatchObject({ + task_mode: "normal_task", + repository_mode: "selected", + }); + }); + + it("does not resolve stale repository selections for non-selected modes", async () => { + const result = await resolveRepositoryIdsForMode( + "ws-1", + [{ kind: "discovered", path: "/tmp/stale", name: "stale", defaultBranch: "main" }], + "none", + { supportsMultiRepo: true }, + ); + + expect(result.ids).toEqual([]); + expect(createRepositoryAction).not.toHaveBeenCalled(); + }); }); diff --git a/apps/web/components/automations/automation-payload.ts b/apps/web/components/automations/automation-payload.ts index 843edbb790..ac69d79297 100644 --- a/apps/web/components/automations/automation-payload.ts +++ b/apps/web/components/automations/automation-payload.ts @@ -1,6 +1,10 @@ import { createRepositoryAction } from "@/app/actions/workspaces"; import type { CreateAutomationRequest, + AutomationRepository, + ContinuationPolicy, + RepositoryMode, + TaskMode, TriggerType, UpdateAutomationRequest, } from "@/lib/types/automation"; @@ -21,6 +25,8 @@ export type FormState = { workflowStepId: string; agentProfileId: string; executorProfileId: string; + taskMode: TaskMode; + repositoryMode: RepositoryMode; // repositorySelections captures an ordered list of registered workspace // repos (id), discovered local repos (path — registered at save time to // obtain an id), or an empty list for repo-less automations. @@ -29,6 +35,7 @@ export type FormState = { taskTitleTemplate: string; enabled: boolean; maxConcurrentRuns: number; + continuationPolicy: ContinuationPolicy; }; export type PendingTrigger = { @@ -46,6 +53,7 @@ export type PendingTrigger = { // re-register the same local path. export type ResolvedRepositories = { ids: string[]; + repositories: AutomationRepository[]; selections: RepositorySelection[]; }; @@ -64,30 +72,57 @@ export async function resolveRepositoryIds( ); const promoted = selections.map((selection, i) => selection.kind === "discovered" && resolvedIds[i] - ? ({ kind: "registered", id: resolvedIds[i] } as const) + ? ({ + kind: "registered", + id: resolvedIds[i], + key: selection.key, + branch: selection.branch || selection.defaultBranch, + } as const) : selection, ); - return { ids: resolvedIds.filter((id) => id !== ""), selections: promoted }; + const repositories = resolvedIds.flatMap((repositoryId, index) => { + if (!repositoryId) return []; + const selection = selections[index]; + const baseBranch = + selection?.branch || (selection?.kind === "discovered" ? selection.defaultBranch : ""); + return [{ repository_id: repositoryId, base_branch: baseBranch }]; + }); + return { + ids: repositories.map((repository) => repository.repository_id), + repositories, + selections: promoted, + }; } // resolveNormalizedRepositoryIds is the save-boundary entry point used by // useSaveHandler (automation-editor.tsx). It enforces the single-repository // invariant (see normalizeRepositorySelections) before resolving, so a form // that had 2+ repos selected under a compatible executor, then switched to -// an incompatible executor or a github_pr trigger without the picker being -// touched again, can't smuggle every stale entry into the saved -// repository_ids — only the first survives. Kept as a named function (not +// an incompatible executor without touching the picker, can't send stale +// repository entries. Only the first survives. Kept as a named function (not // inlined at the call site) deliberately: it's the test seam this module's // unit tests exercise directly, without needing a full AutomationEditor // render harness. export async function resolveNormalizedRepositoryIds( workspaceId: string, selections: RepositorySelection[], - mode: { supportsMultiRepo: boolean; isPRTrigger: boolean }, + mode: { supportsMultiRepo: boolean }, ): Promise { return resolveRepositoryIds(workspaceId, normalizeRepositorySelections(selections, mode)); } +export async function resolveRepositoryIdsForMode( + workspaceId: string, + selections: RepositorySelection[], + repositoryMode: RepositoryMode, + mode: { supportsMultiRepo: boolean }, +): Promise { + if (repositoryMode !== "selected") { + return { ids: [], repositories: [], selections: [] }; + } + return resolveNormalizedRepositoryIds(workspaceId, selections, mode); +} + async function resolveOneRepositoryId( workspaceId: string, selection: RepositorySelection, @@ -118,7 +153,7 @@ async function resolveOneRepositoryId( export function buildCreatePayload( workspaceId: string, form: FormState, - repositoryIds: string[], + repositories: AutomationRepository[], pending: PendingTrigger[], ): CreateAutomationRequest { // i18n-exempt: persisted automation name. See the comment below. @@ -130,33 +165,41 @@ export function buildCreatePayload( name: form.name || "New Automation", description: form.description, workflow_id: form.workflowId, - workflow_step_id: form.workflowStepId, + workflow_step_id: "", agent_profile_id: form.agentProfileId, executor_profile_id: form.executorProfileId, - repository_ids: repositoryIds, + task_mode: form.taskMode, + repository_mode: repositories.length > 0 ? "selected" : "none", + repository_ids: repositories.map((repository) => repository.repository_id), + repositories, prompt: form.prompt, task_title_template: form.taskTitleTemplate, max_concurrent_runs: form.maxConcurrentRuns, + continuation_policy: form.continuationPolicy, triggers: pending.map((t) => ({ type: t.type, config: t.config, enabled: t.enabled })), }; } export function buildUpdatePayload( form: FormState, - repositoryIds: string[], + repositories: AutomationRepository[], ): UpdateAutomationRequest { return { name: form.name, description: form.description, workflow_id: form.workflowId, - workflow_step_id: form.workflowStepId, + workflow_step_id: "", agent_profile_id: form.agentProfileId, executor_profile_id: form.executorProfileId, - repository_ids: repositoryIds, + task_mode: form.taskMode, + repository_mode: repositories.length > 0 ? "selected" : "none", + repository_ids: repositories.map((repository) => repository.repository_id), + repositories, prompt: form.prompt, task_title_template: form.taskTitleTemplate, enabled: form.enabled, max_concurrent_runs: form.maxConcurrentRuns, + continuation_policy: form.continuationPolicy, }; } diff --git a/apps/web/components/automations/automation-repository-rows.tsx b/apps/web/components/automations/automation-repository-rows.tsx deleted file mode 100644 index 2d7e402ed8..0000000000 --- a/apps/web/components/automations/automation-repository-rows.tsx +++ /dev/null @@ -1,145 +0,0 @@ -"use client"; - -import { Button } from "@kandev/ui/button"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; -import { IconPlus, IconTrash } from "@tabler/icons-react"; -import { useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import type { LocalRepository, Repository } from "@/lib/types/http"; -import { - buildRepositoryItems, - pickSelectionFromOptionId, - selectionToOptionId, - type RepositorySelection, -} from "./automation-repository-selection"; -import { SettingsFieldLabel } from "@/components/settings/settings-typography"; -import { settingsActionClassName } from "@/components/settings/settings-control"; - -type AutomationRepositoryRowsProps = { - testId?: string; - repositories: Repository[]; - discoveredRepos: LocalRepository[]; - selections: RepositorySelection[]; - onChange: (selections: RepositorySelection[]) => void; - isDirty?: boolean; -}; - -// AutomationRepositoryRows renders the automation editor's repeatable -// repository picker — one row per selected repository, plus an "Add -// repository" control — shown only when the selected executor profile -// supports launching a task against sibling repositories (see -// getMultiRepoExecutorDisabledReason). Each row excludes the "None" option -// (removing a row is how you clear it) and marks a repository already -// chosen by another row so the same repo can't be picked twice. -export function AutomationRepositoryRows({ - testId, - repositories, - discoveredRepos, - selections, - onChange, - isDirty, -}: AutomationRepositoryRowsProps) { - const { t } = useTranslation(); - const baseItems = useMemo( - () => buildRepositoryItems(repositories, discoveredRepos, t, { includeNone: false }), - [repositories, discoveredRepos, t], - ); - const usedOptionIds = new Set(selections.map(selectionToOptionId)); - const nextAvailableItem = baseItems.find((item) => !usedOptionIds.has(item.id)); - - const updateRow = (index: number, optionId: string) => { - const picked = pickSelectionFromOptionId(optionId, repositories, discoveredRepos); - if (picked.kind === "none") return; - onChange(selections.map((sel, i) => (i === index ? picked : sel))); - }; - const removeRow = (index: number) => { - onChange(selections.filter((_, i) => i !== index)); - }; - const addRow = () => { - if (!nextAvailableItem) return; - const picked = pickSelectionFromOptionId(nextAvailableItem.id, repositories, discoveredRepos); - if (picked.kind === "none") return; - onChange([...selections, picked]); - }; - - return ( -
- {t("automations:repositoryLabel")} -
- {selections.map((selection, index) => ( - updateRow(index, optionId)} - onRemove={() => removeRow(index)} - /> - ))} - -
-

{t("automations:noRepositoriesHelp")}

-
- ); -} - -function RepositoryRow({ - selection, - baseItems, - usedOptionIds, - onChange, - onRemove, -}: { - selection: RepositorySelection; - baseItems: Array<{ id: string; label: string }>; - usedOptionIds: Set; - onChange: (optionId: string) => void; - onRemove: () => void; -}) { - const { t } = useTranslation(); - const currentOptionId = selectionToOptionId(selection); - return ( -
- - -
- ); -} diff --git a/apps/web/components/automations/automation-repository-selection.test.ts b/apps/web/components/automations/automation-repository-selection.test.ts index 80edf3c69b..5fce0140b1 100644 --- a/apps/web/components/automations/automation-repository-selection.test.ts +++ b/apps/web/components/automations/automation-repository-selection.test.ts @@ -54,10 +54,9 @@ describe("normalizeRepositorySelections", () => { { kind: "registered", id: "repo-b" }, ]; - it("keeps every selection when the executor supports multi-repo and it's not a PR trigger", () => { + it("keeps every selection when the executor supports multi-repo", () => { const result = normalizeRepositorySelections(two, { supportsMultiRepo: true, - isPRTrigger: false, }); expect(result).toEqual(two); }); @@ -65,25 +64,12 @@ describe("normalizeRepositorySelections", () => { it("truncates to the first selection when the executor doesn't support multi-repo", () => { const result = normalizeRepositorySelections(two, { supportsMultiRepo: false, - isPRTrigger: false, - }); - expect(result).toEqual([{ kind: "registered", id: "repo-a" }]); - }); - - it("truncates to the first selection for a github_pr trigger even with a multi-repo-capable executor", () => { - const result = normalizeRepositorySelections(two, { - supportsMultiRepo: true, - isPRTrigger: true, }); expect(result).toEqual([{ kind: "registered", id: "repo-a" }]); }); it("leaves an empty or single-entry list untouched", () => { - expect( - normalizeRepositorySelections([], { supportsMultiRepo: false, isPRTrigger: false }), - ).toEqual([]); - expect( - normalizeRepositorySelections([two[0]], { supportsMultiRepo: false, isPRTrigger: false }), - ).toEqual([two[0]]); + expect(normalizeRepositorySelections([], { supportsMultiRepo: false })).toEqual([]); + expect(normalizeRepositorySelections([two[0]], { supportsMultiRepo: false })).toEqual([two[0]]); }); }); diff --git a/apps/web/components/automations/automation-repository-selection.ts b/apps/web/components/automations/automation-repository-selection.ts index 81ba41623f..ba9802b53f 100644 --- a/apps/web/components/automations/automation-repository-selection.ts +++ b/apps/web/components/automations/automation-repository-selection.ts @@ -7,9 +7,16 @@ import type { ExecutorProfile, LocalRepository, Repository } from "@/lib/types/h // before it can be saved) or the single-picker's "Auto" fallback when the // executor doesn't support multiple repositories. export type RepositorySelection = - | { kind: "none" } - | { kind: "registered"; id: string } - | { kind: "discovered"; path: string; name: string; defaultBranch: string }; + | { kind: "none"; key?: string; branch?: string } + | { kind: "registered"; id: string; key?: string; branch?: string } + | { + kind: "discovered"; + path: string; + name: string; + defaultBranch: string; + key?: string; + branch?: string; + }; export const REPO_NONE_OPTION_ID = "__none__"; const DISCOVERED_PREFIX = "path:"; @@ -113,18 +120,17 @@ export function resolveExecutorType( } // normalizeRepositorySelections enforces the single-repository invariant at -// the save boundary: the picker only *renders* repositorySelections[0] once -// the executor stops supporting multi-repo or a github_pr trigger is active -// (see RepositoryPickerField in config-section.tsx), but doesn't itself +// the save boundary: the picker only accepts one row once the executor stops +// supporting multi-repo, but doesn't itself // truncate the underlying selections array — a user who adds 2+ repos, then -// switches to an incompatible executor or trigger without touching the +// switches to an incompatible executor without touching the // picker again, would otherwise still save every stale entry. Called right // before resolveRepositoryIds so the persisted repository_ids always match // what's actually rendered. export function normalizeRepositorySelections( selections: RepositorySelection[], - options: { supportsMultiRepo: boolean; isPRTrigger: boolean }, + options: { supportsMultiRepo: boolean }, ): RepositorySelection[] { - if (options.supportsMultiRepo && !options.isPRTrigger) return selections; + if (options.supportsMultiRepo) return selections; return selections.slice(0, 1); } diff --git a/apps/web/components/automations/automations-list-page.tsx b/apps/web/components/automations/automations-list-page.tsx index 2a6bd28639..4ed05e455f 100644 --- a/apps/web/components/automations/automations-list-page.tsx +++ b/apps/web/components/automations/automations-list-page.tsx @@ -59,7 +59,7 @@ export function AutomationsListPage({ workspaceId }: AutomationsListPageProps) { type="button" size="sm" data-testid="new-automation-button" - className="cursor-pointer" + className="min-h-11 cursor-pointer md:min-h-7" onClick={() => router.push(`/settings/workspaces/${workspaceId}/automations/new`)} > diff --git a/apps/web/components/automations/config-section.test.tsx b/apps/web/components/automations/config-section.test.tsx index 9a26a00546..0ee65537b9 100644 --- a/apps/web/components/automations/config-section.test.tsx +++ b/apps/web/components/automations/config-section.test.tsx @@ -1,33 +1,56 @@ -import { act, cleanup, fireEvent, render, screen, waitFor, within } from "@testing-library/react"; +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; import type { ComponentProps } from "react"; -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, 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 WORKSPACE_ID = "workspace-1"; +const mockRepositories = [ + { + id: "repo-1", + name: "kandev", + local_path: "/code/kandev", + provider_owner: "", + provider_name: "", + }, +]; const mockState = { - features: { dynamicAgentRouting: true }, workflows: { - items: [LOCAL_WORKFLOW] as MockWorkflow[], + items: [ + { + id: "workflow-1", + name: "Development", + workspaceId: WORKSPACE_ID, + agent_profile_id: "agent-1", + }, + ], + }, + kanbanMulti: { + snapshots: { + "workflow-1": { + workflowId: "workflow-1", + workflowName: "Development", + steps: [ + { + id: "step-backlog", + title: "Backlog", + position: 0, + color: "#123456", + is_start_step: true, + agent_profile_id: "agent-1", + }, + ], + tasks: [], + }, + }, }, agentProfiles: { - items: [], + items: [ + { + id: "agent-1", + label: "Codex • Default", + agent_name: "codex", + }, + ], }, executors: { items: [ @@ -35,32 +58,18 @@ const mockState = { id: "executor-worktree", type: "worktree", name: "Worktree", - profiles: [ - { - id: "profile-worktree", - executor_id: "executor-worktree", - name: "Worktree Profile", - }, - ], + profiles: [{ id: "worktree-1", name: "Worktree", executor_type: "worktree" }], }, { id: "executor-local", type: "local_pc", - name: "Local PC", - profiles: [ - { - id: "profile-local", - executor_id: "executor-local", - name: "Local PC Profile", - }, - ], + name: "Local", + profiles: [{ id: "local-1", name: "Local", executor_type: "local_pc" }], }, ], }, }; -const mockRepositories: Array<{ id: string; name: string; local_path: string }> = []; - vi.mock("@/components/state-provider", () => ({ useAppStore: (selector: (state: typeof mockState) => unknown) => selector(mockState), })); @@ -70,7 +79,11 @@ vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ })); vi.mock("@/hooks/use-workflows", () => ({ - useWorkflows: vi.fn(), + useWorkflows: () => ({ workflows: mockState.workflows.items }), +})); + +vi.mock("@/hooks/domains/kanban/use-all-workflow-snapshots", () => ({ + useAllWorkflowSnapshots: vi.fn(), })); vi.mock("@/hooks/domains/workspace/use-repositories", () => ({ @@ -81,327 +94,123 @@ vi.mock("@/app/actions/workspaces", () => ({ discoverRepositoriesAction: vi.fn().mockResolvedValue({ repositories: [] }), })); -vi.mock("@/lib/api/domains/workflow-api", () => ({ - listWorkflowSteps: vi.fn().mockResolvedValue({ steps: [] }), +vi.mock("@/components/task-create-dialog-options", () => ({ + useAgentProfileOptions: (profiles: Array<{ id: string; label: string }>) => + profiles.map((profile) => ({ + value: profile.id, + label: profile.label, + renderLabel: () => {profile.label}, + })), + useExecutorProfileOptions: (profiles: Array<{ id: string; name: string }>) => + profiles.map((profile) => ({ + value: profile.id, + label: profile.name, + renderLabel: () => {profile.name}, + })), })); -// 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), +vi.mock("@/components/task-create-dialog-workspace-repo-chips", () => ({ + WorkspaceRepoChips: ({ + rows, + onAdd, + }: { + rows: Array<{ key: string; branch: string }>; + onAdd: () => void; + }) => ( +
+ {rows.map((row) => ( + {row.branch} + ))} + +
+ ), })); import { ConfigSection, getExecutorItemDisabledReason } from "./config-section"; -// 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 ( +function renderConfig(overrides: Partial> = {}) { + return render( {}} - onStepChange={() => {}} onAgentProfileChange={() => {}} onExecutorProfileChange={() => {}} onRepositoriesChange={() => {}} {...overrides} - /> + />, ); } -function renderConfigSection(overrides: Partial> = {}) { - return render(configSection(overrides)); -} +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); -describe("ConfigSection", () => { - afterEach(() => { - cleanup(); - mockState.workflows.items = [LOCAL_WORKFLOW]; +describe("ConfigSection shared task selectors", () => { + it("shows the shared workflow preview and removes the workflow-step picker", () => { + renderConfig(); + + fireEvent.click(screen.getByTestId("workflow-selector-trigger")); + + expect(screen.getByText("Development")).toBeTruthy(); + expect(screen.getByText("Backlog")).toBeTruthy(); + expect(screen.getByTestId("workflow-agent-logo")).toBeTruthy(); + expect(screen.getByTestId("step-agent-logo")).toBeTruthy(); + expect(screen.queryByText("Workflow Step")).toBeNull(); + expect(screen.queryByTestId("workflow-step-selector")).toBeNull(); }); - it("offers the workflow fields without demanding either of them", () => { - renderConfigSection(); + it("uses the shared searchable profile selectors", async () => { + renderConfig(); - screen.getByText("Workflow"); - screen.getByText("Workflow Step"); - // 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"); + fireEvent.click(screen.getByTestId("agent-profile-selector")); + expect(screen.getByTestId("shared-agent-logo")).toBeTruthy(); + expect(screen.getByPlaceholderText("Search agents...")).toBeTruthy(); + + fireEvent.click(screen.getByTestId("executor-profile-selector")); + expect(screen.getAllByTestId("shared-executor-logo")).toHaveLength(2); + expect(screen.getByPlaceholderText("Search profiles...")).toBeTruthy(); }); - it("keeps the step field disabled and explained until a workflow is picked", () => { - renderConfigSection(); + it("uses paired repository chips and has no workspace-default choice", () => { + renderConfig(); - screen.getByText("Select a workflow before choosing a step."); - expect(screen.getByTestId("workflow-step-selector").getAttribute("aria-describedby")).toBe( - "workflow-step-selector-help", - ); - }); - - it("drops the step hint once a workflow is selected", () => { - renderConfigSection({ workflowId: "workflow-1" }); - - expect(screen.queryByText("Select a workflow before choosing a step.")).toBeNull(); + expect(screen.getByTestId("shared-repository-chips")).toBeTruthy(); + expect(screen.queryByText("Use workspace default")).toBeNull(); expect( - screen.getByTestId("workflow-step-selector").getAttribute("aria-describedby"), - ).toBeNull(); + screen.getByText("Run without repository files in a task-owned scratch workspace."), + ).toBeTruthy(); }); - it("no longer offers an execution mode to choose", () => { - renderConfigSection(); + it("adds an empty repository row through the shared chip control", () => { + const onRepositoriesChange = vi.fn(); + renderConfig({ onRepositoriesChange }); - expect(screen.queryByTestId("execution-mode-selector")).toBeNull(); - expect(screen.queryByText("Execution Mode")).toBeNull(); + fireEvent.click(screen.getByRole("button", { name: "Add repository" })); + + expect(onRepositoriesChange).toHaveBeenCalledWith([ + expect.objectContaining({ kind: "none", branch: "" }), + ]); }); }); -describe("ConfigSection workflow scoping", () => { - afterEach(() => { - cleanup(); - vi.clearAllMocks(); +describe("getExecutorItemDisabledReason", () => { + it("allows Worktree to use a repository-free scratch workspace", () => { + expect(getExecutorItemDisabledReason("worktree", [])).toBeNull(); }); - 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(); - - screen.getByTestId(REPOSITORY_SELECTOR_TEST_ID); - expect(screen.queryByTestId(REPOSITORY_ROWS_TEST_ID)).toBeNull(); - }); - - it("renders a repeatable repository list when the executor profile supports multi-repo", () => { - renderConfigSection({ executorProfileId: "profile-worktree" }); - - expect(screen.queryByTestId(REPOSITORY_SELECTOR_TEST_ID)).toBeNull(); - const rows = screen.getByTestId(REPOSITORY_ROWS_TEST_ID); - within(rows).getByRole("button", { name: "Add repository" }); - screen.getByText( - "With no repositories selected, this automation runs against the workspace's first repository.", - ); - }); - - it("renders a single dropdown when the executor profile does not support multi-repo", () => { - renderConfigSection({ executorProfileId: "profile-local" }); - - screen.getByTestId(REPOSITORY_SELECTOR_TEST_ID); - expect(screen.queryByTestId(REPOSITORY_ROWS_TEST_ID)).toBeNull(); - }); - - it("keeps the single disabled repository picker for github_pr triggers even with a multi-repo-capable executor", () => { - renderConfigSection({ executorProfileId: "profile-worktree", conditionType: "github_pr" }); - - expect(screen.queryByTestId(REPOSITORY_ROWS_TEST_ID)).toBeNull(); - const selector = screen.getByTestId(REPOSITORY_SELECTOR_TEST_ID); - expect(selector.hasAttribute("disabled")).toBe(true); - screen.getByText("PR triggers always use the PR's own repository."); - }); - - it("computes a disabled reason for incompatible executor types once two or more repositories are selected", () => { - const twoRepos = [ - { kind: "registered" as const, id: "repo-1" }, - { kind: "registered" as const, id: "repo-2" }, - ]; - expect(getExecutorItemDisabledReason("local_pc", twoRepos)).toEqual( - expect.stringContaining("Local"), - ); - }); - - it("keeps a compatible executor type enabled with two or more repositories selected", () => { - const twoRepos = [ - { kind: "registered" as const, id: "repo-1" }, - { kind: "registered" as const, id: "repo-2" }, - ]; - expect(getExecutorItemDisabledReason("worktree", twoRepos)).toBeNull(); - }); - - it("never disables an executor type when zero or one repository is selected", () => { - expect(getExecutorItemDisabledReason("local_pc", [])).toBeNull(); + it("uses the shared multi-repository capability guard", () => { expect( - getExecutorItemDisabledReason("local_pc", [{ kind: "registered", id: "repo-1" }]), - ).toBeNull(); + getExecutorItemDisabledReason("local_pc", [ + { kind: "registered", id: "repo-1", branch: "main" }, + { kind: "registered", id: "repo-2", branch: "main" }, + ]), + ).not.toBeNull(); }); }); diff --git a/apps/web/components/automations/config-section.tsx b/apps/web/components/automations/config-section.tsx index 304ba25988..9542174d4c 100644 --- a/apps/web/components/automations/config-section.tsx +++ b/apps/web/components/automations/config-section.tsx @@ -1,38 +1,35 @@ "use client"; +import { useCallback, useEffect, useMemo, useState } from "react"; import { useTranslation } from "react-i18next"; -import { useState, useEffect, useMemo, useCallback } from "react"; -import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@kandev/ui/select"; import { Label } from "@kandev/ui/label"; import { SettingsFieldLabel } from "@/components/settings/settings-typography"; import { settingsControlClassName } from "@/components/settings/settings-control"; import { useAppStore } from "@/components/state-provider"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; -import { useFeature } from "@/hooks/domains/features/use-feature"; 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 { TriggerType } from "@/lib/types/automation"; -import { getMultiRepoExecutorDisabledReason } from "@/components/task-create-dialog-multi-repo-guard"; -import { AutomationRepositoryRows } from "./automation-repository-rows"; +import type { Executor, ExecutorProfile, LocalRepository, Repository } from "@/lib/types/http"; +import type { TaskMode } from "@/lib/types/automation"; +import type { TaskRepoRow } from "@/components/task-create-dialog-types"; +import { WorkflowSelectorRow } from "@/components/workflow-selector-row"; +import { WorkspaceRepoChips } from "@/components/task-create-dialog-workspace-repo-chips"; +import { AgentSelector, ExecutorProfileSelector } from "@/components/task-create-dialog-selectors"; +import { + useAgentProfileOptions, + useExecutorProfileOptions, +} from "@/components/task-create-dialog-options"; +import { getMultiRepoExecutorDisabledReason } from "@/components/task-create-dialog-multi-repo-guard"; +import { useWorkflows } from "@/hooks/use-workflows"; +import { useAllWorkflowSnapshots } from "@/hooks/domains/kanban/use-all-workflow-snapshots"; import { - buildRepositoryItems, pickSelectionFromOptionId, resolveExecutorType, - selectionToOptionId, type RepositorySelection, } from "./automation-repository-selection"; export type { RepositorySelection } from "./automation-repository-selection"; -export { buildRepositoryItems, pickSelectionFromOptionId, selectionToOptionId }; -// getExecutorItemDisabledReason gates the Executor Profile picker: once two -// or more repositories are selected, executor types that can't launch a -// multi-repository task are disabled — mirrors the task-creation dialog's -// pickExecutorDisabledReason (task-create-dialog-computed.ts), reusing the -// same shared predicate so the two surfaces never drift. export function getExecutorItemDisabledReason( executorType: string | null | undefined, repositorySelections: RepositorySelection[], @@ -44,122 +41,37 @@ export function getExecutorItemDisabledReason( type ConfigSectionProps = { workspaceId: string; workflowId: string; - workflowStepId: string; agentProfileId: string; executorProfileId: string; + taskMode?: TaskMode; repositorySelections: RepositorySelection[]; - conditionType: TriggerType | null; dirtyFields?: { workflowId: boolean; - workflowStepId: boolean; agentProfileId: boolean; executorProfileId: boolean; repositorySelections: boolean; }; onWorkflowChange: (id: string) => void; - onStepChange: (id: string) => void; onAgentProfileChange: (id: string) => void; onExecutorProfileChange: (id: string) => void; onRepositoriesChange: (selections: RepositorySelection[]) => 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 = { workflowId: false, - workflowStepId: false, agentProfileId: false, executorProfileId: false, repositorySelections: false, }; -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([]); useEffect(() => { if (!workspaceId) return; let cancelled = false; discoverRepositoriesAction(workspaceId) - .then((res) => { - if (cancelled) return; - setItems(res.repositories ?? []); + .then((response) => { + if (!cancelled) setItems(response.repositories ?? []); }) .catch(() => { if (!cancelled) setItems([]); @@ -171,138 +83,133 @@ function useDiscoveredRepositories(workspaceId: string) { return items; } -type StepOption = { id: string; name: string }; - -function getWorkflowStepHelpText( - workflowId: string, - t: (key: string) => string, -): string | undefined { - if (!workflowId) return t("automations:workflowStepHelpNoWorkflow"); - return undefined; +function executorProfiles(executors: Executor[]): ExecutorProfile[] { + return executors.flatMap((executor) => + (executor.profiles ?? []).map((profile) => ({ + ...profile, + executor_type: profile.executor_type ?? executor.type, + executor_name: profile.executor_name ?? executor.name, + })), + ); } -function useWorkflowSteps(workflowId: string) { - const [steps, setSteps] = useState([]); - - useEffect(() => { - if (!workflowId) return; - let cancelled = false; - listWorkflowSteps(workflowId) - .then((response) => { - if (cancelled) return; - const sorted = [...response.steps].sort((a, b) => a.position - b.position); - setSteps(sorted.map((s) => ({ id: s.id, name: s.name }))); - }) - .catch(() => { - if (!cancelled) setSteps([]); - }); - return () => { - cancelled = true; - }; - }, [workflowId]); - - return steps; +function selectionRows(selections: RepositorySelection[]): TaskRepoRow[] { + return selections.map((selection, index) => ({ + key: selection.key ?? `automation-repository-${index}`, + repositoryId: selection.kind === "registered" ? selection.id : undefined, + localPath: selection.kind === "discovered" ? selection.path : undefined, + branch: selection.branch ?? (selection.kind === "discovered" ? selection.defaultBranch : ""), + })); } -type AgentProfileLike = { - id: string; - label: string; - cli_passthrough?: boolean; - kind?: "concrete" | "dynamic"; -}; -type ExecutorLike = { type: string; name: string; profiles?: ExecutorProfile[] }; +function selectionFromValue( + key: string, + value: string, + repositories: Repository[], + discovered: LocalRepository[], +): RepositorySelection { + const picked = pickSelectionFromOptionId(value, repositories, discovered); + return { ...picked, key, branch: "" }; +} -// useConfigSectionComputed derives the Agent Profile / Executor Profile / -// Repository picker item lists and the executor's multi-repo capability. -// Pulled out of ConfigSection to keep that component under the -// function-length lint cap. -function useConfigSectionComputed({ - agentProfiles, - executors, - executorProfileId, - repositorySelections, +function RepositoryAccess({ + workspaceId, repositories, - discoveredRepos, + discovered, + selections, + supportsMultiRepo, + onChange, }: { - agentProfiles: AgentProfileLike[]; - executors: ExecutorLike[]; - executorProfileId: string; - repositorySelections: RepositorySelection[]; + workspaceId: string; repositories: Repository[]; - discoveredRepos: LocalRepository[]; + discovered: LocalRepository[]; + selections: RepositorySelection[]; + supportsMultiRepo: boolean; + onChange: (selections: RepositorySelection[]) => void; }) { const { t } = useTranslation(); - const dynamicRoutingEnabled = useFeature("dynamicAgentRouting"); - const filteredAgentProfiles = agentProfiles.filter( - (profile) => !profile.cli_passthrough && (dynamicRoutingEnabled || profile.kind !== "dynamic"), + const rows = selectionRows(selections); + const updateSelection = ( + key: string, + update: (item: RepositorySelection) => RepositorySelection, + ) => onChange(selections.map((item, index) => (rows[index]?.key === key ? update(item) : item))); + return ( +
+
+

{t("automations:repositoryModeTitle")}

+

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

+
+
+ + onChange([ + ...selections, + { kind: "none", key: `automation-repository-${Date.now()}`, branch: "" }, + ]) + } + onRemove={(key) => onChange(selections.filter((_, index) => rows[index]?.key !== key))} + onRowRepositoryChange={(key, value) => + updateSelection(key, () => selectionFromValue(key, value, repositories, discovered)) + } + onRowBranchChange={(key, branch) => + updateSelection(key, (item) => ({ ...item, key, branch })) + } + /> +
+ {rows.length === 0 ? ( +

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

+ ) : null} +
); - // Profiles returned by the executors list/boot payload don't always carry - // their own executor_type/executor_name (only the settings > Executors - // page's local mapper attaches those today) — fall back to the parent - // executor's fields, mirroring task-create-dialog-computed.ts's identical - // enrichment so the two surfaces never disagree on an executor's type. - const allExecutorProfiles = executors - .filter((executor) => executor.type !== "local") - .flatMap((executor) => - (executor.profiles ?? []).map((p) => ({ - ...p, - executor_type: p.executor_type ?? executor.type, - executor_name: p.executor_name ?? executor.name, - })), - ); - const supportsMultiRepo = - getMultiRepoExecutorDisabledReason(resolveExecutorType(executors, executorProfileId)) === null; - const executorItems = allExecutorProfiles.map((p) => { - const disabledReason = getExecutorItemDisabledReason(p.executor_type, repositorySelections); - return { - id: p.id, - label: p.name, - disabled: disabledReason !== null, - disabledReason: disabledReason ?? undefined, - }; - }); - const singleRepositoryItems = useMemo( - () => buildRepositoryItems(repositories, discoveredRepos, t), - [repositories, discoveredRepos, t], - ); - return { filteredAgentProfiles, executorItems, supportsMultiRepo, singleRepositoryItems }; } export function ConfigSection({ workspaceId, workflowId, - workflowStepId, agentProfileId, executorProfileId, + taskMode = "automation_run", repositorySelections, - conditionType, dirtyFields = CLEAN_FIELDS, onWorkflowChange, - onStepChange, onAgentProfileChange, onExecutorProfileChange, onRepositoriesChange, }: ConfigSectionProps) { const { t } = useTranslation(); useSettingsData(true); + const { workflows: allWorkflows } = useWorkflows(workspaceId); + useAllWorkflowSnapshots(workspaceId); + const workflows = useMemo( + () => allWorkflows.filter((workflow) => workflow.workspaceId === workspaceId), + [allWorkflows, workspaceId], + ); + const snapshots = useAppStore((state) => state.kanbanMulti.snapshots); const { repositories } = useRepositories(workspaceId, true); - const discoveredRepos = useDiscoveredRepositories(workspaceId); - - const workflowState = useWorkspaceWorkflows(workspaceId); - const workflows = workflowState.workflows; + const discovered = useDiscoveredRepositories(workspaceId); const agentProfiles = useAppStore((state) => state.agentProfiles.items); const executors = useAppStore((state) => state.executors.items); - const steps = useWorkflowSteps(workflowId); - const isPRTrigger = conditionType === "github_pr"; - const { filteredAgentProfiles, executorItems, supportsMultiRepo, singleRepositoryItems } = - useConfigSectionComputed({ - agentProfiles, - executors, - executorProfileId, - repositorySelections, - repositories, - discoveredRepos, - }); + const agents = useAgentProfileOptions(agentProfiles); + const allExecutorProfiles = useMemo(() => executorProfiles(executors), [executors]); + const disabledReasonFor = useCallback( + (profile: ExecutorProfile) => + getExecutorItemDisabledReason(profile.executor_type, repositorySelections), + [repositorySelections], + ); + const executorOptions = useExecutorProfileOptions(allExecutorProfiles, { disabledReasonFor }); + const supportsMultiRepo = + getMultiRepoExecutorDisabledReason(resolveExecutorType(executors, executorProfileId)) === null; return (
@@ -310,250 +217,53 @@ export function ConfigSection({ {t("automations:configurationLabel")}
- - ({ - id: p.id, - label: p.label, - }))} - /> - - + {t("automations:workflowLabel")} + +
+
+ {t("automations:agentProfileLabel")} + +
+
+ {t("automations:executorProfileLabel")} + +
+
); } - -// RepositoryPickerField branches between the repeatable multi-repo row list -// and the legacy single dropdown, gated on executor capability (see -// getExecutorItemDisabledReason) and PR-trigger overrides (a github_pr -// trigger always uses the PR's own repository, so the picker stays a single -// disabled field even when the executor otherwise supports multi-repo). -function RepositoryPickerField({ - supportsMultiRepo, - isPRTrigger, - repositories, - discoveredRepos, - repositorySelections, - singleRepositoryItems, - isDirty, - onRepositoriesChange, -}: { - supportsMultiRepo: boolean; - isPRTrigger: boolean; - repositories: Repository[]; - discoveredRepos: LocalRepository[]; - repositorySelections: RepositorySelection[]; - singleRepositoryItems: Array<{ id: string; label: string }>; - isDirty: boolean; - onRepositoriesChange: (selections: RepositorySelection[]) => void; -}) { - const { t } = useTranslation(); - if (supportsMultiRepo && !isPRTrigger) { - return ( - - ); - } - return ( - { - const picked = pickSelectionFromOptionId(v, repositories, discoveredRepos); - onRepositoriesChange(picked.kind === "none" ? [] : [picked]); - }} - placeholder={t("automations:repositoryPlaceholderAuto")} - items={singleRepositoryItems} - disabled={isPRTrigger} - helpText={isPRTrigger ? t("automations:prTriggerRepositoryHelp") : undefined} - /> - ); -} - -function WorkflowFields({ - workflowId, - workflowStepId, - workflows, - workflowsFailed, - onRetryWorkflows, - steps, - workflowDirty, - workflowStepDirty, - onWorkflowChange, - onStepChange, -}: { - workflowId: string; - workflowStepId: string; - workflows: Array<{ id: string; name: string }>; - workflowsFailed: boolean; - onRetryWorkflows: () => void; - steps: StepOption[]; - workflowDirty: boolean; - workflowStepDirty: boolean; - onWorkflowChange: (id: string) => void; - onStepChange: (id: string) => void; -}) { - // 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 ( - <> - 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:selectStepOptional") : t("automations:pickAWorkflowFirst") - } - items={[ - { id: NONE_OPTION_ID, label: t("automations:noStep") }, - ...steps.map((s) => ({ id: s.id, label: s.name })), - ]} - disabled={!hasWorkflow} - helpText={getWorkflowStepHelpText(workflowId, t)} - /> - - ); -} - -function SelectField({ - testId, - label, - value, - onChange, - placeholder, - items, - disabled, - helpText, - isDirty = false, -}: { - testId?: string; - label: string; - value: string; - onChange: (value: string) => void; - placeholder: string; - items: Array<{ id: string; label: string; disabled?: boolean; disabledReason?: string }>; - disabled?: boolean; - helpText?: string; - isDirty?: boolean; -}) { - const helpId = testId && helpText ? `${testId}-help` : undefined; - return ( -
- {label} - - {helpText && ( -

- {helpText} -

- )} -
- ); -} diff --git a/apps/web/components/automations/schedule-selector.test.tsx b/apps/web/components/automations/schedule-selector.test.tsx index 512daa0966..415ba6a41f 100644 --- a/apps/web/components/automations/schedule-selector.test.tsx +++ b/apps/web/components/automations/schedule-selector.test.tsx @@ -6,6 +6,8 @@ import { ScheduleSelector, isValidExpression } from "./schedule-selector"; const NEXT_RUN = "schedule-next-run"; const ADOPT_TZ = "schedule-adopt-timezone"; +const FREQUENCY_SELECTOR = "schedule-frequency"; +const CUSTOM_INPUT = "schedule-custom-input"; const CUSTOM_WEEKDAYS = "0 9 * * 1-5"; const DAILY_9 = "0 9 * * *"; @@ -55,6 +57,30 @@ describe("ScheduleSelector preview", () => { renderSelector(null); expect(screen.getByTestId(NEXT_RUN).textContent).toContain("won't run on its own"); }); + + it("shows the unscheduled state and lets the user create a daily schedule", () => { + const onChange = renderSelector(null); + + expect(screen.getByTestId(FREQUENCY_SELECTOR).textContent).toBe("No schedule"); + + fireEvent.click(screen.getByTestId(FREQUENCY_SELECTOR)); + fireEvent.click(screen.getByRole("option", { name: "every day" })); + + expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ cron_expression: DAILY_9 })); + }); + + it("opens the custom editor from the unscheduled state", () => { + const onChange = renderSelector(null); + + fireEvent.click(screen.getByTestId(FREQUENCY_SELECTOR)); + fireEvent.click(screen.getByRole("option", { name: "a custom schedule" })); + + const input = screen.getByTestId(CUSTOM_INPUT); + fireEvent.change(input, { target: { value: "@every 2h" } }); + fireEvent.blur(input); + + expect(onChange).toHaveBeenCalledWith({ cron_expression: "@every 2h", timezone: undefined }); + }); }); describe("ScheduleSelector timezone migration", () => { @@ -86,10 +112,11 @@ describe("ScheduleSelector timezone migration", () => { it("gives a brand new schedule the viewer's own timezone", () => { const onChange = renderSelector(null); - fireEvent.change(screen.getByTestId("schedule-time"), { target: { value: "07:15" } }); + fireEvent.click(screen.getByTestId(FREQUENCY_SELECTOR)); + fireEvent.click(screen.getByRole("option", { name: "every day" })); const emitted = onChange.mock.calls[0][0]; - expect(emitted.cron_expression).toBe("15 7 * * *"); + expect(emitted.cron_expression).toBe(DAILY_9); expect(emitted.timezone).toBeTruthy(); expect(screen.queryByTestId(ADOPT_TZ)).toBeNull(); }); @@ -98,13 +125,13 @@ describe("ScheduleSelector timezone migration", () => { describe("ScheduleSelector custom expressions", () => { it("opens an unrepresentable cron in the custom field, verbatim", () => { renderSelector({ cron_expression: CUSTOM_WEEKDAYS, timezone: "UTC" }); - expect(screen.getByTestId("schedule-custom-input").getAttribute("value")).toBe(CUSTOM_WEEKDAYS); + expect(screen.getByTestId(CUSTOM_INPUT).getAttribute("value")).toBe(CUSTOM_WEEKDAYS); }); it("rejects an unreadable expression without emitting", () => { const onChange = renderSelector({ cron_expression: CUSTOM_WEEKDAYS, timezone: "UTC" }); - const input = screen.getByTestId("schedule-custom-input"); + const input = screen.getByTestId(CUSTOM_INPUT); fireEvent.change(input, { target: { value: "not a cron" } }); fireEvent.blur(input); @@ -115,7 +142,7 @@ describe("ScheduleSelector custom expressions", () => { it("accepts the grammar the backend accepts", () => { const onChange = renderSelector({ cron_expression: CUSTOM_WEEKDAYS, timezone: "UTC" }); - const input = screen.getByTestId("schedule-custom-input"); + const input = screen.getByTestId(CUSTOM_INPUT); fireEvent.change(input, { target: { value: "*/10 8-18 * * 1-5" } }); fireEvent.blur(input); diff --git a/apps/web/components/automations/schedule-selector.tsx b/apps/web/components/automations/schedule-selector.tsx index 335d50c9cb..7d0fa125b0 100644 --- a/apps/web/components/automations/schedule-selector.tsx +++ b/apps/web/components/automations/schedule-selector.tsx @@ -29,10 +29,13 @@ type ScheduleSelectorProps = { onChange: (config: Record) => void; }; +type ScheduleFrequency = Frequency | "none"; + // Catalog keys rather than copy: both tables are built at module load, so a // `t()` here would freeze at the boot locale (docs/i18n.md). Each render // resolves them. -const FREQUENCY_OPTIONS: { value: Frequency; labelKey: string }[] = [ +const FREQUENCY_OPTIONS: { value: ScheduleFrequency; labelKey: string }[] = [ + { value: "none", labelKey: "automations:scheduleNone" }, { value: "every-5m", labelKey: "automations:frequencyEvery5Minutes" }, { value: "every-15m", labelKey: "automations:frequencyEvery15Minutes" }, { value: "every-30m", labelKey: "automations:frequencyEvery30Minutes" }, @@ -115,15 +118,36 @@ const pad = (value: number) => String(value).padStart(2, "0"); * for one would be a promise the automation will not keep. */ function previewFor( - frequency: Frequency, + frequency: ScheduleFrequency, customDraft: string, storedExpression: string, expression: string, ): string { + if (frequency === "none") return ""; if (frequency === "custom") return customDraft; return storedExpression ? expression : ""; } +function activeFrequency( + hasStoredSchedule: boolean, + customMode: boolean, + parsedFrequency: Frequency, +): ScheduleFrequency { + if (customMode) return "custom"; + if (!hasStoredSchedule) return "none"; + return parsedFrequency; +} + +function activeExpression( + frequency: ScheduleFrequency, + customDraft: string, + parsed: ScheduleSpec, +): string { + if (frequency === "none") return ""; + if (frequency === "custom") return customDraft; + return buildExpression(parsed); +} + /** * The "how often is this actually checked" note. * @@ -155,9 +179,10 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule const { t } = useTranslation(); const storedExpression = (config?.cron_expression as string) ?? ""; const storedTimezone = (config?.timezone as string) ?? ""; + const hasStoredSchedule = Boolean(storedExpression.trim()); const parsed = useMemo(() => parseExpression(storedExpression), [storedExpression]); - const [customMode, setCustomMode] = useState(parsed.frequency === "custom"); + const [customMode, setCustomMode] = useState(hasStoredSchedule && parsed.frequency === "custom"); const [customDraft, setCustomDraft] = useState(parsed.expression); const [customError, setCustomError] = useState(null); @@ -165,10 +190,10 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule useEffect(() => { const next = parseExpression(storedExpression); setCustomDraft(next.expression); - if (next.frequency === "custom") setCustomMode(true); - }, [storedExpression]); + setCustomMode(hasStoredSchedule && next.frequency === "custom"); + }, [hasStoredSchedule, storedExpression]); - const frequency: Frequency = customMode ? "custom" : parsed.frequency; + const frequency = activeFrequency(hasStoredSchedule, customMode, parsed.frequency); // A schedule that has never been saved adopts the viewer's own timezone. One // that was saved without a timezone stays on UTC — the backend has been @@ -176,7 +201,7 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule // automation by the offset without anyone asking for it. const isNewSchedule = !storedExpression; const timezone = storedTimezone || (isNewSchedule ? browserTimeZone() : ""); - const expression = frequency === "custom" ? customDraft : buildExpression(parsed); + const expression = activeExpression(frequency, customDraft, parsed); const wallClock = usesTimezone(expression); const previewExpression = previewFor(frequency, customDraft, storedExpression, expression); @@ -194,12 +219,16 @@ export function ScheduleSelector({ config, isDirty = false, onChange }: Schedule /** The spec currently on screen, including an in-progress custom draft. */ const currentSpec = (): ScheduleSpec => ({ ...parsed, - frequency, + frequency: frequency === "none" ? parsed.frequency : frequency, expression: frequency === "custom" ? customDraft.trim() : parsed.expression, }); - const handleFrequency = (value: Frequency) => { + const handleFrequency = (value: ScheduleFrequency) => { setCustomError(null); + if (value === "none") { + setCustomMode(false); + return; + } if (value === "custom") { // Seed the field with the schedule already in effect, so opening the // escape hatch never changes when the automation runs. @@ -269,17 +298,22 @@ function FrequencySelect({ value, onChange, }: { - value: Frequency; - onChange: (value: Frequency) => void; + value: ScheduleFrequency; + onChange: (value: ScheduleFrequency) => void; }) { const { t } = useTranslation(); + const options = + value === "none" + ? FREQUENCY_OPTIONS + : FREQUENCY_OPTIONS.filter((option) => option.value !== "none"); + return ( - onChange(next as ScheduleFrequency)}> - {FREQUENCY_OPTIONS.map((option) => ( + {options.map((option) => ( {t(option.labelKey)} @@ -299,7 +333,7 @@ function FrequencyDetail({ onCustomDraftChange, onCustomBlur, }: { - frequency: Frequency; + frequency: ScheduleFrequency; spec: ScheduleSpec; isDirty: boolean; customDraft: string; @@ -310,6 +344,8 @@ function FrequencyDetail({ }) { const { t } = useTranslation(); + if (frequency === "none") return null; + if (frequency === "custom") { return ( diff --git a/apps/web/components/automations/triggers-section.test.tsx b/apps/web/components/automations/triggers-section.test.tsx index b498d3745f..0861efcf76 100644 --- a/apps/web/components/automations/triggers-section.test.tsx +++ b/apps/web/components/automations/triggers-section.test.tsx @@ -79,11 +79,12 @@ describe("TriggersSection schedule config", () => { it("creates a scheduled trigger when none exists yet", () => { const props = renderTriggersSection(); - fireEvent.change(screen.getByTestId("schedule-time"), { target: { value: "07:15" } }); + fireEvent.click(screen.getByTestId("schedule-frequency")); + fireEvent.click(screen.getByRole("option", { name: "every day" })); expect(props.onAddTrigger).toHaveBeenCalledWith( "scheduled", - expect.objectContaining({ cron_expression: "15 7 * * *" }), + expect.objectContaining({ cron_expression: "0 9 * * *" }), ); }); }); diff --git a/apps/web/components/runs/automation-detail-page.test.tsx b/apps/web/components/runs/automation-detail-page.test.tsx index 8807c2e588..64b4921fca 100644 --- a/apps/web/components/runs/automation-detail-page.test.tsx +++ b/apps/web/components/runs/automation-detail-page.test.tsx @@ -26,6 +26,7 @@ const mocks = vi.hoisted(() => ({ getAutomationSummary: vi.fn(), listAutomationRuns: vi.fn(), triggerAutomation: vi.fn(), + stopAutomationRun: 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. @@ -42,6 +43,7 @@ vi.mock("@/lib/api/domains/automation-api", () => ({ getAutomationSummary: mocks.getAutomationSummary, listAutomationRuns: mocks.listAutomationRuns, triggerAutomation: mocks.triggerAutomation, + stopAutomationRun: mocks.stopAutomationRun, })); vi.mock("@/lib/routing/client-router", () => ({ @@ -62,8 +64,8 @@ vi.mock("@/components/automations/automation-editor", () => ({ // 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 }) => ( -
+ RunTranscript: ({ sessionId, turnId }: { sessionId: string; turnId?: string }) => ( +
), })); @@ -126,6 +128,7 @@ beforeEach(() => { }); mocks.listAutomationRuns.mockResolvedValue([run()]); mocks.triggerAutomation.mockResolvedValue({ triggered: true }); + mocks.stopAutomationRun.mockResolvedValue({ run_id: "run-1", status: "failed" }); }); afterEach(() => { @@ -202,7 +205,9 @@ describe("AutomationDetailPage", () => { fireEvent.click(await screen.findByTestId(DETAIL_TOGGLE)); expect(screen.getByTestId(PROMPT)).toBeTruthy(); }); +}); +describe("AutomationDetailPage run selection", () => { it("honours an explicitly requested run", async () => { // "The run that failed overnight" is a thing people link each other to. mocks.listAutomationRuns.mockResolvedValue([ @@ -216,6 +221,39 @@ describe("AutomationDetailPage", () => { expect(transcript.getAttribute(SESSION_ATTR)).toBe(SESSION_OLDER); }); + it("focuses the exact turn when two runs share a session", async () => { + mocks.listAutomationRuns.mockResolvedValue([ + run({ id: "first", turn_id: "turn-1", session_id: "shared-session" }), + run({ + id: "second", + turn_id: "turn-2", + session_id: "shared-session", + created_at: NEWEST_AT, + }), + ]); + + render(); + + const transcript = await screen.findByTestId(TRANSCRIPT); + expect(transcript.getAttribute("data-session-id")).toBe("shared-session"); + expect(transcript.getAttribute("data-turn-id")).toBe("turn-2"); + }); + + it("offers exact-run stop for a selected open run", async () => { + mocks.listAutomationRuns.mockResolvedValue([ + run({ id: "open-run", status: "task_created", turn_id: "turn-open" }), + ]); + + render(); + + const stop = await screen.findByTestId("automation-stop-run"); + fireEvent.click(stop); + + await waitFor(() => + expect(mocks.stopAutomationRun).toHaveBeenCalledWith(AUTOMATION_ID, "open-run"), + ); + }); + it("falls back to the newest run when the requested one is not in the window", async () => { render(); diff --git a/apps/web/components/runs/automation-detail-page.tsx b/apps/web/components/runs/automation-detail-page.tsx index 7c0ef384ef..3ee27c0121 100644 --- a/apps/web/components/runs/automation-detail-page.tsx +++ b/apps/web/components/runs/automation-detail-page.tsx @@ -1,8 +1,8 @@ "use client"; import { useTranslation } from "react-i18next"; -import { useCallback, useEffect } from "react"; -import { IconBolt, IconPlayerPlay, IconRefresh } from "@tabler/icons-react"; +import { useCallback, useEffect, useState } from "react"; +import { IconBolt, IconPlayerPlay, IconPlayerStop, IconRefresh } from "@tabler/icons-react"; import { Button } from "@kandev/ui/button"; import { PageTopbar } from "@/components/page-topbar"; import { AutomationEditor } from "@/components/automations/automation-editor"; @@ -10,6 +10,7 @@ import { SettingsSaveProvider } from "@/components/settings/settings-save-provid import { useAppStore } from "@/components/state-provider"; import { useRouter } from "@/lib/routing/client-router"; import { cn } from "@/lib/utils"; +import { stopAutomationRun } from "@/lib/api/domains/automation-api"; import type { Automation, AutomationRun } from "@/lib/types/automation"; import { nextFiring } from "./automation-rows"; import { RunsDrawer } from "./runs-drawer"; @@ -22,6 +23,7 @@ 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"; +import { statusLabelKey } from "./run-status"; const MUTED_NOTE = "py-16 text-center text-sm text-muted-foreground"; @@ -180,15 +182,41 @@ function ConfigureView({ automation }: { automation: Automation }) { * for every time. */ function ActivityView({ + automationId, selected, hasRuns, loading, + refresh, }: { + automationId: string; selected: AutomationRun | null; hasRuns: boolean; loading: boolean; + refresh: () => void; }) { const { t } = useTranslation(); + const [stopping, setStopping] = useState(false); + const [stopError, setStopError] = useState(null); + + useEffect(() => { + setStopping(false); + setStopError(null); + }, [selected?.id]); + + const handleStop = async () => { + if (!selected || !isOpenRun(selected.status) || stopping) return; + setStopping(true); + setStopError(null); + try { + await stopAutomationRun(automationId, selected.id); + refresh(); + } catch (error) { + setStopError(error instanceof Error ? error.message : t("common:requestFailed")); + } finally { + setStopping(false); + } + }; + if (!selected) { if (loading && !hasRuns) return

{t("automations:loadingActivity")}

; @@ -200,12 +228,41 @@ function ActivityView({ } return (
- {/* Keyed on the session so switching runs remounts the conversation - rather than letting one run's messages animate into another's. */} +
+
+

+ {selected.display_title || t("automations:run")} +

+

+ {t(statusLabelKey(selected.status))} +

+
+ {isOpenRun(selected.status) && ( + + )} +
+ {stopError && ( +

+ {t("automations:failedToStopRun", { error: stopError })} +

+ )} + {/* Keyed on the exact session turn so selecting two runs in one shared + session remounts the conversation for the requested firing. */}
); @@ -225,6 +282,7 @@ function DetailBody({ selected, openRuns, loading, + refresh, isMobile, rail, onSelectRun, @@ -235,6 +293,7 @@ function DetailBody({ selected: AutomationRun | null; openRuns: number; loading: boolean; + refresh: () => void; isMobile: boolean; rail: ReturnType; onSelectRun: (run: AutomationRun) => void; @@ -276,7 +335,13 @@ function DetailBody({ {tab === "configure" ? ( ) : ( - 0} loading={loading} /> + 0} + loading={loading} + refresh={refresh} + /> )} {!isMobile && switcher} @@ -335,6 +400,7 @@ export function AutomationDetailPage({ selected={selected} openRuns={openRuns} loading={loading} + refresh={refresh} isMobile={isMobile} rail={rail} onSelectRun={(run) => router.push(runHref(automationId, run.id))} diff --git a/apps/web/components/runs/run-transcript.test.tsx b/apps/web/components/runs/run-transcript.test.tsx index 52703dc956..6ed9fb78f9 100644 --- a/apps/web/components/runs/run-transcript.test.tsx +++ b/apps/web/components/runs/run-transcript.test.tsx @@ -16,10 +16,12 @@ vi.mock("@/hooks/domains/settings/use-settings-data", () => ({ import { StateProvider } from "@/components/state-provider"; import { ToastProvider } from "@/components/toast-provider"; import { TooltipProvider } from "@kandev/ui/tooltip"; -import { RunTranscript } from "./run-transcript"; +import { focusRunTranscriptTurn, RunTranscript } from "./run-transcript"; const SESSION_ID = "session-1"; const TASK_ID = "task-1"; +const OLD_TURN_ID = "turn-old"; +const CURRENT_TURN_ID = "turn-current"; function transcript() { return ( @@ -77,6 +79,25 @@ describe("RunTranscript session hydration", () => { }); }); +describe("RunTranscript turn selection", () => { + it("focuses the selected turn without removing surrounding transcript rows", () => { + const root = document.createElement("div"); + const previous = document.createElement("div"); + previous.dataset.turnId = OLD_TURN_ID; + const selected = document.createElement("div"); + selected.dataset.turnId = CURRENT_TURN_ID; + selected.scrollIntoView = vi.fn(); + root.append(previous, selected); + document.body.append(root); + + expect(focusRunTranscriptTurn(root, CURRENT_TURN_ID)).toBe(true); + expect(root.querySelectorAll("[data-turn-id]")).toHaveLength(2); + expect(document.activeElement).toBe(selected); + expect(selected.scrollIntoView).toHaveBeenCalledWith({ behavior: "auto", block: "center" }); + root.remove(); + }); +}); + describe("RunTranscript live-session controls", () => { // Replying to a run starts the agent, it works the prompt, and it shuts down // again. Controls that speak to a live ACP session — the model picker above diff --git a/apps/web/components/runs/run-transcript.tsx b/apps/web/components/runs/run-transcript.tsx index 178cbdb86c..401f5b01af 100644 --- a/apps/web/components/runs/run-transcript.tsx +++ b/apps/web/components/runs/run-transcript.tsx @@ -1,7 +1,7 @@ "use client"; import { useTranslation } from "react-i18next"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useSettingsData } from "@/hooks/domains/settings/use-settings-data"; import { useEnsureTaskSession } from "@/hooks/use-ensure-task-session"; import { type ChatInputContainerHandle } from "@/components/task/chat/chat-input-container"; @@ -14,6 +14,18 @@ import { } from "@/components/task/chat/chat-input-area"; import { routePanelMouseDown } from "@/components/task/chat/route-panel-mouse-down"; +export function focusRunTranscriptTurn(root: HTMLElement | null, turnId?: string): boolean { + if (!root || !turnId) return false; + const target = Array.from(root.querySelectorAll("[data-turn-id]")).find( + (element) => element.dataset.turnId === turnId, + ); + if (!target) return false; + + target.focus({ preventScroll: true }); + target.scrollIntoView?.({ behavior: "auto", block: "center" }); + return true; +} + /** * A run's conversation, in place. * @@ -24,10 +36,19 @@ import { routePanelMouseDown } from "@/components/task/chat/route-panel-mouse-do * mounting the full task panel: this surface wants the transcript and the * composer, not the sessions dropdown, plan mode, or the file editors. */ -export function RunTranscript({ sessionId, taskId }: { sessionId: string; taskId: string | null }) { +export function RunTranscript({ + sessionId, + taskId, + turnId, +}: { + sessionId: string; + taskId: string | null; + turnId?: string; +}) { const { t } = useTranslation(); const chatInputRef = useRef(null); const scopeRef = useRef(null); + const focusedTurnKeyRef = useRef(null); const [clarificationKey, setClarificationKey] = useState(0); useSettingsData(true); @@ -46,6 +67,15 @@ export function RunTranscript({ sessionId, taskId }: { sessionId: string; taskId const { isSending, handleSubmit } = useSubmitHandler(panelState, undefined); const { handleCancelTurn } = useChatPanelHandlers(panelState.resolvedSessionId, chatInputRef); + useEffect(() => { + if (!turnId) return; + const turnKey = `${sessionId}:${turnId}`; + if (focusedTurnKeyRef.current === turnKey) return; + if (focusRunTranscriptTurn(scopeRef.current, turnId)) { + focusedTurnKeyRef.current = turnKey; + } + }, [panelState.groupedItems, sessionId, turnId]); + // An automation run is not a session anyone is sitting in: replying starts // the agent, it works the prompt, and it shuts down again. Controls that talk // to a live ACP session — the model picker above all, but also mode, MCP and @@ -70,11 +100,13 @@ export function RunTranscript({ sessionId, taskId }: { sessionId: string; taskId onMouseDown={handleScopeMouseDown} className="flex min-h-0 flex-1 flex-col outline-none" data-testid="run-transcript" + data-session-id={sessionId} + data-turn-id={turnId ?? ""} >
- {formatRelativeTime(run.created_at)} + {run.display_title || formatRelativeTime(run.created_at)} + {run.display_title && ( + + {formatRelativeTime(run.created_at)} + + )} {t(statusLabelKey(run.status))} diff --git a/apps/web/components/task-create-dialog-workspace-repo-chips.tsx b/apps/web/components/task-create-dialog-workspace-repo-chips.tsx index 032dbb5e79..ba1f5963ee 100644 --- a/apps/web/components/task-create-dialog-workspace-repo-chips.tsx +++ b/apps/web/components/task-create-dialog-workspace-repo-chips.tsx @@ -141,7 +141,7 @@ export function WorkspaceRepoChips({ data-testid="add-repository" className={cn( "inline-flex items-center justify-center gap-1.5 rounded-md text-muted-foreground", - addLabel ? "h-9 px-2 text-xs" : "h-7 w-7", + addLabel ? "h-11 px-2 text-xs md:h-9" : "h-11 w-11 md:h-7 md:w-7", canAddMore ? "hover:bg-muted hover:text-foreground cursor-pointer" : "opacity-40 cursor-not-allowed", diff --git a/apps/web/components/task/chat/message-list-native.tsx b/apps/web/components/task/chat/message-list-native.tsx index 6505f83b86..3fae945c3f 100644 --- a/apps/web/components/task/chat/message-list-native.tsx +++ b/apps/web/components/task/chat/message-list-native.tsx @@ -170,6 +170,12 @@ type MessageRowProps = { dividerBeforeItemKey?: string | null; }; +function getItemTurnId(item: RenderItem): string | undefined { + if (item.type === "turn_group") return item.turnId ?? undefined; + if (item.type === "message") return item.message.turn_id ?? undefined; + return undefined; +} + /** One transcript row, keyed and DOM-id'd by `getItemKey` so the scroll * affordances (and `scrollToMessage`) can locate it directly. */ function MessageRow({ @@ -190,6 +196,8 @@ function MessageRow({ return (
diff --git a/apps/web/components/workflow-selector-row.tsx b/apps/web/components/workflow-selector-row.tsx index 6bf2a17571..4619a2e252 100644 --- a/apps/web/components/workflow-selector-row.tsx +++ b/apps/web/components/workflow-selector-row.tsx @@ -88,6 +88,8 @@ type WorkflowSelectorRowProps = { selectedWorkflowId: string | null; onWorkflowChange: (workflowId: string) => void; agentProfiles: AgentProfileOption[]; + clearLabel?: string; + placeholder?: string; }; export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({ @@ -96,6 +98,8 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({ selectedWorkflowId, onWorkflowChange, agentProfiles, + clearLabel, + placeholder, }: WorkflowSelectorRowProps) { const { t } = useTranslation(); const [open, setOpen] = useState(false); @@ -111,12 +115,12 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({ @@ -125,6 +129,18 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({
{t("workflows:workflow")}
+ {clearLabel ? ( + + ) : null} {workflows.map((wf) => { const isSelected = wf.id === selectedWorkflowId; const snapshot = snapshots[wf.id]; @@ -137,7 +153,7 @@ export const WorkflowSelectorRow = memo(function WorkflowSelectorRow({ onWorkflowChange(wf.id); setOpen(false); }} - className="w-full flex flex-col gap-1 px-2 py-1.5 rounded-sm hover:bg-muted transition-colors cursor-pointer text-left relative pr-8" + className="relative flex min-h-11 w-full cursor-pointer flex-col gap-1 rounded-sm px-2 py-1.5 pr-8 text-left transition-colors hover:bg-muted" >
diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 7267bc1302..3a9f755877 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -3197,6 +3197,10 @@ export class ApiClient { name: string; workflowId?: string; workflowStepId?: string; + taskMode?: "automation_run" | "normal_task"; + repositoryMode?: "workspace_default" | "selected" | "none"; + repositoryIds?: string[]; + repositories?: Array<{ repository_id: string; base_branch: string }>; /** * The automation's standing instruction. The run view only renders the * instruction card when there is one, so specs asserting on where that @@ -3232,6 +3236,10 @@ export class ApiClient { name: opts.name, workflow_id: opts.workflowId ?? "", workflow_step_id: opts.workflowStepId ?? "", + task_mode: opts.taskMode, + repository_mode: opts.repositoryMode, + repository_ids: opts.repositoryIds, + repositories: opts.repositories, prompt: opts.prompt ?? "", agent_profile_id: opts.agentProfileId ?? "", executor_profile_id: opts.executorProfileId ?? "", @@ -3271,11 +3279,21 @@ export class ApiClient { automationId: string, status = "skipped", taskId?: string, - ): Promise<{ id: string; automation_id: string; status: string; task_id: string }> { + opts?: { sessionId?: string; turnId?: string }, + ): Promise<{ + id: string; + automation_id: string; + status: string; + task_id: string; + session_id: string; + turn_id: string; + }> { return this.request("POST", "/api/v1/e2e/automation-runs", { automation_id: automationId, status, task_id: taskId ?? "", + session_id: opts?.sessionId, + turn_id: opts?.turnId, }); } diff --git a/apps/web/e2e/pages/automations-page.ts b/apps/web/e2e/pages/automations-page.ts index 6b42afdd63..8a668b3d58 100644 --- a/apps/web/e2e/pages/automations-page.ts +++ b/apps/web/e2e/pages/automations-page.ts @@ -17,7 +17,6 @@ export class AutomationsPage { readonly nextRun: Locator; readonly addConditionButton: Locator; readonly workflowSelector: Locator; - readonly workflowStepSelector: Locator; constructor( private page: Page, @@ -40,8 +39,7 @@ export class AutomationsPage { this.timezoneButton = page.getByTestId("schedule-timezone"); this.nextRun = page.getByTestId("schedule-next-run"); this.addConditionButton = page.getByTestId("add-condition-button"); - this.workflowSelector = page.getByTestId("workflow-selector"); - this.workflowStepSelector = page.getByTestId("workflow-step-selector"); + this.workflowSelector = page.getByTestId("workflow-selector-trigger"); } async goto() { @@ -93,12 +91,6 @@ export class AutomationsPage { /** Select a workflow by clicking the selector and picking an item by name. */ async selectWorkflow(name: string) { await this.workflowSelector.click(); - await this.page.getByRole("option", { name }).click(); - } - - /** Select a workflow step by clicking the selector and picking an item by name. */ - async selectWorkflowStep(name: string) { - await this.workflowStepSelector.click(); - await this.page.getByRole("option", { name }).click(); + await this.page.getByRole("button", { name: new RegExp(name) }).click(); } } diff --git a/apps/web/e2e/tests/automations-pr-merged-trigger.spec.ts b/apps/web/e2e/tests/automations-pr-merged-trigger.spec.ts index 7b81b74ee8..b4b8942c2c 100644 --- a/apps/web/e2e/tests/automations-pr-merged-trigger.spec.ts +++ b/apps/web/e2e/tests/automations-pr-merged-trigger.spec.ts @@ -76,7 +76,6 @@ test.describe("automations — Pull request merged trigger", () => { await automations.nameInput.fill("PR Merged Round-trip"); await automations.selectWorkflow("E2E Workflow"); - await automations.selectWorkflowStep(seedData.steps[0].name); // Add the condition await automations.addConditionButton.click(); @@ -89,7 +88,8 @@ test.describe("automations — Pull request merged trigger", () => { .click(); // The panel should show "All repositories" checked by default (all_repos:true from registry default) - const allReposSwitch = testPage.getByRole("switch", { + const triggerCard = testPage.getByTestId("trigger-card-github_pr_merged"); + const allReposSwitch = triggerCard.getByRole("switch", { name: /All repositories allowed/i, }); await expect(allReposSwitch).toBeChecked({ timeout: 5_000 }); @@ -371,7 +371,8 @@ test.describe("automations — Pull request merged trigger", () => { .click(); // Uncheck "All repositories" to show the repo selector - const allReposSwitch = testPage.getByRole("switch", { + const triggerCard = testPage.getByTestId("trigger-card-github_pr_merged"); + const allReposSwitch = triggerCard.getByRole("switch", { name: /All repositories allowed/i, }); await expect(allReposSwitch).toBeChecked({ timeout: 5_000 }); @@ -379,7 +380,7 @@ test.describe("automations — Pull request merged trigger", () => { // The "Add repository" button appears when all_repos=false and must NOT be disabled. // (For github_pr the picker is disabled; for github_pr_merged it must be enabled.) - const addRepoButton = testPage.getByRole("button", { name: /Add repository/i }); + const addRepoButton = triggerCard.getByRole("button", { name: /Add repository/i }); await expect(addRepoButton).toBeVisible({ timeout: 5_000 }); await expect(addRepoButton).not.toBeDisabled(); }); diff --git a/apps/web/e2e/tests/automations-run-detail.spec.ts b/apps/web/e2e/tests/automations-run-detail.spec.ts index d280f32d49..33d84f5715 100644 --- a/apps/web/e2e/tests/automations-run-detail.spec.ts +++ b/apps/web/e2e/tests/automations-run-detail.spec.ts @@ -14,7 +14,13 @@ import { AutomationsPage } from "../pages/automations-page"; * and no toolbar, which would pass most of what follows while broken. */ -type Seed = { workspaceId: string; workflowId: string; startStepId: string }; +type Seed = { + workspaceId: string; + workflowId: string; + startStepId: string; + agentProfileId: string; + repositoryId: string; +}; const STANDING_INSTRUCTION = "Check the overnight drift report and summarise what changed."; @@ -49,6 +55,42 @@ async function openRunView(testPage: Page, automationId: string) { await expect(testPage.getByTestId("run-transcript")).toBeVisible({ timeout: 15_000 }); } +async function seedOpenRun(apiClient: ApiClient, seed: Seed, name: string) { + const automation = await apiClient.seedAutomation({ + workspaceId: seed.workspaceId, + name, + workflowId: seed.workflowId, + workflowStepId: seed.startStepId, + prompt: STANDING_INSTRUCTION, + agentProfileId: seed.agentProfileId, + }); + const task = await apiClient.createTaskWithAgent( + seed.workspaceId, + `${name} — running task`, + seed.agentProfileId, + { + description: "/sleep 30", + workflow_id: seed.workflowId, + workflow_step_id: seed.startStepId, + repository_ids: [seed.repositoryId], + }, + ); + await apiClient.setTaskOrigin(task.id, "automation_run"); + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return sessions.find((session) => session.id === task.session_id)?.state; + }, + { timeout: 30_000 }, + ) + .toBe("RUNNING"); + const run = await apiClient.seedAutomationRun(automation.id, "task_created", task.id); + expect(run.session_id).not.toBe(""); + expect(run.turn_id).not.toBe(""); + return { automation, task }; +} + test.describe("Automation run detail disclosure", () => { test("keeps the standing instruction out of the transcript and behind a rail control", async ({ testPage, @@ -83,7 +125,95 @@ test.describe("Automation run detail disclosure", () => { }); }); +test.describe("Automation exact-run controls", () => { + test("stops the selected running run and leaves it failed", async ({ + testPage, + apiClient, + seedData, + }) => { + const { automation } = await seedOpenRun(apiClient, seedData, "Exact Stop"); + await openRunView(testPage, automation.id); + + const stop = testPage.getByRole("button", { name: "Stop current run" }); + await expect(stop).toBeVisible({ timeout: 15_000 }); + await stop.click(); + + await expect(testPage.getByTestId("run-group-completed")).toBeVisible({ timeout: 15_000 }); + await expect(testPage.getByTestId("run-group-completed")).toContainText("Failed"); + await expect(testPage.getByRole("button", { name: "Stop current run" })).toHaveCount(0); + }); +}); + test.describe("Automation run composer", () => { + test("keeps a reply visible in the complete shared transcript", async ({ + testPage, + apiClient, + seedData, + }) => { + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Shared transcript reply", + seedData.agentProfileId, + { + description: 'e2e:message("initial shared turn")', + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + }, + ); + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return sessions.find((session) => session.id === task.session_id)?.state; + }, + { timeout: 60_000 }, + ) + .toBe("WAITING_FOR_INPUT"); + + await apiClient.setTaskOrigin(task.id, "automation_run"); + const turns = await apiClient.listSessionTurns(task.session_id!); + expect(turns.turns).toHaveLength(1); + const initialTurnId = turns.turns[0].id; + const automation = await apiClient.seedAutomation({ + workspaceId: seedData.workspaceId, + name: "Shared transcript automation", + workflowId: seedData.workflowId, + workflowStepId: seedData.startStepId, + prompt: STANDING_INSTRUCTION, + agentProfileId: seedData.agentProfileId, + }); + const run = await apiClient.seedAutomationRun(automation.id, "succeeded", task.id, { + sessionId: task.session_id, + turnId: initialTurnId, + }); + expect(run.session_id).not.toBe(""); + expect(run.turn_id).not.toBe(""); + + await openRunView(testPage, automation.id); + const transcript = testPage.getByTestId("run-transcript"); + await expect(transcript).toContainText("initial shared turn", { timeout: 15_000 }); + + const reply = "Reply stays visible in the selected run"; + const editor = transcript.getByTestId("chat-input-editor"); + await expect(editor).toBeEditable({ timeout: 15_000 }); + await editor.fill(reply); + const submit = transcript.getByTestId("submit-message-button"); + await expect(submit).toBeEnabled({ timeout: 15_000 }); + await submit.click(); + + await expect + .poll(async () => (await apiClient.listSessionTurns(task.session_id!)).turns.length, { + timeout: 15_000, + }) + .toBe(2); + const updatedTurns = await apiClient.listSessionTurns(task.session_id!); + const replyTurnId = updatedTurns.turns.find((turn) => turn.id !== initialTurnId)?.id; + expect(replyTurnId).toBeTruthy(); + await expect(transcript).toContainText(reply, { timeout: 15_000 }); + await expect(transcript.locator(`[data-turn-id="${replyTurnId}"]`)).toBeVisible(); + }); + test("sits on the run page's own background, not the task workbench's card", async ({ testPage, apiClient, @@ -193,14 +323,12 @@ test.describe("Automation concurrency note", () => { const automations = new AutomationsPage(testPage, seed.workspaceId); await automations.gotoNew(); await automations.nameInput.fill(name); - // A time, not a frequency: the frequency select already reads "every day", - // and Radix does not fire onValueChange for the value already selected, so - // picking it adds no trigger at all. Changing the time does, and 03:17 - // keeps the real cron scheduler's one-minute-a-day firing window well away - // from the test. + // Select the schedule explicitly. A new automation starts unscheduled, so + // changing the time alone cannot create a trigger. 03:17 keeps the real + // cron scheduler's one-minute-a-day firing window well away from the test. + await automations.selectFrequency("every day"); await automations.timeInput.fill("03:17"); await automations.selectWorkflow("E2E Workflow"); - await automations.selectWorkflowStep(seed.steps[0].name); await expect(automations.saveButton).toBeEnabled({ timeout: 5_000 }); await automations.saveButton.click(); await expect(testPage).toHaveURL(/automations$/, { timeout: 15_000 }); diff --git a/apps/web/e2e/tests/automations-settings.spec.ts b/apps/web/e2e/tests/automations-settings.spec.ts index cbc1349142..d69926d49e 100644 --- a/apps/web/e2e/tests/automations-settings.spec.ts +++ b/apps/web/e2e/tests/automations-settings.spec.ts @@ -28,7 +28,6 @@ test.describe("Automations settings page", () => { // Select workflow and step await automations.selectWorkflow("E2E Workflow"); - await automations.selectWorkflowStep(seedData.steps[0].name); // Save — button should be enabled now await expect(automations.saveButton).toBeEnabled({ timeout: 5_000 }); @@ -40,6 +39,58 @@ test.describe("Automations settings page", () => { await expect(testPage.getByText("Daily Check")).toBeVisible(); }); + test("shows continuity choices and persists a reusable automation", async ({ + testPage, + seedData, + }) => { + const automations = new AutomationsPage(testPage, seedData.workspaceId); + await automations.gotoNew(); + + await expect(testPage.getByText("Context between runs", { exact: true })).toBeVisible(); + await expect( + testPage.getByText( + "Choose whether each run starts fresh or continues the same conversation and files.", + { exact: true }, + ), + ).toBeVisible(); + await expect( + testPage.getByText( + "Each run starts with a separate conversation and files. These tasks do not appear in Kanban or the sidebar. Use this option for independent jobs and concurrent runs.", + { exact: true }, + ), + ).toBeVisible(); + await expect( + testPage.getByText( + "Runs continue the same conversation and files, so the agent keeps prior context and changes. Runs execute one at a time.", + { exact: true }, + ), + ).toBeVisible(); + + const reuse = testPage.getByRole("radio", { name: "Continue the previous session" }); + await expect(reuse).not.toBeChecked(); + await reuse.check(); + await expect(reuse).toBeChecked(); + await expect(testPage.getByRole("spinbutton")).toHaveValue("1"); + await expect(testPage.getByRole("spinbutton")).toBeDisabled(); + await expect( + testPage.getByText("This option supports one active run at a time."), + ).toBeVisible(); + + await testPage.getByTestId("automation-name-input").fill("Reusable Context"); + await automations.selectFrequency("every day"); + await automations.selectWorkflow("E2E Workflow"); + await expect(automations.saveButton).toBeEnabled({ timeout: 5_000 }); + await automations.saveButton.click(); + + await expect(testPage).toHaveURL(/automations$/, { timeout: 15_000 }); + await automations.openByName("Reusable Context"); + await expect(testPage).toHaveURL(/automations\/[a-f0-9-]+$/, { timeout: 10_000 }); + await expect( + testPage.getByRole("radio", { name: "Continue the previous session" }), + ).toBeChecked(); + await expect(testPage.getByRole("spinbutton")).toHaveValue("1"); + }); + test("create automation with custom schedule expression", async ({ testPage, seedData }) => { const automations = new AutomationsPage(testPage, seedData.workspaceId); await automations.gotoNew(); @@ -49,7 +100,6 @@ test.describe("Automations settings page", () => { // Select workflow and step await automations.selectWorkflow("E2E Workflow"); - await automations.selectWorkflowStep(seedData.steps[0].name); await expect(automations.saveButton).toBeEnabled({ timeout: 5_000 }); await automations.saveButton.click(); @@ -78,7 +128,6 @@ test.describe("Automations settings page", () => { await automations.nameInput.fill("Original Name"); await automations.selectFrequency("every hour"); await automations.selectWorkflow("E2E Workflow"); - await automations.selectWorkflowStep(seedData.steps[0].name); await expect(automations.saveButton).toBeEnabled({ timeout: 5_000 }); await automations.saveButton.click(); @@ -110,7 +159,6 @@ test.describe("Automations settings page", () => { await automations.nameInput.fill("To Be Deleted"); await automations.selectFrequency("every week"); await automations.selectWorkflow("E2E Workflow"); - await automations.selectWorkflowStep(seedData.steps[0].name); await expect(automations.saveButton).toBeEnabled({ timeout: 5_000 }); await automations.saveButton.click(); @@ -145,7 +193,6 @@ test.describe("Automations settings page", () => { // Select workflow and step await automations.selectWorkflow("E2E Workflow"); - await automations.selectWorkflowStep(seedData.steps[0].name); // Save await expect(automations.saveButton).toBeEnabled({ timeout: 5_000 }); @@ -190,7 +237,6 @@ test.describe("Automations settings page", () => { await automations.nameInput.fill("Reveal Me"); await testPage.getByText("Or use a webhook instead").click(); await automations.selectWorkflow("E2E Workflow"); - await automations.selectWorkflowStep(seedData.steps[0].name); await expect(automations.saveButton).toBeEnabled({ timeout: 5_000 }); await automations.saveButton.click(); @@ -218,14 +264,39 @@ test.describe("Automations settings page", () => { await expect(secretInput).not.toHaveValue(/^•+$/); }); - test("repository defaults to none on a fresh automation", async ({ testPage, seedData }) => { + test("repository starts empty with no workspace fallback", async ({ testPage, seedData }) => { const automations = new AutomationsPage(testPage, seedData.workspaceId); await automations.gotoNew(); - // The repository selector should show "None" by default - await expect(testPage.getByTestId("repository-selector")).toContainText(/None|no repository/i, { - timeout: 10_000, - }); + await expect(testPage.getByTestId("repo-chip")).toHaveCount(0); + await expect(testPage.getByText(/run without repository files/i)).toBeVisible(); + await expect(testPage.getByText(/use workspace default/i)).toHaveCount(0); + }); + + test("shares workflow previews and repository branch chips with New Task", async ({ + testPage, + apiClient, + seedData, + }) => { + const automations = new AutomationsPage(testPage, seedData.workspaceId); + await automations.gotoNew(); + + await automations.workflowSelector.click(); + await expect(testPage.getByText(seedData.steps[0].name, { exact: true })).toBeVisible(); + await testPage.keyboard.press("Escape"); + + const { repositories } = await apiClient.listRepositories(seedData.workspaceId); + const repository = repositories[0]; + expect(repository).toBeTruthy(); + await testPage.getByRole("button", { name: "Add repository" }).click(); + await testPage.getByTestId("repo-chip-trigger").click(); + await testPage.getByText(repository!.name, { exact: true }).last().click(); + + await expect(testPage.getByTestId("repo-chip")).toHaveAttribute( + "data-repository-id", + repository!.id, + ); + await expect(testPage.getByTestId("branch-chip-trigger")).toBeEnabled(); }); test("create page keeps scrolling inside the settings pane", async ({ testPage, seedData }) => { @@ -262,7 +333,6 @@ test.describe("Automations settings page", () => { await automations.nameInput.fill("Toggle Test"); await automations.selectFrequency("every day"); await automations.selectWorkflow("E2E Workflow"); - await automations.selectWorkflowStep(seedData.steps[0].name); await expect(automations.saveButton).toBeEnabled({ timeout: 5_000 }); await automations.saveButton.click(); await expect(testPage).toHaveURL(/automations$/, { timeout: 15_000 }); diff --git a/apps/web/e2e/tests/automations-target-modes.spec.ts b/apps/web/e2e/tests/automations-target-modes.spec.ts new file mode 100644 index 0000000000..8802e7c639 --- /dev/null +++ b/apps/web/e2e/tests/automations-target-modes.spec.ts @@ -0,0 +1,61 @@ +import { test, expect } from "../fixtures/test-base"; + +test.describe("Automation target modes", () => { + test("runs a hidden automation without a workflow or repository", async ({ + testPage, + apiClient, + seedData, + }) => { + const automation = await apiClient.seedAutomation({ + workspaceId: seedData.workspaceId, + name: "Repository-free report", + taskMode: "automation_run", + repositoryMode: "none", + agentProfileId: seedData.agentProfileId, + executorProfileId: seedData.worktreeExecutorProfileId, + prompt: 'e2e:message("scratch-run-ok")', + }); + + const result = await apiClient.triggerAutomationManual(automation.id); + expect(result.run_task_id).toBeTruthy(); + + await testPage.goto(`/t/${result.run_task_id!}`); + await expect(testPage.getByText("scratch-run-ok", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + + const { tasks } = await apiClient.listTasks(seedData.workspaceId); + expect(tasks.some((task) => task.id === result.run_task_id)).toBe(false); + }); + + test("creates a visible normal task in the selected workflow", async ({ + testPage, + apiClient, + seedData, + }) => { + const automation = await apiClient.seedAutomation({ + workspaceId: seedData.workspaceId, + name: "Visible workflow task", + workflowId: seedData.workflowId, + taskMode: "normal_task", + repositoryMode: "selected", + repositories: [{ repository_id: seedData.repositoryId, base_branch: "main" }], + agentProfileId: seedData.agentProfileId, + executorProfileId: seedData.worktreeExecutorProfileId, + prompt: 'e2e:delay(5000)\ne2e:message("visible-task-ok")', + }); + + const result = await apiClient.triggerAutomationManual(automation.id); + expect(result.run_task_id).toBeTruthy(); + + const { tasks } = await apiClient.listTasks(seedData.workspaceId); + const task = tasks.find((candidate) => candidate.id === result.run_task_id); + expect(task).toBeTruthy(); + expect(task?.workflow_step_id).toBe(seedData.startStepId); + + await testPage.goto(`/t/${result.run_task_id!}`); + await expect(testPage.getByText("visible-task-ok", { exact: true })).toBeVisible({ + timeout: 30_000, + }); + }); +}); diff --git a/apps/web/e2e/tests/mobile-automation-detail.spec.ts b/apps/web/e2e/tests/mobile-automation-detail.spec.ts index bb6f065c26..95bf4f9dbb 100644 --- a/apps/web/e2e/tests/mobile-automation-detail.spec.ts +++ b/apps/web/e2e/tests/mobile-automation-detail.spec.ts @@ -52,6 +52,46 @@ async function openDetail(testPage: Page, automationId: string) { await expect(testPage.getByTestId("runs-drawer-trigger")).toBeVisible({ timeout: 15_000 }); } +async function seedOpenRun( + apiClient: import("../helpers/api-client").ApiClient, + seed: Seed & { agentProfileId: string; repositoryId: string }, + name: string, +) { + const automation = await apiClient.seedAutomation({ + workspaceId: seed.workspaceId, + name, + workflowId: seed.workflowId, + workflowStepId: seed.startStepId, + prompt: STANDING_INSTRUCTION, + agentProfileId: seed.agentProfileId, + }); + const task = await apiClient.createTaskWithAgent( + seed.workspaceId, + `${name} — running task`, + seed.agentProfileId, + { + description: "/sleep 30", + workflow_id: seed.workflowId, + workflow_step_id: seed.startStepId, + repository_ids: [seed.repositoryId], + }, + ); + await apiClient.setTaskOrigin(task.id, "automation_run"); + await expect + .poll( + async () => { + const { sessions } = await apiClient.listTaskSessions(task.id); + return sessions.find((session) => session.id === task.session_id)?.state; + }, + { timeout: 30_000 }, + ) + .toBe("RUNNING"); + const run = await apiClient.seedAutomationRun(automation.id, "task_created", task.id); + expect(run.session_id).not.toBe(""); + expect(run.turn_id).not.toBe(""); + return automation; +} + test.describe("Automation detail on mobile", () => { test("shows the runs in a drawer rather than a rail", async ({ testPage, @@ -160,6 +200,35 @@ test.describe("Automation detail on mobile", () => { await expect(testPage.getByText(/Triggered|Skipped/)).toBeVisible({ timeout: 15_000 }); }); + test("stops the selected running run from the drawer header", async ({ + testPage, + apiClient, + seedData, + }) => { + const automation = await seedOpenRun(apiClient, seedData, "Mobile Exact Stop"); + await openDetail(testPage, automation.id); + + await testPage.getByTestId("runs-drawer-trigger").click(); + const running = testPage.getByTestId("run-group-running"); + await expect(running).toBeVisible({ timeout: 10_000 }); + await running.getByRole("button").first().click(); + + const stop = testPage.getByRole("button", { name: "Stop current run" }); + await expect(stop).toBeVisible({ timeout: 10_000 }); + const box = await stop.boundingBox(); + const width = testPage.viewportSize()?.width ?? 0; + expect(box, "stop action should have a box").not.toBeNull(); + expect(box!.x).toBeGreaterThanOrEqual(0); + expect(box!.x + box!.width).toBeLessThanOrEqual(width); + + await stop.click(); + // Selecting a run closes the mobile drawer; the selected-run header is + // therefore the terminal-state surface after the stop. + await expect(testPage.getByText("Failed", { exact: true }).first()).toBeVisible({ + timeout: 15_000, + }); + }); + test("does not scroll sideways with a transcript mounted", async ({ testPage, apiClient, diff --git a/apps/web/e2e/tests/mobile-automations-export.spec.ts b/apps/web/e2e/tests/mobile-automations-export.spec.ts index 06c38b8be1..b0be4ec468 100644 --- a/apps/web/e2e/tests/mobile-automations-export.spec.ts +++ b/apps/web/e2e/tests/mobile-automations-export.spec.ts @@ -18,10 +18,16 @@ test.describe("Automations export on mobile", () => { await automations.goto(); await expect(automations.emptyState).toBeVisible({ timeout: 10_000 }); await expect(automations.exportButton).toBeEnabled(); + await expect(automations.newAutomationButton).toBeVisible(); - const box = await automations.exportButton.boundingBox(); - expect(box).not.toBeNull(); - expect(box!.height).toBeGreaterThanOrEqual(44); + const [exportBox, newAutomationBox] = await Promise.all([ + automations.exportButton.boundingBox(), + automations.newAutomationButton.boundingBox(), + ]); + expect(exportBox).not.toBeNull(); + expect(newAutomationBox).not.toBeNull(); + expect(exportBox!.height).toBeGreaterThanOrEqual(44); + expect(newAutomationBox!.height).toBeCloseTo(exportBox!.height, 1); const downloadPromise = testPage.waitForEvent("download"); await automations.exportButton.tap(); diff --git a/apps/web/e2e/tests/mobile-automations-pr-merged-trigger.spec.ts b/apps/web/e2e/tests/mobile-automations-pr-merged-trigger.spec.ts index f3b6ae602a..ccec13a946 100644 --- a/apps/web/e2e/tests/mobile-automations-pr-merged-trigger.spec.ts +++ b/apps/web/e2e/tests/mobile-automations-pr-merged-trigger.spec.ts @@ -12,10 +12,7 @@ test.describe("Pull request merged automation on mobile", () => { await automations.gotoNew(); await automations.nameInput.fill(automationName); - await automations.workflowSelector.tap(); - await testPage.getByRole("option", { name: "E2E Workflow" }).tap(); - await automations.workflowStepSelector.tap(); - await testPage.getByRole("option", { name: seedData.steps[0].name }).tap(); + await automations.selectWorkflow("E2E Workflow"); await automations.addConditionButton.tap(); await testPage.getByRole("option", { name: /Pull request merged/i }).tap(); diff --git a/apps/web/e2e/tests/mobile-automations-scroll.spec.ts b/apps/web/e2e/tests/mobile-automations-scroll.spec.ts index 24ec34e035..aef93ca147 100644 --- a/apps/web/e2e/tests/mobile-automations-scroll.spec.ts +++ b/apps/web/e2e/tests/mobile-automations-scroll.spec.ts @@ -30,6 +30,48 @@ async function swipeUpOnElement(page: Page, element: Locator): Promise { } test.describe("Automations settings on mobile", () => { + test("keeps continuity choices visible and contained on a phone", async ({ + testPage, + seedData, + }) => { + const automations = new AutomationsPage(testPage, seedData.workspaceId); + await automations.gotoNew(); + + const workflowSelectorBox = await automations.workflowSelector.boundingBox(); + expect(workflowSelectorBox?.height).toBeGreaterThanOrEqual(44); + + await expect(testPage.getByText("Context between runs", { exact: true })).toBeVisible(); + await expect( + testPage.getByText( + "Each run starts with a separate conversation and files. These tasks do not appear in Kanban or the sidebar. Use this option for independent jobs and concurrent runs.", + { exact: true }, + ), + ).toBeVisible(); + await expect( + testPage.getByText( + "Runs continue the same conversation and files, so the agent keeps prior context and changes. Runs execute one at a time.", + { exact: true }, + ), + ).toBeVisible(); + + const reuse = testPage.getByRole("radio", { name: "Continue the previous session" }); + await reuse.check(); + await expect(testPage.getByRole("spinbutton")).toBeDisabled(); + + const addRepository = testPage.getByRole("button", { name: "Add repository" }); + const addRepositoryBox = await addRepository.boundingBox(); + expect(addRepositoryBox?.height).toBeGreaterThanOrEqual(44); + await addRepository.click(); + await expect(testPage.getByTestId("repo-chip-trigger")).toBeVisible(); + await expect(testPage.getByTestId("branch-chip-trigger")).toBeVisible(); + + const overflow = await testPage.evaluate(() => ({ + scrollWidth: document.documentElement.scrollWidth, + clientWidth: document.documentElement.clientWidth, + })); + expect(overflow.scrollWidth).toBeLessThanOrEqual(overflow.clientWidth); + }); + test("create page does not hand off bottom overscroll to the document", async ({ testPage, seedData, diff --git a/apps/web/hooks/use-lazy-load-sentinel.test.ts b/apps/web/hooks/use-lazy-load-sentinel.test.ts index 2ba8e577d1..a4a15ef660 100644 --- a/apps/web/hooks/use-lazy-load-sentinel.test.ts +++ b/apps/web/hooks/use-lazy-load-sentinel.test.ts @@ -342,7 +342,7 @@ describe("useLazyLoadSentinel — pin refresh before a load", () => { await act(async () => { resolveLoad(20); }); - expect(loadMore).toHaveBeenCalledTimes(1); + expect(loadMore).toHaveBeenCalled(); expect(scroller.scrollTop).toBeGreaterThanOrEqual( scroller.scrollHeight - scroller.clientHeight, ); diff --git a/apps/web/lib/api/domains/automation-api.ts b/apps/web/lib/api/domains/automation-api.ts index 591cb335b2..bc10de4503 100644 --- a/apps/web/lib/api/domains/automation-api.ts +++ b/apps/web/lib/api/domains/automation-api.ts @@ -176,3 +176,13 @@ export async function deleteAllAutomationRuns( workspace_id: workspaceId, }); } + +export async function stopAutomationRun( + automationId: string, + runId: string, +): Promise<{ run_id: string; status: string }> { + return requireClient().request<{ run_id: string; status: string }>("automation.run.stop", { + automation_id: automationId, + run_id: runId, + }); +} diff --git a/apps/web/lib/api/domains/automation-runs-api.test.ts b/apps/web/lib/api/domains/automation-runs-api.test.ts index 75b445efa2..171e8a0c93 100644 --- a/apps/web/lib/api/domains/automation-runs-api.test.ts +++ b/apps/web/lib/api/domains/automation-runs-api.test.ts @@ -5,7 +5,7 @@ vi.mock("@/lib/ws/connection", () => ({ getWebSocketClient: () => ({ request: mockRequest }), })); -import { listWorkspaceAutomationRuns } from "./automation-api"; +import { listWorkspaceAutomationRuns, stopAutomationRun } from "./automation-api"; const WORKSPACE = "ws-1"; const ACTION = "automation.runs.list_workspace"; @@ -53,3 +53,18 @@ describe("listWorkspaceAutomationRuns", () => { await expect(listWorkspaceAutomationRuns(WORKSPACE)).resolves.toEqual([]); }); }); + +describe("stopAutomationRun", () => { + it("stops the exact automation run", async () => { + mockRequest.mockResolvedValue({ run_id: "run-7", status: "failed" }); + + await expect(stopAutomationRun("automation-1", "run-7")).resolves.toEqual({ + run_id: "run-7", + status: "failed", + }); + expect(mockRequest).toHaveBeenCalledWith("automation.run.stop", { + automation_id: "automation-1", + run_id: "run-7", + }); + }); +}); diff --git a/apps/web/lib/types/automation.ts b/apps/web/lib/types/automation.ts index 535426c0d0..4d110fbd15 100644 --- a/apps/web/lib/types/automation.ts +++ b/apps/web/lib/types/automation.ts @@ -17,6 +17,15 @@ export type RunStatus = | "archived" | "cancelled"; +export type ContinuationPolicy = "new_task" | "reuse_thread"; +export type TaskMode = "automation_run" | "normal_task"; +export type RepositoryMode = "workspace_default" | "selected" | "none"; + +export type AutomationRepository = { + repository_id: string; + base_branch: string; +}; + export type Automation = { id: string; workspace_id: string; @@ -26,11 +35,16 @@ export type Automation = { workflow_step_id: string; agent_profile_id: string; executor_profile_id: string; + task_mode?: TaskMode; + repository_mode?: RepositoryMode; repository_ids: string[]; + repositories?: AutomationRepository[]; prompt: string; task_title_template: string; enabled: boolean; max_concurrent_runs: number; + /** How later firings get their task and conversation context. */ + continuation_policy?: ContinuationPolicy; last_triggered_at: string | null; created_at: string; updated_at: string; @@ -79,6 +93,12 @@ export type AutomationRun = { * without one is reported rather than offered as something to open. */ session_id?: string; + /** Exact provider turn represented by this run when a session is shared. */ + turn_id?: string; + thread_action?: "created" | "resumed" | "replaced"; + thread_reason?: string; + /** Snapshot of the rendered task title at admission time. */ + display_title?: string; }; /** @@ -169,9 +189,13 @@ export type CreateAutomationRequest = { agent_profile_id: string; executor_profile_id: string; repository_ids?: string[]; + repositories?: AutomationRepository[]; prompt?: string; task_title_template?: string; max_concurrent_runs?: number; + continuation_policy?: ContinuationPolicy; + task_mode?: TaskMode; + repository_mode?: RepositoryMode; triggers?: Array<{ type: TriggerType; config: Record; @@ -187,10 +211,14 @@ export type UpdateAutomationRequest = { agent_profile_id?: string; executor_profile_id?: string; repository_ids?: string[]; + repositories?: AutomationRepository[]; prompt?: string; task_title_template?: string; enabled?: boolean; max_concurrent_runs?: number; + continuation_policy?: ContinuationPolicy; + task_mode?: TaskMode; + repository_mode?: RepositoryMode; }; // CreateAutomationResponse mirrors the backend's one-time webhook secret diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 4851b59a11..11b4add560 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -419,7 +419,13 @@ export type Task = ActiveSubagentCountFields & { }; // Task origin values mirror models.TaskOrigin* constants in the Go backend. -export type TaskOrigin = "manual" | "agent_created" | "routine" | "onboarding"; +export type TaskOrigin = + | "manual" + | "agent_created" + | "routine" + | "onboarding" + | "automation_run" + | "automation_task"; // isFromOffice reads the backend-computed flag (predicate lives in SQL at // apps/backend/internal/task/repository/sqlite/task.go). Use to gate diff --git a/apps/web/src/locales/en/automations.json b/apps/web/src/locales/en/automations.json index a659336079..1e63a48461 100644 --- a/apps/web/src/locales/en/automations.json +++ b/apps/web/src/locales/en/automations.json @@ -32,6 +32,14 @@ "comingSoonSuffix": " (Coming soon)", "completeRequiredFields": "Complete the required automation fields before saving.", "completed": "Completed", + "contextBetweenRunsConcurrencyLock": "This option supports one active run at a time.", + "contextBetweenRunsDescription": "Choose whether each run starts fresh or continues the same conversation and files.", + "contextBetweenRunsNewTask": "Start a new task for every run", + "contextBetweenRunsNewTaskDescription": "Each run starts with a separate conversation and files. These tasks do not appear in Kanban or the sidebar. Use this option for independent jobs and concurrent runs.", + "contextBetweenRunsNewTaskNormalDescription": "Each run starts with a separate conversation and files. These tasks appear in Kanban and the sidebar. Use this option for independent jobs and concurrent runs.", + "contextBetweenRunsReuseThread": "Continue the previous session", + "contextBetweenRunsReuseThreadDescription": "Runs continue the same conversation and files, so the agent keeps prior context and changes. Runs execute one at a time.", + "contextBetweenRunsTitle": "Context between runs", "configurationLabel": "Configuration", "couldNotLoadWorkflows": "Couldn't load workflows", "couldNotRefreshRuns": "Could not refresh runs; restored from local cache", @@ -68,6 +76,7 @@ "failedToDeleteRuns": "Failed to delete runs", "failedToLoadWebhookSecret": "Failed to load webhook secret: {{error}}", "failedToSaveAutomation": "Failed to save automation: {{error}}", + "failedToStopRun": "Could not stop this run: {{error}}", "hasNotRunYet": "This automation has not run yet.", "hideRunDetail": "Hide run detail", "instructionsLabel": "Instructions", @@ -87,7 +96,6 @@ "nextRun": "Next run", "noAutomationsYet": "No automations yet", "noConversationForRun": "This run produced no conversation to read.", - "noRepositoriesHelp": "With no repositories selected, this automation runs against the workspace's first repository.", "noRunsMatchFilters": "No runs match these filters.", "noRunsYet": "No runs yet", "noSchedule": "No schedule; this automation won't run on its own.", @@ -113,6 +121,9 @@ "replyToThisRun": "Reply to this run…", "repositoryLabel": "Repository", "repositoryNone": "None; no repository", + "repositoryModeTitle": "Repository access", + "repositoryModeDescription": "Add one or more repository and base branch pairs, or run with no repository.", + "repositoryModeNoneDescription": "Run without repository files in a task-owned scratch workspace.", "repositoryPlaceholderAuto": "Auto", "required": "required", "resizeRuns": "Resize runs", @@ -158,6 +169,8 @@ "skipped": "Skipped; {{reason}}", "skippedAlreadyRunning": "Skipped; already running", "starting": "Starting…", + "stopCurrentRun": "Stop current run", + "stopping": "Stopping…", "stateIdle": "Idle", "statePaused": "Paused", "stateRunning": "Running", @@ -172,6 +185,15 @@ "taskTitleHelp": "Leave empty to use the default. Supports placeholders.", "taskTitleLabel": "Task title", "taskTitlePlaceholder": "[Auto] automation name", + "taskModeTitle": "Run destination", + "taskModeDescription": "Choose whether each firing stays in automation history or creates a normal task.", + "taskModeAutomationRun": "Run in automation history only", + "taskModeAutomationRunDescription": "Each run uses a private task and conversation. These tasks do not appear in Kanban or the sidebar.", + "taskModeNormalTask": "Create a normal task", + "taskModeNormalTaskDescription": "Each run creates a normal task in the selected workflow. It appears in Kanban and the sidebar.", + "workflowRequiredLabel": "Workflow (required)", + "selectWorkflowRequired": "Select a workflow", + "normalTaskWorkflowHelp": "Choose a workflow so this task appears in Kanban and the sidebar.", "thenDescription": "A new task will be created each time this automation triggers", "thenTitle": "Then", "triggerCategoryGithub": "GitHub", @@ -203,8 +225,6 @@ "whenTitle": "When", "workflowLabel": "Workflow", "workflowLoadError": "Couldn't load workflows.", - "workflowStepHelpNoWorkflow": "Select a workflow before choosing a step.", - "workflowStepLabel": "Workflow Step", "scheduleNone": "No schedule", "scheduleIntervalEvery5Minutes": "Every 5 minutes", "scheduleIntervalEvery15Minutes": "Every 15 minutes", diff --git a/apps/web/src/locales/pseudo/automations.json b/apps/web/src/locales/pseudo/automations.json index eb1d542dfd..ab9956c868 100644 --- a/apps/web/src/locales/pseudo/automations.json +++ b/apps/web/src/locales/pseudo/automations.json @@ -32,6 +32,14 @@ "comingSoonSuffix": " (Ćōḿĩńĝ śōōń)", "completeRequiredFields": "Ćōḿƥĺēţē ţĥē ŕēqũĩŕēď àũţōḿàţĩōń ƒĩēĺďś ƀēƒōŕē śàvĩńĝ.", "completed": "Ćōḿƥĺēţēď", + "contextBetweenRunsConcurrencyLock": "Ţĥĩś ōƥţĩōń śũƥƥōŕţś ōńē àćţĩṽē ŕũń àţ à ţĩḿē.", + "contextBetweenRunsDescription": "Ćĥōōśē ŵĥēţĥēŕ ēàćĥ ŕũń śţàŕţś ƒŕēśĥ ōŕ ćōńţĩńũēś ţĥē śàḿē ćōńṽēŕśàţĩōń àńď ƒĩĺēś.", + "contextBetweenRunsNewTask": "Śţàŕţ à ńēŵ ţàśķ ƒōŕ ēṽēŕŷ ŕũń", + "contextBetweenRunsNewTaskDescription": "Ēàćĥ ŕũń śţàŕţś ŵĩţĥ à śēƥàŕàţē ćōńvēŕśàţĩōń àńď ƒĩĺēś. Ţĥēśē ţàśķś ďō ńōţ àƥƥēàŕ ĩń Ķàńƀàń ōŕ ţĥē śĩďēƀàŕ. Ũśē ţĥĩś ōƥţĩōń ƒōŕ ĩńďēƥēńďēńţ ĵōƀś àńď ćōńćũŕŕēńţ ŕũńś.", + "contextBetweenRunsNewTaskNormalDescription": "Ēàćĥ ŕũń śţàŕţś ŵĩţĥ à śēƥàŕàţē ćōńvēŕśàţĩōń àńď ƒĩĺēś. Ţĥēśē ţàśķś àƥƥēàŕ ĩń Ķàńƀàń àńď ţĥē śĩďēƀàŕ. Ũśē ţĥĩś ōƥţĩōń ƒōŕ ĩńďēƥēńďēńţ ĵōƀś àńď ćōńćũŕŕēńţ ŕũńś.", + "contextBetweenRunsReuseThread": "Ćōńţĩńũē ţĥē ƥŕēṽĩōũś śēśśĩōń", + "contextBetweenRunsReuseThreadDescription": "Ŕũńś ćōńţĩńũē ţĥē śàḿē ćōńṽēŕśàţĩōń àńď ƒĩĺēś, śō ţĥē àĝēńţ ķēēƥś ƥŕĩōŕ ćōńţēxţ àńď ćĥàńĝēś. Ŕũńś ēxēćũţē ōńē àţ à ţĩḿē.", + "contextBetweenRunsTitle": "Ćōńţēxţ ƀēţŵēēń ŕũńś", "configurationLabel": "Ćōńƒĩĝũŕàţĩōń", "couldNotLoadWorkflows": "Ćōũĺďń'ţ ĺōàď ŵōŕķƒĺōŵś", "couldNotRefreshRuns": "Ćōũĺď ńōţ ŕēƒŕēśĥ ŕũńś; ŕēśţōŕēď ƒŕōḿ ĺōćàĺ ćàćĥē", @@ -68,6 +76,7 @@ "failedToDeleteRuns": "Ƒàĩĺēď ţō ďēĺēţē ŕũńś", "failedToLoadWebhookSecret": "Ƒàĩĺēď ţō ĺōàď ŵēƀĥōōķ śēćŕēţ: {{error}}", "failedToSaveAutomation": "Ƒàĩĺēď ţō śàvē àũţōḿàţĩōń: {{error}}", + "failedToStopRun": "Ćōũĺď ńōţ śţōƥ ţĥĩś ŕũń: {{error}}", "hasNotRunYet": "Ţĥĩś àũţōḿàţĩōń ĥàś ńōţ ŕũń ŷēţ.", "hideRunDetail": "Ĥĩďē ŕũń ďēţàĩĺ", "instructionsLabel": "Ĩńśţŕũćţĩōńś", @@ -87,7 +96,6 @@ "nextRun": "Ńēxţ ŕũń", "noAutomationsYet": "Ńō àũţōḿàţĩōńś ŷēţ", "noConversationForRun": "Ţĥĩś ŕũń ƥŕōďũćēď ńō ćōńvēŕśàţĩōń ţō ŕēàď.", - "noRepositoriesHelp": "Ŵĩţĥ ńō ŕēƥōśĩţōŕĩēś śēĺēćţēď, ţĥĩś àũţōḿàţĩōń ŕũńś àĝàĩńśţ ţĥē ŵōŕķśƥàćē'ś ƒĩŕśţ ŕēƥōśĩţōŕŷ.", "noRunsMatchFilters": "Ńō ŕũńś ḿàţćĥ ţĥēśē ƒĩĺţēŕś.", "noRunsYet": "Ńō ŕũńś ŷēţ", "noSchedule": "Ńō śćĥēďũĺē; ţĥĩś àũţōḿàţĩōń ŵōń'ţ ŕũń ōń ĩţś ōŵń.", @@ -113,6 +121,9 @@ "replyToThisRun": "Ŕēƥĺŷ ţō ţĥĩś ŕũń…", "repositoryLabel": "Ŕēƥōśĩţōŕŷ", "repositoryNone": "Ńōńē; ńō ŕēƥōśĩţōŕŷ", + "repositoryModeTitle": "Ŕēƥōśĩţōŕŷ àććēśś", + "repositoryModeDescription": "Àďď ōńē ōŕ mōŕē ŕēƥōśĩţōŕŷ àńď ƀàśē ƀŕàńćĥ ƥàĩŕś, ōŕ ŕũń ŵĩţĥ ńō ŕēƥōśĩţōŕŷ.", + "repositoryModeNoneDescription": "Ŕũń ŵĩţĥōũţ ŕēƥōśĩţōŕŷ ƒĩĺēś ĩń à ţàśķ-ōŵńēď śćŕàţćĥ ŵōŕķśƥàćē.", "repositoryPlaceholderAuto": "Àũţō", "required": "ŕēqũĩŕēď", "resizeRuns": "Ŕēśĩźē ŕũńś", @@ -158,6 +169,8 @@ "skipped": "Śķĩƥƥēď; {{reason}}", "skippedAlreadyRunning": "Śķĩƥƥēď; àĺŕēàďŷ ŕũńńĩńĝ", "starting": "Śţàŕţĩńĝ…", + "stopCurrentRun": "Śţōƥ ćũŕŕēńţ ŕũń", + "stopping": "Śţōƥƥĩńĝ…", "stateIdle": "Ĩďĺē", "statePaused": "Ƥàũśēď", "stateRunning": "Ŕũńńĩńĝ", @@ -172,6 +185,15 @@ "taskTitleHelp": "Ĺēàvē ēḿƥţŷ ţō ũśē ţĥē ďēƒàũĺţ. Śũƥƥōŕţś ƥĺàćēĥōĺďēŕś.", "taskTitleLabel": "Ţàśķ ţĩţĺē", "taskTitlePlaceholder": "[Àũţō] àũţōḿàţĩōń ńàḿē", + "taskModeTitle": "Ŕũń ďēśţĩńàţĩōń", + "taskModeDescription": "Ćĥōōśē ŵĥēţĥēŕ ēàćĥ ƒĩŕĩńĝ śţàŷś ĩń àũţōḿàţĩōń ĥĩśţōŕŷ ōŕ ćŕēàţēś à ńōŕḿàĺ ţàśķ.", + "taskModeAutomationRun": "Ŕũń ĩń àũţōḿàţĩōń ĥĩśţōŕŷ ōńĺŷ", + "taskModeAutomationRunDescription": "Ēàćĥ ŕũń ũśēś à ƥŕĩvàţē ţàśķ àńď ćōńvēŕśàţĩōń. Ţĥēśē ţàśķś ďō ńōţ àƥƥēàŕ ĩń Ķàńƀàń ōŕ ţĥē śĩďēƀàŕ.", + "taskModeNormalTask": "Ćŕēàţē à ńōŕḿàĺ ţàśķ", + "taskModeNormalTaskDescription": "Ēàćĥ ŕũń ćŕēàţēś à ńōŕḿàĺ ţàśķ ĩń ţĥē śēĺēćţēď ŵōŕķƒĺōŵ. Ĩţ àƥƥēàŕś ĩń Ķàńƀàń àńď ţĥē śĩďēƀàŕ.", + "workflowRequiredLabel": "Ŵōŕķƒĺōŵ (ŕēqũĩŕēď)", + "selectWorkflowRequired": "Śēĺēćţ à ŵōŕķƒĺōŵ", + "normalTaskWorkflowHelp": "Ćĥōōśē à ŵōŕķƒĺōŵ śō ţĥĩś ţàśķ àƥƥēàŕś ĩń Ķàńƀàń àńď ţĥē śĩďēƀàŕ.", "thenDescription": "À ńēŵ ţàśķ ŵĩĺĺ ƀē ćŕēàţēď ēàćĥ ţĩḿē ţĥĩś àũţōḿàţĩōń ţŕĩĝĝēŕś", "thenTitle": "Ţĥēń", "triggerCategoryGithub": "ĜĩţĤũƀ", @@ -203,8 +225,6 @@ "whenTitle": "Ŵĥēń", "workflowLabel": "Ŵōŕķƒĺōŵ", "workflowLoadError": "Ćōũĺďń'ţ ĺōàď ŵōŕķƒĺōŵś.", - "workflowStepHelpNoWorkflow": "Śēĺēćţ à ŵōŕķƒĺōŵ ƀēƒōŕē ćĥōōśĩńĝ à śţēƥ.", - "workflowStepLabel": "Ŵōŕķƒĺōŵ Śţēƥ", "scheduleNone": "Ńō śćĥēďũĺē", "scheduleIntervalEvery5Minutes": "Ēvēŕŷ 5 ḿĩńũţēś", "scheduleIntervalEvery15Minutes": "Ēvēŕŷ 15 ḿĩńũţēś", diff --git a/apps/web/src/locales/pt-pt/automations.json b/apps/web/src/locales/pt-pt/automations.json index fc748a539c..6000cf648e 100644 --- a/apps/web/src/locales/pt-pt/automations.json +++ b/apps/web/src/locales/pt-pt/automations.json @@ -32,6 +32,14 @@ "comingSoonSuffix": " (Brevemente)", "completeRequiredFields": "Preencha os campos obrigatórios da automatização antes de guardar.", "completed": "Concluída", + "contextBetweenRunsConcurrencyLock": "Esta opção permite uma execução ativa de cada vez.", + "contextBetweenRunsDescription": "Escolha se cada execução começa de novo ou continua a mesma conversa e ficheiros.", + "contextBetweenRunsNewTask": "Iniciar uma tarefa nova em cada execução", + "contextBetweenRunsNewTaskDescription": "Cada execução começa com uma conversa e ficheiros separados. Estas tarefas não aparecem no Kanban nem na barra lateral. Use esta opção para trabalhos independentes e execuções simultâneas.", + "contextBetweenRunsNewTaskNormalDescription": "Cada execução começa com uma conversa e ficheiros separados. Estas tarefas aparecem no Kanban e na barra lateral. Use esta opção para trabalhos independentes e execuções simultâneas.", + "contextBetweenRunsReuseThread": "Continuar a sessão anterior", + "contextBetweenRunsReuseThreadDescription": "As execuções continuam a mesma conversa e ficheiros, para que o agente mantenha o contexto e as alterações anteriores. As execuções acontecem uma de cada vez.", + "contextBetweenRunsTitle": "Contexto entre execuções", "configurationLabel": "Configuração", "couldNotLoadWorkflows": "Não foi possível carregar os fluxos de trabalho", "couldNotRefreshRuns": "Não foi possível atualizar as execuções; repostas a partir da cache local", @@ -68,6 +76,7 @@ "failedToDeleteRuns": "Não foi possível eliminar as execuções", "failedToLoadWebhookSecret": "Não foi possível carregar o segredo do webhook: {{error}}", "failedToSaveAutomation": "Não foi possível guardar a automatização: {{error}}", + "failedToStopRun": "Não foi possível parar esta execução: {{error}}", "hasNotRunYet": "Esta automatização ainda não foi executada.", "hideRunDetail": "Ocultar os detalhes da execução", "instructionsLabel": "Instruções", @@ -87,7 +96,6 @@ "nextRun": "Próxima execução", "noAutomationsYet": "Ainda não há automatizações", "noConversationForRun": "Esta execução não produziu nenhuma conversa para ler.", - "noRepositoriesHelp": "Sem repositórios selecionados, esta automatização é executada sobre o primeiro repositório da área de trabalho.", "noRunsMatchFilters": "Nenhuma execução corresponde a estes filtros.", "noRunsYet": "Ainda não há execuções", "noSchedule": "Sem agendamento; esta automatização não é executada por si só.", @@ -112,6 +120,9 @@ "replyToThisRun": "Responder a esta execução…", "repositoryLabel": "Repositório", "repositoryNone": "Nenhum; sem repositório", + "repositoryModeTitle": "Acesso ao repositório", + "repositoryModeDescription": "Adicione um ou mais pares de repositório e ramo base, ou execute sem repositório.", + "repositoryModeNoneDescription": "Execute sem ficheiros de repositório num espaço de trabalho temporário pertencente à tarefa.", "repositoryPlaceholderAuto": "Automático", "required": "obrigatório", "resizeRuns": "Redimensionar as execuções", @@ -157,6 +168,8 @@ "skipped": "Ignorada; {{reason}}", "skippedAlreadyRunning": "Ignorada; já está em execução", "starting": "A iniciar…", + "stopCurrentRun": "Parar execução atual", + "stopping": "A parar…", "stateIdle": "Inativa", "statePaused": "Em pausa", "stateRunning": "Em execução", @@ -170,6 +183,15 @@ "taskTitleHelp": "Deixe vazio para usar a pré-definição. Aceita marcadores.", "taskTitleLabel": "Título da tarefa", "taskTitlePlaceholder": "[Auto] nome da automatização", + "taskModeTitle": "Destino da execução", + "taskModeDescription": "Escolha se cada acionamento fica no histórico da automatização ou cria uma tarefa normal.", + "taskModeAutomationRun": "Executar apenas no histórico da automatização", + "taskModeAutomationRunDescription": "Cada execução usa uma tarefa e conversa privadas. Estas tarefas não aparecem no Kanban nem na barra lateral.", + "taskModeNormalTask": "Criar uma tarefa normal", + "taskModeNormalTaskDescription": "Cada execução cria uma tarefa normal no fluxo selecionado. Aparece no Kanban e na barra lateral.", + "workflowRequiredLabel": "Fluxo (obrigatório)", + "selectWorkflowRequired": "Selecione um fluxo", + "normalTaskWorkflowHelp": "Escolha um fluxo para que esta tarefa apareça no Kanban e na barra lateral.", "thenDescription": "Será criada uma nova tarefa sempre que esta automatização for acionada", "thenTitle": "Então", "triggerCategoryGithub": "GitHub", @@ -199,8 +221,6 @@ "whenTitle": "Quando", "workflowLabel": "Fluxo de trabalho", "workflowLoadError": "Não foi possível carregar os fluxos de trabalho.", - "workflowStepHelpNoWorkflow": "Selecione um fluxo de trabalho antes de escolher um passo.", - "workflowStepLabel": "Passo do fluxo de trabalho", "failedToLoadAutomationActivity": "Falha ao carregar a atividade das automações", "failedToLoadAutomations": "Falha ao carregar as automações", "failedToLoadRuns": "Falha ao carregar as execuções", diff --git a/apps/web/src/locales/zh-cn/automations.json b/apps/web/src/locales/zh-cn/automations.json index edbbed0bf0..999f82dfe8 100644 --- a/apps/web/src/locales/zh-cn/automations.json +++ b/apps/web/src/locales/zh-cn/automations.json @@ -32,6 +32,14 @@ "comingSoonSuffix": "(即将推出)", "completeRequiredFields": "请先填写必填的自动化字段,再保存。", "completed": "已完成", + "contextBetweenRunsConcurrencyLock": "此选项一次只支持一个运行中的任务。", + "contextBetweenRunsDescription": "选择每次运行是从头开始,还是继续使用相同的对话和文件。", + "contextBetweenRunsNewTask": "每次运行都开始新任务", + "contextBetweenRunsNewTaskDescription": "每次运行都会使用独立的对话和文件。这些任务不会出现在看板或侧边栏中。此选项适用于相互独立的作业和并发运行。", + "contextBetweenRunsNewTaskNormalDescription": "每次运行都会使用独立的对话和文件。这些任务会出现在看板和侧边栏中。此选项适用于相互独立的作业和并发运行。", + "contextBetweenRunsReuseThread": "继续之前的会话", + "contextBetweenRunsReuseThreadDescription": "运行会继续使用相同的对话和文件,因此代理可以保留之前的上下文和更改。运行会依次执行。", + "contextBetweenRunsTitle": "运行之间的上下文", "configurationLabel": "配置", "couldNotLoadWorkflows": "无法加载工作流", "couldNotRefreshRuns": "无法刷新运行记录;已从本地缓存恢复", @@ -68,6 +76,7 @@ "failedToDeleteRuns": "删除运行记录失败", "failedToLoadWebhookSecret": "加载 webhook 密钥失败:{{error}}", "failedToSaveAutomation": "保存自动化失败:{{error}}", + "failedToStopRun": "无法停止此运行:{{error}}", "hasNotRunYet": "此自动化尚未运行。", "hideRunDetail": "隐藏运行详情", "instructionsLabel": "说明", @@ -87,7 +96,6 @@ "nextRun": "下次运行", "noAutomationsYet": "还没有自动化", "noConversationForRun": "此运行未产生可阅读的对话。", - "noRepositoriesHelp": "未选择仓库时,此自动化会针对工作区的第一个仓库运行。", "noRunsMatchFilters": "没有符合这些筛选条件的运行记录。", "noRunsYet": "还没有运行记录", "noSchedule": "无计划;此自动化不会自行运行。", @@ -113,6 +121,9 @@ "replyToThisRun": "回复此运行…", "repositoryLabel": "仓库", "repositoryNone": "无;不使用仓库", + "repositoryModeTitle": "仓库访问", + "repositoryModeDescription": "添加一个或多个仓库与基础分支组合,或在无仓库的情况下运行。", + "repositoryModeNoneDescription": "在任务专属的临时工作区中运行,不使用仓库文件。", "repositoryPlaceholderAuto": "自动", "required": "必填", "resizeRuns": "调整运行记录区域大小", @@ -158,6 +169,8 @@ "skipped": "已跳过;{{reason}}", "skippedAlreadyRunning": "已跳过;已在运行", "starting": "正在启动…", + "stopCurrentRun": "停止当前运行", + "stopping": "正在停止…", "stateIdle": "空闲", "statePaused": "已暂停", "stateRunning": "运行中", @@ -172,6 +185,15 @@ "taskTitleHelp": "留空以使用默认值。支持占位符。", "taskTitleLabel": "任务标题", "taskTitlePlaceholder": "[自动] 自动化名称", + "taskModeTitle": "运行目标", + "taskModeDescription": "选择每次触发是只保留在自动化历史中,还是创建普通任务。", + "taskModeAutomationRun": "仅在自动化历史中运行", + "taskModeAutomationRunDescription": "每次运行使用私有任务和对话。这些任务不会出现在看板或侧边栏中。", + "taskModeNormalTask": "创建普通任务", + "taskModeNormalTaskDescription": "每次运行都会在选定的工作流中创建普通任务。任务会出现在看板和侧边栏中。", + "workflowRequiredLabel": "工作流(必填)", + "selectWorkflowRequired": "选择工作流", + "normalTaskWorkflowHelp": "选择工作流,让此任务出现在看板和侧边栏中。", "thenDescription": "每次此自动化触发时都会创建一个新任务", "thenTitle": "然后", "triggerCategoryGithub": "GitHub", @@ -203,8 +225,6 @@ "whenTitle": "当", "workflowLabel": "工作流", "workflowLoadError": "无法加载工作流。", - "workflowStepHelpNoWorkflow": "请先选择工作流,再选择步骤。", - "workflowStepLabel": "工作流步骤", "failedToLoadAutomationActivity": "加载自动化活动失败", "failedToLoadAutomations": "加载自动化失败", "failedToLoadRuns": "加载运行记录失败", diff --git a/apps/web/src/locales/zh-hk/automations.json b/apps/web/src/locales/zh-hk/automations.json index df40291471..9e3d1e6149 100644 --- a/apps/web/src/locales/zh-hk/automations.json +++ b/apps/web/src/locales/zh-hk/automations.json @@ -32,6 +32,14 @@ "comingSoonSuffix": "(即將推出)", "completeRequiredFields": "請先填寫必填的自動化欄位,再儲存。", "completed": "已完成", + "contextBetweenRunsConcurrencyLock": "此選項一次只支援一個執行中的工作。", + "contextBetweenRunsDescription": "選擇每次執行是重新開始,還是繼續使用相同的對話和檔案。", + "contextBetweenRunsNewTask": "每次執行都開始新工作", + "contextBetweenRunsNewTaskDescription": "每次執行都會使用獨立的對話和檔案。這些工作不會出現在看板或側邊欄。此選項適用於互相獨立的工作和並行執行。", + "contextBetweenRunsNewTaskNormalDescription": "每次執行都會使用獨立的對話和檔案。這些工作會出現在看板和側邊欄。此選項適用於互相獨立的工作和並行執行。", + "contextBetweenRunsReuseThread": "繼續之前的工作階段", + "contextBetweenRunsReuseThreadDescription": "執行會繼續使用相同的對話和檔案,讓代理保留之前的上下文和變更。執行會依次進行。", + "contextBetweenRunsTitle": "執行之間的上下文", "configurationLabel": "設定", "couldNotLoadWorkflows": "無法載入工作流程", "couldNotRefreshRuns": "無法刷新執行記錄;已從本地快取恢復", @@ -68,6 +76,7 @@ "failedToDeleteRuns": "刪除執行記錄失敗", "failedToLoadWebhookSecret": "載入 webhook 密鑰失敗:{{error}}", "failedToSaveAutomation": "儲存自動化失敗:{{error}}", + "failedToStopRun": "無法停止此執行:{{error}}", "hasNotRunYet": "此自動化尚未執行。", "hideRunDetail": "隱藏執行詳情", "instructionsLabel": "說明", @@ -87,7 +96,6 @@ "nextRun": "下次執行", "noAutomationsYet": "還沒有自動化", "noConversationForRun": "此執行未產生可閲讀的對話。", - "noRepositoriesHelp": "未選擇儲存庫時,此自動化會針對工作區的第一個儲存庫執行。", "noRunsMatchFilters": "沒有符合這些篩選條件的執行記錄。", "noRunsYet": "還沒有執行記錄", "noSchedule": "無計劃;此自動化不會自行執行。", @@ -113,6 +121,9 @@ "replyToThisRun": "回覆此執行…", "repositoryLabel": "儲存庫", "repositoryNone": "無;不使用儲存庫", + "repositoryModeTitle": "儲存庫存取權", + "repositoryModeDescription": "新增一個或多個儲存庫與基礎分支組合,或在沒有儲存庫的情況下執行。", + "repositoryModeNoneDescription": "在工作專屬的暫存工作區中執行,不使用儲存庫檔案。", "repositoryPlaceholderAuto": "自動", "required": "必填", "resizeRuns": "調整執行記錄區域大小", @@ -158,6 +169,8 @@ "skipped": "已跳過;{{reason}}", "skippedAlreadyRunning": "已跳過;已在執行", "starting": "正在啓動…", + "stopCurrentRun": "停止目前執行", + "stopping": "正在停止…", "stateIdle": "空閒", "statePaused": "已暫停", "stateRunning": "執行中", @@ -172,6 +185,15 @@ "taskTitleHelp": "留空以使用預設值。支援佔位符。", "taskTitleLabel": "任務標題", "taskTitlePlaceholder": "[自動] 自動化名稱", + "taskModeTitle": "執行目標", + "taskModeDescription": "選擇每次觸發只保留在自動化記錄中,還是建立普通任務。", + "taskModeAutomationRun": "只在自動化記錄中執行", + "taskModeAutomationRunDescription": "每次執行使用私人任務和對話。這些任務不會出現在看板或側邊欄。", + "taskModeNormalTask": "建立普通任務", + "taskModeNormalTaskDescription": "每次執行都會在選取的工作流程中建立普通任務。任務會出現在看板和側邊欄。", + "workflowRequiredLabel": "工作流程(必填)", + "selectWorkflowRequired": "選擇工作流程", + "normalTaskWorkflowHelp": "選擇工作流程,讓此任務出現在看板和側邊欄。", "thenDescription": "每次此自動化觸發時都會建立一個新任務", "thenTitle": "然後", "triggerCategoryGithub": "GitHub", @@ -203,8 +225,6 @@ "whenTitle": "當", "workflowLabel": "工作流程", "workflowLoadError": "無法載入工作流程。", - "workflowStepHelpNoWorkflow": "請先選擇工作流程,再選擇步驟。", - "workflowStepLabel": "工作流程步驟", "failedToLoadAutomationActivity": "載入自動化活動失敗", "failedToLoadAutomations": "載入自動化失敗", "failedToLoadRuns": "載入執行記錄失敗", diff --git a/apps/web/src/locales/zh-tw/automations.json b/apps/web/src/locales/zh-tw/automations.json index bb4085e9e5..abd0417918 100644 --- a/apps/web/src/locales/zh-tw/automations.json +++ b/apps/web/src/locales/zh-tw/automations.json @@ -32,6 +32,14 @@ "comingSoonSuffix": "(即將推出)", "completeRequiredFields": "請先填寫必填的自動化欄位,再儲存。", "completed": "已完成", + "contextBetweenRunsConcurrencyLock": "此選項一次只支援一個執行中的工作。", + "contextBetweenRunsDescription": "選擇每次執行是重新開始,還是繼續使用相同的對話和檔案。", + "contextBetweenRunsNewTask": "每次執行都開始新工作", + "contextBetweenRunsNewTaskDescription": "每次執行都會使用獨立的對話和檔案。這些工作不會出現在看板或側邊欄。此選項適用於互相獨立的工作和並行執行。", + "contextBetweenRunsNewTaskNormalDescription": "每次執行都會使用獨立的對話和檔案。這些工作會出現在看板和側邊欄。此選項適用於互相獨立的工作和並行執行。", + "contextBetweenRunsReuseThread": "繼續之前的工作階段", + "contextBetweenRunsReuseThreadDescription": "執行會繼續使用相同的對話和檔案,讓代理保留之前的上下文和變更。執行會依次進行。", + "contextBetweenRunsTitle": "執行之間的上下文", "configurationLabel": "設定", "couldNotLoadWorkflows": "無法載入工作流程", "couldNotRefreshRuns": "無法重新整理執行記錄;已從本地快取恢復", @@ -68,6 +76,7 @@ "failedToDeleteRuns": "刪除執行記錄失敗", "failedToLoadWebhookSecret": "載入 webhook 金鑰失敗:{{error}}", "failedToSaveAutomation": "儲存自動化失敗:{{error}}", + "failedToStopRun": "無法停止此執行:{{error}}", "hasNotRunYet": "此自動化尚未執行。", "hideRunDetail": "隱藏執行詳情", "instructionsLabel": "說明", @@ -87,7 +96,6 @@ "nextRun": "下次執行", "noAutomationsYet": "還沒有自動化", "noConversationForRun": "此執行未產生可閱讀的對話。", - "noRepositoriesHelp": "未選擇儲存庫時,此自動化會針對工作區的第一個儲存庫執行。", "noRunsMatchFilters": "沒有符合這些篩選條件的執行記錄。", "noRunsYet": "還沒有執行記錄", "noSchedule": "無計劃;此自動化不會自行執行。", @@ -113,6 +121,9 @@ "replyToThisRun": "回覆此執行…", "repositoryLabel": "儲存庫", "repositoryNone": "無;不使用儲存庫", + "repositoryModeTitle": "儲存庫存取權", + "repositoryModeDescription": "新增一個或多個儲存庫與基礎分支組合,或在沒有儲存庫的情況下執行。", + "repositoryModeNoneDescription": "在任務專屬的暫存工作區中執行,不使用儲存庫檔案。", "repositoryPlaceholderAuto": "自動", "required": "必填", "resizeRuns": "調整執行記錄區域大小", @@ -158,6 +169,8 @@ "skipped": "已跳過;{{reason}}", "skippedAlreadyRunning": "已跳過;已在執行", "starting": "正在啟動…", + "stopCurrentRun": "停止目前執行", + "stopping": "正在停止…", "stateIdle": "空閒", "statePaused": "已暫停", "stateRunning": "執行中", @@ -172,6 +185,15 @@ "taskTitleHelp": "留空以使用預設值。支援佔位符。", "taskTitleLabel": "任務標題", "taskTitlePlaceholder": "[自動] 自動化名稱", + "taskModeTitle": "執行目標", + "taskModeDescription": "選擇每次觸發只保留在自動化記錄中,還是建立普通任務。", + "taskModeAutomationRun": "只在自動化記錄中執行", + "taskModeAutomationRunDescription": "每次執行使用私人任務和對話。這些任務不會出現在看板或側邊欄。", + "taskModeNormalTask": "建立普通任務", + "taskModeNormalTaskDescription": "每次執行都會在選取的工作流程中建立普通任務。任務會出現在看板和側邊欄。", + "workflowRequiredLabel": "工作流程(必填)", + "selectWorkflowRequired": "選擇工作流程", + "normalTaskWorkflowHelp": "選擇工作流程,讓此任務出現在看板和側邊欄。", "thenDescription": "每次此自動化觸發時都會建立一個新任務", "thenTitle": "然後", "triggerCategoryGithub": "GitHub", @@ -203,8 +225,6 @@ "whenTitle": "當", "workflowLabel": "工作流程", "workflowLoadError": "無法載入工作流程。", - "workflowStepHelpNoWorkflow": "請先選擇工作流程,再選擇步驟。", - "workflowStepLabel": "工作流程步驟", "failedToLoadAutomationActivity": "載入自動化活動失敗", "failedToLoadAutomations": "載入自動化失敗", "failedToLoadRuns": "載入執行記錄失敗", diff --git a/docs/decisions/2026-08-22-user-configured-automation-continuity.md b/docs/decisions/2026-08-22-user-configured-automation-continuity.md new file mode 100644 index 0000000000..8e415ed8dc --- /dev/null +++ b/docs/decisions/2026-08-22-user-configured-automation-continuity.md @@ -0,0 +1,149 @@ +# ADR-2026-08-22: Let Users Configure Continuity, Not MCP Authority + +**Status:** accepted +**Date:** 2026-08-22 +**Area:** backend, agentctl, frontend, protocol, security, workflow +**Related ADRs:** [MCP tool profiles](2026-08-08-mcp-tool-profiles.md), [task-owned worktree lifetime](2026-08-08-task-owned-worktree-lifetime.md), [live agent permission authority](2026-08-11-live-agent-permission-authority.md) +**Related specs:** [Automations in Settings](../specs/office/automations-settings.md), [Automation Runs](../specs/office/automation-runs.md) + +## Context + +Each automation firing currently creates a task, session, and task-owned worktree. This is a good +default for independent reports, but it is expensive and semantically wrong for heartbeat agents +that repeatedly inspect and coordinate the same workspace. Those agents benefit from one durable +conversation and worktree across scheduled firings. + +Coordinator agents also need Kandev administration operations. Pending-question and live permission +tools already exist on the authenticated external MCP surface, but ordinary task sessions do not +receive them. The current external surface is not a suitable template: it includes workflow, agent, +executor, and MCP configuration mutation plus permanent task deletion, but omits coordinator +actions such as messaging and restarting task sessions. + +## Decision + +1. An automation stores a user-selected `continuation_policy`: + - `new_task` is the default and preserves the current behavior; + - `reuse_thread` reuses one automation-owned task, primary session, task environment, and + worktree across firings. +2. Every accepted or skipped firing still creates a distinct `AutomationRun`. A dispatched run is + bound to its exact `task_id`, `session_id`, and `turn_id`. Completion and summary projection use + the turn identity, never "the latest run for this task." +3. `reuse_thread` permits one scheduled turn at a time. Its effective and persisted + `max_concurrent_runs` is 1, enforced by backend validation and atomic admission. The UI explains + and locks that value while the policy is selected. +4. The automation stores its current continuation task as runtime state. The first firing creates + it. Later firings prompt its primary session through the normal resumable task path. If the task, + session, runtime, or task environment cannot be resumed, Kandev creates and binds a replacement + thread, runs the firing there, and records `created`, `resumed`, or `replaced` plus a safe reason + on the run. + The agent remains responsible for its normal in-session context compaction. Kandev does not + rotate a healthy thread because of age or turn count. The non-native fallback resume prompt uses + only the newest 50 non-empty user or assistant text messages. Tool calls and tool results do not + appear in the prompt and do not consume the limit. Kandev keeps the selected messages in + chronological order and retains the existing per-message truncation. The new automation prompt + appears separately as the current request and does not consume the limit. +5. The create and edit forms expose the continuation choice directly. The section and both choices + have persistent help text that explains conversation, files, isolation, and single-run behavior. + Changes to launch identity rotate the continuation on the next firing. Launch identity includes + agent profile, executor profile, repositories, workflow, and workflow step. Name, description, + prompt, and trigger changes do not rotate it. The next prompt uses the newly saved value. +6. Add `SurfaceAutomation` as a backend-owned MCP base surface. Every automation session receives + the same versioned tool profile; there is no per-automation MCP checkbox list or arbitrary tool + allowlist. +7. The automation surface contains workspace discovery, task discovery, task creation and + coordination, session recovery, pending-question resolution, and live permission resolution: + `list_workspaces_kandev`, `list_workflows_kandev`, `list_workflow_steps_kandev`, + `list_repositories_kandev`, `list_tasks_kandev`, `list_agents_kandev`, + `list_executors_kandev`, `list_executor_profiles_kandev`, `list_related_tasks_kandev`, + `get_task_conversation_kandev`, + `list_task_sessions_kandev`, `create_task_kandev`, `update_task_kandev`, `move_task_kandev`, + `archive_task_kandev`, `add_task_dependency_kandev`, `remove_task_dependency_kandev`, + `message_task_kandev`, `stop_task_kandev`, `spawn_session_kandev`, + `list_pending_questions_kandev`, `answer_question_kandev`, + `list_pending_agent_permissions_kandev`, and `resolve_agent_permission_kandev`. +8. The surface deliberately excludes workflow/agent/executor/MCP configuration writes, permanent + task deletion, task-local planning and walkthrough tools, user/parent questions, reviews, rich + output, branch/source mutation, step completion, title ownership, diagnostics, and provider PR/MR + automation. Arbitrary plugin tools are not loaded into this base surface. Autonomous + coordination must not silently become install configuration, external-account authority, or + permanent data destruction. +9. In-session dispatch resolves one trusted automation MCP principal from the execution's own task + and session before any tool handler runs. The principal carries the automation ID, workspace ID, + caller task ID, caller session ID, and surface. One shared authorization boundary constrains + every included workspace, workflow, task, session, question, permission, and newly-created task; + handlers do not independently derive owner-wide scope. Missing, malformed, or foreign scope + fails closed. The agent cannot request another surface or widen it with tool arguments. +10. Permission-resolution audit records identify an automation actor and an `automation_mcp` source. + The source is derived from trusted in-session transport context. It is not accepted from the + tool request and does not impersonate an interactive user or the external MCP bridge. +11. The automation's own task and every session on it are invalid targets for task/session + mutations, messaging, stopping, session spawning, and question or permission discovery or + resolution. An automation cannot inspect or approve its own blocker through the coordinator + surface or create concurrent sessions in its reused worktree. A session spawned on another task + receives the target task's normal MCP profile, never `SurfaceAutomation` inherited from the + coordinator. +12. The existing `triggered` run status is the durable admitted-but-not-yet-bound state. Both + `triggered` and `task_created` count as open and display under Running. No duplicate + `dispatching` status is introduced. +13. Each run stores its own rendered display title. Creating or replacing a thread also uses that + title for the new task; resuming a thread does not rename the shared task. Run history therefore + preserves trigger-specific titles without rewriting older entries. +14. Kandev does not reset, rebase, or otherwise rewrite a reused worktree between firings. Agent + work and local changes remain intact. A replacement thread starts from the repository's current + configured base branch. +15. Deleting an automation owns cleanup of every distinct hidden task it references, including its + current continuation. The delete path stops live sessions, removes the automation and run + references, and reclaims each now-unreferenced task/worktree through normal task cleanup. The + deletion transaction first inserts one durable `automation_task_cleanup_jobs` row per distinct + task. Jobs are deleted after cleanup succeeds and otherwise retried by orphan reconciliation. +16. Portable automation export includes `continuation_policy`. It excludes the fixed MCP profile, + continuation task pointers, and run bindings because they are product/runtime behavior rather + than per-automation portable configuration. + +## Consequences + +- Independent automations remain backward compatible and continue to receive a fresh task per run. +- Heartbeat automations retain conversation context and avoid repeated task environment setup. +- A shared transcript remains readable as run history because each run has a durable turn anchor. +- Run deletion and worktree retention must operate on distinct task identities. Deleting one run + cannot delete a task still referenced by another run or by the automation continuation pointer. +- Automation agents get one predictable, autonomous coordinator surface without settings clutter. +- The question and permission services gain a workspace-bound automation caller path without + weakening their existing external-client authorization or live-runtime authority. +- Evolving the automation surface is a versioned product decision that requires registry and + authorization tests; individual users cannot accidentally omit a required discovery half or add + a dangerous configuration tool. +- A continuation automation cannot run concurrent scheduled turns. Users who need concurrency use + `new_task`. +- Agent-side compaction bounds provider context; Kandev still retains the durable transcript. This + is the same persistence policy as ordinary tasks and is not a reason to rotate a healthy thread. +- Tool activity cannot displace conversation from fallback context because only text messages + consume the 50-message limit. +- Automation deletion becomes a lifecycle operation rather than a single cascading row delete. + +## Alternatives Considered + +1. **Always reuse the previous task.** Rejected because independent reports need isolated history, + branches, and failures, and the user explicitly needs control of the behavior. +2. **Reuse only the agent process while creating a new task and worktree.** Rejected because task + environments own worktrees and conversation continuity belongs to the durable task/session, not + to a replaceable process. +3. **Create a new session in one task for every firing.** Rejected because it loses working memory + and still permits concurrent sessions to mutate one shared worktree. Token cost is + provider-dependent and is not the deciding guarantee. +4. **Give coordinator automations the whole external MCP surface.** Rejected because the external + surface includes unrelated configuration authority, permanent deletion, and the wrong mix of + task coordination operations. +5. **Expose per-automation MCP capability checkboxes.** Rejected because pending discovery and + resolution are core coordinator behavior, not an expert configuration matrix. Checkboxes can + produce incomplete profiles, increase support states, and imply that unsafe system-configuration + tools are reasonable opt-ins. +6. **Let the prompt or agent request arbitrary tool names.** Rejected because MCP discovery is a + backend capability boundary. Agents cannot grant themselves tools. +7. **Fail the firing when a saved thread cannot resume.** Rejected because a deleted or incompatible + session would permanently jam a heartbeat. A recorded fresh-thread fallback preserves service + while making the loss of continuity observable. +8. **Rotate every reusable thread after a fixed age or turn count.** Rejected because supported + agents compact their live context and rotation discards useful working memory without reducing + retained database history. Kandev bounds only its fallback resume prompt and replaces a thread + when resume actually fails. diff --git a/docs/decisions/2026-08-23-automation-target-modes.md b/docs/decisions/2026-08-23-automation-target-modes.md new file mode 100644 index 0000000000..ae89acacb2 --- /dev/null +++ b/docs/decisions/2026-08-23-automation-target-modes.md @@ -0,0 +1,87 @@ +# ADR-2026-08-23: Give Automations Explicit Hidden and Visible Task Targets + +**Status:** accepted +**Date:** 2026-08-23 +**Area:** backend, frontend, workflow, protocol +**Related ADRs:** [User-configured automation continuity](2026-08-22-user-configured-automation-continuity.md), [Task-owned worktree lifetime](2026-08-08-task-owned-worktree-lifetime.md) +**Related specs:** [Automation target modes](../specs/office/requirements/automation-target-modes.md), [Automation target modes design](../specs/office/system-design/automation-target-modes.md) + +## Context + +The current automation contract treats every firing as hidden work. That is +appropriate for coordinator agents, but it prevents a scheduled action from +entering a selected workflow as ordinary Kanban work. It also rejects a valid +repository-free automation even though the agent lifecycle already supports a +task-owned scratch workspace. A first-workspace-repository fallback is also +ambiguous: it does not record user intent, cannot record a base branch, and +changes behavior when repository ordering changes. + +The hidden task contract and the normal task contract have different authority +and cleanup rules. A visible task must not receive the coordinator MCP profile, +and deleting its automation must not delete the task that a person expects to +find in the sidebar. + +## Decision + +1. Persist `task_mode` with `automation_run` as the compatibility default and + `normal_task` as the explicit visible target. +2. Persist an ordered list of explicit repository and base-branch pairs. An + empty list means intentional repository-free execution. There is no + workspace-default or first-repository behavior. Compatibility ID-only + requests use each named repository's configured default branch; an empty + compatibility request remains empty. +3. A repository-free automation uses a task-owned scratch workspace. Worktree + and Local-compatible executor profiles remain valid choices. A Worktree + profile does not create a Git worktree when no repository is attached. +4. Hidden target mode keeps the fixed `SurfaceAutomation` profile and hidden + `automation_run` origin. Normal-task mode requires a workflow, uses a + visible `automation_task` origin, and receives the normal task profile and + lifecycle. The automation editor does not choose a workflow step; normal + task creation resolves the workflow's configured start step. +5. The existing `new_task` and `reuse_thread` policy applies to both target + modes. A reusable visible target continues one visible task and primary + session, while an isolated visible firing creates a separate visible task. + Every firing remains an exact `AutomationRun`. +6. Hidden tasks remain owned by automation cleanup. Visible normal tasks remain + ordinary task records when an automation is disabled or deleted. Open run + records are terminalized without deleting the visible task. +7. The automation editor composes the repository/base-branch, workflow, + agent-profile, and executor-profile selectors from task creation. Search, + logos, workflow previews, availability rules, and mobile behavior therefore + have one implementation across both surfaces. + +## Consequences + +- Users can run reports and coordination prompts without a repository or + workflow, even when Worktree is the only available executor profile. +- Users can choose whether a scheduled firing becomes ordinary Kanban work or + remains a background automation run. +- Target mode, repository mode, continuation compatibility, and exact-run + finalization become persisted contracts that require migrations and tests. +- The run dispatcher must keep hidden and visible task origins separate while + sharing admission, continuation, and exact identity logic. +- Existing empty repository rows become explicit no-repository automations. + Existing selected repositories keep their order and receive a concrete base + branch during migration. +- Visible tasks remain after automation deletion, so the automation UI must not + promise that deleting an automation deletes all work it created. + +## Alternatives Considered + +1. **Reuse the withdrawn `execution_mode` column.** Rejected because that + column describes a retired behavior and is intentionally excluded from the + current Go model. A new typed target makes the contract explicit and keeps + migration-only data separate. +2. **Treat an empty `repository_ids` list as no repository.** Rejected because + existing automations use an empty list for the workspace-first fallback. + `repository_mode` preserves both meanings without guessing from history. +3. **Keep visible tasks on the hidden `automation_run` origin.** Rejected + because board/sidebar queries and coordinator authorization use that origin + as a hidden boundary. A distinct visible provenance keeps the security and + visibility rules auditable. +4. **Force visible tasks to use `new_task`.** Rejected because a user may want + a visible recurring task that keeps one conversation and task environment. +5. **Require Local for repository-free execution.** Rejected because the + lifecycle already gives repository-free sessions a task-owned scratch + directory. Requiring another configured executor would block valid + automations without improving isolation. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index af12bbff1d..544f10185c 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -192,6 +192,8 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-08-20-acp-client-non-underscore-extension-methods | [Route Non-Underscore Inbound Client Methods to the Extension Handler](2026-08-20-acp-client-non-underscore-extension-methods.md) | accepted | backend, protocol | 2026-08-20 | | 2026-08-22-persistent-fork-approval-labels | [Persist Fork Approval Labels Across Pushes](2026-08-22-persistent-fork-approval-labels.md) | accepted (amended by 2026-08-24-unified-fork-approval-label) | infra, workflow, security | 2026-08-22 | | 2026-08-22-plugin-version-retention | [Keep Exactly One Superseded Plugin Version](2026-08-22-plugin-version-retention.md) | accepted | backend | 2026-08-22 | +| 2026-08-22-user-configured-automation-continuity | [Let Users Configure Continuity, Not MCP Authority](2026-08-22-user-configured-automation-continuity.md) | accepted | backend, agentctl, frontend, protocol, security, workflow | 2026-08-22 | +| 2026-08-23-automation-target-modes | [Give Automations Explicit Hidden and Visible Task Targets](2026-08-23-automation-target-modes.md) | accepted | backend, frontend, protocol, workflow | 2026-08-23 | | 2026-08-22-preserve-newest-bounded-backend-logs | [Preserve Newest Bounded Backend Logs](2026-08-22-preserve-newest-bounded-backend-logs.md) | accepted | backend, infra, operations | 2026-08-22 | | 2026-08-12-plugin-webhook-auth-gate | [Require Auth for Plugin Webhooks Unless the Manifest Declares Them Public](2026-08-12-plugin-webhook-auth-gate.md) | accepted | backend, frontend, security | 2026-08-12 | | 2026-08-22-system-oriented-specifications | [Organize specifications by system](2026-08-22-system-oriented-specifications.md) | accepted | workflow, infra | 2026-08-22 | diff --git a/docs/plans/automation-continuation-and-coordinator-access/plan.md b/docs/plans/automation-continuation-and-coordinator-access/plan.md new file mode 100644 index 0000000000..bc3957304e --- /dev/null +++ b/docs/plans/automation-continuation-and-coordinator-access/plan.md @@ -0,0 +1,227 @@ +--- +spec: docs/specs/office/requirements/automation-continuity.md +decision: docs/decisions/2026-08-22-user-configured-automation-continuity.md +created: 2026-08-22 +status: completed +--- + +# Implementation Plan: Automation Continuation and Coordinator Access + +## Overview + +Add one explicit automation setting: create an isolated task for each firing or continue one task, +primary session, task environment, and worktree. Every firing remains a distinct `AutomationRun` +bound to an exact turn. Automation sessions receive a fixed, workspace-scoped coordinator MCP +surface whose trusted principal is resolved once before handler dispatch. + +Agent-side compaction remains responsible for live provider context. Kandev bounds only its +non-native fallback resume prompt to the newest 50 user or assistant text messages. Tool calls and +tool results do not appear and do not consume the limit. The design also +closes the reviewed lifecycle gaps: self-targeting, stale open runs, automation deletion, run-title +snapshots, registration decomposition, and shared-worktree concurrency. + +## Backend + +### Persistence and run contracts + +Extend `apps/backend/internal/automation/models.go` and `store.go` with +`continuation_policy`, `continuation_task_id`, exact `session_id`/`turn_id` bindings, +`thread_action`, `thread_reason`, and `display_title`. Reuse the existing `triggered` status for an +admitted but unbound run. The shared open-run predicate counts both `triggered` and `task_created`; +legacy unbound `triggered` rows reconcile to failed instead of remaining open. + +Render `display_title` before admission. A newly created or replacement task receives that title; +a resumed shared task is not renamed. YAML exports only `continuation_policy` and excludes runtime +pointers, bindings, titles, and the fixed MCP profile. + +### Reusable dispatch and recovery + +Update `apps/backend/internal/automation/service.go` and +`apps/backend/internal/orchestrator/event_handlers_automation.go` so admission writes the run before +event publication and the event carries the run ID. Dispatch returns the exact +`{task_id, session_id, turn_id}` and records `created`, `resumed`, or `replaced`. + +Serialize admission and continuation binding per automation. Reconcile an open bound run to failed +when its exact turn has no live execution, open turn, or pending blocker. Add a backend action that +stops the exact run/session/turn, marks only that run failed, and releases its slot. A genuinely +live or blocked turn remains open until it completes or a person stops it. + +### Bounded fallback resume history + +Change `apps/backend/internal/agent/runtime/lifecycle/session_history.go` so +`GenerateResumeContext` selects the newest 50 non-empty `user_message` and `agent_message` entries. +It excludes tool calls, tool results, status events, and unknown types before selection. It restores +message order and applies the current per-message truncation. The new prompt stays outside the +window. Native session continuation and agent compaction remain unchanged. Add boundary tests for +49, 50, and 51+ messages, heavy intervening tool activity, ordering, and unchanged history files. + +### Automation MCP principal and tool catalog + +Add `SurfaceAutomation` to `apps/backend/internal/mcp/profile/profile.go`. Decompose +`registerKanbanTools` and configuration registration into small reusable groups so the automation +catalog can include the named tools without `delete_task_kandev`, configuration mutation, +task-local authoring, provider PR/MR, diagnostics, or plugin tools. Pin all base-surface catalogs to +prevent regressions. + +Extend `apps/backend/internal/mcp/scope` and the lifecycle stream wrapper to resolve one trusted +automation principal before MCP dispatch. It carries automation/workspace/caller task/caller +session/surface identity and is the single workspace boundary used by the action dispatcher and +handlers. Owner identity remains necessary but is not sufficient for automation calls. + +Reject the automation's own hidden task and every session on it for mutations, messaging, stopping, +spawning, and blocker discovery or resolution. Cross-task spawning uses the target task's normal +profile; it never inherits `SurfaceAutomation` from the caller. Permission audit derives the +automation actor and `automation_mcp` source from the trusted principal. + +### Shared-run lifecycle and cleanup + +Project summaries by exact turn and preserve the legacy task-based fallback only for rows without a +binding. Retention counts distinct task IDs and protects the current continuation. Run deletion and +delete-all remain reference-aware. + +Replace row-only automation deletion with service-owned lifecycle cleanup: prevent new admission, +capture distinct referenced task IDs plus `continuation_task_id`, stop live sessions, delete the +automation/run records, and pass each now-unreferenced hidden task through normal task/worktree +cleanup. Insert one durable `automation_task_cleanup_jobs` row per distinct task in the deletion +transaction; delete the job only after task/worktree cleanup succeeds. Startup and normal orphan +reconciliation retry remaining jobs. The workspace reset path reuses the same ownership logic. + +Kandev does not reset or rebase a reused checkout. Replacement creates a fresh task environment +from the repository's current configured base branch. + +## Frontend + +### Automation editor + +The create and edit forms add a visible Context between runs radio group. `new_task` is selected by +default; `reuse_thread` locks concurrency at 1. The section, both options, and the concurrency lock +use the exact visible descriptions from the spec. Descriptions are linked to the applicable heading +or radio control and do not require hover or focus. There is no MCP capability UI. + +### Automation detail and run transcript + +Thread `display_title`, exact turn identity, and the `triggered` Running state through API types and +run components. Selecting two runs that share a session still focuses different turns. Both +`triggered` and `task_created` appear in the Running group/filter. + +Expose Stop current run for a selected open run. It calls the exact-run cancellation action and +keeps its busy/error state local to that run. + +## Mobile design contract + +- Desktop outcome: the selected open run has a visible Stop current run action beside its live + status. Mobile entry point: the same action appears in the selected-run drawer/content header. +- Nearest exemplar: the existing automation detail mobile drawer for run selection; retain its + hierarchy and use its selected-run content header for the action. +- Presentation: keep run navigation in the existing inset bottom drawer and transcript as the + primary surface. Do not introduce a second confirmation drawer; use the existing destructive + confirmation primitive if cancellation requires confirmation. +- Scrolling and touch: the detail page remains the single main scroll owner, drawer content keeps + its internal scroll, the action is at least 44 px, and document horizontal overflow remains zero. +- Shared logic: desktop and mobile use the same selected-run identity, cancellation mutation, busy + state, and error mapping. +- Mobile proof: open a running reusable automation, stop its exact run, observe Failed and an + enabled next firing, and assert viewport containment and no horizontal overflow. + +## Tests + +- Automation store/service tests: policy defaults, `triggered` admission, exact bindings, + `display_title`, open predicates, reconciliation, deletion ownership, and export exclusions. +- Orchestrator tests: first creation, resume, fallback replacement, exact-turn completion, stale + bound-run repair, exact-run stop, and no shared-task rename. +- Lifecycle tests: newest-50 user/assistant messages, exclusion of tool events, chronological order, + message truncation, current-request placement, and source-history preservation. +- MCP profile/server/handler tests: exact catalogs, split-registration regressions, principal + derivation, same/foreign/self target matrices, non-forgeable audit, and target-profile spawning. +- Frontend tests: create/edit policy payloads, visible and accessible descriptions, concurrency + lock, run-title rendering, `triggered` filtering, shared-session turn selection, and exact-run + stop terminal paths. + +## E2E tests + +- Desktop: read both continuity descriptions, create a reusable automation, fire it twice, verify + one task/session with distinct turns and titles, then stop an open exact run. +- Mobile: read both continuity descriptions, create/edit continuity, select a shared-session run, + stop it through the visible touch action, and verify no horizontal overflow. +- Backend integration: fixed catalog, self-target denial, foreign-workspace denial, target-profile + spawning, stale-run reconciliation, and automation deletion cleanup. + +## Public documentation + +Implementation updates `docs/public/automation-and-mcp.md`, +`docs/public/agents-and-profiles.md`, `docs/public/websocket-api.md`, and +`docs/public/feature-status.md`. Document continuity choices, agent-managed compaction, the +newest-50 message bound, tool-event exclusion, workspace/self boundaries, exact-run recovery, fixed +MCP authority, and non-destructive reused-worktree behavior. + +## Implementation waves and parallel candidates + +Wave 1 (parallel candidates; user authorization required): + +- [x] [Task 01: Persist continuation and run contracts](task-01-persist-continuation-policy.md) +- [x] [Task 03: Bound fallback resume history](task-03-bound-fallback-resume-history.md) + +Wave 2 (parallel candidates after Task 01; user authorization required): + +- [x] [Task 02: Dispatch reusable automation turns](task-02-dispatch-reusable-turns.md) +- [x] [Task 04: Define automation MCP surface](task-04-define-automation-mcp-surface.md) +- [x] [Task 06: Add continuation control](task-06-add-continuation-control.md) + +Wave 3: + +- [x] [Task 05: Maintain shared run lifecycle](task-05-maintain-shared-run-lifecycle.md) +- [x] [Task 07: Anchor and control shared runs](task-07-anchor-and-control-shared-runs.md) + +Wave 4: + +- [x] [Task 08: Validate coordinator automations](task-08-prove-and-document-coordinators.md) + +The primary conversation executes sequentially unless the user explicitly authorizes subagents. + +## Verification + +```bash +cd apps/backend && go test -tags fts5 ./internal/automation ./internal/orchestrator +cd apps/backend && go test ./internal/agent/runtime/lifecycle +cd apps/backend && go test -tags fts5 ./internal/mcp/profile ./internal/mcp/scope ./internal/mcp/server ./internal/mcp/handlers +cd apps && pnpm install --frozen-lockfile +cd apps && pnpm --filter @kandev/web test -- --run components/automations components/runs lib/api/domains/automation-runs-api.test.ts +cd apps/web && pnpm run typecheck +cd apps/web && pnpm run i18n:check +cd apps/web && pnpm run i18n:ratchet +cd apps/web && pnpm e2e:run tests/automations-settings.spec.ts tests/automations-run-detail.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome --no-build tests/mobile-automations-scroll.spec.ts tests/mobile-automation-detail.spec.ts +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +## Risks + +- A completion or stop action without exact run/turn identity can settle the wrong shared run. +- A missing dispatch-level principal can fall back to owner-wide workspace reach. +- Allowing self-target mutations can bypass permission authority or create concurrent sessions in + the reused checkout. +- Fallback history filtering in the wrong order can return the oldest messages or let tool events + displace conversation from the 50-message window. +- Deleting database references before capturing task ownership can orphan hidden worktrees. +- Automatically rewriting a reused checkout can destroy work that continuity promises to retain. + +## Out of scope + +- Concurrent scheduled turns or additional sessions on the reusable automation task. +- Age/turn-based rotation of a healthy compacting agent session. +- Per-automation MCP capability settings, arbitrary tool allowlists, or the full external surface. +- Cross-workspace coordinator authority or self-approval. +- Automatic reset/rebase of reused worktrees. +- Automation-specific transcript pruning; transcript retention remains a product-wide task policy. +- Reconstructing live provider permission prompts after runtime loss. +- Making `AutomationRun` the direct owner of sessions or worktrees. + +## Verification results + +- Backend: automation/orchestrator 2,452 tests; lifecycle 1,947 tests; MCP/profile/scope/server/handlers 877 tests; backendapp 466 tests. +- Frontend: focused automation/run/API tests 308 tests; transcript/detail tests 32 tests; typecheck passed. +- Internationalization and public docs checks passed, including the new-code ratchet. +- Desktop E2E passed 24 tests; mobile E2E passed 10 tests. +- `pnpm install --frozen-lockfile`, `git diff --check`, and public-doc validation passed. diff --git a/docs/plans/automation-continuation-and-coordinator-access/task-01-persist-continuation-policy.md b/docs/plans/automation-continuation-and-coordinator-access/task-01-persist-continuation-policy.md new file mode 100644 index 0000000000..d4dddf7dbc --- /dev/null +++ b/docs/plans/automation-continuation-and-coordinator-access/task-01-persist-continuation-policy.md @@ -0,0 +1,75 @@ +--- +id: "01-persist-continuation-policy" +title: "Persist continuation and run contracts" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/office/requirements/automation-continuity.md" +--- + +# Task 01: Persist Continuation and Run Contracts + +## Acceptance + +- Automation create, update, get, and list carry validated `continuation_policy`; existing rows + default to `new_task`, and `reuse_thread` requires concurrency 1. +- Runs persist exact session/turn, thread outcome, and `display_title`; the existing `triggered` + status represents admitted-but-unbound work and counts as open with `task_created`. +- YAML exports `continuation_policy` but excludes continuation pointers, run bindings, run titles, + and the fixed MCP profile. + +## TDD scenarios + +1. RED: Add SQLite/Postgres migration and round-trip tests for policy, pointer, bindings, thread + fields, and title snapshots. +2. RED: Add service/store tests for invalid policy/concurrency and the shared open predicate over + `triggered` plus `task_created`. +3. RED: Extend export disposition, deterministic order, runtime exclusion, and literal fixtures. +4. GREEN: Add typed fields, migrations, request validation, run scans, and export DTO changes. +5. REFACTOR: Keep policy parsing and open-status ownership in one automation package location. + +## Verification + +- `cd apps/backend && go test -tags fts5 ./internal/automation` +- `git diff --check` + +## Files likely touched + +- `apps/backend/internal/automation/models.go` +- `apps/backend/internal/automation/store.go` +- `apps/backend/internal/automation/store_test.go` +- `apps/backend/internal/automation/store_runs_test.go` +- `apps/backend/internal/automation/store_summaries_test.go` +- `apps/backend/internal/automation/service.go` +- `apps/backend/internal/automation/service_test.go` +- `apps/backend/internal/automation/export.go` +- `apps/backend/internal/automation/export_service.go` +- `apps/backend/internal/automation/export_ac22_test.go` +- `apps/backend/internal/automation/export_document_test.go` + +## Dependencies + +None. + +## Inputs + +- Data model, API, and state-machine sections in the automation settings spec. +- Export requirements in `docs/specs/automations-yaml-export/spec.md`. + +## Parallelism + +Parallel-safe with Task 03 only: this task owns automation persistence/export files; Task 03 owns +agent lifecycle history files. + +## Output contract + +Report migrations, invalid cases, open-status results, export bytes, files changed, and exact tests. + +## Risks + +- Treating only `task_created` as open can admit a second run while the first is still unbound. + +## Results + +Implemented continuation policy defaults/validation, admitted `triggered` runs, exact task/session/turn bindings, title snapshots, thread actions/reasons, reference-aware deletion, and export exclusions. Verified with the automation package suite: 389 tests passed. diff --git a/docs/plans/automation-continuation-and-coordinator-access/task-02-dispatch-reusable-turns.md b/docs/plans/automation-continuation-and-coordinator-access/task-02-dispatch-reusable-turns.md new file mode 100644 index 0000000000..84fe5cd7a7 --- /dev/null +++ b/docs/plans/automation-continuation-and-coordinator-access/task-02-dispatch-reusable-turns.md @@ -0,0 +1,77 @@ +--- +id: "02-dispatch-reusable-turns" +title: "Dispatch reusable automation turns" +status: completed +wave: 2 +depends_on: ["01-persist-continuation-policy"] +plan: "plan.md" +spec: "../../specs/office/requirements/automation-continuity.md" +--- + +# Task 02: Dispatch Reusable Automation Turns + +## Acceptance + +- Every firing owns a `triggered` run before publication; reuse mode admits at most one active turn + and binds the exact accepted task/session/turn. +- First creation, normal resume, launch-identity replacement, and non-resumable fallback record the + correct thread action without renaming a reused task. +- Reconciliation fails an exact bound run that has no execution, open turn, or pending blocker; + exact-run stop cancels only its bound turn and releases the slot. + +## TDD scenarios + +1. RED: Race two firings and prove one reusable turn plus one visible concurrency skip. +2. RED: Cover creation, resume, fallback classes, title behavior, and launch-identity rotation. +3. RED: Complete two bound turns in one task and prove terminal events update only their run; prove + a manual reply changes no run. +4. RED: Cover pre-dispatch restart repair, dead bound-run repair, live blocker preservation, and + exact-run cancellation. +5. GREEN: Add atomic admission, run-ID events, reusable dispatch, exact binding/finalization, and + recovery action. +6. REFACTOR: Keep fresh/reuse branches behind one typed dispatch result and exact-run owner. + +## Verification + +- `cd apps/backend && go test -tags fts5 ./internal/automation ./internal/orchestrator` +- `git diff --check` + +## Files likely touched + +- `apps/backend/internal/automation/service.go` +- `apps/backend/internal/automation/store.go` +- `apps/backend/internal/automation/scheduler_test.go` +- `apps/backend/internal/automation/handlers.go` +- `apps/backend/internal/automation/handlers_test.go` +- `apps/backend/pkg/websocket/actions.go` +- `apps/backend/internal/orchestrator/event_handlers_automation.go` +- `apps/backend/internal/orchestrator/event_handlers_automation_test.go` +- `apps/backend/internal/orchestrator/event_handlers_streaming.go` +- `apps/backend/internal/orchestrator/task_operations.go` + +## Dependencies + +Task 01 defines the policy, exact binding, title, and open-status contracts. + +## Inputs + +- Runtime state machine, failure modes, and exact-run recovery scenarios. +- Existing `StartTask`, `PromptTask`, cancellation, and startup-reconciliation paths. + +## Parallelism + +Parallel-safe with Tasks 04 and 06 after Task 01: this task owns automation/orchestrator firing +paths, Task 04 owns MCP scope/registration, and Task 06 owns frontend editor files. + +## Output contract + +Report race ownership, bindings, fallback reasons, title behavior, reconciliation, cancellation, +files changed, and exact test output. + +## Risks + +- A task- or session-only terminal lookup can settle the wrong run in a shared thread. + +## Results + +Implemented reusable session dispatch, replacement on missing or incompatible launch identity, exact-turn completion, and no-reset/no-rebase worktree behavior. Verified with the orchestrator suite: 2,060 tests passed. diff --git a/docs/plans/automation-continuation-and-coordinator-access/task-03-bound-fallback-resume-history.md b/docs/plans/automation-continuation-and-coordinator-access/task-03-bound-fallback-resume-history.md new file mode 100644 index 0000000000..ea50e9890e --- /dev/null +++ b/docs/plans/automation-continuation-and-coordinator-access/task-03-bound-fallback-resume-history.md @@ -0,0 +1,70 @@ +--- +id: "03-bound-fallback-resume-history" +title: "Bound fallback resume history" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/office/requirements/automation-continuity.md" +--- + +# Task 03: Bound Fallback Resume History + +## Acceptance + +- `GenerateResumeContext` includes at most the newest 50 non-empty user or assistant text messages, + in chronological order with existing per-message truncation. +- Tool calls, tool results, status events, unknown types, and contentless messages are excluded + before selection. They do not appear and do not consume the limit. +- The new prompt remains a separate current request and does not consume the message limit. +- Generating fallback context does not delete or rewrite durable history, and native ACP resume and + agent-managed compaction behavior remain unchanged. + +## TDD scenarios + +1. RED: Cover 49, 50, and 51 user/assistant messages and assert exact first/last messages. +2. RED: Replace the existing every-entry-kind expectation. Put 150 tool calls and results after an + assistant message. Prove that no tool event appears or consumes a slot and the message remains + eligible. +3. RED: Interleave unknown, status, and contentless entries. Prove that 50 conversation messages + still appear. +4. RED: Prove chronological output, current message truncation, current-request placement, and + unchanged source JSONL bytes. +5. GREEN: Add one named message limit and newest-window selection after message-type filtering. +6. REFACTOR: Keep selection independent from formatting so ordering and truncation stay testable. + +## Verification + +- `cd apps/backend && go test ./internal/agent/runtime/lifecycle -run 'TestGenerateResumeContext|TestTruncateForContext'` +- `git diff --check` + +## Files likely touched + +- `apps/backend/internal/agent/runtime/lifecycle/session_history.go` +- `apps/backend/internal/agent/runtime/lifecycle/session_history_resume_test.go` + +## Dependencies + +None. + +## Inputs + +- Continuation fallback-history contract in the automation settings spec and ADR. +- Existing `SessionHistoryManager.GenerateResumeContext` behavior. + +## Parallelism + +Parallel-safe with Task 01: ownership is limited to agent lifecycle history files. + +## Output contract + +Report boundary fixtures, included message identities/order, excluded tool-event counts, +source-file preservation, files changed, and exact tests. + +## Risks + +- Counting tool events can displace the conversation messages that the fallback needs. + +## Results + +Implemented newest-50 filtering for non-empty user/assistant messages, tool/status exclusion, chronological reconstruction, per-message truncation, and source-history preservation. Lifecycle verification passed with 1,947 tests. diff --git a/docs/plans/automation-continuation-and-coordinator-access/task-04-define-automation-mcp-surface.md b/docs/plans/automation-continuation-and-coordinator-access/task-04-define-automation-mcp-surface.md new file mode 100644 index 0000000000..51be6f1f84 --- /dev/null +++ b/docs/plans/automation-continuation-and-coordinator-access/task-04-define-automation-mcp-surface.md @@ -0,0 +1,89 @@ +--- +id: "04-define-automation-mcp-surface" +title: "Define automation MCP surface" +status: completed +wave: 2 +depends_on: ["01-persist-continuation-policy"] +plan: "plan.md" +spec: "../../specs/office/requirements/automation-continuity.md" +--- + +# Task 04: Define Automation MCP Surface + +## Acceptance + +- `SurfaceAutomation` exposes exactly the fixed catalog while all existing base surfaces retain + their current tools; monolithic registration is decomposed so exclusions are structural. +- Lifecycle dispatch resolves one trusted automation principal, and a shared authorizer applies its + workspace/self boundary before any included read, create, or mutation. +- Self, foreign, malformed, and forged targets fail closed; cross-task spawning uses the target + task's normal profile and permission audit derives `automation_mcp` identity. + +## TDD scenarios + +1. RED: Snapshot every base-surface catalog and prove automation excludes deletion, configuration, + task-local authoring, diagnostics, provider, and plugin groups. +2. RED: Prove principal identity comes from execution task/session and is absent from tool schemas. +3. RED: Exercise same-workspace, foreign-workspace, and self-target matrices for inventory, + creation/coordination, blocker discovery/resolution, messaging, stopping, and spawning. +4. RED: Prove a spawned cross-task session resolves the target task profile while same-automation- + task spawning is denied. +5. RED: Prove audit source/actor cannot be forged. +6. GREEN: Split registrations, add typed surface/principal, wire lifecycle scope, and centralize + authorization. +7. REFACTOR: Remove handler-local scope derivation and retain external PAT authorization unchanged. + +## Verification + +- `cd apps/backend && go test -tags fts5 ./internal/mcp/profile ./internal/mcp/scope ./internal/mcp/server ./internal/mcp/handlers ./internal/agent/runtime/lifecycle ./internal/orchestrator/executor` +- `git diff --check` + +## Files likely touched + +- `apps/backend/internal/mcp/profile/profile.go` +- `apps/backend/internal/mcp/profile/profile_test.go` +- `apps/backend/internal/mcp/scope/scope.go` +- `apps/backend/internal/mcp/scope/scope_test.go` +- `apps/backend/internal/mcp/server/server.go` +- `apps/backend/internal/mcp/server/server_test.go` +- `apps/backend/internal/mcp/server/question_handlers.go` +- `apps/backend/internal/mcp/server/agent_permissions_test.go` +- `apps/backend/internal/mcp/handlers/handlers.go` +- `apps/backend/internal/mcp/handlers/question_handlers.go` +- `apps/backend/internal/mcp/handlers/agent_permissions.go` +- `apps/backend/internal/mcp/handlers/spawn_session.go` +- `apps/backend/internal/mcp/handlers/spawn_session_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/mcp_identity.go` +- `apps/backend/internal/agent/runtime/lifecycle/mcp_identity_test.go` +- `apps/backend/internal/orchestrator/executor/executor_execute.go` +- `apps/backend/internal/orchestrator/executor/executor_mcp_mode_test.go` +- `apps/backend/internal/backendapp/helpers.go` + +## Dependencies + +Task 01 supplies the durable automation-run origin/binding contract used to resolve the principal. + +## Inputs + +- Automation MCP surface and Permissions sections in the automation settings spec. +- Surface-placement rules in the external question and permission specs. +- Existing lifecycle `taskScopedMCPHandler`, `internal/mcp/scope`, and profile registry. + +## Parallelism + +Parallel-safe with Tasks 02 and 06 after Task 01. Ownership is limited to MCP, lifecycle identity, +executor profile resolution, and backend wiring; Task 02 must not edit those files. + +## Output contract + +Report exact catalogs, split groups, principal contents, self/foreign matrices, spawned profiles, +audit evidence, files changed, and exact tests. + +## Risks + +- Falling back to owner identity or missing one action in the central policy can cross workspace or + self-approval boundaries. + +## Results + +Implemented the fixed `SurfaceAutomation` catalog, execution-time allowlist, trusted principal derivation, workspace/self guards, automation permission attribution, and target-profile spawning. Added workspace-list and principal regression coverage. MCP verification passed with 870 tests. diff --git a/docs/plans/automation-continuation-and-coordinator-access/task-05-maintain-shared-run-lifecycle.md b/docs/plans/automation-continuation-and-coordinator-access/task-05-maintain-shared-run-lifecycle.md new file mode 100644 index 0000000000..640a2222c2 --- /dev/null +++ b/docs/plans/automation-continuation-and-coordinator-access/task-05-maintain-shared-run-lifecycle.md @@ -0,0 +1,84 @@ +--- +id: "05-maintain-shared-run-lifecycle" +title: "Maintain shared run lifecycle" +status: completed +wave: 3 +depends_on: ["02-dispatch-reusable-turns"] +plan: "plan.md" +spec: "../../specs/office/automation-runs.md" +--- + +# Task 05: Maintain Shared Run Lifecycle + +## Acceptance + +- Per-automation/workspace queries project each run's exact-turn summary, title, thread outcome, and + open state; legacy unbound rows remain readable. +- Retention counts distinct tasks and protects the continuation; run/delete-all cleanup is + reference-aware and never rewrites a reused checkout. +- Automation deletion prevents admission, captures all referenced tasks, stops live sessions, and + inserts a durable cleanup job for every now-unreferenced hidden task/worktree before deleting its + references. Jobs survive restart and are removed only after cleanup succeeds. + +## TDD scenarios + +1. RED: Seed two scheduled turns plus a human turn in one session; assert exact summaries, titles, + statuses, and legacy fallback. +2. RED: Cover distinct-task retention, continuation protection, and no reset/rebase call on resume. +3. RED: Delete one shared run, delete all runs, and delete an automation with current/superseded/live + tasks; assert reference ownership, session stop, and worktree cleanup. +4. RED: Fail post-commit task cleanup, restart the service, and prove the persisted cleanup job is + retried and removed only after task/worktree cleanup succeeds. +5. GREEN: Update projections, retention ownership, automation lifecycle deletion, and retry state. +6. REFACTOR: Centralize hidden-task ownership and cleanup across run, automation, and workspace + deletion. + +## Verification + +- `cd apps/backend && go test -tags fts5 ./internal/automation ./internal/orchestrator ./internal/task/service` +- `git diff --check` + +## Files likely touched + +- `apps/backend/internal/automation/store.go` +- `apps/backend/internal/automation/store_runs_test.go` +- `apps/backend/internal/automation/store_summaries_test.go` +- `apps/backend/internal/automation/store_workspace_runs_test.go` +- `apps/backend/internal/automation/run_retention.go` +- `apps/backend/internal/automation/run_retention_test.go` +- `apps/backend/internal/automation/service.go` +- `apps/backend/internal/automation/service_test.go` +- `apps/backend/internal/automation/cleanup_jobs.go` +- `apps/backend/internal/automation/cleanup_jobs_test.go` +- `apps/backend/internal/automation/handlers.go` +- `apps/backend/internal/automation/handlers_test.go` +- `apps/backend/internal/orchestrator/event_handlers_automation.go` +- `apps/backend/internal/orchestrator/event_handlers_automation_test.go` +- `apps/backend/internal/task/service/service.go` + +## Dependencies + +Task 02 supplies exact run bindings, terminal ownership, stop behavior, and continuation updates. + +## Inputs + +- Persistence, deletion, and retention guarantees in both automation specs. +- Existing task deletion/worktree cleanup and automation retention paths. + +## Parallelism + +Sequential after Task 02. It overlaps automation store/service files with Task 02 and supplies the +projection consumed by Task 07. + +## Output contract + +Report projection fixtures, retention IDs, cleanup ownership/order, orphan retry evidence, files +changed, and exact tests. + +## Risks + +- Deleting references before capturing task IDs can leave hidden worktrees permanently unreachable. + +## Results + +Implemented exact-turn liveness/reconciliation, exact-run stopping, distinct-task retention, reference-aware deletion, durable cleanup jobs, startup retry, and worktree-preserving reuse. Automation and orchestrator verification passed with 392 and 2,060 tests respectively. diff --git a/docs/plans/automation-continuation-and-coordinator-access/task-06-add-continuation-control.md b/docs/plans/automation-continuation-and-coordinator-access/task-06-add-continuation-control.md new file mode 100644 index 0000000000..b4824cdb6c --- /dev/null +++ b/docs/plans/automation-continuation-and-coordinator-access/task-06-add-continuation-control.md @@ -0,0 +1,82 @@ +--- +id: "06-add-continuation-control" +title: "Add continuation control" +status: completed +wave: 2 +depends_on: ["01-persist-continuation-policy"] +plan: "plan.md" +spec: "../../specs/office/requirements/automation-continuity.md" +--- + +# Task 06: Add Continuation Control + +## Acceptance + +- Create and edit forms always show Context between runs and round-trip both choices; new + automations visibly default to a new task per run. +- Reuse mode locks concurrency at 1 and explains continued conversation, environment, and worktree; + no MCP capability control is rendered. +- The section, both choices, and the concurrency lock use the exact visible descriptions from the + spec. Each description has an accessible relationship and does not require hover or focus. +- Desktop and mobile use the same localized stacked radio group with 44 px targets, one scroll + owner, and no horizontal overflow. + +## TDD scenarios + +1. RED: Add payload tests for default, both policies, create/edit hydration, and switching. +2. RED: Add component tests for exact visible descriptions, `aria-describedby` relationships, + visible-on-create choices, and concurrency locking. +3. GREEN: Extend TypeScript contracts, form state, payload builders, and Settings card controls. +4. GREEN: Add English, Portuguese, Simplified Chinese, generated Traditional Chinese, and pseudo + locale keys. +5. REFACTOR: Keep presentation within the existing Settings card and shared responsive logic. + +## Verification + +- `cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- --run components/automations/automation-payload.test.ts components/automations/config-section.test.tsx` +- `cd apps/web && pnpm run typecheck` +- `cd apps/web && pnpm run i18n:zh-hant` +- `cd apps/web && pnpm run i18n:check` +- `cd apps/web && pnpm run i18n:ratchet` +- `git diff --check` + +## Files likely touched + +- `apps/web/lib/types/automation.ts` +- `apps/web/components/automations/automation-payload.ts` +- `apps/web/components/automations/automation-payload.test.ts` +- `apps/web/components/automations/automation-editor.tsx` +- `apps/web/components/automations/automation-editor-sections.tsx` +- `apps/web/components/automations/config-section.test.tsx` +- `apps/web/src/locales/en/automations.json` +- `apps/web/src/locales/pt-pt/automations.json` +- `apps/web/src/locales/zh-cn/automations.json` +- `apps/web/src/locales/zh-hk/automations.json` +- `apps/web/src/locales/zh-tw/automations.json` +- `apps/web/src/locales/pseudo/automations.json` + +## Dependencies + +Task 01 defines backend policy values, defaults, and validation. + +## Inputs + +- Continuation UI rules in the automation settings spec. +- Existing AutomationEditor Settings card and payload builder. + +## Parallelism + +Parallel-safe with Tasks 02 and 04 after Task 01: this task owns editor/payload/locale files only. + +## Output contract + +Report payload snapshots, exact rendered descriptions, accessible relationships, create/edit +behavior, mobile/desktop geometry, locales, files changed, and exact tests. + +## Risks + +- UI-only concurrency handling can drift from backend validation or hide the create-time choice. + +## Results + +Implemented localized create/edit controls, visible accessible descriptions, reuse concurrency locking, and continuation payloads. Focused frontend verification passed with 307 tests and typecheck passed. diff --git a/docs/plans/automation-continuation-and-coordinator-access/task-07-anchor-and-control-shared-runs.md b/docs/plans/automation-continuation-and-coordinator-access/task-07-anchor-and-control-shared-runs.md new file mode 100644 index 0000000000..69b1e9b384 --- /dev/null +++ b/docs/plans/automation-continuation-and-coordinator-access/task-07-anchor-and-control-shared-runs.md @@ -0,0 +1,89 @@ +--- +id: "07-anchor-and-control-shared-runs" +title: "Anchor and control shared runs" +status: completed +wave: 3 +depends_on: + - "01-persist-continuation-policy" + - "02-dispatch-reusable-turns" + - "05-maintain-shared-run-lifecycle" + - "06-add-continuation-control" +plan: "plan.md" +spec: "../../specs/office/automation-runs.md" +--- + +# Task 07: Anchor and Control Shared Runs + +## Acceptance + +- Run types and views carry exact turn/thread data and per-run `display_title`; shared-session + selection focuses the chosen turn and both `triggered`/`task_created` appear as Running. +- Stop current run cancels the selected exact run with correct pending/error cleanup and does not + affect another scheduled or human turn. +- Desktop rail and mobile drawer/content expose the same outcome; the mobile action is visible, + at least 44 px, contained, and requires no hover or horizontal scrolling. + +## TDD scenarios + +1. RED: Render two run IDs sharing a session with distinct turns/titles and prove selection changes + viewport, title, live state, and summary. +2. RED: Cover `triggered` Running grouping/filtering, legacy rows, deep links, replacement metadata, + and inert skipped rows. +3. RED: Cover stop success, stale/terminal no-op, backend error, duplicate click, and cancellation of + one exact run while another turn exists. +4. GREEN: Thread run identity through APIs/components and add one shared cancellation mutation. +5. GREEN: Add localized desktop and mobile actions using existing detail/drawer composition. +6. REFACTOR: Keep transcript hydration session-scoped while viewport/control state is run-scoped. + +## Verification + +- `cd apps && pnpm install --frozen-lockfile && pnpm --filter @kandev/web test -- --run components/runs lib/api/domains/automation-runs-api.test.ts` +- `cd apps/web && pnpm run typecheck` +- `cd apps/web && pnpm run i18n:check` +- `cd apps/web && pnpm run i18n:ratchet` +- `git diff --check` + +## Files likely touched + +- `apps/web/lib/types/automation.ts` +- `apps/web/lib/api/domains/automation-api.ts` +- `apps/web/lib/api/domains/automation-runs-api.test.ts` +- `apps/web/components/runs/automation-detail-page.tsx` +- `apps/web/components/runs/automation-detail-page.test.tsx` +- `apps/web/components/runs/run-transcript.tsx` +- `apps/web/components/runs/run-transcript.test.tsx` +- `apps/web/components/runs/runs-rail.tsx` +- `apps/web/components/runs/runs-drawer.tsx` +- `apps/web/src/locales/en/automations.json` +- `apps/web/src/locales/pt-pt/automations.json` +- `apps/web/src/locales/zh-cn/automations.json` +- `apps/web/src/locales/zh-hk/automations.json` +- `apps/web/src/locales/zh-tw/automations.json` +- `apps/web/src/locales/pseudo/automations.json` + +## Dependencies + +Tasks 01/02 provide exact run and stop contracts, Task 05 provides projection, and Task 06 owns the +shared frontend type/editor baseline. + +## Inputs + +- Detail behavior and exact-turn model in the automation runs spec. +- Mobile design contract in `plan.md` and current rail/drawer components. + +## Parallelism + +Sequential after Tasks 05 and 06 because it consumes their API and shared TypeScript contracts. + +## Output contract + +Report selection/title/filter assertions, cancellation terminal paths, mobile geometry, locales, +files changed, and exact tests. + +## Risks + +- Session-only React keys or task-only cancellation can display or stop the wrong shared run. + +## Results + +Implemented exact run/session/turn API rendering, selected-turn transcript filtering, running-status grouping, and local exact-run stop state shared across desktop and mobile layouts. Desktop and mobile E2E passed 24 and 10 tests. diff --git a/docs/plans/automation-continuation-and-coordinator-access/task-08-prove-and-document-coordinators.md b/docs/plans/automation-continuation-and-coordinator-access/task-08-prove-and-document-coordinators.md new file mode 100644 index 0000000000..d587b2cfeb --- /dev/null +++ b/docs/plans/automation-continuation-and-coordinator-access/task-08-prove-and-document-coordinators.md @@ -0,0 +1,90 @@ +--- +id: "08-prove-and-document-coordinators" +title: "Validate coordinator automations" +status: completed +wave: 4 +depends_on: + - "01-persist-continuation-policy" + - "02-dispatch-reusable-turns" + - "03-bound-fallback-resume-history" + - "04-define-automation-mcp-surface" + - "05-maintain-shared-run-lifecycle" + - "06-add-continuation-control" + - "07-anchor-and-control-shared-runs" +plan: "plan.md" +spec: "../../specs/office/requirements/automation-continuity.md" +--- + +# Task 08: Validate Coordinator Automations + +## Acceptance + +- Desktop/mobile E2E show the continuity help descriptions, create a reusable automation, fire it + twice, observe distinct titled turns in one task/session/worktree, and stop an exact open run. +- Backend integration proves the fixed catalog, central principal, self/foreign denial, + target-profile spawning, bounded fallback history, stale-run repair, and restart-safe deletion + cleanup. +- Public/API docs accurately describe configuration, agent compaction, the newest-50 message + fallback, tool-event exclusion, recovery, security boundaries, and non-destructive worktree + behavior. + +## TDD scenarios + +1. RED: Add stable mock/backend fixtures for shared task/session, distinct turns/titles, and a live + stoppable run. +2. RED: Add desktop help-copy, editor/detail flow, and exact-run stop assertions. +3. RED: Add mobile help-copy and create/select/stop parity, 44 px actions, drawer containment, and + overflow checks. +4. RED: Add integration matrices for principal scope, self/foreign targets, spawned profiles, + history bound, stale-run recovery, and cleanup-job retry after restart. +5. GREEN: Complete integration wiring and public documentation. +6. REFACTOR: Remove obsolete execution-mode, `dispatching`, and handler-local scope wording. + +## Verification + +- `cd apps/backend && go test -tags fts5 ./internal/automation ./internal/orchestrator ./internal/mcp/profile ./internal/mcp/scope ./internal/mcp/handlers ./internal/mcp/server && go test ./internal/agent/runtime/lifecycle` +- `cd apps && pnpm install --frozen-lockfile` +- `cd apps/web && pnpm e2e:run tests/automations-settings.spec.ts tests/automations-run-detail.spec.ts` +- `cd apps/web && pnpm e2e:run --project mobile-chrome --no-build tests/mobile-automations-scroll.spec.ts tests/mobile-automation-detail.spec.ts` +- `cd apps/web && pnpm run typecheck` +- `cd apps/web && pnpm run i18n:check` +- `node --test scripts/validate-public-docs.test.mjs` +- `node scripts/validate-public-docs.mjs` +- `git diff --check` + +## Files likely touched + +- `apps/web/e2e/tests/automations-settings.spec.ts` +- `apps/web/e2e/tests/automations-run-detail.spec.ts` +- `apps/web/e2e/tests/mobile-automations-scroll.spec.ts` +- `apps/web/e2e/tests/mobile-automation-detail.spec.ts` +- `docs/public/automation-and-mcp.md` +- `docs/public/agents-and-profiles.md` +- `docs/public/websocket-api.md` +- `docs/public/feature-status.md` + +## Dependencies + +All contract, runtime, security, cleanup, and UI tasks must finish before end-to-end evidence. + +## Inputs + +- Completed Tasks 01 through 07 and their targeted evidence. +- Public automation, agent/profile, WebSocket API, and feature-status pages. + +## Parallelism + +Sequential final integration, E2E, and documentation task. + +## Output contract + +Report desktop/mobile traces, exact identities/titles, catalog/scope matrices, history bounds, +cleanup evidence, docs validation, files changed, and every command result. + +## Risks + +- Mock fixtures can hide real resume, compaction, scope, cancellation, or cleanup integration gaps. + +## Results + +Added coordinator security/catalog tests, continuity and exact-stop desktop/mobile E2E coverage, and updated the four public documentation pages. Public-doc tests and validation passed; all 34 focused desktop/mobile E2E tests passed. diff --git a/docs/plans/automation-target-modes/plan.md b/docs/plans/automation-target-modes/plan.md new file mode 100644 index 0000000000..d4fd3017f4 --- /dev/null +++ b/docs/plans/automation-target-modes/plan.md @@ -0,0 +1,120 @@ +--- +created: 2026-08-23 +status: completed +requirements: + - REQ-OFFICE-AUTOMATION-TARGETS-001 + - REQ-OFFICE-AUTOMATION-TARGETS-002 + - REQ-OFFICE-AUTOMATION-TARGETS-003 +system_design: + - ../../specs/office/system-design/automation-target-modes.md +legacy_specs: + - ../../specs/office/automations-settings.md +--- + +# Implementation Plan: Automation Target Modes + +## Overview + +Add an explicit hidden automation-run or visible normal-task target, make +repository-free firings use task-owned scratch execution, and preserve exact +continuation/run accounting in both modes. Work is sequential because the +persisted contract and dispatch lifecycle must land before the editor and E2E +flows can rely on them. + +## Scope + +### In scope + +- Persist target mode and explicit repository mode with compatibility migration. +- Admit and dispatch hidden repository/workflow-free runs. +- Create, continue, finalize, and delete visible normal tasks without applying + hidden coordinator authority or hidden cleanup. +- Expose target, workflow, repository, executor, and continuity choices with + accessible desktop/mobile copy and equal toolbar button heights. +- Add backend, frontend, mobile, export, and end-to-end regression coverage. +- Update public automation documentation and feature status. + +### Out of scope + +- Concurrent turns or additional sessions on a reusable target. +- Per-automation MCP capability selection. +- Automatic repository provisioning. +- Converting an existing hidden task into a visible task in place. + +## Technical approach + +1. Add typed `task_mode` and `repository_mode` contracts in + `internal/automation`, including store migration, request validation, export, + and compatibility defaults. +2. Split orchestrator task creation into hidden and visible target paths while + sharing title rendering, continuation admission, exact run binding, and + repository resolution. Add a visible automation task origin and use the + existing lifecycle scratch path for empty repository lists. +3. Route terminal run accounting by exact run identity. Keep hidden + `SurfaceAutomation` and hidden cleanup constrained to hidden origin tasks; + leave visible tasks in the normal task lifecycle on automation deletion. +4. Extend the editor payload and types with target/repository modes, allow + Worktree and Local-compatible profiles to use repository-free scratch, + require workflow only for visible mode, and keep descriptions accessible on + phones. +5. Add focused tests and E2E flows for both modes, then update public docs and + verification evidence. + +## Tests + +- `internal/automation`: schema/default/migration, target validation, + repository-mode resolution, export, and open-run behavior. +- `internal/orchestrator`: hidden no-repo launch, visible task creation, + visible continuation, exact completion/stop, normal profile selection, and + deletion ownership. +- `internal/task/repository/sqlite` and `internal/task/service`: visible + automation origin appears in task queries and accepts scratch tasks. +- `apps/web/components/automations`: target/repository payloads, conditional + validation, Worktree/Local options, descriptions, continuity, and toolbar + button geometry. +- `apps/web/components/runs`: shared transcript and visible-run terminal paths. + +## E2E tests + +- Desktop: create a no-repository/no-workflow hidden automation with Worktree, + run it in scratch, and verify it succeeds without a repository error. +- Desktop: create a workflow-backed normal-task automation, fire it with + `new_task`, and verify the task appears in the Kanban/sidebar and its run + completes. +- Desktop/mobile: switch both target and continuity settings, read the visible + explanations, exercise a reusable visible task, and assert no horizontal + overflow. Verify Export and New Automation have equal height. + +## Work orders + +- [x] [Task 01: Persist target and repository contracts](task-01-persist-target-contracts.md) +- [x] [Task 02: Dispatch and maintain target lifecycles](task-02-dispatch-target-lifecycles.md) +- [x] [Task 03: Build target-mode editor and proof](task-03-editor-and-proof.md) +- [x] [Task 04: Share task-creation selectors](task-04-share-task-creation-selectors.md) + +## Verification results + +- Backend: 2,937 automation, orchestrator, and backendapp tests passed. Earlier + task service/repository, MCP, and lifecycle suites remain green on this PR. +- Frontend: 31 focused files and 296 tests passed; typecheck, i18n check, and + i18n ratchet passed. +- Desktop E2E: all 19 automation-settings tests and both target-mode tests + passed. The target-mode flow proves repository-free Worktree execution and + visible task placement in the workflow's configured start step. +- Mobile E2E: all three automation settings/scroll tests passed, including + 44-pixel shared selectors and horizontal-overflow assertions. +- Public docs: 61 validator tests and 41 published-page validations passed. +- `git diff --check` passed. + +## Risks + +- Reusing an empty repository list without a migration mode would silently + change existing automations from workspace-default to scratch execution. +- A visible task accidentally marked with the hidden origin would disappear + from boards or receive coordinator self-target restrictions. +- Shared continuation code can finalize the wrong visible run unless every + completion and stop path carries the exact session and turn. +- Worktree profiles must keep using scratch rather than Git worktree + preparation when no repository is attached. +- Compatibility requests that name only repository IDs must resolve concrete + base branches without restoring the removed first-repository fallback. diff --git a/docs/plans/automation-target-modes/task-01-persist-target-contracts.md b/docs/plans/automation-target-modes/task-01-persist-target-contracts.md new file mode 100644 index 0000000000..90f09b667c --- /dev/null +++ b/docs/plans/automation-target-modes/task-01-persist-target-contracts.md @@ -0,0 +1,93 @@ +--- +id: "01-persist-target-contracts" +title: "Persist target and repository contracts" +status: completed +wave: 1 +depends_on: [] +plan: "plan.md" +requirements: + - REQ-OFFICE-AUTOMATION-TARGETS-001 +system_design: + - ../../specs/office/system-design/automation-target-modes.md +acceptance_criteria: + - AC-OFFICE-AUTOMATION-TARGETS-001.1 + - AC-OFFICE-AUTOMATION-TARGETS-001.2 + - AC-OFFICE-AUTOMATION-TARGETS-001.5 + - AC-OFFICE-AUTOMATION-TARGETS-001.6 + - AC-OFFICE-AUTOMATION-TARGETS-001.7 + - AC-OFFICE-AUTOMATION-TARGETS-001.8 +--- + +# Task 01: Persist target and repository contracts + +## Summary + +Add typed target and repository-mode values to automation models, requests, +storage, migrations, validation, and export. Migrate legacy empty-repository +behavior to an explicit no-repository choice and persist exact base branches. + +## In scope + +- `internal/automation/models.go`, `store.go`, service requests, create/update + validation, and export. +- API and wire types for `task_mode` and `repository_mode`. +- Compatibility defaults and schema contract tests. + +## Out of scope + +- Orchestrator task creation and terminal lifecycle behavior. +- Editor rendering and E2E flows. + +## Acceptance + +- Omitted target fields load as hidden automation runs and existing empty + repository rows migrate to no repository. +- Explicit `repository_mode=none` is persisted, exported, and does not resolve + a workspace repository. +- Normal-task mode requires a workflow. Worktree and Local-compatible profiles + accept repository-free scratch execution. + +## Verification + +```bash +cd apps/backend && go test -tags fts5 ./internal/automation +``` + +## Files likely touched + +- `apps/backend/internal/automation/models.go` +- `apps/backend/internal/automation/store.go` +- `apps/backend/internal/automation/service.go` +- `apps/backend/internal/automation/export.go` +- `apps/backend/internal/automation/*target*test.go` +- `apps/backend/pkg/api/v1/*automation*` + +## Dependencies + +None. + +## Risks + +- Import and old-client behavior can accidentally treat an omitted field as an + explicit no-repository request. + +## Parallelism + +`sequential` + +## Inputs + +- `docs/specs/office/requirements/automation-target-modes.md` +- `docs/specs/office/system-design/automation-target-modes.md` +- `docs/decisions/2026-08-23-automation-target-modes.md` + +## Results + +- Added persisted `task_mode` and `repository_mode` values with compatibility + defaults and migration/backfill behavior for legacy empty repository lists. +- Added request validation for hidden versus visible targets, explicit + repository-free execution, ordered repository/base-branch pairs, and + workspace repository ownership. +- Added export and store/service regression coverage. +- Verification: `go test -tags fts5 ./internal/automation` passed as part of + the final automation/orchestrator suite (2,464 tests across both packages). diff --git a/docs/plans/automation-target-modes/task-02-dispatch-target-lifecycles.md b/docs/plans/automation-target-modes/task-02-dispatch-target-lifecycles.md new file mode 100644 index 0000000000..630167aa82 --- /dev/null +++ b/docs/plans/automation-target-modes/task-02-dispatch-target-lifecycles.md @@ -0,0 +1,110 @@ +--- +id: "02-dispatch-target-lifecycles" +title: "Dispatch and maintain target lifecycles" +status: completed +wave: 2 +depends_on: + - "01-persist-target-contracts" +plan: "plan.md" +requirements: + - REQ-OFFICE-AUTOMATION-TARGETS-001 + - REQ-OFFICE-AUTOMATION-TARGETS-002 +system_design: + - ../../specs/office/system-design/automation-target-modes.md +acceptance_criteria: + - AC-OFFICE-AUTOMATION-TARGETS-001.3 + - AC-OFFICE-AUTOMATION-TARGETS-001.4 + - AC-OFFICE-AUTOMATION-TARGETS-002.1 + - AC-OFFICE-AUTOMATION-TARGETS-002.2 + - AC-OFFICE-AUTOMATION-TARGETS-002.3 + - AC-OFFICE-AUTOMATION-TARGETS-002.4 + - AC-OFFICE-AUTOMATION-TARGETS-002.5 + - AC-OFFICE-AUTOMATION-TARGETS-002.6 +--- + +# Task 02: Dispatch and maintain target lifecycles + +## Summary + +Route automation firings to hidden or visible task creation, allow valid +repository-free scratch launches, and keep continuation, exact run binding, +terminal status, profile selection, and deletion ownership correct for both +targets. + +## In scope + +- `internal/orchestrator/event_handlers_automation.go` and task creator + contracts. +- Visible automation task origin and normal task-list/query behavior. +- Exact run completion, stop, stale recovery, and deletion behavior. +- Hidden versus normal MCP/profile selection. +- No-repository lifecycle integration and focused backend tests. + +## Out of scope + +- Form controls, translations, or browser tests. +- New executor implementations. + +## Acceptance + +- A hidden automation with no workflow or repository launches in scratch with + Worktree or a Local-compatible profile. +- A normal-task automation creates a visible workflow task, supports both + continuity policies, and does not receive `SurfaceAutomation`. +- Completion and stop terminalize only the exact visible or hidden run, and + deleting an automation leaves visible tasks intact while cleaning hidden + tasks through existing ownership jobs. + +## Verification + +```bash +cd apps/backend && go test -tags fts5 ./internal/automation ./internal/orchestrator ./internal/task/service ./internal/task/repository/sqlite +cd apps/backend && go test ./internal/agent/runtime/lifecycle +cd apps/backend && go test -tags fts5 ./internal/mcp/profile ./internal/mcp/scope ./internal/mcp/server ./internal/mcp/handlers +``` + +## Files likely touched + +- `apps/backend/internal/orchestrator/event_handlers_automation.go` +- `apps/backend/internal/orchestrator/event_handlers_git.go` +- `apps/backend/internal/orchestrator/executor/executor_execute.go` +- `apps/backend/internal/task/models/models.go` +- `apps/backend/internal/task/repository/sqlite/task.go` +- `apps/backend/internal/task/service/service_tasks.go` +- `apps/backend/internal/agent/runtime/lifecycle/*` +- `apps/backend/internal/mcp/scope/*` +- `apps/backend/internal/automation/*target*test.go` + +## Dependencies + +Task 01. + +## Risks + +- The existing hidden lifecycle contains intentional stop and permission + behavior that must not be applied to visible normal tasks. +- Reused visible tasks may be moved by a person, so compatibility and + replacement behavior must remain explicit and exact-run safe. + +## Parallelism + +`sequential` + +## Inputs + +- Task 01 contract and migration. +- Existing continuation and cleanup design. +- Lifecycle scratch-workspace behavior in + `apps/backend/internal/agent/runtime/lifecycle/manager_launch.go`. + +## Results + +- Added hidden scratch and visible normal-task dispatch paths with + distinct task origins and exact automation-run bindings. +- Kept hidden coordinator MCP authority and cleanup limited to hidden tasks; + visible automation tasks use normal profiles and remain in task lists after + automation deletion. +- Preserved continuation, exact-turn completion/stop, publish-failure repair, + profile selection, and cleanup ownership behavior. +- Verification: automation/orchestrator, task service/repository, MCP, and + lifecycle suites passed (2,464 + 1,908 + 878 + 1,948 tests). diff --git a/docs/plans/automation-target-modes/task-03-editor-and-proof.md b/docs/plans/automation-target-modes/task-03-editor-and-proof.md new file mode 100644 index 0000000000..bfc2d20dcc --- /dev/null +++ b/docs/plans/automation-target-modes/task-03-editor-and-proof.md @@ -0,0 +1,119 @@ +--- +id: "03-editor-and-proof" +title: "Build target-mode editor and proof" +status: completed +wave: 3 +depends_on: + - "02-dispatch-target-lifecycles" +plan: "plan.md" +requirements: + - REQ-OFFICE-AUTOMATION-TARGETS-001 + - REQ-OFFICE-AUTOMATION-TARGETS-002 + - REQ-OFFICE-AUTOMATION-TARGETS-003 +system_design: + - ../../specs/office/system-design/automation-target-modes.md +acceptance_criteria: + - AC-OFFICE-AUTOMATION-TARGETS-001.1 + - AC-OFFICE-AUTOMATION-TARGETS-001.3 + - AC-OFFICE-AUTOMATION-TARGETS-001.4 + - AC-OFFICE-AUTOMATION-TARGETS-002.1 + - AC-OFFICE-AUTOMATION-TARGETS-003.1 + - AC-OFFICE-AUTOMATION-TARGETS-003.2 + - AC-OFFICE-AUTOMATION-TARGETS-003.3 + - AC-OFFICE-AUTOMATION-TARGETS-003.4 +--- + +# Task 03: Build target-mode editor and proof + +## Summary + +Expose target and repository choices in the automation editor, allow available +executor profiles for repository-free runs, and make visible normal-task rules +clear and accessible on desktop and mobile. Prove the two modes and toolbar +geometry with focused component and Playwright coverage. + +## In scope + +- Automation frontend types, payloads, form state, selectors, repository mode, + validation, and translations in all supported locales. +- Accessible target and continuity descriptions, mobile scroll/overflow and + touch geometry. +- Export and New Automation equal-height toolbar actions. +- Desktop and mobile E2E flows for hidden scratch and visible workflow tasks. +- Public automation documentation and feature-status wording. + +## Out of scope + +- Backend schema or dispatch changes. +- New mobile navigation surfaces or a second confirmation drawer. + +## Acceptance + +- The editor can save a hidden no-repository/no-workflow automation with + scratch execution and explains that its per-run tasks stay out of Kanban and + the sidebar. +- The editor can save a workflow-backed normal-task automation, select either + continuity policy, and clearly explains that its tasks are visible. +- Desktop and mobile tests prove target selection, visible task creation, + reusable visible continuation, equal toolbar heights, 44px controls, and no + horizontal overflow. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run components/automations components/runs +cd apps/web && pnpm run typecheck +cd apps/web && pnpm run i18n:check +cd apps/web && pnpm run i18n:ratchet +cd apps/web && pnpm e2e:run tests/automations-settings.spec.ts tests/automations-run-detail.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome --no-build tests/mobile-automations-scroll.spec.ts tests/mobile-automation-detail.spec.ts +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +``` + +## Files likely touched + +- `apps/web/lib/types/automation.ts` +- `apps/web/components/automations/automation-payload.ts` +- `apps/web/components/automations/automation-editor.tsx` +- `apps/web/components/automations/config-section.tsx` +- `apps/web/components/automations/automation-editor-sections.tsx` +- `apps/web/components/automations/automation-repository-selection.ts` +- `apps/web/components/automations/*.test.*` +- `apps/web/src/locales/*/automations.json` +- `apps/web/e2e/tests/automations-settings.spec.ts` +- `apps/web/e2e/tests/mobile-automations-scroll.spec.ts` +- `docs/public/automation-and-mcp.md` +- `docs/public/feature-status.md` + +## Dependencies + +Task 02. + +## Risks + +- Conditional form controls can leave stale workflow, repository, or executor + values in the payload when the target changes. +- The phone editor must keep one scroll owner while adding explanatory copy. + +## Parallelism + +`sequential` + +## Inputs + +- Tasks 01 and 02. +- `docs/specs/office/requirements/automation-target-modes.md`. +- `docs/specs/office/system-design/automation-target-modes.md`. +- `apps/web/components/automations` and existing mobile automation tests. + +## Results + +- Added target and repository mode controls, conditional workflow validation, + scratch execution, accessible hidden/visible destination + descriptions, all-locale translations, and equal-height toolbar actions. +- Kept the complete shared transcript mounted and focused the selected turn; + replies remain visible while viewing a run. +- Added hidden no-repository and visible workflow-task E2E coverage. +- Verification: 313 focused frontend tests, typecheck, i18n checks, 27 desktop + E2E tests, 10 mobile E2E tests, and public documentation validation passed. diff --git a/docs/plans/automation-target-modes/task-04-share-task-creation-selectors.md b/docs/plans/automation-target-modes/task-04-share-task-creation-selectors.md new file mode 100644 index 0000000000..90e8b6653c --- /dev/null +++ b/docs/plans/automation-target-modes/task-04-share-task-creation-selectors.md @@ -0,0 +1,83 @@ +--- +id: "04-share-task-creation-selectors" +title: "Share task-creation selectors" +status: completed +wave: 4 +depends_on: + - "03-editor-and-proof" +plan: "plan.md" +requirements: + - REQ-OFFICE-AUTOMATION-TARGETS-001 + - REQ-OFFICE-AUTOMATION-TARGETS-003 +system_design: + - ../../specs/office/system-design/automation-target-modes.md +acceptance_criteria: + - AC-OFFICE-AUTOMATION-TARGETS-001.9 + - AC-OFFICE-AUTOMATION-TARGETS-001.10 + - AC-OFFICE-AUTOMATION-TARGETS-001.11 + - AC-OFFICE-AUTOMATION-TARGETS-003.2 + - AC-OFFICE-AUTOMATION-TARGETS-003.3 + - AC-OFFICE-AUTOMATION-TARGETS-003.5 +--- + +# Task 04: Share task-creation selectors + +## Summary + +Remove the first-workspace-repository fallback, persist exact repository and +base-branch pairs, and compose the automation configuration from the shared +task-creation workflow, repository, agent, and executor selectors. + +## In scope + +- Add ordered base branches to automation repository persistence and API data. +- Migrate empty workspace-default rows to no repository and selected rows to a + concrete default branch. +- Dispatch exact saved base branches and include them in continuation checks. +- Remove the automation workflow-step and repository-mode controls. +- Reuse task-creation workflow previews, profile search/logos, and paired + repository/base-branch chips. +- Add focused backend/frontend tests and desktop/mobile E2E proof. +- Update public docs and localized copy. + +## Acceptance + +- The automation editor has no workspace-default repository option and no + workflow-step picker. +- A person can select multiple repository/base-branch pairs or no repository. +- Workflow and profile controls provide the same previews, search, logos, and + availability behavior as task creation. +- A new visible automation task starts at the workflow's configured start step. +- Desktop and mobile retain one scroll owner, 44-pixel touch controls, and zero + horizontal overflow. + +## Verification + +```bash +cd apps/backend && go test -tags fts5 ./internal/automation ./internal/orchestrator +cd apps && pnpm --filter @kandev/web test -- --run components/automations +cd apps/web && pnpm run typecheck +cd apps/web && pnpm run i18n:check && pnpm run i18n:ratchet +cd apps/web && pnpm e2e:run tests/automations-target-modes.spec.ts tests/automations-settings.spec.ts +cd apps/web && pnpm e2e:run --project mobile-chrome --no-build tests/mobile-automations-scroll.spec.ts +node --test scripts/validate-public-docs.test.mjs +node scripts/validate-public-docs.mjs +git diff --check +``` + +## Parallelism + +`sequential` + +## Results + +- Removed the first-workspace-repository and workflow-step controls. +- Reused the store-backed New Task workflow preview, searchable profile + selectors, and paired repository/base-branch chips. +- Persisted ordered repository/base-branch pairs and used them for dispatch and + continuation compatibility. +- Kept Worktree valid for an empty repository list through task-owned scratch + execution. +- Verification: 2,937 focused backend tests, 296 focused frontend tests, + typecheck, i18n checks, 19 desktop settings tests, two target-mode tests, + three mobile settings tests, and public documentation validation passed. diff --git a/docs/public/agents-and-profiles.md b/docs/public/agents-and-profiles.md index 937a51812b..15535eabeb 100644 --- a/docs/public/agents-and-profiles.md +++ b/docs/public/agents-and-profiles.md @@ -129,19 +129,19 @@ That parser is not a shell and is not quote-aware: quotes and backslashes do not Select an agent, create a profile, then open **Settings > Agents > _Agent_ > _Profile_**. The page shows the resolved command preview and only the settings supported by that agent. -| Setting | Runtime behavior | -|---|---| -| Name | Label shown in workflow, session, and automation selectors. | -| Model | Requested through ACP when the agent supports model selection. Leaving it unset uses the agent's default where the form allows that. | -| Mode | Requested with ACP `session/set_mode`. The choices come from the installed agent. | -| Configuration options | Dynamic ACP values requested with `session/set_config_option`. | -| CLI flags | Enabled entries are tokenized and appended to the ACP launch command. | -| Command prefix | Optional ACP-only launcher argv prepended to the command, for example `greywall --`. | -| Environment | Literal values or references to Kandev secrets, resolved when the process starts. | -| CLI passthrough | Uses the CLI's native terminal interface instead of a structured ACP conversation. | -| Enabled | Keeps the profile available to existing sessions and settings while hiding it from new task, session, handoff, and Quick Chat selectors. | +| Setting | Runtime behavior | +| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Name | Label shown in workflow, session, and automation selectors. | +| Model | Requested through ACP when the agent supports model selection. Leaving it unset uses the agent's default where the form allows that. | +| Mode | Requested with ACP `session/set_mode`. The choices come from the installed agent. | +| Configuration options | Dynamic ACP values requested with `session/set_config_option`. | +| CLI flags | Enabled entries are tokenized and appended to the ACP launch command. | +| Command prefix | Optional ACP-only launcher argv prepended to the command, for example `greywall --`. | +| Environment | Literal values or references to Kandev secrets, resolved when the process starts. | +| CLI passthrough | Uses the CLI's native terminal interface instead of a structured ACP conversation. | +| Enabled | Keeps the profile available to existing sessions and settings while hiding it from new task, session, handoff, and Quick Chat selectors. | | Auto-approve all permissions | Answers automatically: the first `allow_once`/`allow_always` option, otherwise the first option supplied by the agent; no options cancels. It is off by default. | -| MCP servers | Adds profile-specific external MCP servers when the agent supports MCP. | +| MCP servers | Adds profile-specific external MCP servers when the agent supports MCP. | Model, mode, command, and configuration choices are probed from the locally installed CLI and cached. The managed **Update agent** action refreshes them automatically; after other CLI changes, refresh the profile manually. Probe status can report **auth required**, **not installed**, **not configured**, or **failed**; a saved model name does not prove that the current provider account can use it. @@ -201,6 +201,7 @@ candidate is eligible, the session waits for a recovery action. After the current turn settles, use **Retry current agent** or **Try next agent** in the session recovery surface. These actions use the current route generation, so a stale browser action does not replace a newer route decision. + ### Host probes and executor model catalogs The model list shown while editing a profile comes from a host probe. It is an @@ -318,7 +319,7 @@ environment values, headers, raw MCP arguments, or option metadata. See Auto approval can authorize shell commands, file changes, network calls, or any other capability exposed by that agent. Agent-specific flags that suppress permission prompts can be broader still. Use either only with a constrained executor, repository, environment, and credential set. -Workspace automation selectors do not offer passthrough agent profiles or Local executor profiles. **Run**-mode automations also cannot wait for a permission response: an unanswered request is rejected and the run fails. Use **Task** mode when a person must approve agent actions, or use a profile whose safe work does not prompt. See [Automation and MCP](automation-and-mcp.md). +Workspace automation selectors do not offer passthrough agent profiles. Local executor profiles are available for the repository-free target; Worktree requires a repository. Hidden automation sessions receive a fixed workspace-scoped coordinator MCP surface. Visible normal-task automations use the ordinary task profile and MCP surface. The trusted automation principal is resolved before hidden-run dispatch, and a hidden automation task and its sessions cannot be used as mutation, messaging, stopping, spawning, or blocker targets. Cross-task spawning uses the target task's normal profile. Native provider continuation and compaction remain authoritative for a healthy reusable session; Kandev's fallback resume prompt uses only the newest 50 non-empty user or assistant messages and excludes tool events. See [Automation and MCP](automation-and-mcp.md). ## Structured ACP and terminal passthrough diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index fb191f109d..76bb4188ba 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -40,23 +40,32 @@ See [Tasks and workflows](tasks-and-workflows.md) for event configuration and de Open **Settings > Workspaces > _Workspace_ > Automations** (`/settings/workspace/{workspaceId}/automations`) and select **New Automation**. The top-level `/settings/automations` route redirects to, or asks you to select, a workspace. 1. Enter a required name and optional description. -2. Select an agent profile and a non-local executor profile. Passthrough agent profiles are not offered. -3. Optionally select a workflow and starting step. Both are optional: no automation run is placed on a board, so none needs a starting column. -4. Select a registered repository, a discovered local repository, or **None**. A discovered repository is registered in the workspace when the automation is saved. +2. Select an agent profile and executor profile. Passthrough profiles are not offered. Worktree and Local-compatible profiles can run without a repository in a task-owned scratch workspace. +3. Choose the run destination: + - **Run in automation history only** keeps each generated task out of Kanban and the sidebar. Workflow selection is optional. + - **Create a normal task** creates ordinary workflow work that appears in Kanban and the sidebar. A workflow is required; an empty starting step uses that workflow's configured starting step. +4. Add one or more repository and base-branch pairs, or leave the list empty. The selector is the same searchable paired-chip control used by New Task. A discovered repository is registered in the workspace when the automation is saved. An empty list uses a task-scoped scratch workspace and does not create a Git worktree. Kandev never selects the workspace's first repository for you. 5. Enter a prompt and optional task-title template. -6. Keep the default maximum concurrency of 1 unless parallel work is safe. -7. Choose a schedule and optional GitHub condition, or switch to webhook mode. -8. Save, use **Run now** on the automation's page, then read what it said before widening credentials or scope. +6. Choose **Context between runs**: + - **Start a new task for every run** creates a separate task, conversation, and task environment for each firing. In the hidden destination, those tasks do not appear in Kanban or the sidebar; in the normal-task destination, they do. + - **Continue the previous session** reuses one task, primary session, conversation, and worktree across firings. The destination and exact repository/base-branch pairs remain part of continuation compatibility. +7. Keep the default maximum concurrency of 1 unless parallel work is safe. Reused sessions always use one active run. +8. Choose a schedule and optional GitHub condition, or switch to webhook mode. +9. Save, use **Run now** on the automation's page, then read what it said before widening credentials or scope. -The form can save an empty agent, executor, or repository selection, but launch still needs a usable agent/executor and a repository. For scheduled, webhook, and manual work, an empty repository falls back to the workspace's first repository. If the workspace has none, the run fails with `no repository available; add a repository to the workspace`. A GitHub pull-request run instead checks out that PR's head branch and uses its base branch. +The form can save an empty agent or executor selection, but launch still needs a usable profile. Scheduled, manual, webhook, and provider-triggered runs can remain repository-free. When a provider event supplies an exact repository through its established contract, Kandev uses that event context. A GitHub pull-request run with configured repository access checks out that PR's head branch and uses its base branch. ### What a firing produces -Every automation produces the same thing: an ordinary, persistent task tagged `origin = automation_run`. That origin (not `is_ephemeral`) is what keeps it off the kanban and out of task lists, which means the task keeps its worktree and stays repliable. Worktrees are retained for the ten most recent finished runs of each automation and reclaimed beyond that, so an older run stays readable but can no longer be answered. The trigger is the start signal, so the agent starts immediately rather than waiting for a workflow step's `auto_start_agent` action. +Every firing produces a distinct `AutomationRun` with exact task, session, and turn identity. A hidden destination uses `origin = automation_run`; that origin, not `is_ephemeral`, keeps its persistent task out of Kanban and task lists. A normal-task destination uses a separate visible automation origin, enters the selected workflow at its configured start step, and follows the ordinary task lifecycle. The workflow selector shows the same ordered step preview as New Task; automations do not have a separate step selector. The trigger is the start signal, so the agent starts immediately rather than waiting for a workflow step's `auto_start_agent` action. -A finished run parks in `WAITING_FOR_INPUT` rather than `COMPLETED`, so you can reply to it and the agent continues in the same session and worktree. A run is a thread, not a receipt. +With **Start a new task for every run**, each firing gets its own task, primary session, worktree or scratch workspace, and conversation. With **Continue the previous session**, the first firing creates one task and later firings send a new turn to its primary session. Kandev does not reset or rebase a reused checkout. If the saved task, session, runtime, or task environment is missing or incompatible, the firing creates a replacement thread and records that action on the run. A reused task keeps its creation title; each run keeps its own rendered `display_title` snapshot. -There is no execution-mode choice. Earlier versions asked for **Task** or **Run** up front; the column was retained so existing rows need no migration, but it is no longer read, is accepted and ignored on the wire, and is omitted from responses. Automations created before the change behave like every other one, and cards already on a board are left alone; they are ordinary tasks now and can be archived by hand. +A finished run parks in `WAITING_FOR_INPUT` rather than `COMPLETED`, so you can reply to it and the agent continues in the same session and worktree. A run is a thread, not a receipt. Worktrees are retained for the ten most recent finished task IDs of each automation and reclaimed beyond that, so an older run stays readable but can no longer be answered. + +The run destination and ordered repository/base-branch pairs are explicit saved settings. Existing empty repository selections migrate to scratch execution; there is no workspace-default fallback. Existing selected repositories use their configured default branch until the automation is saved with an explicit branch. Changing the destination, workflow, repository pairs, or runtime profiles replaces an incompatible reusable continuation on its next firing. Visible automation tasks survive automation deletion; hidden tasks use the automation cleanup lifecycle. + +Selecting a run opens the complete shared transcript and focuses its exact turn. Replies sent from that transcript create a new turn in the same conversation and remain visible; run selection does not filter away newer replies. A run cannot wait for a permission response. Kandev rejects the request and marks the run failed. Use only a profile whose intended, constrained actions can complete without a prompt. @@ -163,19 +172,29 @@ Trigger payloads are untrusted input. Do not let a PR body or webhook field sile ## Read what an automation has been doing -**Automations** in the sidebar lists the workspace's automations with a health dot. Picking one opens it. **`/automations`** is the agenda across all of them, what fires next, and the recent runs of every automation in one feed. **`/automations/`** is one automation's conversation: it opens on the newest run's transcript, pins the standing instruction above it, and carries a reply box. Runs sit in a rail beside it, grouped Running / Completed, as a switcher between instances. Configuration is behind **Details** in that rail, because an automation is configured once and read continuously. +**Automations** in the sidebar lists the workspace's automations with a health dot. Picking one opens it. **`/automations`** is the agenda across all of them, what fires next, and the recent runs of every automation in one feed. **`/automations/`** is one automation's conversation: it opens on the newest run's transcript, carries the run's title snapshot and a reply box, and groups runs as Running / Completed in a rail or mobile drawer. A selected open run exposes **Stop current run**, which cancels its exact task, session, and turn without touching another run in a shared session. Configuration is behind **Details**, because an automation is configured once and read continuously. `/runs` still resolves to the same places, so older links keep working. ## Concurrency, history, and cleanup -Maximum concurrent runs defaults to 1 and cannot be less than 1. A run counts as active while its status is `task_created` **and** its task is neither deleted, archived, nor explicitly cancelled, the same definition the UI uses when it says an automation will not fire because a run is still open, so the reason shown and the cap causing it cannot disagree. When the cap is reached, Kandev records a `skipped` run and advances the schedule's evaluation time rather than retrying every 30 seconds. +Maximum concurrent runs defaults to 1 and cannot be less than 1. An admitted `triggered` run and a bound `task_created` run are both open until their exact turn is settled. A run counts as active while its task is neither deleted, archived, nor explicitly cancelled, the same definition the UI uses when it says an automation will not fire because a run is still open, so the reason shown and the cap causing it cannot disagree. `reuse_thread` requires `max_concurrent_runs = 1`. When the cap is reached, Kandev records a `skipped` run and advances the schedule's evaluation time rather than retrying every 30 seconds. -Run history can report `triggered`, `task_created`, `succeeded`, `failed`, `skipped`, `archived`, or `cancelled`. The last two are derived at read time, not stored: a `task_created` run whose task was deleted or whose primary session was cancelled reads as `cancelled`, and one whose task was archived reads as `archived`. That derivation is defined once and shared by every view, so two surfaces cannot disagree about the same run. +Run history can report `triggered`, `task_created`, `succeeded`, `failed`, `skipped`, `archived`, or `cancelled`. `triggered` means that admission succeeded but task/session/turn binding is not complete. The last two are derived at read time, not stored: a `task_created` run whose task was deleted or whose primary session was cancelled reads as `cancelled`, and one whose task was archived reads as `archived`. That derivation is defined once and shared by every view, so two surfaces cannot disagree about the same run. A run that produced a task opens its conversation. A run that never produced one (a skipped firing) is listed but inert; there is nothing to read. -Deleting one run also deletes its associated task. **Delete all runs** deletes all associated tasks and history for that automation and is irreversible. +Deleting one run also deletes its associated task when no other automation run references it. **Delete all runs** deletes all now-unreferenced associated tasks and history for that automation and is irreversible. Automation deletion captures referenced hidden tasks in durable cleanup jobs and retries task/worktree cleanup after the database deletion if necessary. + +### Continuation and fallback history + +Native session continuation and provider-managed compaction remain authoritative for a healthy reusable session. If Kandev must synthesize a non-native resume prompt, it includes only the newest 50 non-empty `user_message` and `agent_message` entries, returned in chronological order and truncated per message. Tool calls, tool results, status events, and unknown event types do not appear and do not consume slots. The current firing prompt is added outside that 50-message window. Durable session history is not rewritten. + +### Automation coordinator MCP + +Automation sessions receive one fixed, workspace-scoped coordinator MCP surface. The server resolves the trusted automation principal before dispatch and uses that principal for workspace, caller task, caller session, surface, and audit identity; a prompt or tool argument cannot forge those values. The catalog includes coordination and pending-question or permission actions needed by an automation, but excludes task deletion, configuration mutation, task-local authoring, provider PR/MR actions, diagnostics, plugins, and arbitrary capability settings. + +The automation's own hidden task and every session on it are invalid targets for mutation, messaging, stopping, spawning, and blocker discovery or resolution. Foreign-workspace targets return the same not-found result as unknown targets. A task spawned on another allowed task receives that target task's normal MCP profile and never inherits the automation surface. Reused worktrees are not reset or rebased by coordinator actions. ## Export automations diff --git a/docs/public/feature-status.md b/docs/public/feature-status.md index 24e5bbfedb..0da75de459 100644 --- a/docs/public/feature-status.md +++ b/docs/public/feature-status.md @@ -18,38 +18,38 @@ Released builds can lag this page. Check **Settings > System > About** or `kande ## Status meanings -| Status | Meaning | -|---|---| -| Supported | Available through the normal product path and backed by maintained implementation and tests. | +| Status | Meaning | +| ---------------- | ------------------------------------------------------------------------------------------------------------------ | +| Supported | Available through the normal product path and backed by maintained implementation and tests. | | Dependency-bound | Shipped, but useful behavior depends on a provider, agent CLI, executor, platform, credential, or install channel. | -| Limited | Implemented and reachable, but the current UI or operating contract has a material restriction called out here. | -| Experimental | Intentionally unstable or based on a fragile/nonstandard integration contract. | -| In progress | Source, schema, tests, a flag, or a stub exists, but there is no supported production workflow yet. | -| Internal | Development, test, diagnostic, or implementation support rather than an end-user feature. | +| Limited | Implemented and reachable, but the current UI or operating contract has a material restriction called out here. | +| Experimental | Intentionally unstable or based on a fragile/nonstandard integration contract. | +| In progress | Source, schema, tests, a flag, or a stub exists, but there is no supported production workflow yet. | +| Internal | Development, test, diagnostic, or implementation support rather than an end-user feature. |
Tasks, workflows, and coordination ## Tasks, workflows, and coordination -| Capability | Status | Current boundary | -|---|---|---| -| Workspaces, local repositories, Kanban board, task list, workflow steps, and task archive | Supported | A fresh database contains Default Workspace and a Development workflow. Repository settings add existing local Git paths; remote GitHub sources enter through New Task. Archive state is immediate, while runtime/resource cleanup is asynchronous and best-effort. | -| Workflow templates, custom steps, prompts, per-step agent profiles, WIP/pull controls, and auto-archive | Supported | Configure under **Settings > Workspaces > _workspace_ > Workflows**. Imported/synced configuration can still require local profile mapping. | -| Regular workflow events and actions | Supported | Current UI covers step entry, turn start/completion, step exit, and direct-child completion, including auto-start, plan mode, reset context, moves, explicit completion gating, and human wait steps. The generic Office triggers and quorum/participant actions are not part of this contract. | -| Human-gated operation | Supported | A step can do nothing on turn completion and wait for a user; an optional `step_complete_kandev` signal can gate auto-advance. Kandev does not bypass repository checks, provider permissions, or branch protection. | -| One structured plan per regular task | Dependency-bound | Plan mode, the Plan panel, revisions, compare/revert, and Implement are shipped. Creating/updating the plan from an agent requires task MCP support; passthrough-only sessions do not have the same contract. | -| Multiple named task documents | In progress | Storage, revisions, Office UI, and Office MCP tools exist. Regular Kanban has no document panel and its task MCP does not register the document tools. | -| Task labels and label filters | In progress | The shared task row stores a JSON label field, but label catalogs, pickers, filters, and maintained E2E coverage are on the feature-flagged Office surface, not regular Kanban. | -| Named parallel sessions | Supported | Users can add, name, make primary, stop, resume, delete, and hand off sessions. Blank, copied, or summarized context is available when starting another agent; resume still depends on retained agent/executor state. | -| Agent-spawned sessions, targeted task messages, and direct-child stop | Dependency-bound | `spawn_session_kandev`, `message_task_kandev`, and `stop_task_kandev` are task-MCP tools. Queue limits, session IDs, direct-parent authority, and agent MCP capability apply. Stop is halt-only, covers all observed live sessions on the child, and schedules runtime teardown asynchronously. | -| One-level subtasks | Supported | Regular Kanban supports a parent plus direct children through UI and MCP. A child may inherit the parent's materialized workspace or request a new one; inherited/shared files can conflict. Arbitrary-depth Office trees remain in progress. | -| Blocker/dependency editing | In progress | Same-workspace storage and cycle checks exist, and related-task MCP can report stored relations. The picker and blocker properties are currently Office UI; regular Kanban has no complete create/edit/filter path. | -| Multi-repository tasks | Limited | New Task supports multiple local or remote GitHub rows, and Changes/Review/PR surfaces group by repository. Two or more repositories currently require the Worktree executor. | -| Additional task branches | Limited | `add_branch_to_task_kandev` can attach another branch from the same or another repository only with a Worktree task. There is no equivalent regular after-start UI. Changing Kandev's comparison base does not retarget commits or an existing pull request. | -| Workflow YAML import/export | Supported | Available for regular Kanban workflows. Office workflows are deliberately excluded. | -| GitHub-backed workflow sync | Dependency-bound | Requires GitHub access. Synced workflows are read-only in Kandev and guarded updates can need local agent/executor mapping. | -| Coordinator-led teams, participants/quorum, arbitrary task trees, routines, budgets, and heartbeats | In progress | These belong to Office. Office is disabled in the production profile and remains an evolving, feature-flagged autonomy layer. | +| Capability | Status | Current boundary | +| ------------------------------------------------------------------------------------------------------- | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Workspaces, local repositories, Kanban board, task list, workflow steps, and task archive | Supported | A fresh database contains Default Workspace and a Development workflow. Repository settings add existing local Git paths; remote GitHub sources enter through New Task. Archive state is immediate, while runtime/resource cleanup is asynchronous and best-effort. | +| Workflow templates, custom steps, prompts, per-step agent profiles, WIP/pull controls, and auto-archive | Supported | Configure under **Settings > Workspaces > _workspace_ > Workflows**. Imported/synced configuration can still require local profile mapping. | +| Regular workflow events and actions | Supported | Current UI covers step entry, turn start/completion, step exit, and direct-child completion, including auto-start, plan mode, reset context, moves, explicit completion gating, and human wait steps. The generic Office triggers and quorum/participant actions are not part of this contract. | +| Human-gated operation | Supported | A step can do nothing on turn completion and wait for a user; an optional `step_complete_kandev` signal can gate auto-advance. Kandev does not bypass repository checks, provider permissions, or branch protection. | +| One structured plan per regular task | Dependency-bound | Plan mode, the Plan panel, revisions, compare/revert, and Implement are shipped. Creating/updating the plan from an agent requires task MCP support; passthrough-only sessions do not have the same contract. | +| Multiple named task documents | In progress | Storage, revisions, Office UI, and Office MCP tools exist. Regular Kanban has no document panel and its task MCP does not register the document tools. | +| Task labels and label filters | In progress | The shared task row stores a JSON label field, but label catalogs, pickers, filters, and maintained E2E coverage are on the feature-flagged Office surface, not regular Kanban. | +| Named parallel sessions | Supported | Users can add, name, make primary, stop, resume, delete, and hand off sessions. Blank, copied, or summarized context is available when starting another agent; resume still depends on retained agent/executor state. | +| Agent-spawned sessions, targeted task messages, and direct-child stop | Dependency-bound | `spawn_session_kandev`, `message_task_kandev`, and `stop_task_kandev` are task-MCP tools. Queue limits, session IDs, direct-parent authority, and agent MCP capability apply. Stop is halt-only, covers all observed live sessions on the child, and schedules runtime teardown asynchronously. | +| One-level subtasks | Supported | Regular Kanban supports a parent plus direct children through UI and MCP. A child may inherit the parent's materialized workspace or request a new one; inherited/shared files can conflict. Arbitrary-depth Office trees remain in progress. | +| Blocker/dependency editing | In progress | Same-workspace storage and cycle checks exist, and related-task MCP can report stored relations. The picker and blocker properties are currently Office UI; regular Kanban has no complete create/edit/filter path. | +| Multi-repository tasks | Limited | New Task supports multiple local or remote GitHub rows, and Changes/Review/PR surfaces group by repository. Two or more repositories currently require the Worktree executor. | +| Additional task branches | Limited | `add_branch_to_task_kandev` can attach another branch from the same or another repository only with a Worktree task. There is no equivalent regular after-start UI. Changing Kandev's comparison base does not retarget commits or an existing pull request. | +| Workflow YAML import/export | Supported | Available for regular Kanban workflows. Office workflows are deliberately excluded. | +| GitHub-backed workflow sync | Dependency-bound | Requires GitHub access. Synced workflows are read-only in Kandev and guarded updates can need local agent/executor mapping. | +| Coordinator-led teams, participants/quorum, arbitrary task trees, routines, budgets, and heartbeats | In progress | These belong to Office. Office is disabled in the production profile and remains an evolving, feature-flagged autonomy layer. |
@@ -58,24 +58,24 @@ Released builds can lag this page. Check **Settings > System > About** or `kande ## Sessions, review, and developer tools -| Capability | Status | Current boundary | -|---|---|---| -| Structured chat, tool activity, files, integrated PTYs, and session lifecycle | Dependency-bound | Panel availability follows the task environment, agent protocol, executor, and viewport. A terminal needs a materialized task environment. | -| Changes and cumulative Review | Supported | Supports staged, unstaged, commit, multi-repository, and initialized direct/nested submodule views; repository-scoped paths, persisted reviewed hashes, stale detection, and anchored comments are implemented. Unavailable submodules retain their parent gitlink. Discard is destructive and permanent, and submodule pull requests remain separate repository workflows. | -| Agent-authored code walkthrough | Dependency-bound | One saved walkthrough per task is supported through task MCP. A new walkthrough replaces the old one; anchors can drift, and the backend does not prove that referenced file content is still current. | -| GitHub pull-request review, checks, merge, and task PR automation | Dependency-bound | Requires GitHub credentials and repository permissions. GitHub-only controls, scoped independently per linked PR, can auto-fix feedback, auto-merge, and notify agents when their review is requested or when a PR merges or closes. Lifecycle messages are factual, immutable server-owned templates with only a canonical PR URL as dynamic content. Review-request matching uses the task workspace's connected GitHub account and a quiet baseline; delivery uses an existing task session or queue and never creates a new session. Azure has a create-PR path only; other Git hosts do not get the full task PR panel. | -| GitLab merge-request review and task MR automation | Dependency-bound | Requires GitLab credentials and project permissions. Task-level controls can auto-fix pipeline/discussion feedback, auto-merge, and notify agents when their review is requested (assignment as a reviewer, since GitLab has no distinct review-request event), or when a linked MR merges or closes. Auto-fix is capped at 10 rounds per MR; auto-merge requires GitLab's own merge-readiness verdict plus Kandev's own gates (open, not draft, pipeline passing, no unresolved discussions). Lifecycle messages are factual, immutable server-owned templates with only a canonical MR URL as dynamic content, delivered through the same durable queue as GitHub's. | -| Share a session snapshot as a secret GitHub Gist | Dependency-bound | Requires GitHub auth and a structured session outside `CREATED`/`STARTING`. The viewing URL is rendered through `gist.githack.com`; unlisted URLs and heuristic redaction are not access control or exhaustive secret detection. Payloads are capped and shares can be revoked. | -| Quick Chat | Dependency-bound | Requires an agent profile. Repository-backed chats use isolated Kandev worktrees; closing deletes the ephemeral task permanently. Chats older than seven days are swept at startup and every 24 hours unless their session is `RUNNING` or `IDLE`. | -| Utility agents and custom one-shot helpers | Dependency-bound | Require an ACP inference-capable agent and usable model. Passthrough-only agents do not satisfy this path. | -| Configuration Chat | Limited | Each workspace currently presents one repository-less configuration conversation. Settings shows it in a tabless floating panel and can move the same session into Quick Chat. Confirmed tab closure deletes its backing task; config-mode tasks are excluded from Quick Chat expiry. | -| Voice input | Plugin | Not built in. Provided by the [Voice Mode plugin](https://github.com/kdlbs/kandev-plugin-voice): browser speech, an in-browser Whisper model, and an operator-keyed server relay. Selection does not retry another engine after a recognition error. | -| Embedded VS Code | Limited | code-server runs inside the task runtime with `--auth none` on `0.0.0.0:`. It relies on task-runtime and network isolation rather than its own login. Native Windows Local and Worktree sessions omit it; Linux-backed Local Docker, Sprites, and supported SSH sessions can offer it even when Kandev runs on Windows. The active session executor, not the visitor's browser platform, controls the topbar entry. | -| Built-in editors and host editor launch | Dependency-bound | Built-in editing is shipped. External editors depend on host discovery/custom commands. A multi-worktree session presents a repository/worktree picker before host launch; clients that omit the worktree ID retain a first-worktree fallback. A remote executor path may not work without remote-editor configuration. | -| Language servers | Limited | Desktop Monaco maps TypeScript/JavaScript, Go, Rust, Python, and experimental Kotlin to task-host servers with diagnostics, completion, hover, navigation, signature help, and semantic tokens. Status distinguishes a launched process awaiting `initialize` from server-reported project work; slow Kotlin Gradle imports can take minutes, with no automatic timeout or universal percentage/ETA. Fine-pointer desktop can place active-file status in the editor toolbar or the opt-in status surface controlled by **Show status bar**; when that surface is off it falls back to the toolbar. Touch tablet uses the toolbar, and phone has no LSP control. Auto-start/installation default off; Kotlin is manual-only. Local PC and Local Docker are supported, while SSH, Sprites, remote Docker, and mobile LSP are not. | -| Integrated shell terminals | Dependency-bound | Create, reopen, rename, and terminate are shipped. Shell settings affect new terminals, and executor/task-environment availability controls where the PTY runs. | -| Prompts, keyboard shortcuts, notifications, appearance, and task actions | Supported | These are normal settings surfaces. Browser notification permission, custom command validity, and local shell/editor availability still apply to their effects. | -| Web UI localization | Limited | **Settings > General > Appearance** offers English, Português (Portugal), and 简体中文. The choice applies immediately, persists per browser through a `kandev_locale` cookie, and is negotiated from `Accept-Language` before defaulting to English when no cookie is set. Externalization is complete: every reachable web UI surface renders through the translation layer, and dates, numbers, and relative timestamps follow the selected locale. Per-language completeness still differs. Portuguese tracks English closely; Simplified Chinese is maintained out of band and lags, most visibly in the task workbench. An untranslated key falls back to English rather than failing. About fifteen shared `@kandev/ui` accessibility labels (mostly screen-reader-only text such as a dialog's **Close**) stay English even where the visible UI is translated. Desktop-shell and CLI surfaces, API/log diagnostics, and agent output were never in scope; user content such as task titles, chat, and diffs is never translated. Development and E2E builds list an extra **Pseudo (QA)** locale that production builds do not ship. | +| Capability | Status | Current boundary | +| ----------------------------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Structured chat, tool activity, files, integrated PTYs, and session lifecycle | Dependency-bound | Panel availability follows the task environment, agent protocol, executor, and viewport. A terminal needs a materialized task environment. | +| Changes and cumulative Review | Supported | Supports staged, unstaged, commit, multi-repository, and initialized direct/nested submodule views; repository-scoped paths, persisted reviewed hashes, stale detection, and anchored comments are implemented. Unavailable submodules retain their parent gitlink. Discard is destructive and permanent, and submodule pull requests remain separate repository workflows. | +| Agent-authored code walkthrough | Dependency-bound | One saved walkthrough per task is supported through task MCP. A new walkthrough replaces the old one; anchors can drift, and the backend does not prove that referenced file content is still current. | +| GitHub pull-request review, checks, merge, and task PR automation | Dependency-bound | Requires GitHub credentials and repository permissions. GitHub-only controls, scoped independently per linked PR, can auto-fix feedback, auto-merge, and notify agents when their review is requested or when a PR merges or closes. Lifecycle messages are factual, immutable server-owned templates with only a canonical PR URL as dynamic content. Review-request matching uses the task workspace's connected GitHub account and a quiet baseline; delivery uses an existing task session or queue and never creates a new session. Azure has a create-PR path only; other Git hosts do not get the full task PR panel. | +| GitLab merge-request review and task MR automation | Dependency-bound | Requires GitLab credentials and project permissions. Task-level controls can auto-fix pipeline/discussion feedback, auto-merge, and notify agents when their review is requested (assignment as a reviewer, since GitLab has no distinct review-request event), or when a linked MR merges or closes. Auto-fix is capped at 10 rounds per MR; auto-merge requires GitLab's own merge-readiness verdict plus Kandev's own gates (open, not draft, pipeline passing, no unresolved discussions). Lifecycle messages are factual, immutable server-owned templates with only a canonical MR URL as dynamic content, delivered through the same durable queue as GitHub's. | +| Share a session snapshot as a secret GitHub Gist | Dependency-bound | Requires GitHub auth and a structured session outside `CREATED`/`STARTING`. The viewing URL is rendered through `gist.githack.com`; unlisted URLs and heuristic redaction are not access control or exhaustive secret detection. Payloads are capped and shares can be revoked. | +| Quick Chat | Dependency-bound | Requires an agent profile. Repository-backed chats use isolated Kandev worktrees; closing deletes the ephemeral task permanently. Chats older than seven days are swept at startup and every 24 hours unless their session is `RUNNING` or `IDLE`. | +| Utility agents and custom one-shot helpers | Dependency-bound | Require an ACP inference-capable agent and usable model. Passthrough-only agents do not satisfy this path. | +| Configuration Chat | Limited | Each workspace currently presents one repository-less configuration conversation. Settings shows it in a tabless floating panel and can move the same session into Quick Chat. Confirmed tab closure deletes its backing task; config-mode tasks are excluded from Quick Chat expiry. | +| Voice input | Plugin | Not built in. Provided by the [Voice Mode plugin](https://github.com/kdlbs/kandev-plugin-voice): browser speech, an in-browser Whisper model, and an operator-keyed server relay. Selection does not retry another engine after a recognition error. | +| Embedded VS Code | Limited | code-server runs inside the task runtime with `--auth none` on `0.0.0.0:`. It relies on task-runtime and network isolation rather than its own login. Native Windows Local and Worktree sessions omit it; Linux-backed Local Docker, Sprites, and supported SSH sessions can offer it even when Kandev runs on Windows. The active session executor, not the visitor's browser platform, controls the topbar entry. | +| Built-in editors and host editor launch | Dependency-bound | Built-in editing is shipped. External editors depend on host discovery/custom commands. A multi-worktree session presents a repository/worktree picker before host launch; clients that omit the worktree ID retain a first-worktree fallback. A remote executor path may not work without remote-editor configuration. | +| Language servers | Limited | Desktop Monaco maps TypeScript/JavaScript, Go, Rust, Python, and experimental Kotlin to task-host servers with diagnostics, completion, hover, navigation, signature help, and semantic tokens. Status distinguishes a launched process awaiting `initialize` from server-reported project work; slow Kotlin Gradle imports can take minutes, with no automatic timeout or universal percentage/ETA. Fine-pointer desktop can place active-file status in the editor toolbar or the opt-in status surface controlled by **Show status bar**; when that surface is off it falls back to the toolbar. Touch tablet uses the toolbar, and phone has no LSP control. Auto-start/installation default off; Kotlin is manual-only. Local PC and Local Docker are supported, while SSH, Sprites, remote Docker, and mobile LSP are not. | +| Integrated shell terminals | Dependency-bound | Create, reopen, rename, and terminate are shipped. Shell settings affect new terminals, and executor/task-environment availability controls where the PTY runs. | +| Prompts, keyboard shortcuts, notifications, appearance, and task actions | Supported | These are normal settings surfaces. Browser notification permission, custom command validity, and local shell/editor availability still apply to their effects. | +| Web UI localization | Limited | **Settings > General > Appearance** offers English, Português (Portugal), and 简体中文. The choice applies immediately, persists per browser through a `kandev_locale` cookie, and is negotiated from `Accept-Language` before defaulting to English when no cookie is set. Externalization is complete: every reachable web UI surface renders through the translation layer, and dates, numbers, and relative timestamps follow the selected locale. Per-language completeness still differs. Portuguese tracks English closely; Simplified Chinese is maintained out of band and lags, most visibly in the task workbench. An untranslated key falls back to English rather than failing. About fifteen shared `@kandev/ui` accessibility labels (mostly screen-reader-only text such as a dialog's **Close**) stay English even where the visible UI is translated. Desktop-shell and CLI surfaces, API/log diagnostics, and agent output were never in scope; user content such as task titles, chat, and diffs is never translated. Development and E2E builds list an extra **Pseudo (QA)** locale that production builds do not ship. | @@ -84,27 +84,27 @@ Released builds can lag this page. Check **Settings > System > About** or `kande ## Agents, executors, integrations, and automation -| Capability | Status | Current boundary | -|---|---|---| -| Built-in ACP agent registry and profiles | Dependency-bound | The registry supports many CLIs, but installation, authentication, models, modes, configuration options, resume, and usage reporting come from the installed CLI and account. | -| Dynamic agent profiles | Experimental | Enable `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` or the matching **Dynamic agent routing** toggle, then restart. Create and manage profiles from the **Dynamic agents** card at the top of **Settings > Agents**. Each candidate has transient and hard-error retry, reset-wait, skip, or stop policies. Unclassified and ambiguous failures fail closed for manual recovery. A logical profile remains selected while the concrete execution profile is routed internally. | -| Bring-your-own TUI/passthrough agent | Limited | Native PTY interaction is supported. Passthrough does not provide full structured chat, task MCP, resumable ACP state, usage, plans, utility-agent execution, or workspace automation compatibility. | -| Profile permissions, CLI flags, literal environment values, and encrypted secret references | Supported | Secret references resolve at launch; a missing/deleted reference omits that variable. Auto-approve and permission-skipping settings enlarge the process trust boundary. | -| Local and Worktree executors | Supported | Both are seeded. They execute on the Kandev host; a worktree isolates Git checkouts, not processes, network, credentials, or the rest of the filesystem. | -| Local Docker and Sprites executors | Dependency-bound | Require the relevant daemon or provider, compatible host/image, token, platform, and credential-copy configuration. | -| SSH executor | Limited | Remote sessions support trusted Linux or macOS hosts on amd64 or arm64, with public-key authentication, SFTP, and TCP forwarding. The runtime materializes the primary repository at the remote task-workspace root, reuses matching checkouts without resetting local work, runs the resolved prepare hook before `agentctl`, and runs cleanup hooks only for terminal archive/delete stops. Failed preparation blocks launch; task directories remain for resume/manual housekeeping, and password authentication, hostile-user isolation, and automatic directory deletion are not provided. | -| Remote Docker executor | In progress | The type and legacy/direct settings fields are registered, but runtime create/stop return `not yet implemented`, and the current **Settings > Executors** hub does not offer it. | -| GitHub integration | Dependency-bound | Workspace repository scope, issue/PR browsing, actions, watches, presets, reviews, checks, and Gists depend on GitHub auth, scopes, rate limits, and repository permissions. Supported Local Docker, Sprites, and SSH launch paths may receive the globally stored token as `GITHUB_TOKEN`/`GH_TOKEN`; workspace repository scope does not constrain its privileges. | -| Azure DevOps integration | Limited | Azure DevOps Services supports workspace PAT configuration, boards, work-item/PR browsing, constrained assignment and board moves, feedback, task launch and associations, configurable actions/queries, and polling watches. Azure DevOps Server/TFS, Entra OAuth, arbitrary work-item editing, and webhooks are not supported; neither `gh` nor `az` is required. | -| GitLab integration | Limited | Each workspace can connect one `gitlab.com` or self-managed origin, browse and launch from issues/MRs, link tasks to MRs, run review/issue watches, review and act on linked MRs, assign reviewers, toggle MR notifications, and create/auto-link MRs. Provider permissions and exact host/project identity apply. Browse project narrowing is current-page-only, watches inspect at most their first 50 matches, GitLab has no team review-request equivalent, and issue notification subscription has no UI control. | -| Jira integration | Limited | Search/browse, supported transitions, launch presets, task launch, and watches require provider credentials. Launch copies data rather than creating a durable association; each watch poll reads only the first 50 JQL matches. | -| Linear integration | Limited | Search/browse, state changes, task launch, and watches require provider credentials. Launch has no preset/durable association; a watch reads 50 matches in provider order or at most 250 with explicit local sorting. | -| Sentry integration | Limited | Multi-instance configuration, issue watches, and current-task issue actions are shipped; there is no top-level browser. Watches read only the newest first page, and the status picker allows multiple selections while the backend accepts at most one. | -| Integration **Enabled** switches | Limited | Jira/Linear/Sentry switches are browser-local presentation state; they do not stop backend pollers. Pause/delete watches or remove the provider configuration when polling must stop. | -| Manual, preset/`@every` schedule, authenticated webhook, GitHub PR-open, and GitHub PR-merged automations | Limited | Every automation now produces the same kind of run; the Task/Run mode selector is withdrawn and runs have their own destination instead of the board. PR-open triggers need explicit repositories and ignore the saved Opened checkbox. PR-merged triggers watch task-linked pull requests, poll at up to one-minute latency, and bind their archive target to the event task; a manual run does not replay a merge. Webhooks silently retain only the first 1 MiB. **Play** remains clickable while disabled but produces no history. | -| Fixed cron timezone behavior | Limited | The UI accepts fixed cron and timezone values, but the current interval scheduler does not honor the configured timezone and some fixed cron expressions may never fire. Prefer tested presets or `@every` intervals. | -| GitHub push and CI automation triggers | In progress | Trigger types and UI labels exist as coming-soon/stub paths; they are not working production triggers. GitHub's separate opt-in PR CI-fix automation is shipped. | -| Automation runs | Limited | A firing creates a persistent task hidden from the board by its origin, starts immediately, and stays answerable; the old Run mode's inability to take user input is gone with the mode itself. Automations that predate this change stop producing kanban cards on their next firing. Worktrees are kept for the ten most recent finished runs per automation and reclaimed beyond that, so an older run is still readable but no longer repliable. | +| Capability | Status | Current boundary | +| --------------------------------------------------------------------------------------------------------- | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Built-in ACP agent registry and profiles | Dependency-bound | The registry supports many CLIs, but installation, authentication, models, modes, configuration options, resume, and usage reporting come from the installed CLI and account. | +| Dynamic agent profiles | Experimental | Enable `KANDEV_FEATURES_DYNAMIC_AGENT_ROUTING` or the matching **Dynamic agent routing** toggle, then restart. Create and manage profiles from the **Dynamic agents** card at the top of **Settings > Agents**. Each candidate has transient and hard-error retry, reset-wait, skip, or stop policies. Unclassified and ambiguous failures fail closed for manual recovery. A logical profile remains selected while the concrete execution profile is routed internally. | +| Bring-your-own TUI/passthrough agent | Limited | Native PTY interaction is supported. Passthrough does not provide full structured chat, task MCP, resumable ACP state, usage, plans, utility-agent execution, or workspace automation compatibility. | +| Profile permissions, CLI flags, literal environment values, and encrypted secret references | Supported | Secret references resolve at launch; a missing/deleted reference omits that variable. Auto-approve and permission-skipping settings enlarge the process trust boundary. | +| Local and Worktree executors | Supported | Both are seeded. They execute on the Kandev host; a worktree isolates Git checkouts, not processes, network, credentials, or the rest of the filesystem. | +| Local Docker and Sprites executors | Dependency-bound | Require the relevant daemon or provider, compatible host/image, token, platform, and credential-copy configuration. | +| SSH executor | Limited | Remote sessions support trusted Linux or macOS hosts on amd64 or arm64, with public-key authentication, SFTP, and TCP forwarding. The runtime materializes the primary repository at the remote task-workspace root, reuses matching checkouts without resetting local work, runs the resolved prepare hook before `agentctl`, and runs cleanup hooks only for terminal archive/delete stops. Failed preparation blocks launch; task directories remain for resume/manual housekeeping, and password authentication, hostile-user isolation, and automatic directory deletion are not provided. | +| Remote Docker executor | In progress | The type and legacy/direct settings fields are registered, but runtime create/stop return `not yet implemented`, and the current **Settings > Executors** hub does not offer it. | +| GitHub integration | Dependency-bound | Workspace repository scope, issue/PR browsing, actions, watches, presets, reviews, checks, and Gists depend on GitHub auth, scopes, rate limits, and repository permissions. Supported Local Docker, Sprites, and SSH launch paths may receive the globally stored token as `GITHUB_TOKEN`/`GH_TOKEN`; workspace repository scope does not constrain its privileges. | +| Azure DevOps integration | Limited | Azure DevOps Services supports workspace PAT configuration, boards, work-item/PR browsing, constrained assignment and board moves, feedback, task launch and associations, configurable actions/queries, and polling watches. Azure DevOps Server/TFS, Entra OAuth, arbitrary work-item editing, and webhooks are not supported; neither `gh` nor `az` is required. | +| GitLab integration | Limited | Each workspace can connect one `gitlab.com` or self-managed origin, browse and launch from issues/MRs, link tasks to MRs, run review/issue watches, review and act on linked MRs, assign reviewers, toggle MR notifications, and create/auto-link MRs. Provider permissions and exact host/project identity apply. Browse project narrowing is current-page-only, watches inspect at most their first 50 matches, GitLab has no team review-request equivalent, and issue notification subscription has no UI control. | +| Jira integration | Limited | Search/browse, supported transitions, launch presets, task launch, and watches require provider credentials. Launch copies data rather than creating a durable association; each watch poll reads only the first 50 JQL matches. | +| Linear integration | Limited | Search/browse, state changes, task launch, and watches require provider credentials. Launch has no preset/durable association; a watch reads 50 matches in provider order or at most 250 with explicit local sorting. | +| Sentry integration | Limited | Multi-instance configuration, issue watches, and current-task issue actions are shipped; there is no top-level browser. Watches read only the newest first page, and the status picker allows multiple selections while the backend accepts at most one. | +| Integration **Enabled** switches | Limited | Jira/Linear/Sentry switches are browser-local presentation state; they do not stop backend pollers. Pause/delete watches or remove the provider configuration when polling must stop. | +| Manual, preset/`@every` schedule, authenticated webhook, GitHub PR-open, and GitHub PR-merged automations | Limited | Automations can use hidden runs or create normal workflow tasks. Repository access is an explicit ordered list of repository/base-branch pairs or no repository; no first-workspace-repository fallback applies. The editor shares New Task's searchable workflow, profile, repository, and branch controls, and workflows start at their configured start step. PR-open triggers ignore the saved Opened checkbox. PR-merged triggers watch task-linked pull requests, poll at up to one-minute latency, and bind their archive target to the event task; a manual run does not replay a merge. Webhooks silently retain only the first 1 MiB. **Play** remains clickable while disabled but produces no history. | +| Fixed cron timezone behavior | Limited | The UI accepts fixed cron and timezone values, but the current interval scheduler does not honor the configured timezone and some fixed cron expressions may never fire. Prefer tested presets or `@every` intervals. | +| GitHub push and CI automation triggers | In progress | Trigger types and UI labels exist as coming-soon/stub paths; they are not working production triggers. GitHub's separate opt-in PR CI-fix automation is shipped. | +| Automation runs | Limited | Every firing has its own run row. Hidden runs may omit workflow and repository data and use a task-owned scratch workspace with Worktree or Local-compatible profiles; normal-task mode requires a workflow and creates visible Kanban/sidebar work. New-task mode creates an isolated task per firing; reusable mode continues one task, primary session, worktree or scratch workspace, and conversation with exact task/session/turn bindings and one active run at a time. Native provider compaction remains authoritative; the non-native fallback uses the newest 50 user/assistant messages and excludes tool events. The automation surface has fixed workspace/self boundaries, exact-run stop, title snapshots, and durable cleanup retry. | @@ -113,23 +113,23 @@ Released builds can lag this page. Check **Settings > System > About** or `kande ## MCP, clients, and operation -| Capability | Status | Current boundary | -|---|---|---| -| Task-scoped Kandev MCP | Dependency-bound | Injected into compatible task sessions with scoped task/session, plan, walkthrough, branch, workflow-step, and coordination tools. Available tools vary by MCP mode and task/session context. | -| Profile-defined external MCP servers | Limited | Stdio, HTTP, SSE, and streamable HTTP are implemented, but current launch resolution starts from an allow-all baseline and overlays only explicit executor policy. Blank remote policies therefore allow transports instead of applying the separate deny-all remote default. A stdio process is per-session and cannot be shared like a network server. | -| External Kandev MCP endpoint | Supported | **Settings > External MCP** exposes streamable HTTP at `/mcp` and SSE at `/mcp/sse` for configuration and task management. It has no Kandev user authentication; keep it on loopback/VPN or place it behind an authenticated proxy. It intentionally omits `stop_task_kandev` along with live-session question, plan, walkthrough, spawn, and message tools. | -| External MCP tool preview | Limited | The endpoint registration is authoritative. The current settings preview omits several registered tools and should not be used as a complete allow-list. | -| Office MCP and autonomous coordination | In progress | Office-only document, team, routine, and coordinator behavior is feature-gated and is not part of the supported external or regular task MCP contract. | -| CLI/browser runtime, Linux/macOS service, Docker deployment, and remote hosting | Dependency-bound | Platform, filesystem, browser, systemd/launchd, container, reverse-proxy, and network configuration determine availability. The web app, API, WebSocket, and external MCP add no Kandev user-login boundary; protect the whole remotely reachable origin. | -| Native desktop app | Dependency-bound | macOS, Windows, and Linux artifacts and a Tauri shell are implemented. Signing trust and update behavior differ by platform and release artifact. | -| Kubernetes deployment guide | Experimental | The guide describes one deployment approach; Kandev does not ship a Kubernetes executor or operator. | -| Statistics and host resource metrics | Supported | Workspace stats and on-demand CPU/memory/disk display are shipped. Open **Stats** from the desktop sidebar or from **Menu → Utilities → Stats** on phones. Execution-environment metrics depend on executor/runtime support. | -| SQLite backups, database maintenance, status, and logs | Supported | Backups use SQLite `VACUUM INTO` snapshots in `backups/` beside the configured database file. The default is `data/backups/`; Kandev does not move snapshots from another directory. Retention, free space, file permissions, and off-host copies remain operator responsibilities. | -| Update checking and applying updates | Dependency-bound | Checking is shipped. Package-manager installs update through their package manager; backend self-update is service-install-specific; desktop updater support varies by artifact/platform. | -| Feature Toggle settings | Supported | The page exposes Office, Dynamic agent routing, and Debug overrides, environment locks, and restart-required state. Debug is high risk because logs/endpoints can expose prompt, file, and tool data. A toggle being present does not promote its target feature to supported. | -| Office mode | In progress | Production defaults `KANDEV_FEATURES_OFFICE=false`; development and E2E enable it for implementation/testing. Its routes, agents, labels, documents, dependencies, routines, skills, routing, costs, and approvals may change between releases. | -| Plugin system | Supported | The plugin system ships in the base product with no feature flag: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges, so install only plugins you trust; manifest schema, capabilities, and host API may still change between releases. | -| Mock providers and E2E-only routes | Internal | They are selected only by development/test runtime profiles and must not be exposed as product integrations. | +| Capability | Status | Current boundary | +| ------------------------------------------------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| Task-scoped Kandev MCP | Dependency-bound | Injected into compatible task sessions with scoped task/session, plan, walkthrough, branch, workflow-step, and coordination tools. Available tools vary by MCP mode and task/session context. | +| Profile-defined external MCP servers | Limited | Stdio, HTTP, SSE, and streamable HTTP are implemented, but current launch resolution starts from an allow-all baseline and overlays only explicit executor policy. Blank remote policies therefore allow transports instead of applying the separate deny-all remote default. A stdio process is per-session and cannot be shared like a network server. | +| External Kandev MCP endpoint | Supported | **Settings > External MCP** exposes streamable HTTP at `/mcp` and SSE at `/mcp/sse` for configuration and task management. It has no Kandev user authentication; keep it on loopback/VPN or place it behind an authenticated proxy. It intentionally omits `stop_task_kandev` along with live-session question, plan, walkthrough, spawn, and message tools. | +| External MCP tool preview | Limited | The endpoint registration is authoritative. The current settings preview omits several registered tools and should not be used as a complete allow-list. | +| Office MCP and autonomous coordination | In progress | Office-only document, team, routine, and coordinator behavior is feature-gated and is not part of the supported external or regular task MCP contract. Automation sessions have a fixed workspace-scoped coordinator surface with trusted principal resolution and self-target denial. | +| CLI/browser runtime, Linux/macOS service, Docker deployment, and remote hosting | Dependency-bound | Platform, filesystem, browser, systemd/launchd, container, reverse-proxy, and network configuration determine availability. The web app, API, WebSocket, and external MCP add no Kandev user-login boundary; protect the whole remotely reachable origin. | +| Native desktop app | Dependency-bound | macOS, Windows, and Linux artifacts and a Tauri shell are implemented. Signing trust and update behavior differ by platform and release artifact. | +| Kubernetes deployment guide | Experimental | The guide describes one deployment approach; Kandev does not ship a Kubernetes executor or operator. | +| Statistics and host resource metrics | Supported | Workspace stats and on-demand CPU/memory/disk display are shipped. Open **Stats** from the desktop sidebar or from **Menu → Utilities → Stats** on phones. Execution-environment metrics depend on executor/runtime support. | +| SQLite backups, database maintenance, status, and logs | Supported | Backups use SQLite `VACUUM INTO` snapshots in `backups/` beside the configured database file. The default is `data/backups/`; Kandev does not move snapshots from another directory. Retention, free space, file permissions, and off-host copies remain operator responsibilities. | +| Update checking and applying updates | Dependency-bound | Checking is shipped. Package-manager installs update through their package manager; backend self-update is service-install-specific; desktop updater support varies by artifact/platform. | +| Feature Toggle settings | Supported | The page exposes Office, Dynamic agent routing, and Debug overrides, environment locks, and restart-required state. Debug is high risk because logs/endpoints can expose prompt, file, and tool data. A toggle being present does not promote its target feature to supported. | +| Office mode | In progress | Production defaults `KANDEV_FEATURES_OFFICE=false`; development and E2E enable it for implementation/testing. Its routes, agents, labels, documents, dependencies, routines, skills, routing, costs, and approvals may change between releases. | +| Plugin system | Supported | The plugin system ships in the base product with no feature flag: install/manage plugins, spawn plugin backends, and load native UI bundles. Loaded plugin code runs with backend privileges, so install only plugins you trust; manifest schema, capabilities, and host API may still change between releases. | +| Mock providers and E2E-only routes | Internal | They are selected only by development/test runtime profiles and must not be exposed as product integrations. | diff --git a/docs/public/websocket-api.md b/docs/public/websocket-api.md index c73d9a3f67..fc7d305698 100644 --- a/docs/public/websocket-api.md +++ b/docs/public/websocket-api.md @@ -489,6 +489,7 @@ automation.enable automation.get automation.list automation.run.delete +automation.run.stop automation.runs.delete_all automation.runs.list automation.runs.list_workspace @@ -505,6 +506,18 @@ automation.webhook.reveal_secret These are trusted local-administration operations. In particular, `secrets.reveal` and `automation.webhook.reveal_secret` make the lack of WebSocket authentication security-critical. +`automation.run.stop` requires `automation_id` and `run_id`. It cancels the +selected open run's exact task/session/turn binding and returns `{run_id, +status}`. A stale or terminal binding returns not found; it never stops another +turn that happens to share the same task or session. + +Automation payloads expose `task_mode` (`automation_run` or `normal_task`) and +`repository_mode` (`workspace_default`, `selected`, or `none`). Hidden runs may +omit workflow and repository data and use a Local scratch workspace. Normal +tasks require a workflow and remain visible in Kanban and the sidebar. A +repository-required provider trigger rejects `repository_mode=none` before it +creates an `AutomationRun`. + ### GitHub and GitLab ```text diff --git a/docs/specs/agents/requirements/external-permission-resolution.md b/docs/specs/agents/requirements/external-permission-resolution.md index 9a0d4b2df0..36e320ec2a 100644 --- a/docs/specs/agents/requirements/external-permission-resolution.md +++ b/docs/specs/agents/requirements/external-permission-resolution.md @@ -45,15 +45,17 @@ another client such as Hermes. ## What -- The external Kandev MCP endpoint exposes one read-only tool that lists pending agent - permission requests for a task, optionally limited to one task session. +- The external Kandev MCP endpoint exposes one read-only tool that lists pending agent permission + requests for a task, optionally limited to one task session. The fixed automation MCP profile + exposes the same tool to every automation-run task within its workspace. - Each result identifies the exact task, task session, Kandev request generation, provider pending request, and originating tool call. It includes creation time, pending status, a safe human-readable action presentation, and every provider-offered choice with its immutable option ID, label, and kind. -- The external endpoint exposes one mutation tool that selects exactly one option from exactly - one listed request. It accepts no command, tool arguments, replacement option, cancellation - flag, or free-form approval text. +- The external endpoint exposes one mutation tool that selects exactly one option from exactly one + listed request. The fixed automation MCP profile exposes the same tool together with discovery; + there is no per-automation permission-tool setting. It accepts no command, tool arguments, + replacement option, cancellation flag, or free-form approval text. - A resolution is accepted only while the complete task/session/request/pending identity still names the live request and the selected option is byte-for-byte one of that request's offered option IDs. @@ -103,7 +105,7 @@ display options, and status in metadata. Its resolution audit records: | `claim_id` | string | Opaque identity for one serialized resolution attempt | | `actor_user_id` | string | Kandev user identity; synthetic single-user identity is recorded as synthetic | | `actor_kind` | enum | `browser`, `personal_access_token`, `automation`, or `synthetic` | -| `source` | enum | `web`, `external_mcp`, or existing internal automation source | +| `source` | enum | `web`, `external_mcp`, `automation_mcp`, or existing internal automation source | | `request_id` / `pending_id` | string | Exact request identities selected | | `option_id` / `option_kind` | string | Exact provider-offered option identity and semantics | | `selected_at` / `finalized_at` | timestamp | UTC audit times; finalization may be absent after abrupt shutdown | @@ -220,6 +222,16 @@ Stable failure codes are: - The `external_mcp` audit source is derived only from the process-local transport attestation set by the authenticated external `/mcp` bridge. Shared in-session dispatcher calls and raw WebSocket traffic cannot supply or forge that source. +- An automation-run MCP server injects its caller task ID outside the tool schema. The backend + resolves one trusted automation principal before handler dispatch, containing the automation ID, + workspace, caller task, and caller session. It requires the target task/session to belong to the + same workspace and forbids the caller's own task and every session on it before reading live + runtime state or claiming a resolution. Missing metadata, self targets, and foreign targets fail + closed. +- The `automation_mcp` audit source and automation actor are derived only after that trusted scope + resolves. The request cannot supply the source, actor, workspace, or surface. The fixed + automation profile does not become personal-access-token authority and does not grant access to + another workspace owned by the same user. - Unauthorized and nonexistent task/session identities use the same not-found result. ## Failure modes @@ -284,6 +296,16 @@ Stable failure codes are: - **GIVEN** the existing web UI displays a permission prompt, **WHEN** the user selects one of its choices, **THEN** the UI submits the request generation and retains its existing pending, approved/rejected, and expired behavior. +- **GIVEN** an automation session starts or resumes, **WHEN** it discovers MCP tools, **THEN** both + permission discovery and resolution tools are present without a per-automation setting. +- **GIVEN** an automation resolves an offered option for a task in its workspace, **WHEN** the live + request still matches, **THEN** the live runtime remains authoritative and the audit records an + automation actor with source `automation_mcp`. +- **GIVEN** an automation names a task or session in another workspace, **WHEN** it lists or + resolves, **THEN** the request returns not-found without reading or mutating that runtime. +- **GIVEN** an automation names its own task or any session on that task, **WHEN** it lists or + resolves, **THEN** the request returns not-found and the provider prompt remains pending for a + person. ## Out of scope diff --git a/docs/specs/office/README.md b/docs/specs/office/README.md index 86f0bc22b6..a954ad14b7 100644 --- a/docs/specs/office/README.md +++ b/docs/specs/office/README.md @@ -38,6 +38,8 @@ dashboard projections, and Office testing contracts. - [Automation Runs](requirements/automation-runs.md) - [Automations — "Pull request merged" trigger](requirements/automations-pr-merged-trigger.md) - [Automations in Settings](requirements/automations-settings.md) +- [Automation Continuity](requirements/automation-continuity.md) +- [Automation Target Modes](requirements/automation-target-modes.md) - [Automations YAML Export](requirements/automations-yaml-export.md) - [Office: Cost Tracking & Budget Management](requirements/costs.md) - [Office Dashboard](requirements/dashboard.md) @@ -61,6 +63,7 @@ dashboard projections, and Office testing contracts. - [Office: Agents System Design Part 3](system-design/agents-03.md) - [Office: Personal Assistant Agent, Channels & Agent Memory](system-design/assistant.md) - [Automation Runs](system-design/automation-runs.md) +- [Automation Target Modes](system-design/automation-target-modes.md) - [Automations — "Pull request merged" trigger System Design Part 1](system-design/automations-pr-merged-trigger-01.md) - [Automations — "Pull request merged" trigger System Design Part 2](system-design/automations-pr-merged-trigger-02.md) - [Automations — "Pull request merged" trigger System Design Part 3](system-design/automations-pr-merged-trigger-03.md) diff --git a/docs/specs/office/requirements/automation-continuity.md b/docs/specs/office/requirements/automation-continuity.md new file mode 100644 index 0000000000..13d9846946 --- /dev/null +++ b/docs/specs/office/requirements/automation-continuity.md @@ -0,0 +1,145 @@ +--- +status: active +system: office +created: 2026-08-23 +owners: + - Kandev +--- + +# Automation Continuity Requirements + +## Overview + +By default, automation firings produce hidden work that people read through the +automation run surface. The separate [automation target mode +requirements](automation-target-modes.md) let a workspace owner opt into +visible normal tasks. For either target, the owner can choose whether each +firing gets an isolated task or continues one conversation and task +environment. The system must keep each firing addressable, preserve the +complete shared transcript, and expose one backend-owned coordinator authority +only to hidden automation agents. + +## Terminology + +- **New-task policy:** `new_task`, which creates an isolated hidden task, + primary session, task environment, and conversation for each firing. +- **Reusable policy:** `reuse_thread`, which sends later firings as new turns + to one hidden task and primary session. +- **Automation run:** The durable record for one firing. A run is distinct + from the task and is bound to the exact session turn that accepted it. +- **Hidden automation target:** The default target described by this document. + Its generated task is not ordinary Kanban work. +- **Coordinator authority:** The backend-resolved `SurfaceAutomation` MCP + profile and its trusted automation principal. + +## Requirements + +### REQ-OFFICE-AUTOMATION-CONTINUITY-001: Choose context between runs + +**Intent:** Let a workspace owner choose independent hidden automation work or +a single continuing hidden conversation without exposing hidden automation +tasks as ordinary Kanban work. + +#### Acceptance criteria + +- **AC-OFFICE-AUTOMATION-CONTINUITY-001.1:** When an automation is created or + edited, the settings form shall show a visible Context between runs choice + with `new_task` and `reuse_thread` options. +- **AC-OFFICE-AUTOMATION-CONTINUITY-001.2:** When no policy is selected, the + system shall use `new_task`. +- **AC-OFFICE-AUTOMATION-CONTINUITY-001.3:** When the automation uses the + hidden target and `new_task` is selected, each firing shall create a separate + conversation, files, and hidden task, and that task shall not appear in + Kanban or the sidebar. +- **AC-OFFICE-AUTOMATION-CONTINUITY-001.4:** When `reuse_thread` is selected, + the system shall limit the automation to one open run at a time. +- **AC-OFFICE-AUTOMATION-CONTINUITY-001.5:** When the hidden target is + selected, the form shall state that new-task runs use separate conversation + and files and that those tasks do not appear in Kanban or the sidebar. +- **AC-OFFICE-AUTOMATION-CONTINUITY-001.6:** Both policy descriptions shall + remain visible and accessible on desktop and mobile, and each choice shall + provide a touch target of at least 44 pixels. + +### REQ-OFFICE-AUTOMATION-CONTINUITY-002: Preserve and focus shared turns + +**Intent:** Make a reusable automation a readable conversation while keeping +each scheduled firing independently selectable and controllable. + +#### Acceptance criteria + +- **AC-OFFICE-AUTOMATION-CONTINUITY-002.1:** When a firing is admitted, its run + shall record the exact task, session, and turn identities before the turn is + dispatched. +- **AC-OFFICE-AUTOMATION-CONTINUITY-002.2:** When two runs in either target + mode share a session, selecting either run shall keep the complete session + transcript mounted and shall scroll or focus the selected turn instead of + removing other turns. +- **AC-OFFICE-AUTOMATION-CONTINUITY-002.3:** When a person replies from a + selected run in either target mode, the new turn and its messages shall + remain visible in the complete transcript even when its turn identity differs + from the selected scheduled turn. +- **AC-OFFICE-AUTOMATION-CONTINUITY-002.4:** When native session continuation + is unavailable, fallback resume context shall use the newest 50 non-empty + user or assistant text messages in chronological order. Tool calls, tool + results, status events, and unknown entries shall not consume that limit. +- **AC-OFFICE-AUTOMATION-CONTINUITY-002.5:** Reusing a checkout shall not reset + or rebase it. Replacement shall create a fresh task environment from the + configured repository base branch. + +### REQ-OFFICE-AUTOMATION-CONTINUITY-003: Recover admitted runs + +**Intent:** Ensure an admitted firing cannot permanently consume a concurrency + slot or be confused with another firing. + +#### Acceptance criteria + +- **AC-OFFICE-AUTOMATION-CONTINUITY-003.1:** The service shall persist an + admitted run before publishing the dispatch event. +- **AC-OFFICE-AUTOMATION-CONTINUITY-003.2:** If dispatch publication fails, + the admitted run shall become failed and `last_triggered_at` shall remain + unchanged. The next firing shall be eligible for admission. +- **AC-OFFICE-AUTOMATION-CONTINUITY-003.3:** A completion or stop action shall + identify the exact run, session, and turn and shall change no other shared + run. +- **AC-OFFICE-AUTOMATION-CONTINUITY-003.4:** Startup and dispatch recovery + shall fail an open bound run only when its exact turn has no live execution, + open turn, or pending blocker. A live or blocked turn shall remain open. +- **AC-OFFICE-AUTOMATION-CONTINUITY-003.5:** A replacement task shall receive + the rendered run title, while a resumed shared task shall keep its existing + title. Exported YAML shall include only the continuation policy and shall + exclude runtime pointers, bindings, titles, and MCP authority. + +### REQ-OFFICE-AUTOMATION-CONTINUITY-004: Constrain coordinator authority + +**Intent:** Give hidden automation agents enough workspace coordination access +while preventing owner-wide access, self-targeting, and task-local questions. + +#### Acceptance criteria + +- **AC-OFFICE-AUTOMATION-CONTINUITY-004.1:** Every hidden automation task + session shall receive the fixed `SurfaceAutomation` profile resolved by the + backend before MCP dispatch. A visible normal task shall receive its normal + task profile instead. +- **AC-OFFICE-AUTOMATION-CONTINUITY-004.2:** The fixed catalog shall include + workspace inventory, read-only launch catalog, task/session inspection, + task coordination, and blocker coordination tools, and shall exclude + `ask_user_question_kandev`, permanent task deletion, configuration writes, + task-local authoring, diagnostics, plugin tools, and provider PR/MR tools. +- **AC-OFFICE-AUTOMATION-CONTINUITY-004.3:** The trusted principal shall carry + the automation, workspace, caller task, caller session, and surface + identities. Handlers shall use it as the workspace boundary and audit the + source as `automation_mcp`. +- **AC-OFFICE-AUTOMATION-CONTINUITY-004.4:** Mutations, messages, stopping, + spawning, and blocker discovery or resolution through `SurfaceAutomation` + shall reject the automation's own hidden task and every session on it, as + well as foreign workspaces. +- **AC-OFFICE-AUTOMATION-CONTINUITY-004.5:** A task spawned on another task + shall receive that target task's normal profile and shall not inherit + `SurfaceAutomation` from its caller. + +## Out of scope + +- Concurrent turns or additional sessions on a reusable automation. +- Per-automation MCP capability settings or arbitrary tool allowlists. +- Cross-workspace coordinator authority or self-approval. +- Automatic reset or rebase of a reused worktree. diff --git a/docs/specs/office/requirements/automation-target-modes.md b/docs/specs/office/requirements/automation-target-modes.md new file mode 100644 index 0000000000..97f68f0ca2 --- /dev/null +++ b/docs/specs/office/requirements/automation-target-modes.md @@ -0,0 +1,143 @@ +--- +status: active +system: office +created: 2026-08-23 +owners: + - Kandev +--- + +# Automation Target Modes Requirements + +## Overview + +An automation can run as hidden coordinator work or create an ordinary task +for each firing. Hidden work is useful for scheduled reports and background +coordination. Visible work is useful when a firing should enter a selected +workflow and be handled like a normal task by a person. Both modes can use a +repository-backed execution or a task-owned scratch workspace when no +repository is attached. + +## Terminology + +- **Task mode:** The persisted firing target. `automation_run` is the hidden + default. `normal_task` creates a visible task. +- **Repository selection:** An ordered list of repository and base-branch + pairs. An empty list means that no repository is attached. +- **Scratch workspace:** A task-owned local directory created when a task has + no attached repository. It is not a repository selection. +- **Visible normal task:** A task with the normal task origin and workflow + behavior. It appears in the Kanban and sidebar and does not receive the + coordinator-only MCP surface. + +## Requirements + +### REQ-OFFICE-AUTOMATION-TARGETS-001: Choose an automation target + +**Intent:** Let a workspace owner choose whether an automation firing is +background coordination or ordinary workflow work. + +#### Acceptance criteria + +- **AC-OFFICE-AUTOMATION-TARGETS-001.1:** When an automation is created or + edited, the form shall show a visible target choice with hidden automation + run and normal task options. +- **AC-OFFICE-AUTOMATION-TARGETS-001.2:** When the target is omitted by a new + client or an existing stored automation has no target value, the system shall + use `automation_run` so existing automations retain hidden-run behavior. +- **AC-OFFICE-AUTOMATION-TARGETS-001.3:** When the hidden target is selected, + workflow and repository selection shall each be optional. A firing with no + workflow or repository shall be admitted and launched in a task-owned scratch + workspace. +- **AC-OFFICE-AUTOMATION-TARGETS-001.4:** When no repository is attached, the + selected executor profile shall run in a task-owned scratch workspace. + Worktree shall remain a valid profile choice and shall not require a Git + worktree when the repository list is empty. +- **AC-OFFICE-AUTOMATION-TARGETS-001.5:** When the normal task target is + selected, a workflow shall be required. A missing step shall use the + workflow's configured starting step. Repository selection shall remain + explicit, so an empty list never silently falls back to the workspace's + first repository. +- **AC-OFFICE-AUTOMATION-TARGETS-001.6:** Portable automation export shall + include the target and repository modes and shall exclude runtime task, + session, turn, and cleanup pointers. +- **AC-OFFICE-AUTOMATION-TARGETS-001.7:** An invalid target, workflow, + repository, or executor combination shall fail validation before an + `AutomationRun` is admitted, and shall return an actionable error to the + editor or trigger caller. +- **AC-OFFICE-AUTOMATION-TARGETS-001.8:** A trigger shall not cause the editor + or backend to attach the workspace's first repository. A repository-free + automation remains repository-free unless the trigger's established event + contract supplies its own exact repository context. +- **AC-OFFICE-AUTOMATION-TARGETS-001.9:** When a person configures repository + access, the editor shall let them add one or more explicit repository and + base-branch pairs or remove every pair. It shall not offer or apply a first + workspace repository fallback. +- **AC-OFFICE-AUTOMATION-TARGETS-001.10:** When a firing uses selected + repositories, the system shall create the task environment from the saved + base branch for each repository and preserve pair order. +- **AC-OFFICE-AUTOMATION-TARGETS-001.11:** When a workflow is selected, the + editor shall show its ordered step preview and the system shall place a new + task in that workflow's configured start step. The editor shall not ask the + person to select a workflow step. + +### REQ-OFFICE-AUTOMATION-TARGETS-002: Create and continue visible tasks + +**Intent:** Make a selected normal-task automation participate in ordinary +task work without losing exact automation-run accounting. + +#### Acceptance criteria + +- **AC-OFFICE-AUTOMATION-TARGETS-002.1:** When `normal_task` and `new_task` + are selected, each firing shall create a distinct visible task in the + selected workflow, and the task shall appear in the Kanban and sidebar. +- **AC-OFFICE-AUTOMATION-TARGETS-002.2:** When `normal_task` and + `reuse_thread` are selected, later firings shall continue the same visible + task and primary session when it is live and compatible. Only one scheduled + run shall be open at a time. +- **AC-OFFICE-AUTOMATION-TARGETS-002.3:** Every visible firing shall remain a + distinct `AutomationRun` with exact task, session, and turn identity. A + visible task shall use the normal agent profile and task lifecycle, not + `SurfaceAutomation` or hidden-run self-target restrictions. +- **AC-OFFICE-AUTOMATION-TARGETS-002.4:** Completion, failure, and exact-run + stop shall terminalize only the matching visible run and release its + automation concurrency slot. +- **AC-OFFICE-AUTOMATION-TARGETS-002.5:** Deleting or disabling an automation + shall not delete a visible normal task. Hidden automation tasks remain owned + by the hidden-run cleanup lifecycle. +- **AC-OFFICE-AUTOMATION-TARGETS-002.6:** A visible task with repository mode + `none` shall retain its normal Kanban/sidebar identity while its agent runs + in a task-owned scratch workspace. + +### REQ-OFFICE-AUTOMATION-TARGETS-003: Explain target choices on every viewport + +**Intent:** Make the difference between background work and normal task work +clear before a person saves an automation. + +#### Acceptance criteria + +- **AC-OFFICE-AUTOMATION-TARGETS-003.1:** The editor shall state that hidden + per-run tasks do not appear in the Kanban or sidebar, and shall state that + normal-task firings do appear there. +- **AC-OFFICE-AUTOMATION-TARGETS-003.2:** Target, repository, workflow, and + executor descriptions shall remain visible without hover or focus and shall + be associated with their headings or controls for assistive technology. +- **AC-OFFICE-AUTOMATION-TARGETS-003.3:** Target and continuity controls shall + remain usable with touch targets of at least 44 pixels on mobile. The editor + shall keep one primary vertical scroll owner and document horizontal overflow + at zero. +- **AC-OFFICE-AUTOMATION-TARGETS-003.4:** The Export and New Automation actions + on the automation settings toolbar shall have the same rendered height on + desktop and mobile. +- **AC-OFFICE-AUTOMATION-TARGETS-003.5:** Repository, workflow, agent-profile, + and executor-profile selection shall use the same searchable controls, + logos, repository/branch chips, and workflow step previews as task creation. + +## Out of scope + +- Concurrent turns or additional sessions on a reusable target. +- Per-automation MCP capability editing or arbitrary executor capability + allowlists. +- Automatic repository creation for a visible normal task. +- Converting an existing hidden task into a visible task in place. A target + change takes effect on the next firing and may replace the continuation. +- Deleting visible tasks as part of automation deletion. diff --git a/docs/specs/office/system-design/automation-runs.md b/docs/specs/office/system-design/automation-runs.md index 1debbc358d..cd5e8e5d8f 100644 --- a/docs/specs/office/system-design/automation-runs.md +++ b/docs/specs/office/system-design/automation-runs.md @@ -21,6 +21,9 @@ This design preserves the technical source detail for `REQ-OFFICE-AUTOMATION-RUN ## Migrated source detail +Decision: +[ADR-2026-08-22-user-configured-automation-continuity](../../decisions/2026-08-22-user-configured-automation-continuity.md) + ## Why An automation that produces a report — a nightly drift sweep, a dependency audit — is read daily and edited almost never. Its output was reachable only through the automation's own edit form: Settings → workspace → Automations → open the automation → scroll past the whole configuration → expand a collapsed section → click a row. Six steps into an editing surface to perform a reading task. @@ -79,6 +82,10 @@ conversation, not a log about it. - The pane shows a **run's transcript**, at full size, and lands on the newest — "what did it say last night" is the question this page exists for, and it should cost zero clicks. +- A run is anchored to an exact session turn. For `new_task`, that session normally belongs only to + the selected run. For `reuse_thread`, several run rows intentionally share one session; selecting + a run opens the shared transcript and scrolls/focuses the selected turn instead of showing the + latest turn for every row. - The automation's **standing instruction** is pinned above the transcript, collapsed. It is the same on every run, so it belongs to the automation rather than to any one of them; it is what makes the turns underneath legible. @@ -101,11 +108,20 @@ conversation, not a log about it. temporarily reports no open runs. - The URL is stable and bookmarkable — checking one automation daily is a first-class use, and it should cost one click from a bookmark. - **Run now** fires the automation from the reading surface, because the alternative — waiting until tomorrow to find out whether a schedule works — is not one. A trigger the concurrency cap turns away SHALL be reported as skipped with its reason, never as a fire: nothing ran, and no run appears under Running. +- A running row exposes **Stop current run**. It cancels the exact bound turn rather than the newest + turn on the shared task, marks the run failed, and releases the concurrency slot. On mobile it is + a visible 44 px action in the selected-run drawer/content header, not a hover-only rail control. +- The selected transcript component is keyed by run ID and turn ID, not only by session ID. Two + adjacent runs that share a session must still move the viewport and live-status binding to the + selected turn. **Entries** - Each entry leads with **what the run said**, not with its status. Kandev's runs are prose written by an agent, so the outcome text is the deliverable; status is metadata. This is the difference between a run log and a CI job table. - An entry shows: relative time, up to two lines of outcome, and status. In the cross-automation view it also carries the automation's name. +- An entry may show `Started new thread`, `Continued thread`, or `Replaced thread` when that context + helps explain a continuity fallback. The safe replacement reason is detail metadata, not a run + failure by itself. - Outcome text is the run's `error_message` when it has one, otherwise its `summary` (the tail of the agent's last message). A failed or skipped run therefore explains itself in place. A **skipped** run is not styled as a failure: the concurrency cap turning a firing away is the cap working, and rendering the reason in red makes a correctly throttled automation look broken. - A run that never produced a task SHALL NOT be clickable. @@ -116,22 +132,38 @@ conversation, not a log about it. ## Data model -No new persistent state. The feed reads existing `automation_runs` rows joined to `automations` for attribution. +The feed reads `automation_runs` rows joined to `automations` for attribution. New runs persist the +conversation anchor and continuation outcome needed when several firings share one task. | Field | Source | Note | |---|---|---| | all `AutomationRun` fields | `automation_runs` | including the read-time-derived `status` | -| `session_id` | `task_sessions` | the run's primary session; the transcript mounts from it, so resolving it in the projection avoids a lookup per run on the client. Empty when the run never started one | +| `display_title` | `automation_runs` | trigger-specific rendered title snapshot; does not change when a shared task is reused | +| `session_id` | `automation_runs` | exact session that accepted this firing; empty when it never dispatched | +| `turn_id` | `automation_runs` | exact scheduled turn; empty for legacy, skipped, or pre-dispatch failed rows | +| `thread_action` | `automation_runs` | `created`, `resumed`, or `replaced` for a dispatched run | +| `thread_reason` | `automation_runs` | optional safe reason for replacement; never raw provider/runtime secrets | | `automation_name` | `automations.name` | INNER join — a run whose automation is gone is unattributable | -| `summary` | `task_session_messages` | tail of the agent's last message, truncated server-side to 280 chars | +| `summary` | `task_session_messages` | tail of the agent message for `turn_id`, truncated server-side to 280 chars | -The status shown is derived at read time, not stored: a `task_created` run whose task was deleted or whose primary session was cancelled reads as `cancelled`, and one whose task was archived reads as `archived`. This derivation is defined once and shared with the per-automation log — the two views MUST NOT be able to disagree about the same run. +The status shown is derived at read time, not stored: a `task_created` run whose task was deleted or whose primary session was cancelled reads as `cancelled`, and one whose task was archived reads as `archived`. Persisted terminal statuses (`failed`, `succeeded`, and `skipped`) take precedence; read-time `cancelled` or `archived` derivation applies only to an open `task_created` run. This derivation is defined once and shared with the per-automation log — the two views MUST NOT be able to disagree about the same run. -The `summary` lookup filters `task_session_messages` by `task_id`, which requires an index leading with that column (`idx_messages_task_author_created`). Without it the lookup falls back to a global `created_at` scan once per row. +Stored `triggered` is also displayed as Running and counts as open. It exists only between durable +admission and accepted-turn binding; reconciliation turns an interrupted pre-dispatch row into a +visible failure rather than leaving it open forever. The Running filter includes both `triggered` +and `task_created`. + +For new rows, the summary lookup filters by the stored turn identity and agent author. It must never +fall back to the latest message in a shared task. Rows created before turn binding existed retain the +legacy task-based projection, which still uses `idx_messages_task_author_created`. + +Terminal status transitions address the run ID or exact session/turn binding. A manual reply in the +same reusable session has no AutomationRun binding and therefore cannot complete, fail, or rewrite a +scheduled run. ## API surface -Four actions. The detail view reads one automation's runs and its summary; the +Five actions. The detail view reads one automation's runs and its summary; the list reads every automation's summary; the flat lens reads the workspace feed. ```text @@ -158,8 +190,16 @@ automation.summaries automation.summary request { automation_id: string (required) } response { summary: AutomationSummary | null } // null = it has never run + +automation.run.stop + request { automation_id: string (required), run_id: string (required) } + response { run_id: string, status: "failed" } ``` +`automation.run.stop` loads the stored task/session/turn binding; the client never supplies those +identities. It succeeds only for an open run owned by `automation_id`. A terminal, missing, or +foreign run returns the same not-found result and does not cancel any newer turn. + Both facts are answered in ONE statement. Two queries are two snapshots: a run created between them reads as a still-open `last_run` with `open_runs = 0`, so the row renders idle and the client never starts polling — permanently stale @@ -182,6 +222,8 @@ cannot disagree. An automation with no summary row has genuinely never run. - Runs are ordered by `created_at` descending across all of the workspace's automations. - An empty result serializes as `[]`, never `null`. - A missing or empty `workspace_id` returns `BAD_REQUEST`. +- `AutomationRun` responses include `display_title`, `turn_id`, `thread_action`, and optional + `thread_reason`; clients treat empty `turn_id` as legacy or non-dispatched history. ## Permissions @@ -195,6 +237,10 @@ The action is workspace-scoped: the caller must be authorized for `workspace_id` | A filter excludes everything | Distinct message from the never-run case, so the user knows a filter is on | | Run has no `task_id` (e.g. a skipped run) | Entry renders and explains itself; it is inert, not a dead link | | Run has no `summary` (agent never spoke) | Entry says why there is nothing to read — still running, or no report recorded. It MUST NOT fabricate an outcome, but a blank line that leaves the reader guessing is not the alternative | +| Several runs share one session | Each row shows the agent output for its own turn. Selecting another row moves to that turn even though `session_id` is unchanged. | +| A user replies between scheduled firings | The reply remains in the shared transcript but does not create or finalize an AutomationRun. | +| One run is deleted from a shared thread | Only the run row is removed. The task/session/worktree remain while another run or the automation continuation pointer references them. | +| Delete-all is confirmed | The continuation pointer is cleared first, run rows are deleted, and each distinct associated task is deleted once through normal task cleanup. | | The list request fails | Surface the failure and offer a retry. A transient failure MUST NOT leave a permanently empty feed | | A slow first load resolves after a later refresh | The later response wins; a stale response MUST NOT overwrite fresher data | | An automation is enabled but cannot fire | The list row states why in place of a next-run time, rather than showing a time that will not happen | @@ -204,6 +250,7 @@ The action is workspace-scoped: the caller must be authorized for `workspace_id` | A run finishes while the page is open | The page stops calling it running without a reload. Polling runs only while something is open, so an idle workspace issues no repeat requests | | A visible run is still reported as open while the health summary says there are no open runs | The detail rail/drawer continues reading until the visible run receives a terminal status, then moves it to Completed without a reload | | An open run falls outside the page's own run window | The open count comes from the server, not from the loaded window, so the page still reports work in flight and keeps polling | +| A user stops a running reused turn | The action addresses its stored run/session/turn identity, cancels that exact turn, and marks only that run failed. | ## Scenarios @@ -217,7 +264,17 @@ The action is workspace-scoped: the caller must be authorized for `workspace_id` - **GIVEN** a link carrying `?run=`, **WHEN** it is opened, **THEN** that run's transcript is shown rather than the newest. - **GIVEN** a requested run that is no longer in the window, **WHEN** the page loads, **THEN** it falls back to the newest rather than rendering an empty pane. - **GIVEN** a finished run, **WHEN** the user types in the composer, **THEN** the agent continues in the same session and worktree. +- **GIVEN** three scheduled runs share one reusable session, **WHEN** the user selects the middle + run, **THEN** the full thread remains available and the view focuses that run's exact turn and + shows that turn's summary. +- **GIVEN** the selected and next run share a session ID, **WHEN** the user changes the run query + parameter, **THEN** the transcript still moves to the newly selected turn. +- **GIVEN** a human reply occurs after a scheduled run completed, **WHEN** that reply turn ends, + **THEN** the scheduled run remains unchanged and no new automation run appears. - **GIVEN** a run is in flight, **WHEN** the user opens it, **THEN** they see the agent's output as it arrives rather than a placeholder. +- **GIVEN** a run is in flight on desktop, **WHEN** the user selects Stop current run, **THEN** the exact run becomes failed and the next firing is no longer blocked by its slot. +- **GIVEN** a run is in flight on a phone, **WHEN** the user opens its run drawer/content and taps Stop current run, **THEN** the same cancellation outcome is available without hover or horizontal scrolling. +- **GIVEN** two runs share a task but render different title-template values, **WHEN** their rows are listed, **THEN** each row shows its own persisted `display_title`. - **GIVEN** a run whose agent asked a question, **WHEN** the user opens it, **THEN** they can reply and the agent continues. - **GIVEN** a workspace with runs from two different automations, **WHEN** the user opens the flat lens, **THEN** all runs appear in one feed ordered newest first, each labelled with its own automation's name. - **GIVEN** a run whose agent reported "Sweep complete across all 32 specs", **WHEN** the feed renders, **THEN** that text is visible on the entry without opening anything. diff --git a/docs/specs/office/system-design/automation-target-modes.md b/docs/specs/office/system-design/automation-target-modes.md new file mode 100644 index 0000000000..095bdcb0fd --- /dev/null +++ b/docs/specs/office/system-design/automation-target-modes.md @@ -0,0 +1,204 @@ +--- +status: current +system: office +requirements: + - REQ-OFFICE-AUTOMATION-TARGETS-001 + - REQ-OFFICE-AUTOMATION-TARGETS-002 + - REQ-OFFICE-AUTOMATION-TARGETS-003 +--- + +# Automation Target Modes System Design + +## Purpose and boundaries + +This design adds an explicit target to automation dispatch. The Office system +owns the saved target and repository choices, admission, exact run bindings, +continuation ownership, and deletion behavior. The task system owns normal +task visibility and workflow placement. The agent runtime owns scratch +workspace creation and provider context. The frontend owns the target choice, +conditional validation, and accessible explanations. + +The existing hidden automation target remains the default. A normal-task +target is visible work with normal task semantics, not a second coordinator +MCP surface. + +## Requirement mapping + +| Requirement | Design section | +| --- | --- | +| `REQ-OFFICE-AUTOMATION-TARGETS-001` | Data and contracts, control flow, persistence | +| `REQ-OFFICE-AUTOMATION-TARGETS-002` | Components and responsibilities, control flow, failure and recovery | +| `REQ-OFFICE-AUTOMATION-TARGETS-003` | Frontend boundary and observability | + +## Components and responsibilities + +- `internal/automation` owns `TaskMode`, ordered repository/base-branch + selections, validation, persistence, portable export, admission, and + `AutomationRun` accounting. +- `internal/orchestrator` resolves the target before task creation. It creates + hidden tasks with `TaskOriginAutomationRun` and visible tasks with a distinct + visible automation origin. It records the run ID in task metadata and binds + the exact session and turn after launch. +- `internal/task/service` receives an empty repository list for a valid scratch + task. It requires a workflow for a visible automation task and retains the + normal workflow-step resolver when the step is omitted. +- `internal/agent/runtime/lifecycle` continues to create a task-scoped scratch + workspace when `RepositoryPath` is empty. It does not create a worktree or + run repository preparation for a repository-free launch. +- `internal/mcp/profile`, `internal/mcp/scope`, and the MCP handlers apply + `SurfaceAutomation` only to hidden automation tasks. Visible automation + tasks use their ordinary task profile and ordinary caller boundaries. +- `apps/web/components/automations` composes the shared task-creation workflow, + repository, branch, agent-profile, and executor-profile selectors. It adds + only automation-specific validation and keeps desktop and mobile behavior + aligned. + +## Frontend boundary + +The editor keeps target mode separate from continuity. Hidden mode explains +that its per-run tasks stay out of the Kanban and sidebar. Normal-task mode +explains that each firing is ordinary workflow work. Switching the target +clears or revalidates fields that are not valid for the new mode, and the save +payload always carries the explicit target and ordered repository/base-branch +pairs. An empty list is the only no-repository representation. The editor does +not offer a workspace-default repository choice. + +The workflow, repository, branch, agent-profile, and executor-profile controls +are shared with task creation. The workflow menu shows ordered steps and the +configured start marker. Repository rows use the same paired chips and branch +search. Profile menus use the same search, logos, labels, and availability +rules. Automation code owns no parallel option catalog. + +On phones the existing automation editor remains the primary scroll surface. +Target and continuity choices use the existing stacked control pattern with +44-pixel touch targets. No second drawer or horizontal editor surface is +introduced. The settings toolbar gives Export and New Automation the same +height through one shared button sizing rule. + +## Data and contracts + +The automation model and API add a persisted task choice and ordered repository +bindings: + +```text +task_mode: automation_run | normal_task +repositories: [{repository_id, base_branch}, ...] +``` + +`automation_run` is the compatibility default when older clients omit the +field. An empty `repositories` list means no repository. The compatibility +`repository_ids` field is accepted from older clients and resolves each ID to +its configured default branch, but no empty request falls back to the first +workspace repository. + +The task model adds a visible automation provenance value, for example +`automation_task`. Board, sidebar, and normal task queries continue to exclude +only `automation_run`, so the new origin remains visible without pretending +that it was manually authored. The run ID, automation ID, target mode, and +trigger identity are carried in server-owned task metadata; callers cannot +choose a different run binding. + +`ReviewTaskRequest` and the automation dispatch contract carry the target and +run identity to the task service. The exact run remains the authority for +automation status; task ID alone is only a compatibility lookup for legacy +unbound rows. + +## Control flow + +### Save and validation + +1. The editor presents the target choice. Hidden mode allows an empty workflow; + normal-task mode requires a workflow. Selecting a workflow shows its step + preview; there is no independent step picker. +2. The editor presents zero or more repository/base-branch chip pairs. Removing + every pair selects scratch execution. Every non-empty row requires a + repository and base branch. +3. The backend validates target, workflow, repository selection, and + continuation policy together. Invalid combinations fail before a firing is + admitted. + +### Admission and dispatch + +1. `FireTrigger` renders the run title, atomically checks the shared open-run + predicate, stores a `triggered` `AutomationRun`, and publishes its run ID. +2. The orchestrator resolves the saved repository/base-branch pairs. An empty + list remains repository-free. A provider event can supply its own exact + repository only where its established event contract already does so. +3. A hidden target creates or resumes an automation-origin task. A normal-task + target creates or resumes a visible automation-origin task. Both paths use + the same exact task/session/turn binding and continuation slot. +4. A new task starts through the normal task launch path. With no repository, + the lifecycle creates a scratch workspace. With a repository, the selected + executor performs its normal repository preparation. +5. Hidden tasks receive `SurfaceAutomation`; visible tasks receive the normal + task MCP profile. A visible task never inherits the hidden profile merely + because its run was scheduled. + +### Continuation + +`new_task` creates a new task for either target. `reuse_thread` continues the +target's current task and primary session, and remains limited to one open run. +Compatibility checks include workspace, target mode, agent profile, executor +profile, and ordered repository/base-branch pairs. A missing or incompatible continuation gets a +replacement task and an explicit `created` or `replaced` action. The configured +workflow is used for a replacement, and normal task creation resolves its +configured start step. A resumed task keeps its existing title. + +## Failure and recovery + +- An empty repository list is valid for Worktree and Local-compatible executor + profiles. The lifecycle creates a scratch directory and skips Git worktree + preparation because there is no repository checkout. +- A task creation or exact launch failure marks the admitted run failed. The + concurrency slot is released without deleting a visible task that was + already created. +- Normal task completion and stop handling resolve the run by exact task, + session, and turn metadata, then update only that run. They do not invoke the + hidden automation agent stop or coordinator self-target rules. +- Hidden task cleanup remains reference-aware and deletes only unreferenced + hidden tasks/worktrees. Visible tasks are left in the normal task lifecycle + when an automation is disabled or deleted. Any open scheduled run is + terminalized, but the visible task remains available to its owner. +- Startup reconciliation handles stale hidden and visible bindings with the + same exact-turn liveness checks. A live, open, or blocked turn remains open. + +## Persistence + +Keep `task_mode` on `automations` and store ordered repository bindings in +`automation_repositories`, including `base_branch`. Fresh rows default to no +repositories. Existing non-empty repository bindings use the repository's +configured default branch when their legacy branch is empty. Existing empty +and `workspace_default` rows migrate to no repositories; the first-repository +fallback is removed. + +Portable YAML includes both user choices and repository IDs, but excludes +`continuation_task_id`, run bindings, rendered titles, session pointers, and +cleanup jobs. Import normalizes omitted target fields to the compatibility +defaults and revalidates executor/workflow combinations. + +Changing target mode, repository/base-branch pairs, workflow, agent profile, or +executor profile rotates the continuation on the next firing. +Name, description, prompt, title template, enabled state, and trigger edits do +not change the existing task until a firing needs a new or replacement task. + +## Security + +The task mode is server-owned configuration, not an MCP argument. Hidden +automation identity is derived only from a task whose origin and metadata bind +it to the automation. Visible automation tasks are not accepted as hidden +coordinator callers. Workspace authorization and exact run identity remain +required for all automation mutations. + +## Observability + +Run records expose target mode through the owning automation and preserve exact +task/session/turn IDs. Dispatch logs include target mode, repository mode, +thread action, and failure reason without logging prompt contents or secrets. +The editor and run history expose whether a run produced a hidden task or a +visible normal task, while the task list remains the source of truth for +visible task state. + +## Related decisions + +- [User-configured automation continuity](../../../decisions/2026-08-22-user-configured-automation-continuity.md) +- [Automation target modes](../../../decisions/2026-08-23-automation-target-modes.md) diff --git a/docs/specs/office/system-design/automations-settings-01.md b/docs/specs/office/system-design/automations-settings-01.md index 8fc6872870..20e6b98d18 100644 --- a/docs/specs/office/system-design/automations-settings-01.md +++ b/docs/specs/office/system-design/automations-settings-01.md @@ -7,6 +7,7 @@ created: 2026-05-21 owners: - jcfs --- + # Automations in Settings System Design Part 1 ## Purpose and boundaries @@ -23,29 +24,25 @@ This design preserves the technical source detail for `REQ-OFFICE-AUTOMATIONS-SE ## Why -Users want to schedule an agent to run a prompt on a cron (or on a GitHub PR event, or on a webhook) without first navigating to per-workspace settings, picking the right workspace, then drilling down into a workflow. What they get back is informational — a report to read — so it must not land on the board as if it were work someone has to move. +Users want to schedule an agent to run a prompt on a cron (or on a GitHub PR event, or on a webhook) without first navigating to per-workspace settings. Most results are reports that belong only in automation history. Some automations intentionally create work that must appear as a normal task in a selected workflow. The Automations feature, originating in PR #406, gives kandev a standalone trigger-based subsystem (cron, GitHub PR events, webhooks) that turns triggers into Tasks. This spec covers the automation object itself — its fields, triggers, and firing semantics. Where those runs are read and watched is `automation-runs.md`; that split is by concern, not by location, so keep both in step when either changes. -An earlier revision offered two execution flavours, `task` and `run`. That choice is withdrawn: it welded *where a run appears* to *how a run lives*, which no label could carry, and the clutter problem it existed to solve is now handled by giving runs their own destination rather than by hiding them. +The target-mode amendment in `../requirements/automation-target-modes.md` restores an explicit product outcome without restoring the old lifecycle ambiguity: a firing either creates a hidden automation-run task or a normal workflow task. Both outcomes still have an `AutomationRun` and exact turn identity. ## What -- Every automation produces the same kind of run. There is no execution-mode choice — the task/run selector is removed, and the field is ignored. A control that decided both *where a run appeared* and *how it lived* could not be labelled honestly: "Run" silently meant the worktree was destroyed, "Task" silently meant the schedule jammed after one firing. -- Every automation has an optional `repository_ids` field — an ordered list of repository IDs. When non-empty, scheduled and webhook firings pin the task to every listed repository (each on its own default branch, mirroring how the task-creation dialog resolves an explicit repository). When empty, falls back to the workspace's first repository (legacy behavior). `github_pr` triggers always use the PR's own repository and ignore `repository_ids`. -- The editor's repository picker matches the task-creation dialog's UX: lists both registered workspace repositories AND filesystem-discovered repositories under the workspace's roots. Picking a discovered repo registers it with the workspace at automation-save time (one round-trip via `createRepositoryAction`), then stores the resulting id on the automation. After the first save, the selection is promoted from `discovered` to `registered` so subsequent edits don't try to re-register. -- **Multi-repository selection is gated on the selected executor profile's capability**, reusing the task-creation dialog's `getMultiRepoExecutorDisabledReason` predicate (`apps/web/components/task-create-dialog-multi-repo-guard.ts`): `worktree`, `local_docker`, `ssh`, and `sprites` executor types support sibling repositories; `local`, `local_pc`, and `remote_docker` do not. - - When the selected executor profile's type supports multi-repo, the picker renders as a repeatable list (0..N rows) with an "Add repository" control. Each row independently picks a registered or discovered repository; a repository already selected in another row is marked and not independently selectable a second time (mirrors the task-creation dialog's "Already added" marker). - - When the selected executor profile's type does not support multi-repo, the picker renders as today's single dropdown (registered/discovered repos, or "Auto"). - - The Executor Profile picker disables executor profiles that don't support multi-repo whenever two or more repositories are currently selected, with the same disabled-reason text as the task-creation dialog, so a user cannot silently strand a multi-repository automation on an incompatible executor. - - Automations created via the WS API directly (bypassing the editor) may still combine an incompatible executor with multiple repository IDs; this is a client-side authoring guard, not a backend rejection. Task launch on an incompatible executor fails the same way manual multi-repo task creation would. -- A trigger fires → a task is created with `origin = "automation_run"` so the existing session pipeline launches an agent. That task: +- Every firing creates an `AutomationRun`. `task_mode = automation_run` creates a hidden task surfaced only through automation history. `task_mode = normal_task` creates an ordinary task that appears in the selected workflow, kanban, and sidebar. +- Every automation has an ordered `repositories` list of explicit repository/base-branch pairs. An empty list means repository-free execution in task-owned scratch space. Kandev never selects the workspace's first repository implicitly. `github_pr` triggers use the repository and branch context supplied by the event. +- The repository, workflow, agent, and executor controls reuse the searchable New Task selectors. Repository rows pair a repository with its base branch. The workflow control previews its steps but does not select one; normal tasks use the configured start step. +- The editor applies the New Task multi-repository executor guard. The API keeps the normal task-launch behavior for incompatible direct requests. +- In hidden mode, a trigger creates a task with `origin = "automation_run"` so the existing session pipeline launches an agent. That task: - **SHALL NOT appear on the kanban or in the task list.** It is hidden by its `origin`, not by ephemerality. Automation output has its own destination (`automation-runs.md`); the board stays the human work list. - **SHALL keep its worktree when the turn ends**, subject to [Run retention](#run-retention). 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. What is withdrawn is the *unconditional, permanent* retention of the original design — not reaping at end-of-turn. - **SHALL be repliable.** A run is a thread the user can open and continue, not a fire-and-forget transcript. - **SHALL reach a terminal run status on its own**, keyed on `origin`, so `max_concurrent_runs` frees up without a human archiving anything. - A firing produces **both** a task and its run row, or neither. The run row is the only thing that makes the work reachable — the task is hidden from every board and list by its `origin` — so a task without one is invisible, unfinalizable, and holds no concurrency slot anyone can see. If the run row cannot be written, the task created for it is deleted rather than left behind. -- Workflow and workflow step are therefore optional for every automation: no automation run is placed on a board, so no automation needs a starting column. +- Workflow is optional for hidden automation-run tasks and required for normal tasks. The backend resolves the normal task's start step from the workflow configuration. A workflow step is not stored as an automation authoring choice. - Because the run row is the surfaced artifact, it MUST actually surface the artifact: each row carries the tail of the agent's last message and links to that run's transcript. Hiding a task from the board is not a reason to withhold the only route to what it said. The reading surface is specified in `automation-runs.md`. - The run log offers a status filter that includes **Skipped**, **Archived** and **Cancelled**. A scheduled firing turned away by the concurrency cap writes a run row and nothing else, so without it a paused automation is indistinguishable from one that was never due. - Automations **auto-start** their agent regardless of any workflow step's `auto_start_agent` setting — nobody opens the task to drag it, so the trigger MUST be the start signal. @@ -74,6 +71,7 @@ automation_repositories id string PK automation_id string FK -> automations.id (ON DELETE CASCADE) repository_id string FK-by-id to repositories, not enforced at the DB layer + base_branch string explicit branch selected with this repository position integer 0-based order, preserved for resolution and UI row order created_at timestamp UNIQUE(automation_id, repository_id) @@ -81,7 +79,7 @@ automation_repositories The legacy `automations.repository_id TEXT NOT NULL DEFAULT ''` column is dropped by the `automation_repositories` migration. Every pre-existing automation with a non-empty `repository_id` is backfilled into `automation_repositories` (position 0) before the column is dropped, so no automation silently loses its configured repository across the upgrade. -`CreateAutomation`/`UpdateAutomation` validate that every submitted repository ID belongs to the automation's `workspace_id` and reject unknown, foreign, or duplicate IDs. +`CreateAutomation`/`UpdateAutomation` validate that every submitted repository belongs to the automation's `workspace_id`, has a base branch, and is not duplicated. The `tasks.origin` column already exists (used by quick-chat); the origin constant `TaskOriginAutomationRun = "automation_run"` lives in `internal/task/models/models.go`. @@ -91,23 +89,22 @@ The `tasks.origin` column already exists (used by quick-chat); the origin consta ## API surface -PR #406's WS-based API gets `repository_ids` (an ordered `string[]`, replacing the old singular `repository_id`) on the payloads below. `execution_mode` is accepted and ignored on input, and omitted from responses; the column stays only so no schema migration is needed. Accepting-and-ignoring is deliberate — an older client that still sends the field gets a successful write rather than a validation error it cannot act on. The behavioural consequence of ignoring it is covered in [Migration](#migration). +The WS API uses canonical `repositories` entries with `repository_id` and `base_branch`, plus `task_mode` and `repository_mode`. Legacy `repository_ids` input remains compatible and resolves each ID to that repository's configured default branch. -- `automation.create` payload (input) — `repository_ids?: string[]` -- `automation.update` payload (input) — `repository_ids?: string[]`; a present-but-empty array clears the automation's repositories, an absent field leaves them unchanged (matches the existing task-update `repositories` convention) -- `automation.get` / `automation.list` responses (output) — `repository_ids: string[]`, ordered by `position` +- `automation.create` payload (input) — `repositories?: { repository_id, base_branch }[]`, `task_mode`, and derived `repository_mode` +- `automation.update` payload (input) — a present-but-empty `repositories` array clears repository access; an absent field leaves it unchanged +- `automation.get` / `automation.list` responses (output) — ordered repository/base-branch pairs; legacy repository IDs remain a compatibility projection No new endpoints. No HTTP routes change. Sidebar deep-links to `/settings/automations` (flat). ## State machine -One pipeline, no branches. Every firing takes the same path through -`orchestrator/event_handlers_automation.go::handleAutomationTriggered`: +Every firing uses the same admission and exact-run lifecycle. Dispatch branches only when it creates the target task through `orchestrator/event_handlers_automation.go::handleAutomationTriggered`: ```text trigger fires → resolve repository - → CreateReviewTask(Origin=automation_run) -- never ephemeral + → CreateReviewTask(Origin=automation_run or normal) -- never ephemeral → record AutomationRun (status=task_created, task_id set) → associate PR if github_pr trigger → StartTask -- unconditional; the trigger IS the start signal @@ -130,14 +127,14 @@ Inherits PR #406's model (no per-action authorization gates). The flat `/setting | Automation's task starts but the agent fails | AutomationRun transitions from `task_created` to `failed`; the row surfaces the failure instead of remaining "Running". | | Automation's agent completes its turn successfully | AutomationRun transitions from `task_created` to `succeeded`; the agent execution is stopped. The worktree stays, and the session parks in `WAITING_FOR_INPUT` so the run can be answered. | | Automation's turn is cancelled by the user | AutomationRun transitions from `task_created` to `failed` with a cancellation error; the hidden session is marked `CANCELLED` and the agent execution is torn down. | -| User manually drags an automation's task on the kanban | Cannot happen — automation tasks are hidden from the board by their `origin`. The "auto-start" rule fires once at trigger time; recovery is by replying to the run, not by dragging. | +| User looks for a hidden automation-run task on the kanban | It is intentionally absent and remains available through the automation run transcript. Normal-task targets are ordinary visible workflow tasks. | | Automation that previously ran in `task` mode | Its next firing no longer produces a board card. Cards already on the board are left alone; they are ordinary tasks now and can be archived by hand. | | Automation that previously ran in `run` mode | Its next firing keeps its worktree instead of having it reaped, so the run's output survives and the run can be answered. | | A finished run parks in `WAITING_FOR_INPUT` so it stays answerable | That state is in the "active session" set, so agent-profile deletion and its blocker list exclude automation runs **in that state only**. One nightly report must not permanently block deleting its profile, nor appear as a blocker the user cannot find on any board. The accepted consequence is that deleting the profile makes those parked conversations non-resumable — replying to an old run afterwards fails. | | Agent-profile deletion while an automation run is `CREATED`, `STARTING` or `RUNNING` | Blocked, and the run is named in the confirmation. The run is using the profile right now, so this is the same hazard as for any other live task; only the parked state is exempt, not the origin. | | A run left in `task_created` before this change | Finalization now keys on `origin`, so the stuck run reaches a terminal status on the next completion event and stops holding `max_concurrent_runs`. | | The run row cannot be written after the task is created | The task is deleted and the firing is abandoned, so nothing survives that no run points at. A delete that itself fails is logged with both ids — the task is then genuinely orphaned and needs manual cleanup, which is why it is reported rather than swallowed. | -| `automation.create`/`automation.update` submits a `repository_ids` entry outside the automation's workspace, or a duplicate ID | Request is rejected with a validation error; no partial repository list is persisted. | +| `automation.create`/`automation.update` submits a repository outside the automation's workspace, a duplicate repository, or an empty base branch | Request is rejected with a validation error; no partial repository list is persisted. | | Editor has 2+ repositories selected and the user picks an executor profile whose type doesn't support multi-repo | Editor disables that executor profile in the picker with the same reason text as the task-creation dialog; the WS API itself does not reject the combination if reached directly. | ## Persistence guarantees @@ -187,3 +184,70 @@ Known residuals, stated rather than implied: - **GIVEN** a run that becomes live *after* it was selected as a candidate, **WHEN** the sweep reaches it, **THEN** its checkout is left alone. - **GIVEN** a removal that reports success but leaves the directory in place, **WHEN** the sweep completes, **THEN** it is not reported as reclaimed and it is retried. - **GIVEN** an automation whose backlog exceeds one sweep window, **WHEN** successive runs finalize, **THEN** the oldest are eventually reached rather than stranded. + +## Migration + +This section records the earlier migration away from the legacy +`execution_mode` lifecycle. It does not override the current `task_mode` +outcomes defined by the target-mode amendment. + +Withdrawing `execution_mode` is a behaviour change for existing installs, and the size of it is easy to understate. `task` was the **default**, so this is not an exotic minority: every automation created before this change that nobody explicitly set to `run` was producing a visible kanban card, and after upgrade it will not. The automations feature has been in the product since 2026-05-22, long enough for that to be somebody's working setup rather than a hypothetical. + +The runs are not lost — they move. A firing still creates a real, persistent, repliable task with its worktree; it is hidden from the board by `origin` and surfaced at `/automations/:id` and in the sidebar's Automations section instead. What disappears is the board card, not the work. + +**Decision: one destination, migrated loudly.** Honouring the stored `execution_mode` was considered and rejected. It would reinstate exactly the dual-destination split this change exists to remove, and would charge for it permanently — two write paths, two places a run can live, and every future automations surface built twice — in order to serve a migration window that closes once. The complexity would outlive the problem. + +What we do instead: + +1. On upgrade, identify automations whose stored `execution_mode` is `task` (still readable — the column is retained, just unread by the runtime). +2. Show a one-time, dismissible notice on the automations surface for workspaces that own such automations, stating that their runs now appear here rather than on the board. The notice is per-workspace and dismissal is durable, so it informs once instead of nagging. +3. Carry the same statement in the release notes for the version that ships this. + +The detection is a **read of a retained column at migration time only**. It deliberately does not become a runtime branch — nothing in the firing path consults `execution_mode`, so the single-destination invariant holds from the first line of the change. + +### Migration scenarios + +- **GIVEN** an install with an automation whose stored `execution_mode` is `task`, **WHEN** the user next opens the automations surface for that workspace, **THEN** a dismissible notice states that automation runs now appear there instead of on the kanban. +- **GIVEN** that notice has been dismissed, **WHEN** the user returns to the same surface later, **THEN** it does not reappear. +- **GIVEN** an install whose automations were all `run` mode, **WHEN** the user opens the automations surface, **THEN** no notice is shown — nothing changed for them. +- **GIVEN** any automation during that migration, **WHEN** a trigger fires after upgrade, **THEN** the stored legacy `execution_mode` does not choose its lifecycle or destination. + +## Scenarios + +- **GIVEN** a hidden automation-run target with a cron trigger, **WHEN** the cron fires, **THEN** its task does NOT appear on the kanban or in the sidebar, the agent starts automatically, and the run appears in the automation's activity. +- **GIVEN** a normal-task target with a selected workflow, **WHEN** the trigger fires, **THEN** a normal task appears in that workflow and the sidebar at the workflow's configured auto-start step. +- **GIVEN** a normal-task target without a workflow, **WHEN** the user saves it, **THEN** validation rejects the automation. +- **GIVEN** an automation run whose agent ended by asking a question and whose worktree is still within the retention window, **WHEN** the user opens that run, **THEN** they can reply to it and the agent continues in the same worktree. +- **GIVEN** an automation run that wrote files, **WHEN** the run finishes, **THEN** those files are still present in its worktree, and remain so until the run falls outside the retention window. +- **GIVEN** an automation at `max_concurrent_runs = 1` whose run has completed, **WHEN** the next scheduled firing is due, **THEN** it runs — no archiving required. +- **GIVEN** an automation agent finishes a turn with `stop_reason = "end_turn"`, **WHEN** the complete event is handled, **THEN** the AutomationRun row is marked `succeeded`, the agent execution is stopped instead of waiting for process exit, and the session is left answerable rather than `COMPLETED`. +- **GIVEN** a firing whose task is created but whose launch then fails, **WHEN** the error is handled, **THEN** the AutomationRun is marked `failed` with the launch error, so the automation's concurrency slot is released instead of jamming permanently. +- **GIVEN** a firing whose task is created but whose run row cannot be written, **WHEN** the error is handled, **THEN** the task is deleted and no agent is launched, so no hidden task survives that nothing can reach or finalize. +- **GIVEN** a user opens `/settings/automations` in an install with one workspace, **WHEN** the page loads, **THEN** the browser redirects to `/settings/workspace//automations`. +- **GIVEN** a user opens `/settings/automations` in an install with three workspaces, **WHEN** the page loads, **THEN** a workspace picker is shown; clicking one navigates to its automations. +- **GIVEN** a user opens `/settings/automations` in a fresh install with zero workspaces, **WHEN** the page loads, **THEN** an empty-state card explains "create a workspace first" with a CTA. +- **GIVEN** a user opens `/settings/automations` in a multi-workspace install, **WHEN** the page loads, **THEN** it renders the picker from the already-loaded workspace list and issues **no** additional `GET /api/v1/workspaces` request on load (guards against the render/refetch loop that a server-style `await listWorkspaces()` in the page body caused after the SPA migration). +- **GIVEN** an automation triggered by a GitHub PR event, **WHEN** the trigger fires, **THEN** the PR is associated with the created task via `AssociatePRWithTask` as before. +- **GIVEN** a scheduled automation with one repository/base-branch pair, **WHEN** the cron fires, **THEN** the resulting task uses that exact repository and branch regardless of other workspace repositories. +- **GIVEN** a scheduled automation with two or more repository/base-branch pairs, **WHEN** the cron fires, **THEN** the resulting task is created with all pairs attached in the saved order, like a manually created multi-repository task. +- **GIVEN** a scheduled automation with an empty repository list in a workspace that has repositories, **WHEN** the cron fires, **THEN** the task uses task-owned scratch space and no workspace repository is attached. +- **GIVEN** a repository-free automation using a Worktree executor profile, **WHEN** it fires, **THEN** it runs in scratch space without creating a Git worktree or failing for lack of a repository. +- **GIVEN** an automation with configured repositories and a `github_pr` trigger, **WHEN** a PR event fires, **THEN** the task uses the event's repository context rather than the configured pairs. +- **GIVEN** the editor's selected executor profile type is `worktree`, `local_docker`, `ssh`, or `sprites`, **WHEN** the user opens the repository picker, **THEN** it renders as a repeatable list and "Add repository" is enabled. +- **GIVEN** the editor's selected executor profile type does not support multiple repositories, **WHEN** the user opens the repository picker, **THEN** it retains the shared paired-chip presentation and prevents an incompatible multi-repository save. +- **GIVEN** the editor has two or more repositories selected, **WHEN** the user opens the Executor Profile picker, **THEN** profiles whose type doesn't support multi-repo are disabled with an explanatory reason, matching the task-creation dialog's guard text. +- **GIVEN** an automation created before `repository_ids` existed with a non-empty legacy `repository_id`, **WHEN** the schema migration runs, **THEN** the editor shows that repository pre-selected as the sole row after upgrade. +- **GIVEN** a user picks a discovered (not-yet-registered) repository in the editor and clicks Save, **WHEN** the save flow runs, **THEN** the discovered repo is registered with the workspace first (`createRepositoryAction`), its new id is written onto the automation, and the picker selection is promoted to `registered` so re-saving doesn't duplicate the registration. +- **GIVEN** an automation created before this change, **WHEN** the user opens the editor, **THEN** no execution-mode selector is shown and the automation behaves like every other one. + +## Out of scope + +- **AutomationRun-as-true-session-owner** (instead of ephemeral task). The cleaner model — make `task_sessions.task_id` nullable, add `task_sessions.automation_run_id`, route automation runs bypassing tasks entirely — was considered and explicitly deferred to a future PR. It touches ~50+ files in the orchestrator + session pipeline + WS layer + frontend state, which is out of scope here. The origin-tagged-task path is the pragmatic shim. +- **Agent-type primary picker.** PR #406's editor still picks an `agent_profile_id` (a fully configured profile), not a raw agent type (`claude` / `codex` / `opencode`). Switching to agent-type-primary requires plumbing changes in the orchestrator (which expects a profile id). Deferred. +- **Auto-provisioned default workspace.** When no workspaces exist, the flat page shows a CTA; it does not auto-create one. Most installs already have a workspace (workspace setup is part of onboarding), so the CTA is sufficient for now. +- **Cross-workspace automation listing** on the flat page. Multi-workspace installs see a picker, not a merged list. Merging would require a new list-all endpoint and a workspace column in the table. +- A standalone AutomationRun detail page showing session output inline. A run links to its task's detail page, which is where the transcript already lives; `automation-runs.md` covers how a reader reaches it. + +## Open questions + +- (none)