From bc218dfbbeb2effe4825e757db597a910c3c1969 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Flor=C3=AAncio?= Date: Sat, 25 Jul 2026 21:29:53 +0100 Subject: [PATCH] feat: add workspace sources from files panel (#1900) * feat: add workspace sources from files panel * fix: preserve worktree identity during recovery * feat: unify workspace source actions * fix: validate recovered workspace repositories * fix: harden workspace source filesystem boundaries * test: split workspace repository chip coverage * fix: restrict repository rescan recovery paths * fix: stabilize workspace source CI checks * test: canonicalize workspace source paths * test: align workspace source container paths * fix: populate direct workspace repository names * test: preserve git fixture rewrites across restarts * test: isolate backend git fixture config * fix: resume remote workspace executions before creation * fix: preserve ssh agent identity during resume * fix: preserve SSH workspace across backend restart * fix: simplify workspace actions trigger * fix: refine add workspace repositories UX * docs: narrow workspace source UX updates * fix: avoid phantom turn on session resume * test(web): normalize workspace source request headers * feat(web): streamline workspace source picker * docs: remove trailing spaces from workspace source plan * fix: prevent resume status from creating phantom turns * fix(web): stabilize workspace source attachment * refactor(web): extract workspace source store contract * fix: avoid phantom turns on idle agent resume * fix: address backend static analysis findings * fix: preserve agentctl readiness status value * fix: preserve resume messages without active turns * fix: resume agents from promoted workspace root * chore: merge origin/main into feature/add-new-repo-folder-d97 * feat(web): explain workspace source consequences * test(websocket): count session activity subscription --- apps/backend/cmd/acpdbg/README.md | 24 +- apps/backend/cmd/acpdbg/main.go | 15 +- apps/backend/internal/agent/acpdbg/ops.go | 42 +- .../backend/internal/agent/acpdbg/ops_test.go | 13 + apps/backend/internal/agent/agents/agent.go | 16 +- .../backend/internal/agent/agents/grok_acp.go | 9 +- .../internal/agent/agents/opencode_acp.go | 7 +- .../agents/workspace_rebind_resume_test.go | 28 + .../agentctl/client_workspace_sources.go | 114 +++ .../agentctl/client_workspace_sources_test.go | 147 ++++ .../agent/runtime/agentctl/control.go | 4 + .../runtime/agentctl/workspace_rescan.go | 50 +- .../runtime/lifecycle/executor_backend.go | 5 +- .../lifecycle/executor_backend_test.go | 10 + .../lifecycle/executor_remote_docker_test.go | 15 + .../agent/runtime/lifecycle/executor_ssh.go | 176 +++- .../lifecycle/executor_ssh_connection_test.go | 18 + .../lifecycle/executor_ssh_credentials.go | 23 +- .../executor_ssh_credentials_test.go | 75 +- .../lifecycle/executor_ssh_operations.go | 62 +- .../lifecycle/executor_ssh_resume_test.go | 235 ++++++ .../runtime/lifecycle/executor_standalone.go | 23 +- .../runtime/lifecycle/manager_boot_test.go | 51 +- .../runtime/lifecycle/manager_events_test.go | 36 +- .../runtime/lifecycle/manager_execution.go | 49 ++ .../lifecycle/manager_execution_test.go | 98 +++ .../agent/runtime/lifecycle/manager_launch.go | 52 +- .../runtime/lifecycle/manager_lifecycle.go | 7 +- .../lifecycle/manager_lifecycle_test.go | 24 + .../lifecycle/manager_workspace_rebind.go | 227 ++++++ .../lifecycle/manager_workspace_rescan.go | 15 +- .../manager_workspace_sources_test.go | 377 +++++++++ .../internal/agent/runtime/lifecycle/types.go | 51 +- .../lifecycle/workspace_materialization.go | 235 ++++++ .../workspace_materialization_test.go | 289 +++++++ .../lifecycle/workspace_sources_reconcile.go | 101 +++ .../workspace_sources_reconcile_test.go | 31 + .../agent/settings/controller/profile_crud.go | 10 +- .../settings/controller/profile_crud_test.go | 5 + .../internal/agentctl/server/api/server.go | 4 + .../server/api/workspace_materialize.go | 534 ++++++++++++ .../server/api/workspace_materialize_test.go | 511 ++++++++++++ .../agentctl/server/api/workspace_rescan.go | 48 +- .../server/api/workspace_rescan_test.go | 140 +++- .../internal/agentctl/server/config/config.go | 17 + .../agentctl/server/config/config_test.go | 13 + .../agentctl/server/instance/instance.go | 3 +- .../agentctl/server/instance/manager.go | 1 + .../agentctl/server/process/manager.go | 116 ++- .../server/process/manager_killgroup_test.go | 33 + .../agentctl/server/process/manager_rescan.go | 322 ++++++-- .../process/manager_rescan_path_test.go | 11 + .../server/process/manager_rescan_test.go | 410 +++++++++- .../server/process/workspace_files.go | 214 +++-- .../server/process/workspace_files_test.go | 256 ++++++ .../server/process/workspace_tracker.go | 4 + apps/backend/internal/backendapp/adapters.go | 3 + .../backendapp/branch_materializer.go | 39 +- .../backendapp/branch_materializer_test.go | 2 +- apps/backend/internal/backendapp/main.go | 5 + apps/backend/internal/backendapp/services.go | 30 +- .../workspace_source_materializer.go | 654 +++++++++++++++ .../workspace_source_materializer_test.go | 613 ++++++++++++++ apps/backend/internal/backendapp/worktree.go | 2 + .../internal/backendapp/worktree_test.go | 66 ++ apps/backend/internal/events/types.go | 11 +- .../gateway/websocket/task_notifications.go | 6 + .../websocket/task_notifications_test.go | 2 +- .../backend/internal/mcp/handlers/handlers.go | 95 +++ .../internal/mcp/handlers/handlers_test.go | 17 + .../internal/mcp/server/handlers_test.go | 47 ++ apps/backend/internal/mcp/server/server.go | 63 +- .../internal/mcp/server/server_test.go | 5 +- .../orchestrator/event_handlers_streaming.go | 6 +- .../event_handlers_streaming_test.go | 85 ++ .../orchestrator/executor/executor.go | 6 +- .../executor/executor_environment_test.go | 52 ++ .../orchestrator/executor/executor_execute.go | 135 +--- .../executor/executor_mocks_test.go | 3 + .../orchestrator/executor/executor_resume.go | 29 + ...ecutor_resume_clone_default_branch_test.go | 15 + apps/backend/internal/orchestrator/service.go | 14 + .../internal/task/dto/converters_test.go | 23 + apps/backend/internal/task/dto/dto.go | 67 +- .../task/handlers/repository_handlers_test.go | 26 + .../internal/task/handlers/task_handlers.go | 1 + .../task/handlers/task_http_handlers.go | 105 +++ .../task/handlers/task_http_handlers_test.go | 31 + apps/backend/internal/task/models/models.go | 66 +- .../repository/cleanup_repository_test.go | 10 + .../internal/task/repository/interface.go | 19 + .../task/repository/sqlite/base_migrations.go | 28 + .../task/repository/sqlite/base_schema.go | 14 + .../repository/sqlite/repository_entity.go | 23 + .../sqlite/repository_entity_test.go | 29 + .../repository/sqlite/workspace_folder.go | 233 ++++++ .../sqlite/workspace_folder_test.go | 175 ++++ apps/backend/internal/task/service/service.go | 163 ++-- .../internal/task/service/service_branches.go | 72 +- .../task/service/service_branches_test.go | 37 + .../internal/task/service/service_events.go | 56 ++ .../task/service/service_events_test.go | 39 + .../internal/task/service/service_messages.go | 19 +- .../internal/task/service/service_requests.go | 1 + .../task/service/service_resources.go | 20 + .../internal/task/service/service_tasks.go | 10 + .../internal/task/service/service_test.go | 34 +- .../internal/task/service/service_turns.go | 152 ++++ .../task/service/service_turns_test.go | 235 ++++++ .../task/service/service_workspace_folders.go | 38 + .../task/service/service_workspace_sources.go | 615 ++++++++++++++ .../service/service_workspace_sources_test.go | 758 ++++++++++++++++++ apps/backend/internal/worktree/branch_plan.go | 133 +++ .../internal/worktree/directory_link.go | 190 +++++ .../internal/worktree/directory_link_test.go | 57 ++ .../internal/worktree/directory_link_unix.go | 18 + .../worktree/directory_link_windows.go | 88 ++ apps/backend/pkg/api/v1/task.go | 46 +- apps/backend/pkg/api/v1/task_test.go | 21 +- apps/backend/pkg/websocket/actions.go | 148 ++-- .../create-local-repository-surface.test.tsx | 13 + .../create-local-repository-surface.tsx | 34 +- .../components/task-create-dialog-computed.ts | 29 +- ...ask-create-dialog-effects-executor.test.ts | 29 +- .../components/task-create-dialog-effects.ts | 22 - ...ask-create-dialog-multi-repo-guard.test.ts | 32 + .../task-create-dialog-multi-repo-guard.ts | 63 +- .../components/task-create-dialog-options.tsx | 4 +- .../task-create-dialog-remote-repo-chip.tsx | 29 +- .../task-create-dialog-repo-chips.test.tsx | 297 +------ .../task-create-dialog-source-mode.tsx | 122 +-- ...reate-dialog-workspace-repo-chips.test.tsx | 215 +++++ ...add-workspace-sources-availability.test.ts | 33 + .../add-workspace-sources-availability.ts | 22 + .../add-workspace-sources-dialog.test.tsx | 285 +++++++ .../add-workspace-sources-dialog.tsx | 612 ++++++++++++++ .../saved-repository-source-row.tsx | 97 +++ .../use-dialog-opener-focus.ts | 33 + .../use-submit-workspace-sources.ts | 67 ++ .../use-workspace-repository-options.ts | 56 ++ .../use-workspace-source-rows.ts | 55 ++ .../workspace-change-consequences.tsx | 121 +++ .../task/file-browser-reset-key.test.ts | 40 + .../task/file-browser-toolbar.test.tsx | 121 +++ .../components/task/file-browser-toolbar.tsx | 199 ++++- apps/web/components/task/file-browser.tsx | 221 +++-- apps/web/components/task/files-panel.tsx | 123 ++- apps/web/components/task/task-files-panel.tsx | 71 +- .../executor-capabilities.test.ts | 65 ++ .../executor-capabilities.ts | 65 ++ .../workspace-source-picker/remote-url.ts | 27 + .../workspace-source-state.test.ts | 159 ++++ .../workspace-source-state.ts | 152 ++++ apps/web/e2e/fixtures/backend-env.ts | 62 ++ apps/web/e2e/fixtures/backend.ts | 28 +- apps/web/e2e/helpers/api-client.ts | 13 + apps/web/e2e/helpers/http-git-server.ts | 141 ++++ .../docker/add-workspace-sources.spec.ts | 238 ++++++ .../tests/ssh/add-workspace-sources.spec.ts | 146 ++++ .../tests/task/add-workspace-sources.spec.ts | 259 ++++++ .../task/mobile-add-workspace-sources.spec.ts | 247 ++++++ apps/web/lib/api/domains/kanban-api.test.ts | 53 +- apps/web/lib/api/domains/kanban-api.ts | 17 + apps/web/lib/backend-fixture-env.test.ts | 90 +++ apps/web/lib/http-git-server.test.ts | 105 +++ apps/web/lib/kanban/map-task.ts | 11 + apps/web/lib/state/slices/kanban/types.ts | 6 + .../session-runtime/session-runtime-slice.ts | 6 + .../lib/state/slices/session-runtime/types.ts | 2 + .../lib/state/slices/session/session-slice.ts | 65 +- .../session/session-slice.upsert.test.ts | 90 +++ .../lib/state/slices/session/turn-actions.ts | 77 ++ apps/web/lib/state/slices/session/types.ts | 2 + apps/web/lib/state/store-reexports.ts | 8 + apps/web/lib/state/store.ts | 1 + apps/web/lib/types/backend.ts | 9 + apps/web/lib/types/http-workspace-sources.ts | 62 ++ apps/web/lib/types/http.ts | 27 +- .../web/lib/ws/handlers/agent-session.test.ts | 32 + apps/web/lib/ws/handlers/agent-session.ts | 20 + .../web/lib/ws/handlers/tasks.deleted.test.ts | 156 ++++ .../web/lib/ws/handlers/tasks.test-helpers.ts | 128 +++ apps/web/lib/ws/handlers/tasks.test.ts | 253 ++---- apps/web/lib/ws/handlers/tasks.ts | 23 + apps/web/lib/ws/handlers/turns.test.ts | 83 ++ apps/web/lib/ws/handlers/turns.ts | 42 +- ...-runtime-mutable-task-workspace-sources.md | 74 ++ ...-23-workspace-source-root-move-boundary.md | 32 + docs/decisions/INDEX.md | 2 + docs/plans/attach-workspace-sources/plan.md | 295 +++++++ .../task-01-durable-source-contracts.md | 53 ++ .../task-02-attachment-service.md | 50 ++ .../task-03-protocol-surfaces.md | 53 ++ .../task-04-host-materialization.md | 53 ++ .../task-05-remote-materialization.md | 57 ++ .../task-06-shared-source-picker.md | 76 ++ .../task-07-files-panel-surface.md | 56 ++ .../task-08-end-to-end-coverage.md | 61 ++ .../task-09-public-documentation.md | 50 ++ .../task-10-final-verification.md | 65 ++ .../task-11-shared-local-remote-selector.md | 66 ++ ...task-12-unified-files-workspace-actions.md | 67 ++ .../task-13-ux-delta-e2e.md | 62 ++ .../task-14-ux-terminology-documentation.md | 55 ++ docs/public/automation-and-mcp.md | 8 +- docs/public/coordination.md | 88 +- docs/public/coverage.json | 1 + docs/public/developer-tools.md | 50 +- docs/public/executors.md | 44 +- docs/public/feature-status.md | 160 ++-- docs/public/k8s.md | 2 +- docs/public/security.md | 6 + docs/public/tasks-and-workflows.md | 115 +-- docs/specs/INDEX.md | 1 + docs/specs/tasks/attach-workspace-sources.md | 230 ++++++ 215 files changed, 17461 insertions(+), 1697 deletions(-) create mode 100644 apps/backend/internal/agent/agents/workspace_rebind_resume_test.go create mode 100644 apps/backend/internal/agent/runtime/agentctl/client_workspace_sources.go create mode 100644 apps/backend/internal/agent/runtime/agentctl/client_workspace_sources_test.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/executor_remote_docker_test.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/executor_ssh_resume_test.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/manager_workspace_sources_test.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/workspace_materialization.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/workspace_materialization_test.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/workspace_sources_reconcile.go create mode 100644 apps/backend/internal/agent/runtime/lifecycle/workspace_sources_reconcile_test.go create mode 100644 apps/backend/internal/agentctl/server/api/workspace_materialize.go create mode 100644 apps/backend/internal/agentctl/server/api/workspace_materialize_test.go create mode 100644 apps/backend/internal/backendapp/workspace_source_materializer.go create mode 100644 apps/backend/internal/backendapp/workspace_source_materializer_test.go create mode 100644 apps/backend/internal/task/repository/cleanup_repository_test.go create mode 100644 apps/backend/internal/task/repository/sqlite/workspace_folder.go create mode 100644 apps/backend/internal/task/repository/sqlite/workspace_folder_test.go create mode 100644 apps/backend/internal/task/service/service_workspace_folders.go create mode 100644 apps/backend/internal/task/service/service_workspace_sources.go create mode 100644 apps/backend/internal/task/service/service_workspace_sources_test.go create mode 100644 apps/backend/internal/worktree/branch_plan.go create mode 100644 apps/backend/internal/worktree/directory_link.go create mode 100644 apps/backend/internal/worktree/directory_link_test.go create mode 100644 apps/backend/internal/worktree/directory_link_unix.go create mode 100644 apps/backend/internal/worktree/directory_link_windows.go create mode 100644 apps/web/components/task-create-dialog-multi-repo-guard.test.ts create mode 100644 apps/web/components/task-create-dialog-workspace-repo-chips.test.tsx create mode 100644 apps/web/components/task/add-workspace-sources/add-workspace-sources-availability.test.ts create mode 100644 apps/web/components/task/add-workspace-sources/add-workspace-sources-availability.ts create mode 100644 apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.test.tsx create mode 100644 apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.tsx create mode 100644 apps/web/components/task/add-workspace-sources/saved-repository-source-row.tsx create mode 100644 apps/web/components/task/add-workspace-sources/use-dialog-opener-focus.ts create mode 100644 apps/web/components/task/add-workspace-sources/use-submit-workspace-sources.ts create mode 100644 apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts create mode 100644 apps/web/components/task/add-workspace-sources/use-workspace-source-rows.ts create mode 100644 apps/web/components/task/add-workspace-sources/workspace-change-consequences.tsx create mode 100644 apps/web/components/task/file-browser-reset-key.test.ts create mode 100644 apps/web/components/task/file-browser-toolbar.test.tsx create mode 100644 apps/web/components/workspace-source-picker/executor-capabilities.test.ts create mode 100644 apps/web/components/workspace-source-picker/executor-capabilities.ts create mode 100644 apps/web/components/workspace-source-picker/remote-url.ts create mode 100644 apps/web/components/workspace-source-picker/workspace-source-state.test.ts create mode 100644 apps/web/components/workspace-source-picker/workspace-source-state.ts create mode 100644 apps/web/e2e/fixtures/backend-env.ts create mode 100644 apps/web/e2e/helpers/http-git-server.ts create mode 100644 apps/web/e2e/tests/docker/add-workspace-sources.spec.ts create mode 100644 apps/web/e2e/tests/ssh/add-workspace-sources.spec.ts create mode 100644 apps/web/e2e/tests/task/add-workspace-sources.spec.ts create mode 100644 apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts create mode 100644 apps/web/lib/backend-fixture-env.test.ts create mode 100644 apps/web/lib/http-git-server.test.ts create mode 100644 apps/web/lib/state/slices/session/turn-actions.ts create mode 100644 apps/web/lib/types/http-workspace-sources.ts create mode 100644 apps/web/lib/ws/handlers/tasks.deleted.test.ts create mode 100644 apps/web/lib/ws/handlers/tasks.test-helpers.ts create mode 100644 apps/web/lib/ws/handlers/turns.test.ts create mode 100644 docs/decisions/2026-07-22-runtime-mutable-task-workspace-sources.md create mode 100644 docs/decisions/2026-07-23-workspace-source-root-move-boundary.md create mode 100644 docs/plans/attach-workspace-sources/plan.md create mode 100644 docs/plans/attach-workspace-sources/task-01-durable-source-contracts.md create mode 100644 docs/plans/attach-workspace-sources/task-02-attachment-service.md create mode 100644 docs/plans/attach-workspace-sources/task-03-protocol-surfaces.md create mode 100644 docs/plans/attach-workspace-sources/task-04-host-materialization.md create mode 100644 docs/plans/attach-workspace-sources/task-05-remote-materialization.md create mode 100644 docs/plans/attach-workspace-sources/task-06-shared-source-picker.md create mode 100644 docs/plans/attach-workspace-sources/task-07-files-panel-surface.md create mode 100644 docs/plans/attach-workspace-sources/task-08-end-to-end-coverage.md create mode 100644 docs/plans/attach-workspace-sources/task-09-public-documentation.md create mode 100644 docs/plans/attach-workspace-sources/task-10-final-verification.md create mode 100644 docs/plans/attach-workspace-sources/task-11-shared-local-remote-selector.md create mode 100644 docs/plans/attach-workspace-sources/task-12-unified-files-workspace-actions.md create mode 100644 docs/plans/attach-workspace-sources/task-13-ux-delta-e2e.md create mode 100644 docs/plans/attach-workspace-sources/task-14-ux-terminology-documentation.md create mode 100644 docs/specs/tasks/attach-workspace-sources.md diff --git a/apps/backend/cmd/acpdbg/README.md b/apps/backend/cmd/acpdbg/README.md index 3b3bb485fc..6a6be43509 100644 --- a/apps/backend/cmd/acpdbg/README.md +++ b/apps/backend/cmd/acpdbg/README.md @@ -27,7 +27,7 @@ Or invoke the binary directly: `apps/backend/bin/acpdbg [flags]`. | `probe ` | `initialize` → `session/new` → close. Shows models, modes, auth methods. | | `probe --exec " [args...]"` | Same but against an arbitrary binary not in the registry | | `prompt --prompt "..." [--model M] [--mode M]` | Full prompt round-trip, collects text chunks from `session/update` | -| `session-load --session-id ` | Reproduce `session/load` for an existing session | +| `session-load --session-id [--prompt "..."]` | Load an existing session in `--workdir`, then optionally verify it with a prompt | | `matrix` | Probe every registered ACP agent in parallel, write one JSONL per agent + `matrix-summary.json` | ### Shared flags @@ -39,6 +39,28 @@ Or invoke the binary directly: `apps/backend/bin/acpdbg [flags]`. - `--verbose` — mirror frames to stderr as they're sent/received - `--stderr` — capture child stderr into the JSONL (useful when an agent crashes before the handshake completes) +### Verify resume under a different working directory + +Create a session in one directory, then load it from another and ask the agent +to report its working directory: + +```bash +apps/backend/bin/acpdbg prompt codex-acp \ + --workdir /tmp/folder-a \ + --prompt "Reply with pwd only" \ + --file /tmp/codex-new.jsonl + +apps/backend/bin/acpdbg session-load codex-acp \ + --session-id \ + --workdir /tmp/folder-b \ + --prompt "Reply with pwd only" \ + --file /tmp/codex-load.jsonl +``` + +`session-load` sends the selected `--workdir` as the ACP `cwd` parameter. The +JSONL wire frames are authoritative when a provider replays earlier session +messages during load. + ## JSONL format One JSON object per line, chronologically ordered. `direction` is one of: diff --git a/apps/backend/cmd/acpdbg/main.go b/apps/backend/cmd/acpdbg/main.go index 23b24eda5a..0af092d861 100644 --- a/apps/backend/cmd/acpdbg/main.go +++ b/apps/backend/cmd/acpdbg/main.go @@ -71,7 +71,7 @@ Usage: acpdbg probe [flags] acpdbg probe --exec " [args...]" [flags] acpdbg prompt --prompt TEXT [--model M] [--mode M] [flags] - acpdbg session-load --session-id ID [flags] + acpdbg session-load --session-id ID [--prompt TEXT] [flags] acpdbg matrix [flags] Shared flags: @@ -239,6 +239,7 @@ func runSessionLoad(ctx context.Context, args []string) error { fs := flag.NewFlagSet("session-load", flag.ExitOnError) shared := registerShared(fs) sessionID := fs.String("session-id", "", "ACP session id to load (required)") + prompt := fs.String("prompt", "", "prompt text to send after loading the session") if err := fs.Parse(args); err != nil { return err } @@ -261,13 +262,21 @@ func runSessionLoad(ctx context.Context, args []string) error { } defer runner.Close("completed") - res, err := acpdbg.SessionLoad(runCtx, runner, *sessionID) + res, err := acpdbg.SessionLoad(runCtx, runner, acpdbg.SessionLoadOptions{ + SessionID: *sessionID, + Prompt: *prompt, + }) if err != nil { fmt.Fprintf(os.Stderr, "session-load failed: %v\n", err) fmt.Fprintf(os.Stderr, "jsonl: %s\n", runner.Path()) return err } - printProbeSummary(cfg.AgentID, runner.Path(), res) + printProbeSummary(cfg.AgentID, runner.Path(), &res.ProbeResult) + if *prompt != "" { + fmt.Println() + fmt.Println("--- response ---") + fmt.Println(res.Text) + } return nil } diff --git a/apps/backend/internal/agent/acpdbg/ops.go b/apps/backend/internal/agent/acpdbg/ops.go index f9f0d17bc3..18ea2e1dd1 100644 --- a/apps/backend/internal/agent/acpdbg/ops.go +++ b/apps/backend/internal/agent/acpdbg/ops.go @@ -70,6 +70,19 @@ type PromptResult struct { Text string } +// SessionLoadOptions configures a session/load operation and an optional +// prompt used to verify the loaded session's effective workspace. +type SessionLoadOptions struct { + SessionID string + Prompt string +} + +// SessionLoadResult holds the loaded session metadata and optional response. +type SessionLoadResult struct { + ProbeResult + Text string +} + // Prompt runs the full prompt round-trip: initialize → session/new → // [set_model] → [set_mode] → session/prompt → drain updates until the // prompt response arrives. @@ -100,19 +113,16 @@ func Prompt(ctx context.Context, r *Runner, opts PromptOptions) (*PromptResult, return &PromptResult{ProbeResult: *probe, Text: text}, nil } -// SessionLoad runs initialize → session/load. -func SessionLoad(ctx context.Context, r *Runner, sessionID string) (*ProbeResult, error) { - if sessionID == "" { +// SessionLoad runs initialize → session/load → [session/prompt]. +func SessionLoad(ctx context.Context, r *Runner, opts SessionLoadOptions) (*SessionLoadResult, error) { + if opts.SessionID == "" { return nil, errors.New("session-id is required") } initResp, err := sendInitialize(ctx, r) if err != nil { return nil, fmt.Errorf("initialize: %w", err) } - req, _ := r.Framer().NewRequest("session/load", map[string]any{ - "sessionId": sessionID, - "mcpServers": []any{}, - }) + req, _ := r.Framer().NewRequest("session/load", sessionLoadParams(opts.SessionID, r.cfg.Workdir)) resp, err := r.Request(ctx, req) if err != nil { return nil, fmt.Errorf("session/load: %w", err) @@ -120,7 +130,23 @@ func SessionLoad(ctx context.Context, r *Runner, sessionID string) (*ProbeResult if errMap, ok := resp["error"].(map[string]any); ok { return nil, fmt.Errorf("session/load error: %v", errMap["message"]) } - return buildProbeResult(initResp, resp), nil + result := &SessionLoadResult{ProbeResult: *buildProbeResult(initResp, resp)} + if opts.Prompt == "" { + return result, nil + } + result.Text, err = sendPromptAndCollect(ctx, r, opts.SessionID, opts.Prompt) + if err != nil { + return nil, fmt.Errorf("session/prompt: %w", err) + } + return result, nil +} + +func sessionLoadParams(sessionID, workdir string) map[string]any { + return map[string]any{ + "sessionId": sessionID, + "cwd": workdir, + "mcpServers": []any{}, + } } // --- JSON-RPC helpers --- diff --git a/apps/backend/internal/agent/acpdbg/ops_test.go b/apps/backend/internal/agent/acpdbg/ops_test.go index f9de61ae61..fa9763cb7a 100644 --- a/apps/backend/internal/agent/acpdbg/ops_test.go +++ b/apps/backend/internal/agent/acpdbg/ops_test.go @@ -2,6 +2,19 @@ package acpdbg import "testing" +func TestSessionLoadParamsIncludeChangedWorkdir(t *testing.T) { + t.Parallel() + + got := sessionLoadParams("session-1", "/workspace-b") + + if got["sessionId"] != "session-1" { + t.Fatalf("sessionId = %v, want session-1", got["sessionId"]) + } + if got["cwd"] != "/workspace-b" { + t.Fatalf("cwd = %v, want /workspace-b", got["cwd"]) + } +} + func TestBuildProbeResult_FallsBackToConfigOptionModels(t *testing.T) { t.Parallel() diff --git a/apps/backend/internal/agent/agents/agent.go b/apps/backend/internal/agent/agents/agent.go index 9e8e7910c0..3374595f94 100644 --- a/apps/backend/internal/agent/agents/agent.go +++ b/apps/backend/internal/agent/agents/agent.go @@ -247,12 +247,16 @@ type ResourceLimits struct { type SessionConfig struct { NativeSessionResume bool HistoryContextInjection bool // Opt-in: inject conversation history on session resume for agents without native resume - ResumeFlag Param - CanRecover *bool - SessionDirTemplate string - SessionDirTarget string - ForkSessionCmd Command - ContinueSessionCmd Command + // NewSessionOnWorkspaceRebind forces session/new when an idle execution's + // working directory changes. Some providers accept session/load with a new + // cwd but keep tool execution pinned to the session's original directory. + NewSessionOnWorkspaceRebind bool + ResumeFlag Param + CanRecover *bool + SessionDirTemplate string + SessionDirTarget string + ForkSessionCmd Command + ContinueSessionCmd Command } // SupportsRecovery returns whether the agent supports session recovery. diff --git a/apps/backend/internal/agent/agents/grok_acp.go b/apps/backend/internal/agent/agents/grok_acp.go index 15698778b3..f22b877a75 100644 --- a/apps/backend/internal/agent/agents/grok_acp.go +++ b/apps/backend/internal/agent/agents/grok_acp.go @@ -82,10 +82,11 @@ func (a *GrokACP) Runtime() *RuntimeConfig { ProjectSkillDir: ".grok/skills", UserSkillDir: ".grok/skills", SessionConfig: SessionConfig{ - NativeSessionResume: true, - CanRecover: &canRecover, - SessionDirTemplate: "{home}/.grok", - SessionDirTarget: "/root/.grok", + NativeSessionResume: true, + NewSessionOnWorkspaceRebind: true, + CanRecover: &canRecover, + SessionDirTemplate: "{home}/.grok", + SessionDirTarget: "/root/.grok", }, } } diff --git a/apps/backend/internal/agent/agents/opencode_acp.go b/apps/backend/internal/agent/agents/opencode_acp.go index d8ee5cf9a4..ab4f57f3f5 100644 --- a/apps/backend/internal/agent/agents/opencode_acp.go +++ b/apps/backend/internal/agent/agents/opencode_acp.go @@ -151,9 +151,10 @@ func (a *OpenCodeACP) Runtime() *RuntimeConfig { // See GH issue #1247. RequiresProcessKill: true, SessionConfig: SessionConfig{ - NativeSessionResume: true, - CanRecover: &canRecover, - SessionDirTemplate: "{home}/.opencode", + NativeSessionResume: true, + NewSessionOnWorkspaceRebind: true, + CanRecover: &canRecover, + SessionDirTemplate: "{home}/.opencode", }, } } diff --git a/apps/backend/internal/agent/agents/workspace_rebind_resume_test.go b/apps/backend/internal/agent/agents/workspace_rebind_resume_test.go new file mode 100644 index 0000000000..ea8dd50930 --- /dev/null +++ b/apps/backend/internal/agent/agents/workspace_rebind_resume_test.go @@ -0,0 +1,28 @@ +package agents + +import "testing" + +func TestWorkspaceRebindSessionCompatibility(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + agent Agent + startsNewSession bool + }{ + {name: "codex loads session in changed cwd", agent: NewCodexACP()}, + {name: "cursor loads session in changed cwd", agent: NewCursorACP()}, + {name: "opencode starts fresh session", agent: NewOpenCodeACP(), startsNewSession: true}, + {name: "grok starts fresh session", agent: NewGrokACP(), startsNewSession: true}, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + got := test.agent.Runtime().SessionConfig.NewSessionOnWorkspaceRebind + if got != test.startsNewSession { + t.Fatalf("NewSessionOnWorkspaceRebind = %v, want %v", got, test.startsNewSession) + } + }) + } +} diff --git a/apps/backend/internal/agent/runtime/agentctl/client_workspace_sources.go b/apps/backend/internal/agent/runtime/agentctl/client_workspace_sources.go new file mode 100644 index 0000000000..939b6d057f --- /dev/null +++ b/apps/backend/internal/agent/runtime/agentctl/client_workspace_sources.go @@ -0,0 +1,114 @@ +package client + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" +) + +// MaterializeRepositoryRequest is the credential-free repository checkout +// request accepted by agentctl. It intentionally has no credential field; +// lifecycle wiring supplies Git credentials separately when that is needed. +type MaterializeRepositoryRequest struct { + RepositoryURL string `json:"repository_url"` + Destination string `json:"destination"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch,omitempty"` +} + +// MaterializeRepositoryResponse reports the adopted workspace subdirectory. +type MaterializeRepositoryResponse struct { + Destination string `json:"destination"` + Reused bool `json:"reused,omitempty"` + Error string `json:"error,omitempty"` +} + +// RemoveMaterializedRepositoryRequest identifies an owned checkout for +// rollback after a later item in a remote materialization batch fails. +type RemoveMaterializedRepositoryRequest struct { + RepositoryURL string `json:"repository_url"` + Destination string `json:"destination"` +} + +type removeMaterializedRepositoryResponse struct { + Removed bool `json:"removed"` + Error string `json:"error,omitempty"` +} + +// WorkspaceMaterializationError preserves an actionable remote status without +// retaining or formatting the repository locator supplied by the caller. +type WorkspaceMaterializationError struct { + StatusCode int + Message string +} + +func (e *WorkspaceMaterializationError) Error() string { + return fmt.Sprintf("workspace repository materialization failed (%d): %s", e.StatusCode, e.Message) +} + +// MaterializeRepository asks the live agentctl instance to atomically clone +// and check out a repository under its current workspace root. +func (c *Client) MaterializeRepository(ctx context.Context, materialization MaterializeRepositoryRequest) (*MaterializeRepositoryResponse, error) { + body, err := json.Marshal(materialization) + if err != nil { + return nil, fmt.Errorf("marshal workspace materialization request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/workspace/materialize-repository", bytes.NewReader(body)) + if err != nil { + return nil, fmt.Errorf("create workspace materialization request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return nil, fmt.Errorf("send workspace materialization request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var response MaterializeRepositoryResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return nil, fmt.Errorf("decode workspace materialization response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || response.Error != "" { + message := response.Error + if message == "" { + message = "remote agentctl rejected the request" + } + return &response, &WorkspaceMaterializationError{StatusCode: resp.StatusCode, Message: message} + } + return &response, nil +} + +// RemoveMaterializedRepository removes only a checkout whose destination and +// origin match the supplied credential-free request. Nonexistent destinations +// are treated as a successful idempotent rollback by agentctl. +func (c *Client) RemoveMaterializedRepository(ctx context.Context, removal RemoveMaterializedRepositoryRequest) error { + body, err := json.Marshal(removal) + if err != nil { + return fmt.Errorf("marshal workspace cleanup request: %w", err) + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/workspace/materialize-repository/remove", bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("create workspace cleanup request: %w", err) + } + req.Header.Set("Content-Type", "application/json") + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("send workspace cleanup request: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + var response removeMaterializedRepositoryResponse + if err := json.NewDecoder(resp.Body).Decode(&response); err != nil { + return fmt.Errorf("decode workspace cleanup response: %w", err) + } + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices || response.Error != "" { + message := response.Error + if message == "" { + message = "remote agentctl rejected the cleanup request" + } + return &WorkspaceMaterializationError{StatusCode: resp.StatusCode, Message: message} + } + return nil +} diff --git a/apps/backend/internal/agent/runtime/agentctl/client_workspace_sources_test.go b/apps/backend/internal/agent/runtime/agentctl/client_workspace_sources_test.go new file mode 100644 index 0000000000..b6937f04c9 --- /dev/null +++ b/apps/backend/internal/agent/runtime/agentctl/client_workspace_sources_test.go @@ -0,0 +1,147 @@ +package client + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/kandev/kandev/internal/common/logger" +) + +func TestMaterializeRepository_ReturnsTypedRemoteError(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("Authorization"); got != "Bearer test-token" { + t.Fatalf("authorization = %q", got) + } + var body MaterializeRepositoryRequest + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.BaseBranch != "main" || body.CheckoutBranch != "feature/work" { + t.Fatalf("branches = base:%q checkout:%q", body.BaseBranch, body.CheckoutBranch) + } + w.WriteHeader(http.StatusConflict) + _, _ = w.Write([]byte(`{"error":"destination already exists"}`)) + })) + defer server.Close() + + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatal(err) + } + c := &Client{baseURL: server.URL, httpClient: server.Client(), logger: log} + c.httpClient.Transport = &authTransport{token: "test-token", base: c.httpClient.Transport} + _, err = c.MaterializeRepository(context.Background(), MaterializeRepositoryRequest{ + RepositoryURL: "https://github.com/kdlbs/kandev.git", + Destination: "kandev", + BaseBranch: "main", + CheckoutBranch: "feature/work", + }) + + var remoteErr *WorkspaceMaterializationError + if !errors.As(err, &remoteErr) { + t.Fatalf("expected WorkspaceMaterializationError, got %v", err) + } + if remoteErr.StatusCode != http.StatusConflict || remoteErr.Message != "destination already exists" { + t.Fatalf("error = %#v", remoteErr) + } +} + +func TestRemoveMaterializedRepository_UsesCleanupEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/workspace/materialize-repository/remove" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte(`{"removed":true}`)) + })) + defer server.Close() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatal(err) + } + c := &Client{baseURL: server.URL, httpClient: server.Client(), logger: log} + + err = c.RemoveMaterializedRepository(context.Background(), RemoveMaterializedRepositoryRequest{ + RepositoryURL: "https://github.com/kdlbs/kandev.git", + Destination: "kandev", + }) + if err != nil { + t.Fatalf("RemoveMaterializedRepository: %v", err) + } +} + +func TestRescanWorkspace_UsesCurrentRootWhenWorkDirIsEmpty(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/workspace/rescan" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + var body struct { + WorkDir string `json:"work_dir"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if body.WorkDir != "" { + t.Fatalf("work_dir = %q, want current root", body.WorkDir) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatal(err) + } + c := &Client{baseURL: server.URL, httpClient: server.Client(), logger: log} + if err := c.RescanWorkspace(context.Background(), ""); err != nil { + t.Fatalf("RescanWorkspace: %v", err) + } +} + +func TestReconcileWorkspace_UsesExactReconciliationEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/workspace/reconcile" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + var body struct { + WorkspaceSourceRoots []string `json:"workspace_source_roots"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if len(body.WorkspaceSourceRoots) != 1 || body.WorkspaceSourceRoots[0] != "/sources/original" { + t.Fatalf("workspace_source_roots = %v", body.WorkspaceSourceRoots) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatal(err) + } + c := &Client{baseURL: server.URL, httpClient: server.Client(), logger: log} + if err := c.ReconcileWorkspace(context.Background(), []string{"/sources/original"}); err != nil { + t.Fatalf("ReconcileWorkspace: %v", err) + } +} + +func TestRebindWorkspace_UsesAuthenticatedRebindEndpoint(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost || r.URL.Path != "/api/v1/workspace/rebind" { + t.Fatalf("request = %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + log, err := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + if err != nil { + t.Fatal(err) + } + c := &Client{baseURL: server.URL, httpClient: server.Client(), logger: log} + if err := c.RebindWorkspace(context.Background(), "/workspace/task-1"); err != nil { + t.Fatalf("RebindWorkspace: %v", err) + } +} diff --git a/apps/backend/internal/agent/runtime/agentctl/control.go b/apps/backend/internal/agent/runtime/agentctl/control.go index 641b270ac6..86241d1f5a 100644 --- a/apps/backend/internal/agent/runtime/agentctl/control.go +++ b/apps/backend/internal/agent/runtime/agentctl/control.go @@ -71,6 +71,10 @@ type CreateInstanceRequest struct { // single-repo tracker. Empty map disables the override and falls back // to the hardcoded origin/main → master priority list inside agentctl. BaseBranches map[string]string `json:"base_branches,omitempty"` + // WorkspaceSourceRoots are canonical host roots explicitly attached to the + // workspace. Agentctl permits file operations through links only beneath + // these roots. + WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"` } // CreateInstanceResponse contains the result of creating a new agent instance. diff --git a/apps/backend/internal/agent/runtime/agentctl/workspace_rescan.go b/apps/backend/internal/agent/runtime/agentctl/workspace_rescan.go index c1becc5ae2..8f2e4054d9 100644 --- a/apps/backend/internal/agent/runtime/agentctl/workspace_rescan.go +++ b/apps/backend/internal/agent/runtime/agentctl/workspace_rescan.go @@ -22,29 +22,57 @@ import ( // Best-effort: failures are returned but do not block the materializer. // The worktree still exists on disk; the next session relaunch will // rebuild trackers via prepareMultiRepo even when this call missed. -func (c *Client) RescanWorkspace(ctx context.Context, workDir string) error { - body, err := json.Marshal(struct { - WorkDir string `json:"work_dir"` - }{WorkDir: workDir}) +func (c *Client) RescanWorkspace(ctx context.Context, workDir string, sourceRoots ...[]string) error { + return c.updateWorkspace(ctx, "/api/v1/workspace/rescan", "rescan", struct { + WorkDir string `json:"work_dir"` + WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"` + }{WorkDir: workDir, WorkspaceSourceRoots: firstSourceRoots(sourceRoots)}) +} + +// ReconcileWorkspace prunes tracker state against the current root after a +// rollback has removed newly-created workspace checkouts. It is intentionally +// distinct from RescanWorkspace, whose empty-root form only appends trackers. +func (c *Client) ReconcileWorkspace(ctx context.Context, sourceRoots ...[]string) error { + return c.updateWorkspace(ctx, "/api/v1/workspace/reconcile", "reconcile", struct { + WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"` + }{WorkspaceSourceRoots: firstSourceRoots(sourceRoots)}) +} + +// RebindWorkspace replaces agentctl's workspace root and every tracker after +// lifecycle has stopped the native child. It is deliberately not a best-effort +// rescan: callers must treat a failure as an adoption failure and roll back. +func (c *Client) RebindWorkspace(ctx context.Context, workDir string, sourceRoots ...[]string) error { + return c.updateWorkspace(ctx, "/api/v1/workspace/rebind", "rebind", struct { + WorkDir string `json:"work_dir"` + WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"` + }{WorkDir: workDir, WorkspaceSourceRoots: firstSourceRoots(sourceRoots)}) +} + +func (c *Client) updateWorkspace(ctx context.Context, path, operation string, payload any) error { + body, err := json.Marshal(payload) if err != nil { return err } - - req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/workspace/rescan", bytes.NewReader(body)) + req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+path, bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Content-Type", "application/json") - resp, err := c.httpClient.Do(req) if err != nil { return err } defer func() { _ = resp.Body.Close() }() - - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - respBody, _ := readResponseBody(resp) - return fmt.Errorf("rescan workspace failed with status %d: %s", resp.StatusCode, string(respBody)) + if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices { + body, _ := readResponseBody(resp) + return fmt.Errorf("%s workspace failed with status %d: %s", operation, resp.StatusCode, string(body)) } return nil } + +func firstSourceRoots(sourceRoots [][]string) []string { + if len(sourceRoots) == 0 { + return nil + } + return sourceRoots[0] +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go index 6e72d8ee28..80246f14c5 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend.go @@ -303,6 +303,7 @@ type ExecutorCreateRequest struct { AgentProfileID string OfficeAgentProfileID string WorkspacePath string + WorkspaceSourceRoots []string Protocol string Env map[string]string AutoApprovePermissions bool @@ -382,7 +383,8 @@ func (ri *ExecutorInstance) ToAgentExecution(req *ExecutorCreateRequest) *AgentE if req.AgentConfig != nil { agentID = req.AgentConfig.ID() if rt := req.AgentConfig.Runtime(); rt != nil { - historyEnabled = rt.SessionConfig.HistoryContextInjection + historyEnabled = rt.SessionConfig.HistoryContextInjection || + rt.SessionConfig.NewSessionOnWorkspaceRebind } } @@ -397,6 +399,7 @@ func (ri *ExecutorInstance) ToAgentExecution(req *ExecutorCreateRequest) *AgentE ContainerID: ri.ContainerID, ContainerIP: ri.ContainerIP, WorkspacePath: workspacePath, + WorkspaceSourceRoots: append([]string(nil), req.WorkspaceSourceRoots...), RuntimeName: ri.RuntimeName, Status: v1.AgentStatusRunning, StartedAt: time.Now(), diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_backend_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_backend_test.go index c9329c2aec..6b430b9c85 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_backend_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_backend_test.go @@ -3,6 +3,7 @@ package lifecycle import ( "testing" + "github.com/kandev/kandev/internal/agent/agents" "github.com/stretchr/testify/require" ) @@ -67,3 +68,12 @@ func TestFilterPersistentMetadata(t *testing.T) { require.NotContains(t, got, "task_description") }) } + +func TestToAgentExecutionRecordsHistoryForWorkspaceRebindFallback(t *testing.T) { + instance := &ExecutorInstance{InstanceID: "execution"} + execution := instance.ToAgentExecution(&ExecutorCreateRequest{ + AgentConfig: agents.NewOpenCodeACP(), + }) + + require.True(t, execution.historyEnabled) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_remote_docker_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_remote_docker_test.go new file mode 100644 index 0000000000..c2a91eefe4 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_remote_docker_test.go @@ -0,0 +1,15 @@ +package lifecycle + +import ( + "context" + "strings" + "testing" +) + +func TestRemoteDockerExecutor_CreateInstanceRemainsExplicitlyUnsupported(t *testing.T) { + executor := NewRemoteDockerExecutor(newTestLogger()) + _, err := executor.CreateInstance(context.Background(), &ExecutorCreateRequest{InstanceID: "instance-1"}) + if err == nil || !strings.Contains(err.Error(), "not yet implemented") { + t.Fatalf("CreateInstance error=%v; want explicit unsupported implementation error", err) + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go index 0d746c4c1c..4118185c92 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go @@ -22,7 +22,9 @@ import ( ) const ( - sshAgentctlHealthTimeout = 20 * time.Second + sshAgentctlHealthTimeout = 20 * time.Second + sshAgentctlCleanupTimeout = 20 * time.Second + sshAgentctlLoopbackHost = "127.0.0.1" sshStatusRunning = "running" sshStatusUnknown = "unknown" @@ -36,11 +38,14 @@ const ( // when many short-lived channels race the long-lived stream channels), and // the local port forwarder. type sshSessionState struct { - target *SSHTarget - client *ssh.Client - forwarder *SSHPortForwarder - pid int - remoteDir string + target *SSHTarget + client *ssh.Client + forwarder *SSHPortForwarder + agentctlClient *agentctl.Client + pid int + remoteDir string + authToken string + reusingProcess bool } // SSHExecutor implements ExecutorBackend for SSH-reachable Linux and macOS hosts. @@ -202,7 +207,7 @@ func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateReq } r.maybeUploadCredentials(ctx, client, req, platform) - port, pid, fwd, err := r.startAndForwardAgentctl(ctx, client, agentctlBin, taskDir, sessionDir, req, platform) + port, pid, fwd, authToken, err := r.startAndForwardAgentctl(ctx, client, agentctlBin, taskDir, sessionDir, req, platform) if err != nil { return nil, err } @@ -214,11 +219,12 @@ func (r *SSHExecutor) CreateInstance(ctx context.Context, req *ExecutorCreateReq forwarder: fwd, pid: pid, remoteDir: sessionDir, + authToken: authToken, } r.mu.Unlock() released = true // ownership transferred to session state; released on StopInstance - return r.buildInstance(req, target, fwd, taskDir, sessionDir, port, pid, workdir), nil + return r.buildInstance(req, target, fwd, taskDir, sessionDir, port, pid, workdir, authToken), nil } // prepareRemoteHost runs the steps that are independent of any particular @@ -283,12 +289,17 @@ func (r *SSHExecutor) startAndForwardAgentctl( agentctlBin, taskDir, sessionDir string, req *ExecutorCreateRequest, platform SSHRemotePlatform, -) (int, int, *SSHPortForwarder, error) { +) (int, int, *SSHPortForwarder, string, error) { + nonce, err := generateBootstrapNonce() + if err != nil { + return 0, 0, nil, "", fmt.Errorf("ssh: generate bootstrap nonce: %w", err) + } + env := sshAgentctlLaunchEnv(req.Env, nonce) shell := sshShellForRemote(req.Metadata, platform) - controlPort, pid, err := startRemoteAgentctl(ctx, client, shell, agentctlBin, taskDir, sessionDir, r.logger) + controlPort, pid, err := startRemoteAgentctl(ctx, client, shell, agentctlBin, taskDir, sessionDir, env, r.logger) if err != nil { r.report(req.OnProgress, "Starting agent controller", PrepareStepFailed, err.Error()) - return 0, 0, nil, err + return 0, 0, nil, "", err } r.report(req.OnProgress, "Starting agent controller", PrepareStepCompleted, fmt.Sprintf("pid=%d control_port=%d", pid, controlPort)) @@ -296,11 +307,16 @@ func (r *SSHExecutor) startAndForwardAgentctl( // The per-instance server's workspace is the remote task dir, not the // host-side req.WorkspacePath (which is meaningless on the remote). // Passed explicitly so we don't briefly mutate the caller's request. - instancePort, ierr := createRemoteAgentInstance(ctx, client, controlPort, taskDir, req, r.logger) + authToken, ierr := remoteControlHandshake(ctx, client, controlPort, nonce) + if ierr != nil { + _ = stopRemoteAgentctl(ctx, client, sessionDir, pid) + return 0, 0, nil, "", ierr + } + instancePort, ierr := createRemoteAgentInstance(ctx, client, controlPort, taskDir, req, authToken, r.logger) if ierr != nil { _ = stopRemoteAgentctl(ctx, client, sessionDir, pid) r.report(req.OnProgress, "Creating agent instance", PrepareStepFailed, ierr.Error()) - return 0, 0, nil, ierr + return 0, 0, nil, "", ierr } r.report(req.OnProgress, "Creating agent instance", PrepareStepCompleted, fmt.Sprintf("instance_port=%d", instancePort)) @@ -308,16 +324,30 @@ func (r *SSHExecutor) startAndForwardAgentctl( fwd, err := StartPortForward(client, instancePort, r.logger) if err != nil { _ = stopRemoteAgentctl(ctx, client, sessionDir, pid) - return 0, 0, nil, fmt.Errorf("ssh: port forward: %w", err) + return 0, 0, nil, "", fmt.Errorf("ssh: port forward: %w", err) } if err := waitAgentctlHealthy(ctx, fwd.LocalPort(), sshAgentctlHealthTimeout); err != nil { _ = fwd.Close() _ = stopRemoteAgentctl(ctx, client, sessionDir, pid) - return 0, 0, nil, fmt.Errorf("ssh: agentctl health: %w", err) + return 0, 0, nil, "", fmt.Errorf("ssh: agentctl health: %w", err) } r.report(req.OnProgress, "Connecting to agent controller", PrepareStepCompleted, fmt.Sprintf("local:%d -> remote:%d", fwd.LocalPort(), instancePort)) - return instancePort, pid, fwd, nil + return instancePort, pid, fwd, authToken, nil +} + +func sshAgentctlLaunchEnv(base map[string]string, nonce string) map[string]string { + env := make(map[string]string, len(base)) + for key, value := range base { + env[key] = value + } + // The handshake returns the bearer token to the backend; never pass it to + // the launched agent process. SSH reaches both listeners through explicit + // loopback direct-tcpip forwards, so it need not expose them remotely. + delete(env, "AGENTCTL_AUTH_TOKEN") + env["AGENTCTL_BOOTSTRAP_NONCE"] = nonce + env["AGENTCTL_LISTEN_HOST"] = sshAgentctlLoopbackHost + return env } func (r *SSHExecutor) buildInstance( @@ -326,17 +356,18 @@ func (r *SSHExecutor) buildInstance( fwd *SSHPortForwarder, taskDir, sessionDir string, port, pid int, - workdir string, + workdir, authToken string, ) *ExecutorInstance { return &ExecutorInstance{ InstanceID: req.InstanceID, TaskID: req.TaskID, SessionID: req.SessionID, RuntimeName: r.Name(), - Client: agentctl.NewClient("127.0.0.1", fwd.LocalPort(), r.logger, + Client: agentctl.NewClient(sshAgentctlLoopbackHost, fwd.LocalPort(), r.logger, agentctl.WithExecutionID(req.InstanceID), - agentctl.WithSessionID(req.SessionID)), + agentctl.WithSessionID(req.SessionID), agentctl.WithAuthToken(authToken)), WorkspacePath: taskDir, + AuthToken: authToken, Metadata: map[string]interface{}{ MetadataKeySSHHost: target.Host, MetadataKeySSHPort: strconv.Itoa(target.Port), @@ -362,15 +393,20 @@ func (r *SSHExecutor) buildResumedInstance(req *ExecutorCreateRequest, state *ss port, _ := strconv.Atoi(getMetadataString(req.Metadata, MetadataKeySSHRemoteAgentctlPort)) taskDir := getMetadataString(req.Metadata, MetadataKeySSHRemoteTaskDir) workdir := r.workdirRoot(req.Metadata) + client := state.agentctlClient + if client == nil { + client = agentctl.NewClient(sshAgentctlLoopbackHost, state.forwarder.LocalPort(), r.logger, + agentctl.WithExecutionID(resumedSSHAgentctlInstanceID(req)), + agentctl.WithSessionID(req.SessionID), agentctl.WithAuthToken(state.authToken)) + } return &ExecutorInstance{ - InstanceID: req.InstanceID, - TaskID: req.TaskID, - SessionID: req.SessionID, - RuntimeName: r.Name(), - Client: agentctl.NewClient("127.0.0.1", state.forwarder.LocalPort(), r.logger, - agentctl.WithExecutionID(req.InstanceID), - agentctl.WithSessionID(req.SessionID)), + InstanceID: req.InstanceID, + TaskID: req.TaskID, + SessionID: req.SessionID, + RuntimeName: r.Name(), + Client: client, WorkspacePath: taskDir, + AuthToken: state.authToken, Metadata: map[string]interface{}{ MetadataKeySSHHost: state.target.Host, MetadataKeySSHPort: strconv.Itoa(state.target.Port), @@ -383,14 +419,19 @@ func (r *SSHExecutor) buildResumedInstance(req *ExecutorCreateRequest, state *ss MetadataKeySSHLocalForwardPort: strconv.Itoa(state.forwarder.LocalPort()), MetadataKeySSHWorkdirRoot: workdir, MetadataKeyIsRemote: true, + "reuse_existing_process": state.reusingProcess, }, } } -// StopInstance kills the per-session agentctl on the remote, closes the local -// port forward, and releases this session's hold on the pooled SSH connection. -// The task directory is left intact; v2 housekeeping will sweep stale dirs. -func (r *SSHExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, _ bool) error { +// StopInstance closes the local port forward and SSH connection for every +// stop. It preserves the remote agentctl only for graceful backend shutdown, +// so a restart can reconnect using the persisted remote PID, port, and token. +// User stops, rollbacks, force, failed-agent, terminal, and stale-replacement +// stops all kill agentctl. +// The task directory is always left intact; v2 housekeeping will sweep stale +// dirs. +func (r *SSHExecutor) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error { if instance == nil { return nil } @@ -406,16 +447,39 @@ func (r *SSHExecutor) StopInstance(ctx context.Context, instance *ExecutorInstan if state.forwarder != nil { _ = state.forwarder.Close() } + if !sshShouldStopRemoteAgentctl(instance, force) { + r.logger.Info("preserving SSH agentctl after agent stop", + zap.String("instance_id", instance.InstanceID), + zap.String("stop_reason", instance.StopReason)) + if state.client != nil { + _ = state.client.Close() + } + return nil + } + // Use the same SSH client we used for CreateInstance — if it's still // alive we can kill the remote agentctl gracefully; otherwise just drop // the connection on the floor. if state.client != nil { - _ = stopRemoteAgentctl(ctx, state.client, state.remoteDir, state.pid) + cleanupCtx, cancel := sshRemoteCleanupContext(ctx) + _ = stopRemoteAgentctl(cleanupCtx, state.client, state.remoteDir, state.pid) + cancel() _ = state.client.Close() } return nil } +func sshShouldStopRemoteAgentctl(instance *ExecutorInstance, force bool) bool { + if instance == nil { + return false + } + return force || instance.AgentStopFailed || instance.StopReason != StopReasonBackendShutdown +} + +func sshRemoteCleanupContext(ctx context.Context) (context.Context, context.CancelFunc) { + return context.WithTimeout(context.WithoutCancel(ctx), sshAgentctlCleanupTimeout) +} + // RecoverInstances re-opens SSH connections for sessions that were live before // a backend restart. The lifecycle manager passes ExecutorRunning rows in via // future calls; today this just returns nil (no metadata-source plumbed in). @@ -441,6 +505,9 @@ func (r *SSHExecutor) ResumeRemoteInstance(ctx context.Context, req *ExecutorCre if pidStr == "" || portStr == "" || sessionDir == "" || taskDir == "" { return nil // not a resume — proceed with normal create } + if err := requireSSHAgentctlAuthToken(req.AuthToken); err != nil { + return err + } // Idempotency: if a previous resume attempt for this InstanceID already // produced live client+forwarder state, return success without dialing @@ -488,14 +555,20 @@ func (r *SSHExecutor) ResumeRemoteInstance(ctx context.Context, req *ExecutorCre _ = client.Close() return fmt.Errorf("ssh resume: agentctl health: %w", err) } + instanceClient, reusingProcess := r.newResumedAgentctlClient( + ctx, fwd.LocalPort(), req, + ) r.mu.Lock() r.sessions[req.InstanceID] = &sshSessionState{ - target: target, - client: client, - forwarder: fwd, - pid: pid, - remoteDir: sessionDir, + target: target, + client: client, + forwarder: fwd, + agentctlClient: instanceClient, + pid: pid, + remoteDir: sessionDir, + authToken: req.AuthToken, + reusingProcess: reusingProcess, } r.mu.Unlock() @@ -509,6 +582,37 @@ func (r *SSHExecutor) ResumeRemoteInstance(ctx context.Context, req *ExecutorCre return nil } +func (r *SSHExecutor) newResumedAgentctlClient( + ctx context.Context, + localPort int, + req *ExecutorCreateRequest, +) (*agentctl.Client, bool) { + client := agentctl.NewClient(sshAgentctlLoopbackHost, localPort, r.logger, + agentctl.WithExecutionID(resumedSSHAgentctlInstanceID(req)), + agentctl.WithSessionID(req.SessionID), agentctl.WithAuthToken(req.AuthToken)) + status, err := client.GetStatus(ctx) + if err != nil { + r.logger.Debug("ssh resume could not get agent status; starting a new process", + zap.String("instance_id", req.InstanceID), zap.Error(err)) + return client, false + } + return client, status != nil && status.IsAgentRunning() +} + +func resumedSSHAgentctlInstanceID(req *ExecutorCreateRequest) string { + if req.PreviousExecutionID != "" { + return req.PreviousExecutionID + } + return req.InstanceID +} + +func requireSSHAgentctlAuthToken(token string) error { + if strings.TrimSpace(token) == "" { + return errors.New("ssh resume: missing agentctl auth token") + } + return nil +} + // GetRemoteStatus reports the SSH host's current reachability for the UI // status badge. Best-effort and bounded by a short timeout. func (r *SSHExecutor) GetRemoteStatus(ctx context.Context, instance *ExecutorInstance) (*RemoteStatus, error) { diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go index 3b428868b7..5216e7c375 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_connection_test.go @@ -1,6 +1,7 @@ package lifecycle import ( + "net/http" "os" "strings" "testing" @@ -8,6 +9,23 @@ import ( "github.com/kandev/kandev/internal/agent/agents" ) +func TestSSHControlRequestUsesBearerToken(t *testing.T) { + req, err := http.NewRequest(http.MethodPost, "http://127.0.0.1/api/v1/instances", nil) + if err != nil { + t.Fatal(err) + } + setSSHControlAuthorization(req, "launch-token") + if got := req.Header.Get("Authorization"); got != "Bearer launch-token" { + t.Fatalf("Authorization = %q", got) + } +} + +func TestRequireSSHAgentctlAuthTokenRejectsEmptyToken(t *testing.T) { + if err := requireSSHAgentctlAuthToken(""); err == nil { + t.Fatal("expected empty SSH agentctl token to be rejected") + } +} + func TestResolveSSHTarget_ExplicitFields(t *testing.T) { target, err := ResolveSSHTarget(SSHConnConfig{ Host: "example.com", diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go index c7b93c1eb0..0329ea1d8a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "os" + "regexp" "strings" "github.com/pkg/sftp" @@ -99,7 +100,14 @@ func (r *SSHExecutor) runOneAuthSetupScript( platform SSHRemotePlatform, ) { shell := sshShellForRemote(req.Metadata, platform) - envScript := buildSSHEnvInitScript(req.Env) + envScript, err := buildSSHEnvInitScript(req.Env) + if err != nil { + r.logger.Warn("auth setup script skipped: invalid environment key", + zap.String("display_name", displayName), + zap.String("method_id", method.MethodID), + zap.Error(err)) + return + } // `. /dev/stdin` sources the env lines fed via session.Stdin; `set -a` // makes those assignments automatically exported so the user's setup // script sees them in env without a per-key `export`. The script body @@ -247,9 +255,14 @@ func selectFileMethods( // remote shell via stdin and sourced under `set -a` — assignments stay // out of the shell's argv (and therefore out of `ps aux`) but still get // exported into the script's environment. Empty input returns "". -func buildSSHEnvInitScript(env map[string]string) string { +func buildSSHEnvInitScript(env map[string]string) (string, error) { if len(env) == 0 { - return "" + return "", nil + } + for key := range env { + if !posixSSHEnvIdentifier.MatchString(key) { + return "", fmt.Errorf("invalid SSH environment variable key %q", key) + } } var b strings.Builder for k, v := range env { @@ -258,9 +271,11 @@ func buildSSHEnvInitScript(env map[string]string) string { b.WriteString(shellQuote(v)) b.WriteString("\n") } - return b.String() + return b.String(), nil } +var posixSSHEnvIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + // sshFileUploader implements FileUploader by writing files via SFTP. Each // WriteFile opens a fresh SFTP session to keep failure scopes small — the // credential upload runs at launch time and only writes a handful of small diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go index 98200c5b5f..780b147d0a 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_credentials_test.go @@ -1,6 +1,7 @@ package lifecycle import ( + "context" "strings" "testing" ) @@ -95,16 +96,27 @@ func TestParentDir(t *testing.T) { func TestBuildSSHEnvInitScript(t *testing.T) { t.Run("empty map returns empty string", func(t *testing.T) { - if got := buildSSHEnvInitScript(nil); got != "" { + got, err := buildSSHEnvInitScript(nil) + if err != nil { + t.Fatalf("buildSSHEnvInitScript(nil): %v", err) + } + if got != "" { t.Errorf("buildSSHEnvInitScript(nil) = %q, want \"\"", got) } - if got := buildSSHEnvInitScript(map[string]string{}); got != "" { + got, err = buildSSHEnvInitScript(map[string]string{}) + if err != nil { + t.Fatalf("buildSSHEnvInitScript(empty): %v", err) + } + if got != "" { t.Errorf("buildSSHEnvInitScript(empty) = %q, want \"\"", got) } }) t.Run("single env var is shell-quoted on its own line", func(t *testing.T) { - got := buildSSHEnvInitScript(map[string]string{"FOO": "bar baz"}) + got, err := buildSSHEnvInitScript(map[string]string{"FOO": "bar baz"}) + if err != nil { + t.Fatalf("buildSSHEnvInitScript: %v", err) + } // Each line is a POSIX shell assignment; the line break separates // entries so `. /dev/stdin` under `set -a` exports each one. if got != "FOO='bar baz'\n" { @@ -113,7 +125,10 @@ func TestBuildSSHEnvInitScript(t *testing.T) { }) t.Run("values with embedded single quotes are escaped", func(t *testing.T) { - got := buildSSHEnvInitScript(map[string]string{"TOKEN": "it's-a-secret"}) + got, err := buildSSHEnvInitScript(map[string]string{"TOKEN": "it's-a-secret"}) + if err != nil { + t.Fatalf("buildSSHEnvInitScript: %v", err) + } // shellQuote replaces ' with '\'' for POSIX-safe escaping. if !strings.Contains(got, `TOKEN='it'\''s-a-secret'`) { t.Errorf("buildSSHEnvInitScript did not escape single quote: %q", got) @@ -122,4 +137,56 @@ func TestBuildSSHEnvInitScript(t *testing.T) { t.Errorf("buildSSHEnvInitScript missing trailing newline: %q", got) } }) + + for _, key := range []string{"BAD KEY", "BAD; touch /tmp/pwned", "BAD\nKEY", "$(touch /tmp/pwned)", "1BAD"} { + t.Run("rejects invalid key "+key, func(t *testing.T) { + script, err := buildSSHEnvInitScript(map[string]string{key: "secret"}) + if err == nil { + t.Fatal("expected invalid key error") + } + if script != "" { + t.Errorf("invalid key appeared in script: %q", script) + } + }) + } +} + +func TestStartRemoteAgentctlRejectsInvalidEnvBeforeSSHLaunch(t *testing.T) { + _, _, err := startRemoteAgentctl( + context.Background(), + nil, + "bash", + "/usr/local/bin/agentctl", + "/workspace", + "/tmp/session", + map[string]string{"BAD; touch /tmp/pwned": "secret"}, + nil, + ) + if err == nil { + t.Fatal("expected invalid SSH environment key to abort agentctl launch") + } + if !strings.Contains(err.Error(), "invalid SSH environment variable key") { + t.Errorf("startRemoteAgentctl error = %q", err) + } +} + +func TestSSHAgentctlLaunchEnvForcesLoopbackAndOmitsBearerToken(t *testing.T) { + env := sshAgentctlLaunchEnv(map[string]string{ + "AGENTCTL_AUTH_TOKEN": "profile-token", + "AGENTCTL_LISTEN_HOST": "0.0.0.0", + "OPENAI_API_KEY": "key", + }, "bootstrap-nonce") + + if got := env["AGENTCTL_BOOTSTRAP_NONCE"]; got != "bootstrap-nonce" { + t.Fatalf("AGENTCTL_BOOTSTRAP_NONCE = %q, want bootstrap nonce", got) + } + if got := env["AGENTCTL_LISTEN_HOST"]; got != "127.0.0.1" { + t.Fatalf("AGENTCTL_LISTEN_HOST = %q, want loopback", got) + } + if _, found := env["AGENTCTL_AUTH_TOKEN"]; found { + t.Fatal("SSH agentctl launch environment must not contain bearer token") + } + if got := env["OPENAI_API_KEY"]; got != "key" { + t.Fatalf("OPENAI_API_KEY = %q, want copied profile value", got) + } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go index 0425dab67c..bfe2284427 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_operations.go @@ -463,17 +463,29 @@ func startRemoteAgentctl( ctx context.Context, client *ssh.Client, shell, agentctlBin, workspacePath, sessionDir string, + env map[string]string, log *logger.Logger, ) (port int, pid int, err error) { port = pickRemoteAgentctlPort() + envScript, err := buildSSHEnvInitScript(env) + if err != nil { + return 0, 0, fmt.Errorf("ssh: launch agentctl: %w", err) + } // Wrap the agentctl exec in a login shell so the spawned process // inherits the user's $PATH (nvm/asdf/brew etc.). Without this, even // if `npx` is installed via nvm, agentctl's child processes won't // find it because the SSH-exec channel runs a non-interactive non- // login shell and `nohup` inherits whatever that shell's PATH was. + // The launch environment is supplied on stdin, never embedded in the + // command string. That keeps transient Git credentials out of argv and + // remote process listings while letting the long-lived agentctl inherit + // exactly the same resolved credentials as clone/setup commands. innerScript := fmt.Sprintf( - `set -e + `set -ae +. /dev/stdin +set +a +set -e mkdir -p %[1]s : > %[1]s/agentctl.log AGENTCTL_PORT=%[4]d nohup %[2]s --workdir %[3]s \ @@ -488,7 +500,7 @@ echo "$AGENTCTL_PID" shellQuote(workspacePath), port, ) - out, stderr, err := runSSHCommand(ctx, client, WrapLoginShell(shell, innerScript)) + out, stderr, err := runSSHCommandStdin(ctx, client, WrapLoginShell(shell, innerScript), strings.NewReader(envScript)) if err != nil { return 0, 0, fmt.Errorf("ssh: launch agentctl: %w (stderr: %s)", err, strings.TrimSpace(stderr)) } @@ -544,6 +556,7 @@ func createRemoteAgentInstance( controlPort int, workspacePath string, req *ExecutorCreateRequest, + authToken string, log *logger.Logger, ) (int, error) { body, err := json.Marshal(agentctl.CreateInstanceRequest{ @@ -587,6 +600,7 @@ func createRemoteAgentInstance( return 0, fmt.Errorf("ssh: build create-instance request: %w", err) } httpReq.Header.Set("Content-Type", "application/json") + setSSHControlAuthorization(httpReq, authToken) httpResp, err := httpClient.Do(httpReq) if err != nil { @@ -616,6 +630,50 @@ func createRemoteAgentInstance( return resp.Port, nil } +func setSSHControlAuthorization(req *http.Request, token string) { + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } +} + +func remoteControlHandshake(ctx context.Context, client *ssh.Client, controlPort int, nonce string) (string, error) { + body, err := json.Marshal(map[string]string{"nonce": nonce}) + if err != nil { + return "", err + } + httpClient := remoteControlHTTPClient(client, controlPort) + defer httpClient.CloseIdleConnections() + req, err := http.NewRequestWithContext(ctx, http.MethodPost, fmt.Sprintf("http://127.0.0.1:%d/auth/handshake", controlPort), bytes.NewReader(body)) + if err != nil { + return "", err + } + req.Header.Set("Content-Type", "application/json") + resp, err := httpClient.Do(req) + if err != nil { + return "", fmt.Errorf("ssh: agentctl handshake: %w", err) + } + defer func() { _ = resp.Body.Close() }() + if resp.StatusCode != http.StatusOK { + return "", fmt.Errorf("ssh: agentctl handshake returned %d", resp.StatusCode) + } + var result struct { + Token string `json:"token"` + } + if err := json.NewDecoder(resp.Body).Decode(&result); err != nil || result.Token == "" { + return "", errors.New("ssh: agentctl handshake returned no token") + } + return result.Token, nil +} + +func remoteControlHTTPClient(client *ssh.Client, controlPort int) *http.Client { + return &http.Client{Timeout: 30 * time.Second, Transport: &http.Transport{ + DialContext: func(_ context.Context, _, _ string) (net.Conn, error) { + return client.Dial("tcp", net.JoinHostPort("127.0.0.1", strconv.Itoa(controlPort))) + }, + DisableKeepAlives: true, + }} +} + // sshRemoteAgentCredentialEnvKeys are the agent-authentication environment // variables forwarded to the remote agent instance. Unlike containerized // executors (Docker/Sprites) the SSH executor's CreateInstanceRequest never diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_resume_test.go b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_resume_test.go new file mode 100644 index 0000000000..8bb8f1837b --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_ssh_resume_test.go @@ -0,0 +1,235 @@ +package lifecycle + +import ( + "context" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/common/logger" +) + +func TestSSHBuildResumedInstanceRecordsProcessReuse(t *testing.T) { + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("new logger: %v", err) + } + executor := &SSHExecutor{logger: log} + for _, tc := range []struct { + name string + reusingProcess bool + }{ + {name: "running agent", reusingProcess: true}, + {name: "stopped agent", reusingProcess: false}, + } { + t.Run(tc.name, func(t *testing.T) { + instance := executor.buildResumedInstance(&ExecutorCreateRequest{ + InstanceID: "instance-1", + TaskID: "task-1", + SessionID: "session-1", + Metadata: map[string]interface{}{ + MetadataKeySSHRemoteAgentctlPort: "43123", + MetadataKeySSHRemoteTaskDir: "/remote/task", + }, + }, &sshSessionState{ + target: &SSHTarget{Host: "ssh.example", Port: 22, User: "agent"}, + forwarder: &SSHPortForwarder{localPort: 43124}, + pid: 99, + remoteDir: "/remote/session", + authToken: "resume-token", + reusingProcess: tc.reusingProcess, + }) + + reusingProcess, ok := instance.Metadata["reuse_existing_process"].(bool) + if !ok || reusingProcess != tc.reusingProcess { + t.Fatalf("reuse_existing_process = (%v, %v), want (%v, true)", reusingProcess, ok, tc.reusingProcess) + } + }) + } +} + +func TestSSHResumedAgentctlClientChecksExistingProcess(t *testing.T) { + for _, tc := range []struct { + name string + statusCode int + response string + wantReusing bool + }{ + {name: "running", statusCode: http.StatusOK, response: `{"agent_status":"running"}`, wantReusing: true}, + {name: "starting", statusCode: http.StatusOK, response: `{"agent_status":"starting"}`, wantReusing: true}, + {name: "stopped", statusCode: http.StatusOK, response: `{"agent_status":"stopped"}`}, + {name: "status error", statusCode: http.StatusInternalServerError, response: "agentctl unavailable"}, + } { + t.Run(tc.name, func(t *testing.T) { + const previousInstanceID = "previous-instance" + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if got := r.Header.Get("X-Instance-ID"); got != previousInstanceID { + t.Errorf("X-Instance-ID = %q, want %q", got, previousInstanceID) + http.Error(w, "stale instance client", http.StatusConflict) + return + } + if got := r.Header.Get("Authorization"); got != "Bearer resume-token" { + t.Errorf("Authorization = %q, want authenticated request", got) + } + if r.URL.Path == "/health" { + w.WriteHeader(http.StatusOK) + return + } + if r.URL.Path != "/api/v1/status" { + t.Errorf("path = %q, want /api/v1/status or /health", r.URL.Path) + } + w.WriteHeader(tc.statusCode) + _, _ = w.Write([]byte(tc.response)) + })) + t.Cleanup(server.Close) + + serverURL, err := url.Parse(server.URL) + if err != nil { + t.Fatalf("parse test server URL: %v", err) + } + port, err := strconv.Atoi(serverURL.Port()) + if err != nil { + t.Fatalf("parse test server port: %v", err) + } + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatalf("new logger: %v", err) + } + executor := &SSHExecutor{logger: log} + + req := &ExecutorCreateRequest{ + InstanceID: "current-instance", + PreviousExecutionID: previousInstanceID, + SessionID: "session-1", + AuthToken: "resume-token", + Metadata: map[string]interface{}{ + MetadataKeySSHRemoteAgentctlPort: "43123", + MetadataKeySSHRemoteTaskDir: "/remote/task", + }, + } + client, reusingProcess := executor.newResumedAgentctlClient(context.Background(), port, req) + if client == nil { + t.Fatal("expected agentctl client") + } + if reusingProcess != tc.wantReusing { + t.Fatalf("reusing process = %v, want %v", reusingProcess, tc.wantReusing) + } + instance := executor.buildResumedInstance(req, &sshSessionState{ + target: &SSHTarget{Host: "ssh.example", Port: 22, User: "agent"}, + forwarder: &SSHPortForwarder{localPort: port}, + agentctlClient: client, + pid: 99, + remoteDir: "/remote/session", + authToken: req.AuthToken, + }) + if instance.InstanceID != req.InstanceID { + t.Fatalf("resumed instance ID = %q, want current ID %q", instance.InstanceID, req.InstanceID) + } + if instance.Client != client { + t.Fatal("resumed instance did not retain the status-probed client") + } + if err := instance.Client.Health(context.Background()); err != nil { + t.Fatalf("retained resumed client health: %v", err) + } + }) + } +} + +func TestResumedSSHAgentctlInstanceID(t *testing.T) { + for _, tc := range []struct { + name string + previous string + want string + }{ + {name: "previous execution", previous: "previous-instance", want: "previous-instance"}, + {name: "no previous execution", want: "current-instance"}, + } { + t.Run(tc.name, func(t *testing.T) { + got := resumedSSHAgentctlInstanceID(&ExecutorCreateRequest{ + InstanceID: "current-instance", + PreviousExecutionID: tc.previous, + }) + if got != tc.want { + t.Fatalf("resumed SSH agentctl instance ID = %q, want %q", got, tc.want) + } + }) + } +} + +func TestSSHStopInstanceKillsRemoteAgentctlOnlyWhenCleanupIsRequired(t *testing.T) { + tests := []struct { + name string + force bool + instance *ExecutorInstance + want bool + }{ + { + name: "backend shutdown preserves resumable agentctl", + instance: &ExecutorInstance{ + StopReason: StopReasonBackendShutdown, + }, + }, + { + name: "non-shutdown stop kills agentctl", + instance: &ExecutorInstance{}, + want: true, + }, + { + name: "force stop kills agentctl", + force: true, + instance: &ExecutorInstance{ + StopReason: StopReasonBackendShutdown, + }, + want: true, + }, + { + name: "failed agent stop kills agentctl", + instance: &ExecutorInstance{ + StopReason: StopReasonBackendShutdown, + AgentStopFailed: true, + }, + want: true, + }, + { + name: "terminal task deletion kills agentctl", + instance: &ExecutorInstance{ + StopReason: StopReasonTaskDeleted, + }, + want: true, + }, + { + name: "stale replacement cleanup kills agentctl", + instance: &ExecutorInstance{ + StopReason: stopReasonStaleExecutionCleanup, + }, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if got := sshShouldStopRemoteAgentctl(tt.instance, tt.force); got != tt.want { + t.Fatalf("sshShouldStopRemoteAgentctl() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestSSHRemoteCleanupContextIgnoresCanceledParentAndIsBounded(t *testing.T) { + parent, parentCancel := context.WithCancel(context.Background()) + parentCancel() + + cleanupCtx, cleanupCancel := sshRemoteCleanupContext(parent) + defer cleanupCancel() + + if err := cleanupCtx.Err(); err != nil { + t.Fatalf("cleanup context should remain usable after parent cancellation, got %v", err) + } + if _, ok := cleanupCtx.Deadline(); !ok { + t.Fatal("cleanup context should be bounded") + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go index 71c3c569d8..3aeec4fb58 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go +++ b/apps/backend/internal/agent/runtime/lifecycle/executor_standalone.go @@ -119,17 +119,18 @@ func (r *StandaloneExecutor) CreateInstance(ctx context.Context, req *ExecutorCr req.AutoApprovePermissions, req.AutoApprovePermissionsOverride, ), - AutoStart: false, - McpServers: req.McpServers, - SessionID: req.SessionID, - TaskID: req.TaskID, - DisableAskQuestion: disableAskQuestion, - AssumeMcpSse: assumeMcpSse, - AssumeMcpHttp: assumeMcpHttp, - McpMode: req.McpMode, - RequiresProcessKill: requiresProcessKill, - StripEnv: stripEnv, - BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), + AutoStart: false, + McpServers: req.McpServers, + SessionID: req.SessionID, + TaskID: req.TaskID, + DisableAskQuestion: disableAskQuestion, + AssumeMcpSse: assumeMcpSse, + AssumeMcpHttp: assumeMcpHttp, + McpMode: req.McpMode, + RequiresProcessKill: requiresProcessKill, + StripEnv: stripEnv, + BaseBranches: getMetadataStringMap(req.Metadata, MetadataKeyBaseBranches), + WorkspaceSourceRoots: req.WorkspaceSourceRoots, } r.logger.Info("CreateInstance: sending request to agentctl", diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_boot_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_boot_test.go index 613c6b1971..5a9a9a5a0f 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_boot_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_boot_test.go @@ -156,33 +156,34 @@ func TestFinalizeBootMessage_NilService(t *testing.T) { mgr.finalizeBootMessage(nil, msg, nil, nil, "exited") } -func TestBootMessage_IsResumingFlag(t *testing.T) { - // Test that the is_resuming flag is correctly based on ACPSessionID presence - tests := []struct { - name string - acpSessionID string - wantResuming bool - }{ - { - name: "new session (no ACP session ID)", - acpSessionID: "", - wantResuming: false, - }, - { - name: "resumed session (has ACP session ID)", - acpSessionID: "acp-session-123", - wantResuming: true, - }, +func TestCreateBootMessage_MarksResumedSession(t *testing.T) { + mgr := newTestManager(t) + bootSvc := &MockBootMessageService{} + mgr.bootMessageService = bootSvc + execution := &AgentExecution{ + TaskID: "task-1", + SessionID: "session-1", + ACPSessionID: "acp-session-1", } - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - // The is_resuming logic is: execution.ACPSessionID != "" - isResuming := tt.acpSessionID != "" - if isResuming != tt.wantResuming { - t.Errorf("is_resuming = %v, want %v", isResuming, tt.wantResuming) - } - }) + message, stopCh := mgr.createBootMessage( + context.Background(), + execution, + "mock-agent --resume acp-session-1", + "Mock", + ) + if stopCh != nil { + close(stopCh) + } + + if message == nil { + t.Fatal("resume boot message = nil") + } + if len(bootSvc.CreatedMessages) != 1 { + t.Fatalf("created boot messages = %d, want 1", len(bootSvc.CreatedMessages)) + } + if got := message.Metadata["is_resuming"]; got != true { + t.Fatalf("is_resuming = %#v, want true", got) } } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_events_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_events_test.go index 7445134976..ef2ebf2553 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_events_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_events_test.go @@ -4,6 +4,7 @@ import ( "context" "errors" "runtime" + "strings" "sync" "testing" "time" @@ -118,6 +119,40 @@ func TestHandleAgentEvent_UserMessageChunkNotBufferedAsAssistant(t *testing.T) { } } +func TestHandleAgentEvent_RecordsStreamedAssistantTextForResumeContext(t *testing.T) { + mgr, _ := createTestManagerWithTracking() + history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger()) + if err != nil { + t.Fatal(err) + } + mgr.historyManager = history + execution := createTestExecution("exec-1", "task-1", "session-1") + execution.historyEnabled = true + if err := mgr.executionStore.Add(execution); err != nil { + t.Fatal(err) + } + + mgr.handleAgentEvent(execution, agentctl.AgentEvent{ + Type: "message_chunk", + Text: "First line.\nSecond line.", + }) + mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"}) + + entries, err := history.ReadHistory(execution.SessionID) + if err != nil { + t.Fatal(err) + } + var recorded strings.Builder + for _, entry := range entries { + if entry.Type == "agent_message" { + recorded.WriteString(entry.Content) + } + } + if recorded.String() != "First line.\nSecond line." { + t.Fatalf("recorded assistant history = %q, want full streamed message; entries=%+v", recorded.String(), entries) + } +} + func TestHandleAgentEvent_ProtocolMessageResumesAcrossToolCall(t *testing.T) { mgr, eventBus := createTestManagerWithTracking() execution := createTestExecution("exec-1", "task-1", "session-1") @@ -362,7 +397,6 @@ func TestHandleAgentEvent_ProtocolAssistantHistoryPersistedOnceInWireOrder(t *te mgr.handleAgentEvent(execution, event) } mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"}) - mgr.handleAgentEvent(execution, agentctl.AgentEvent{Type: "complete"}) entries, err := history.ReadHistory(execution.SessionID) if err != nil { diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go index 45761d80a5..417b2b196e 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution.go @@ -15,6 +15,8 @@ import ( "github.com/kandev/kandev/internal/common/appctx" "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/secrets" + "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" ) // ErrSessionWorkspaceNotReady indicates the task session exists but does not yet @@ -463,6 +465,22 @@ func (m *Manager) verifyPassthroughEnabled(ctx context.Context, sessionID, profi // createExecution creates an agentctl execution. // The agent subprocess is NOT started - call ConfigureAgent + Start explicitly. func (m *Manager) createExecution(ctx context.Context, taskID string, info *WorkspaceInfo) (*AgentExecution, error) { + if info == nil { + return nil, fmt.Errorf("workspace info is required") + } + if err := reconcileWorkspaceSources(ctx, info.WorkspacePath, info.WorkspaceFolders); err != nil { + return nil, err + } + if info.ExecutorType == string(models.ExecutorTypeLocal) || info.ExecutorType == "local_pc" { + if err := reconcileWorkspaceRepositories(info.WorkspacePath, info.WorkspaceRepositories); err != nil { + return nil, err + } + } + if info.ExecutorType == string(models.ExecutorTypeWorktree) { + if err := m.reconcileWorkspaceWorktrees(ctx, taskID, info); err != nil { + return nil, err + } + } activityLease, err := m.acquireActivity(ctx, activity.KindExecutionStarting) if err != nil { return nil, err @@ -540,6 +558,7 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work AgentProfileID: executionProfileID, OfficeAgentProfileID: info.AgentProfileID, WorkspacePath: info.WorkspacePath, + WorkspaceSourceRoots: workspaceSourceRoots(info.WorkspaceFolders, info.WorkspaceRepositories), Protocol: string(agentConfig.Runtime().Protocol), Env: env, AutoApprovePermissions: autoApprove, @@ -551,6 +570,10 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work BootstrapNonce: m.revealRuntimeSecret(ctx, info.Metadata, MetadataKeyBootstrapNonceSecret), } + if err := resumeRemoteInstancePreflight(ctx, rt, req); err != nil { + return nil, err + } + runtimeInstance, err := rt.CreateInstance(ctx, req) if err != nil { return nil, fmt.Errorf("failed to create execution: %w", err) @@ -622,6 +645,32 @@ func (m *Manager) createExecution(ctx context.Context, taskID string, info *Work return execution, nil } +func (m *Manager) reconcileWorkspaceWorktrees(ctx context.Context, taskID string, info *WorkspaceInfo) error { + if len(info.WorkspaceRepositories) == 0 || m.worktreeMgr == nil { + return nil + } + if info.SessionID == "" || info.TaskDirName == "" { + return fmt.Errorf("worktree workspace is missing durable session or task directory") + } + for _, repository := range info.WorkspaceRepositories { + if repository.RepositoryPath == "" { + return fmt.Errorf("workspace repository %q source path is missing", repository.RepoName) + } + if _, err := m.worktreeMgr.Create(ctx, worktree.CreateRequest{ + TaskID: taskID, SessionID: info.SessionID, RepositoryID: repository.RepositoryID, + RepositoryPath: repository.RepositoryPath, BaseBranch: repository.BaseBranch, + FallbackBaseBranch: repository.DefaultBranch, CheckoutBranch: repository.CheckoutBranch, + WorktreeID: repository.WorktreeID, TaskDirName: info.TaskDirName, WorkspaceID: info.WorkspaceID, + RepoName: repository.RepoName, WorktreeBranchPrefix: repository.WorktreeBranchPrefix, + WorktreeBranchTemplate: repository.WorktreeBranchTemplate, PullBeforeWorktree: repository.PullBeforeWorktree, + BranchSlug: repository.BranchSlug, BranchIdentitySlug: repository.BranchIdentitySlug, + }); err != nil { + return fmt.Errorf("recreate workspace worktree %q: %w", repository.RepoName, err) + } + } + return nil +} + func workspaceExecutionProfileID(info *WorkspaceInfo) string { if info == nil { return "" diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go index e67f2e299d..60e4ac5c4c 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_execution_test.go @@ -8,8 +8,10 @@ import ( "net/http" "net/http/httptest" "net/url" + "reflect" "runtime" "strconv" + "strings" "sync" "sync/atomic" "testing" @@ -861,8 +863,104 @@ func TestCreateExecutionResolvesProfileOnceForEnvAndAutoApprove(t *testing.T) { } } +func TestCreateExecutionRunsRemoteResumePreflightBeforeCreatingWorkspaceExecution(t *testing.T) { + log := newTestLogger() + backend := &resumeTrackingExecutor{ + MockExecutor: MockExecutor{name: executor.NameStandalone}, + client: newReadyAgentctlClient(t, log), + } + execRegistry := NewExecutorRegistry(log) + execRegistry.Register(backend) + mgr := NewManager( + newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log, + ) + cleanupManagerStopCh(t, mgr) + + metadata := map[string]interface{}{"ssh_host": "recovery.example", "ssh_port": 2222} + _, err := mgr.createExecution(context.Background(), "task-1", &WorkspaceInfo{ + SessionID: "session-1", + AgentID: "auggie", + WorkspacePath: "/workspace/task-1", + Metadata: metadata, + AgentExecutionID: "previous-execution", + }) + if err != nil { + t.Fatalf("createExecution returned error: %v", err) + } + if got, want := backend.calls, []string{"resume", "create"}; !reflect.DeepEqual(got, want) { + t.Fatalf("backend calls = %v, want %v", got, want) + } + if backend.createRequest == nil { + t.Fatal("CreateInstance was not called") + } + if got := backend.createRequest.PreviousExecutionID; got != "previous-execution" { + t.Fatalf("PreviousExecutionID = %q, want %q", got, "previous-execution") + } + if got := backend.createRequest.Metadata; !reflect.DeepEqual(got, metadata) { + t.Fatalf("Metadata = %#v, want %#v", got, metadata) + } +} + +func TestCreateExecutionReturnsRemoteResumePreflightError(t *testing.T) { + log := newTestLogger() + backend := &resumeTrackingExecutor{ + MockExecutor: MockExecutor{name: executor.NameStandalone}, + client: newReadyAgentctlClient(t, log), + resumeErr: errors.New("remote unavailable"), + } + execRegistry := NewExecutorRegistry(log) + execRegistry.Register(backend) + mgr := NewManager( + newTestRegistry(), &MockEventBus{}, execRegistry, &MockCredentialsManager{}, &MockProfileResolver{}, nil, + ExecutorFallbackWarn, "", log, + ) + cleanupManagerStopCh(t, mgr) + + _, err := mgr.createExecution(context.Background(), "task-1", &WorkspaceInfo{ + SessionID: "session-1", + AgentID: "auggie", + WorkspacePath: "/workspace/task-1", + }) + if !errors.Is(err, backend.resumeErr) { + t.Fatalf("createExecution error = %v, want remote resume error", err) + } + if !strings.Contains(err.Error(), "failed remote resume preflight") { + t.Fatalf("createExecution error = %q, want remote resume preflight context", err) + } + if got, want := backend.calls, []string{"resume"}; !reflect.DeepEqual(got, want) { + t.Fatalf("backend calls = %v, want %v", got, want) + } +} + // --- test helpers --- +type resumeTrackingExecutor struct { + MockExecutor + client *agentctl.Client + resumeErr error + calls []string + createRequest *ExecutorCreateRequest +} + +func (e *resumeTrackingExecutor) ResumeRemoteInstance(_ context.Context, _ *ExecutorCreateRequest) error { + e.calls = append(e.calls, "resume") + return e.resumeErr +} + +func (e *resumeTrackingExecutor) CreateInstance(_ context.Context, req *ExecutorCreateRequest) (*ExecutorInstance, error) { + e.calls = append(e.calls, "create") + e.createRequest = req + return &ExecutorInstance{ + InstanceID: req.InstanceID, + TaskID: req.TaskID, + SessionID: req.SessionID, + RuntimeName: e.Name(), + Client: e.client, + WorkspacePath: req.WorkspacePath, + }, nil +} + type doneObservedContext struct { context.Context doneRead chan struct{} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go index 9be8dd68a8..b130638d5d 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_launch.go @@ -24,6 +24,8 @@ import ( "github.com/kandev/kandev/internal/worktree" ) +const legacyExecutorTypeLocalPC = "local_pc" + // resolveAgentProfile resolves the agent profile and returns the agent type name and profile info. func (m *Manager) resolveAgentProfile(ctx context.Context, req *LaunchRequest) (string, *AgentProfileInfo, error) { profileID := executionProfileID(req) @@ -350,6 +352,11 @@ func (m *Manager) resolveWorkspaceFromProvider(ctx context.Context, req *LaunchR } func (m *Manager) launchResolveWorkspacePath(ctx context.Context, req *LaunchRequest) (workspacePath, mainRepoGitDir, worktreeID, worktreeBranch string) { + // Clone-based runtimes own their workspace filesystem. In particular, never + // pass a host checkout into Docker, SSH, or Sprites on reset/reconnect. + if backend, err := m.getExecutorBackend(req.ExecutorType); err == nil && backend.RequiresCloneURL() { + return "", "", "", "" + } // Worktree mode requires a repository. Repo-less tasks fall through to the // scratch workspace path below — even if the executor type was worktree. useWorktree := req.UseWorktree && req.RepositoryPath != "" @@ -631,6 +638,7 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st AgentProfileID: executionProfileID(reqWithWorktree), OfficeAgentProfileID: reqWithWorktree.AgentProfileID, WorkspacePath: reqWithWorktree.WorkspacePath, + WorkspaceSourceRoots: workspaceSourceRoots(reqWithWorktree.WorkspaceFolders, workspaceRepositorySpecsFromLaunch(reqWithWorktree)), Protocol: string(agentConfig.Runtime().Protocol), Env: env, AutoApprovePermissions: profileInfo != nil && profileInfo.AutoApprove, @@ -645,10 +653,8 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st OnProgress: onProgress, } - if resumer, ok := rt.(RemoteSessionResumer); ok { - if err := resumer.ResumeRemoteInstance(ctx, execReq); err != nil { - return nil, nil, nil, fmt.Errorf("failed remote resume preflight: %w", err) - } + if err := resumeRemoteInstancePreflight(ctx, rt, execReq); err != nil { + return nil, nil, nil, err } execInstance, err := rt.CreateInstance(ctx, execReq) @@ -658,6 +664,17 @@ func (m *Manager) launchBuildExecutorRequest(ctx context.Context, executionID st return execReq, execInstance, rt, nil } +func resumeRemoteInstancePreflight(ctx context.Context, rt ExecutorBackend, req *ExecutorCreateRequest) error { + resumer, ok := rt.(RemoteSessionResumer) + if !ok { + return nil + } + if err := resumer.ResumeRemoteInstance(ctx, req); err != nil { + return fmt.Errorf("failed remote resume preflight: %w", err) + } + return nil +} + // runEnvironmentPreparer runs the environment preparer for the executor type, if one is registered. // Returns the prepare result (nil if no preparer ran). Does NOT publish PrepareCompleted; // the caller is responsible for publishing based on the returned result. @@ -1024,6 +1041,14 @@ func (m *Manager) launchInternal(ctx context.Context, req *LaunchRequest) (*Agen // 4. Resolve workspace path (non-worktree executors use this directly) workspacePath, mainRepoGitDir, worktreeID, worktreeBranch := m.launchResolveWorkspacePath(ctx, req) + if err := reconcileWorkspaceSources(ctx, workspacePath, req.WorkspaceFolders); err != nil { + return nil, err + } + if req.ExecutorType == string(models.ExecutorTypeLocal) || req.ExecutorType == legacyExecutorTypeLocalPC { + if err := reconcileWorkspaceRepositories(workspacePath, workspaceRepositorySpecsFromLaunch(req)); err != nil { + return nil, err + } + } progressRecorder := newPrepareProgressRecorder(m.newProgressCallback(req.TaskID, req.SessionID)) // 4b. Run environment preparation (if preparer registered for this executor type). @@ -1061,6 +1086,21 @@ func (m *Manager) launchInternal(ctx context.Context, req *LaunchRequest) (*Agen m.publishLaunchPrepareCompleted(req, prepResult, progressRecorder, workspacePath, false, err) return nil, err } + // A reset/relaunch receives the complete durable repository projection from + // the orchestrator. Reconcile it through the fresh live agentctl rather than + // relying on the legacy primary-repository prepare script alone. + if rt.RequiresCloneURL() && len(reqWithWorktree.RepoSpecs()) > 1 && execInstance != nil && execInstance.Client != nil { + projection, projectionErr := remoteWorkspaceProjectionFromLaunch(&reqWithWorktree) + if projectionErr == nil { + projectionErr = materializeWorkspaceRepositories(ctx, execInstance.Client, projection) + } + if projectionErr != nil { + _ = rt.StopInstance(context.WithoutCancel(ctx), execInstance, false) + err = fmt.Errorf("reconstruct remote workspace repositories: %w", projectionErr) + m.publishLaunchPrepareCompleted(req, prepResult, progressRecorder, workspacePath, false, err) + return nil, err + } + } // Remote executors (Docker, Sprites) clone the workspace inside the // container, so the worktree path's host-side copy_files never ran. @@ -1295,9 +1335,9 @@ func (m *Manager) resolveApprovalPolicyAndDisplayName(ctx context.Context, execu } // createBootMessage creates a boot message and starts the stderr polling goroutine. -// Returns the message and stop channel (both nil if bootMessageService is not configured). +// Returns nil values when boot messages are unavailable. func (m *Manager) createBootMessage(ctx context.Context, execution *AgentExecution, bootCommand, agentDisplayName string) (*models.Message, chan struct{}) { - if m.bootMessageService == nil { + if m.bootMessageService == nil || execution == nil { return nil, nil } bootMsg, bootErr := m.bootMessageService.CreateMessage(ctx, &BootMessageRequest{ diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go index a3d5c60b3c..2a17521700 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle.go @@ -189,6 +189,11 @@ func (m *Manager) Stop() error { return nil } +// StopReasonBackendShutdown identifies the graceful backend-shutdown path. +// Executors may preserve resumable remote state for this reason only; user +// stops and rollback cleanup deliberately retain their destructive semantics. +const StopReasonBackendShutdown = "backend shutdown" + // StopAllAgents attempts a graceful shutdown of all active agents concurrently. func (m *Manager) StopAllAgents(ctx context.Context) error { m.shuttingDown.Store(true) @@ -205,7 +210,7 @@ func (m *Manager) StopAllAgents(ctx context.Context) error { wg.Add(1) go func(e *AgentExecution) { defer wg.Done() - if err := m.StopAgent(ctx, e.ID, false); err != nil { + if err := m.StopAgentWithReason(ctx, e.ID, StopReasonBackendShutdown, false); err != nil { errCh <- err m.logger.Warn("failed to stop agent during shutdown", zap.String("execution_id", e.ID), diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_test.go index 013bdd7b07..d84830e068 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_test.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_lifecycle_test.go @@ -213,6 +213,7 @@ type mockStopTracker struct { name executor.Name stopCalled bool stoppedInstanceID string + stopReason string } func (m *mockStopTracker) Name() executor.Name { return m.name } @@ -225,8 +226,31 @@ func (m *mockStopTracker) CreateInstance(ctx context.Context, req *ExecutorCreat func (m *mockStopTracker) StopInstance(ctx context.Context, instance *ExecutorInstance, force bool) error { m.stopCalled = true m.stoppedInstanceID = instance.InstanceID + m.stopReason = instance.StopReason return nil } + +func TestManagerStopAllAgentsPassesBackendShutdownReason(t *testing.T) { + log := newTestRegistryLogger() + execRegistry := NewExecutorRegistry(log) + mock := &mockStopTracker{name: "standalone"} + execRegistry.Register(mock) + mgr := NewManager(nil, &MockEventBus{}, execRegistry, nil, nil, nil, ExecutorFallbackWarn, "", log) + if err := mgr.executionStore.Add(&AgentExecution{ + ID: "exec-1", + SessionID: "session-1", + RuntimeName: "standalone", + }); err != nil { + t.Fatalf("add execution: %v", err) + } + + if err := mgr.StopAllAgents(context.Background()); err != nil { + t.Fatalf("StopAllAgents: %v", err) + } + if mock.stopReason != StopReasonBackendShutdown { + t.Fatalf("StopInstance reason = %q, want %q", mock.stopReason, StopReasonBackendShutdown) + } +} func (m *mockStopTracker) RecoverInstances(ctx context.Context) ([]*ExecutorInstance, error) { return nil, nil } diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go new file mode 100644 index 0000000000..36b4e5d20a --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rebind.go @@ -0,0 +1,227 @@ +package lifecycle + +import ( + "context" + "fmt" + "strings" + "time" + + v1 "github.com/kandev/kandev/pkg/api/v1" + "go.uber.org/zap" +) + +const agentctlProcessStatusRunning = "running" + +var ( + workspaceRebindReadyTimeout = 10 * time.Second + workspaceRebindReadyPoll = 500 * time.Millisecond + workspaceRebindStreamTimeout = 10 * time.Second +) + +// RebindWorkspaceForSession moves an idle native host execution to a prepared +// task root without changing its Kandev session. Providers that support loading +// a conversation under a new CWD keep their ACP session; incompatible providers +// get a fresh ACP session whose next prompt receives recorded Kandev history. +// The caller must only invoke this after the complete attachment batch is +// ready; this operation publishes no materialization event itself. +func (m *Manager) RebindWorkspaceForSession(ctx context.Context, sessionID, workspacePath string, sourceRoots ...[]string) error { + execution, ok := m.executionStore.GetBySessionID(sessionID) + if !ok { + // There is no child to adopt. The persisted environment root is enough + // for the next launch, so materialization remains durable rather than + // failing a batch because an old session is no longer live. + return nil + } + if execution.IsPassthrough || execution.agentctl == nil || execution.ACPSessionID == "" { + return fmt.Errorf("workspace rebind is unsupported for this session; start a new session after attaching sources") + } + execution.promptLifecycleMu.Lock() + defer execution.promptLifecycleMu.Unlock() + if execution.Status != v1.AgentStatusReady { + return fmt.Errorf("workspace rebind requires an idle ready execution") + } + oldPath, acpID := execution.WorkspacePath, execution.ACPSessionID + oldRoots := append([]string(nil), execution.WorkspaceSourceRoots...) + newRoots := optionalWorkspaceSourceRoots(oldRoots, sourceRoots) + startNewSession := m.startsNewSessionOnWorkspaceRebind(execution) + execution.Status = v1.AgentStatusStarting + + // Stop before changing agentctl's workdir: a successful rebind must never + // leave a child running in the old CWD. If stop cannot be proven, do not + // attempt adoption or start a duplicate process. + if err := execution.agentctl.Stop(ctx); err != nil { + execution.Status = v1.AgentStatusReady + return fmt.Errorf("stop agent before workspace rebind: %w", err) + } + execution.WorkspaceSourceRoots = newRoots + if err := execution.agentctl.RebindWorkspace(ctx, workspacePath, newRoots); err != nil { + return m.rollbackWorkspaceRebind(ctx, execution, oldPath, oldRoots, acpID, fmt.Errorf("rebind agentctl workspace: %w", err)) + } + execution.WorkspacePath = workspacePath + if _, err := execution.agentctl.Start(ctx); err != nil { + return m.rollbackWorkspaceRebind(ctx, execution, oldPath, oldRoots, acpID, fmt.Errorf("restart agent after workspace rebind: %w", err)) + } + if err := m.restoreReboundACPSession(ctx, execution, acpID, startNewSession); err != nil { + return m.rollbackWorkspaceRebind(ctx, execution, oldPath, oldRoots, acpID, fmt.Errorf("restore ACP session after workspace rebind: %w", err)) + } + execution.Status = v1.AgentStatusReady + return nil +} + +func (m *Manager) rollbackWorkspaceRebind(ctx context.Context, execution *AgentExecution, oldPath string, oldRoots []string, acpID string, cause error) error { + rollbackCtx := context.WithoutCancel(ctx) + // Restore the authoritative in-memory policy first, even if an I/O failure + // prevents the best-effort child rollback below. + execution.WorkspaceSourceRoots = append([]string(nil), oldRoots...) + if err := execution.agentctl.Stop(rollbackCtx); err != nil { + m.executionStore.UpdateError(execution.ID, fmt.Sprintf("%v; rollback stop failed: %v", cause, err)) + return fmt.Errorf("%w; rollback stop failed: %v", cause, err) + } + if err := execution.agentctl.RebindWorkspace(rollbackCtx, oldPath, oldRoots); err != nil { + m.executionStore.UpdateError(execution.ID, fmt.Sprintf("%v; rollback rebind failed: %v", cause, err)) + return fmt.Errorf("%w; rollback rebind failed: %v", cause, err) + } + execution.WorkspacePath = oldPath + if _, err := execution.agentctl.Start(rollbackCtx); err != nil { + m.executionStore.UpdateError(execution.ID, fmt.Sprintf("%v; rollback restart failed: %v", cause, err)) + return fmt.Errorf("%w; rollback restart failed: %v", cause, err) + } + if err := m.restoreReboundACPSession(rollbackCtx, execution, acpID, false); err != nil { + m.executionStore.UpdateError(execution.ID, fmt.Sprintf("%v; rollback session/load failed: %v", cause, err)) + return fmt.Errorf("%w; rollback session/load failed: %v", cause, err) + } + execution.Status = v1.AgentStatusReady + return cause +} + +// restoreReboundACPSession restores agent continuity once the restarted child +// has published a running status and its replacement updates stream is +// connected. Start can acknowledge before the ACP adapter is usable. +func (m *Manager) restoreReboundACPSession(ctx context.Context, execution *AgentExecution, acpID string, startNewSession bool) error { + if err := waitForReboundAgentReady(ctx, execution); err != nil { + return err + } + updatesReady := make(chan struct{}) + m.streamManager.ConnectAll(execution, updatesReady) + if err := waitForReboundUpdatesStream(ctx, updatesReady); err != nil { + return err + } + if _, err := execution.agentctl.Initialize(ctx, "kandev", "1.0.0"); err != nil { + return fmt.Errorf("initialize restarted ACP adapter: %w", err) + } + if startNewSession { + return m.createReboundACPSession(ctx, execution) + } + if err := execution.agentctl.LoadSession(ctx, acpID, nil); err != nil { + return err + } + return nil +} + +func (m *Manager) startsNewSessionOnWorkspaceRebind(execution *AgentExecution) bool { + if m.registry == nil || execution.AgentID == "" { + return false + } + agentConfig, ok := m.registry.Get(execution.AgentID) + if !ok || agentConfig.Runtime() == nil { + return false + } + return agentConfig.Runtime().SessionConfig.NewSessionOnWorkspaceRebind +} + +func (m *Manager) createReboundACPSession(ctx context.Context, execution *AgentExecution) error { + agentConfig, ok := m.registry.Get(execution.AgentID) + if !ok { + return fmt.Errorf("agent type not found: %s", execution.AgentID) + } + mcpServers, err := m.resolveMcpServers(ctx, execution, agentConfig) + if err != nil { + return fmt.Errorf("resolve MCP servers for rebound session: %w", err) + } + previousMode := execution.GetModeState() + previousModel := execution.GetModelState() + newSessionID, err := execution.agentctl.NewSession(ctx, execution.WorkspacePath, mcpServers) + if err != nil { + return fmt.Errorf("create ACP session in rebound workspace: %w", err) + } + execution.ACPSessionID = newSessionID + execution.sessionInitialized = true + execution.resumeContextInjected = false + execution.needsResumeContext = m.historyManager != nil && + m.historyManager.HasHistory(execution.SessionID) + m.reapplyReboundSessionConfig(ctx, execution, newSessionID, previousModel, previousMode) + if m.eventPublisher != nil { + m.eventPublisher.PublishACPSessionCreated(execution, newSessionID) + } + return nil +} + +func (m *Manager) reapplyReboundSessionConfig( + ctx context.Context, + execution *AgentExecution, + sessionID string, + model *CachedModelState, + mode *CachedModeState, +) { + if model != nil && model.CurrentModelID != "" { + if err := execution.agentctl.SetModel(ctx, model.CurrentModelID); err != nil { + m.logger.Warn("failed to re-apply model after workspace rebind", + zap.String("execution_id", execution.ID), + zap.String("model", model.CurrentModelID), + zap.Error(err)) + } + } + if model != nil { + for _, option := range model.ConfigOptions { + if option.ID == "" || option.CurrentValue == "" || + strings.EqualFold(option.Category, "model") || + strings.EqualFold(option.Category, "mode") { + continue + } + if err := execution.agentctl.SetConfigOption(ctx, option.ID, option.CurrentValue); err != nil { + m.logger.Warn("failed to re-apply config option after workspace rebind", + zap.String("execution_id", execution.ID), + zap.String("config_id", option.ID), + zap.Error(err)) + } + } + } + m.reapplySessionModeAfterReset(ctx, execution, sessionID, mode) +} + +func waitForReboundAgentReady(ctx context.Context, execution *AgentExecution) error { + readyCtx, cancel := context.WithTimeout(ctx, workspaceRebindReadyTimeout) + defer cancel() + ticker := time.NewTicker(workspaceRebindReadyPoll) + defer ticker.Stop() + var lastErr error + for { + status, err := execution.agentctl.GetStatus(readyCtx) + if err == nil && status.AgentStatus == agentctlProcessStatusRunning { + return nil + } + if err != nil { + lastErr = err + } else { + lastErr = fmt.Errorf("agent status is %q", status.AgentStatus) + } + select { + case <-readyCtx.Done(): + return fmt.Errorf("wait for restarted agent readiness: %w", lastErr) + case <-ticker.C: + } + } +} + +func waitForReboundUpdatesStream(ctx context.Context, updatesReady <-chan struct{}) error { + timer := time.NewTimer(workspaceRebindStreamTimeout) + defer timer.Stop() + select { + case <-updatesReady: + return nil + case <-ctx.Done(): + return ctx.Err() + case <-timer.C: + return fmt.Errorf("timeout waiting for agent stream after workspace rebind") + } +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rescan.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rescan.go index d6f7adaee9..d06fcc03f0 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rescan.go +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rescan.go @@ -86,7 +86,7 @@ func (m *Manager) NotifyWorktreeMaterialized(ctx context.Context, wt Materialize // Best-effort: returns an error but the caller treats it as a warning. // The worktree still exists on disk; the next session relaunch will pick // it up via prepareMultiRepo regardless of whether this rescan succeeded. -func (m *Manager) RescanWorkspaceForSession(ctx context.Context, sessionID, workDir string) error { +func (m *Manager) RescanWorkspaceForSession(ctx context.Context, sessionID, workDir string, sourceRoots ...[]string) error { if sessionID == "" { return fmt.Errorf("session_id is required") } @@ -106,7 +106,11 @@ func (m *Manager) RescanWorkspaceForSession(ctx context.Context, sessionID, work zap.String("execution_id", execution.ID)) return nil } - if err := client.RescanWorkspace(ctx, workDir); err != nil { + oldRoots := append([]string(nil), execution.WorkspaceSourceRoots...) + newRoots := optionalWorkspaceSourceRoots(oldRoots, sourceRoots) + execution.WorkspaceSourceRoots = newRoots + if err := client.RescanWorkspace(ctx, workDir, newRoots); err != nil { + execution.WorkspaceSourceRoots = oldRoots return fmt.Errorf("rescan workspace via agentctl: %w", err) } m.logger.Info("agentctl workspace rescan ok", @@ -115,3 +119,10 @@ func (m *Manager) RescanWorkspaceForSession(ctx context.Context, sessionID, work zap.String("work_dir", workDir)) return nil } + +func optionalWorkspaceSourceRoots(current []string, roots [][]string) []string { + if len(roots) == 0 { + return append([]string(nil), current...) + } + return append([]string(nil), roots[0]...) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_sources_test.go b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_sources_test.go new file mode 100644 index 0000000000..95c2b705b2 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/manager_workspace_sources_test.go @@ -0,0 +1,377 @@ +package lifecycle + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "sync" + "testing" + "time" + + "github.com/gorilla/websocket" + "github.com/kandev/kandev/internal/agent/registry" + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + v1 "github.com/kandev/kandev/pkg/api/v1" + ws "github.com/kandev/kandev/pkg/websocket" +) + +func TestRescanWorkspaceForSessionRefreshesSourceRoots(t *testing.T) { + var got []string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/workspace/rescan" { + t.Errorf("path = %s", r.URL.Path) + } + var request struct { + WorkspaceSourceRoots []string `json:"workspace_source_roots"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + got = request.WorkspaceSourceRoots + w.WriteHeader(http.StatusOK) + })) + defer server.Close() + + mgr, execution := workspaceSourceTestManager(t, server.URL, []string{"/old"}) + roots := []string{"/attached/folder", "/attached/repo"} + if err := mgr.RescanWorkspaceForSession(context.Background(), execution.SessionID, "", roots); err != nil { + t.Fatalf("RescanWorkspaceForSession: %v", err) + } + if !sameStrings(got, roots) || !sameStrings(execution.WorkspaceSourceRoots, roots) { + t.Fatalf("roots forwarded=%v stored=%v, want %v", got, execution.WorkspaceSourceRoots, roots) + } +} + +func TestRescanWorkspaceForSessionRestoresRootsOnFailure(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusUnprocessableEntity) + })) + defer server.Close() + + mgr, execution := workspaceSourceTestManager(t, server.URL, []string{"/old"}) + if err := mgr.RescanWorkspaceForSession(context.Background(), execution.SessionID, "", []string{"/new"}); err == nil { + t.Fatal("RescanWorkspaceForSession unexpectedly succeeded") + } + if !sameStrings(execution.WorkspaceSourceRoots, []string{"/old"}) { + t.Fatalf("roots after failed rescan = %v, want old roots", execution.WorkspaceSourceRoots) + } +} + +func TestRebindWorkspaceForSessionRestoresRootsAfterRebindFailure(t *testing.T) { + var roots [][]string + stops := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/stop": + stops++ + if stops == 2 { + w.WriteHeader(http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) + case "/api/v1/workspace/rebind": + var request struct { + WorkspaceSourceRoots []string `json:"workspace_source_roots"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Fatal(err) + } + roots = append(roots, request.WorkspaceSourceRoots) + w.WriteHeader(http.StatusUnprocessableEntity) + default: + t.Errorf("unexpected path %s", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer server.Close() + + mgr, execution := workspaceSourceTestManager(t, server.URL, []string{"/old"}) + execution.Status = v1.AgentStatusReady + execution.ACPSessionID = "acp" + if err := mgr.RebindWorkspaceForSession(context.Background(), execution.SessionID, "/new-workspace", []string{"/attached"}); err == nil { + t.Fatal("RebindWorkspaceForSession unexpectedly succeeded") + } + if len(roots) != 1 || !sameStrings(roots[0], []string{"/attached"}) { + t.Fatalf("rebind roots = %v, want attached roots", roots) + } + if !sameStrings(execution.WorkspaceSourceRoots, []string{"/old"}) { + t.Fatalf("roots after failed rebind = %v, want old roots", execution.WorkspaceSourceRoots) + } +} + +func TestRebindWorkspaceForSessionWaitsForRestartedAdapterBeforeLoadingSession(t *testing.T) { + server := newWorkspaceRebindAgentctlServer(t, false) + + mgr, execution := workspaceSourceTestManager(t, server.URL, []string{"/old"}) + t.Cleanup(server.Close) + t.Cleanup(server.closeConnections) + execution.Status = v1.AgentStatusReady + execution.ACPSessionID = "acp-existing" + if err := mgr.RebindWorkspaceForSession(context.Background(), execution.SessionID, "/new-workspace", []string{"/attached"}); err != nil { + t.Fatalf("RebindWorkspaceForSession: %v", err) + } + if loads := server.loads(); len(loads) != 1 || loads[0] != "acp-existing" { + t.Fatalf("loaded ACP sessions = %v, want [acp-existing]", loads) + } + if execution.ACPSessionID != "acp-existing" { + t.Fatalf("ACP session ID = %q, want existing ID", execution.ACPSessionID) + } + if server.statusCalls() < 2 { + t.Fatalf("status calls = %d, want readiness polling before load", server.statusCalls()) + } + if actions := server.actions(); !sameStrings(actions, []string{"agent.initialize", "agent.session.load"}) { + t.Fatalf("ACP actions = %v, want initialize before load", actions) + } +} + +func TestRebindWorkspaceForSessionCreatesNewSessionWhenProviderCannotChangeResumeCWD(t *testing.T) { + server := newWorkspaceRebindAgentctlServer(t, false) + + mgr, execution := workspaceSourceTestManager(t, server.URL, []string{"/old"}) + t.Cleanup(server.Close) + t.Cleanup(server.closeConnections) + mgr.registry = registry.NewRegistry(newTestLogger()) + mgr.registry.LoadDefaults() + history, err := NewSessionHistoryManager(t.TempDir(), "", newTestLogger()) + if err != nil { + t.Fatal(err) + } + mgr.historyManager = history + execution.AgentID = "opencode-acp" + execution.Status = v1.AgentStatusReady + execution.ACPSessionID = "acp-existing" + execution.historyEnabled = true + if err := history.AppendUserMessage(execution.SessionID, "earlier request"); err != nil { + t.Fatal(err) + } + + if err := mgr.RebindWorkspaceForSession(context.Background(), execution.SessionID, "/new-workspace", []string{"/attached"}); err != nil { + t.Fatalf("RebindWorkspaceForSession: %v", err) + } + if loads := server.loads(); len(loads) != 0 { + t.Fatalf("loaded ACP sessions = %v, want none", loads) + } + if execution.ACPSessionID != "acp-new" { + t.Fatalf("ACP session ID = %q, want acp-new", execution.ACPSessionID) + } + if !execution.needsResumeContext { + t.Fatal("fresh workspace session should inject recorded context on the next prompt") + } + if actions := server.actions(); !sameStrings(actions, []string{"agent.initialize", "agent.session.new"}) { + t.Fatalf("ACP actions = %v, want initialize then new session", actions) + } +} + +func TestRebindWorkspaceForSessionReadinessTimeoutRollsBack(t *testing.T) { + server := newWorkspaceRebindAgentctlServer(t, true) + mgr, execution := workspaceSourceTestManager(t, server.URL, []string{"/old"}) + t.Cleanup(server.Close) + t.Cleanup(server.closeConnections) + + previousTimeout, previousPoll := workspaceRebindReadyTimeout, workspaceRebindReadyPoll + workspaceRebindReadyTimeout, workspaceRebindReadyPoll = 20*time.Millisecond, time.Millisecond + t.Cleanup(func() { + workspaceRebindReadyTimeout, workspaceRebindReadyPoll = previousTimeout, previousPoll + }) + + execution.Status = v1.AgentStatusReady + execution.WorkspacePath = "/old-workspace" + execution.ACPSessionID = "acp-existing" + if err := mgr.RebindWorkspaceForSession(context.Background(), execution.SessionID, "/new-workspace", []string{"/attached"}); err == nil { + t.Fatal("RebindWorkspaceForSession unexpectedly succeeded") + } + if got := server.reboundPaths(); !sameStrings(got, []string{"/new-workspace", "/old-workspace"}) { + t.Fatalf("rebound paths = %v, want new then old", got) + } + if execution.WorkspacePath != "/old-workspace" || !sameStrings(execution.WorkspaceSourceRoots, []string{"/old"}) { + t.Fatalf("execution after rollback = path %q roots %v, want old workspace and roots", execution.WorkspacePath, execution.WorkspaceSourceRoots) + } + if loads := server.loads(); len(loads) != 1 || loads[0] != "acp-existing" { + t.Fatalf("rollback loaded ACP sessions = %v, want [acp-existing]", loads) + } +} + +type workspaceRebindAgentctlServer struct { + *httptest.Server + mu sync.Mutex + startCount int + neverReady bool + firstStatus bool + statusCallCount int + paths []string + loadedSessions []string + actionLog []string + connections []*websocket.Conn +} + +func newWorkspaceRebindAgentctlServer(t *testing.T, neverReady bool) *workspaceRebindAgentctlServer { + t.Helper() + server := &workspaceRebindAgentctlServer{neverReady: neverReady} + upgrader := websocket.Upgrader{CheckOrigin: func(*http.Request) bool { return true }} + mux := http.NewServeMux() + mux.HandleFunc("/health", func(w http.ResponseWriter, _ *http.Request) { w.WriteHeader(http.StatusOK) }) + mux.HandleFunc("/api/v1/stop", workspaceRebindSuccess) + mux.HandleFunc("/api/v1/workspace/rebind", func(w http.ResponseWriter, r *http.Request) { + var request struct { + WorkDir string `json:"work_dir"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + t.Error(err) + w.WriteHeader(http.StatusBadRequest) + return + } + server.mu.Lock() + server.paths = append(server.paths, request.WorkDir) + server.mu.Unlock() + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/api/v1/start", func(w http.ResponseWriter, _ *http.Request) { + server.mu.Lock() + server.startCount++ + server.firstStatus = true + server.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]any{"success": true}) + }) + mux.HandleFunc("/api/v1/status", func(w http.ResponseWriter, _ *http.Request) { + server.mu.Lock() + server.statusCallCount++ + status := "running" + if server.startCount == 1 && server.neverReady || server.firstStatus { + status = "starting" + server.firstStatus = false + } + server.mu.Unlock() + _ = json.NewEncoder(w).Encode(map[string]string{"agent_status": status}) + }) + mux.HandleFunc("/api/v1/agent/stream", func(w http.ResponseWriter, r *http.Request) { + conn, err := upgrader.Upgrade(w, r, nil) + if err != nil { + return + } + server.mu.Lock() + server.connections = append(server.connections, conn) + server.mu.Unlock() + defer func() { _ = conn.Close() }() + for { + _, payload, err := conn.ReadMessage() + if err != nil { + return + } + var request ws.Message + if json.Unmarshal(payload, &request) != nil || request.Type != ws.MessageTypeRequest { + continue + } + server.mu.Lock() + server.actionLog = append(server.actionLog, request.Action) + server.mu.Unlock() + if request.Action == "agent.initialize" { + response, _ := ws.NewResponse(request.ID, request.Action, map[string]any{"success": true}) + data, _ := json.Marshal(response) + if conn.WriteMessage(websocket.TextMessage, data) != nil { + return + } + continue + } + if request.Action == "agent.session.new" { + response, _ := ws.NewResponse(request.ID, request.Action, map[string]any{ + "success": true, + "session_id": "acp-new", + }) + data, _ := json.Marshal(response) + if conn.WriteMessage(websocket.TextMessage, data) != nil { + return + } + continue + } + if request.Action != "agent.session.load" { + continue + } + var load struct { + SessionID string `json:"session_id"` + } + _ = request.ParsePayload(&load) + server.mu.Lock() + server.loadedSessions = append(server.loadedSessions, load.SessionID) + server.mu.Unlock() + response, _ := ws.NewResponse(request.ID, request.Action, map[string]bool{"success": true}) + data, _ := json.Marshal(response) + if conn.WriteMessage(websocket.TextMessage, data) != nil { + return + } + } + }) + server.Server = httptest.NewServer(mux) + return server +} + +func workspaceRebindSuccess(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) +} + +func (s *workspaceRebindAgentctlServer) loads() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.loadedSessions...) +} + +func (s *workspaceRebindAgentctlServer) reboundPaths() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.paths...) +} + +func (s *workspaceRebindAgentctlServer) statusCalls() int { + s.mu.Lock() + defer s.mu.Unlock() + return s.statusCallCount +} + +func (s *workspaceRebindAgentctlServer) actions() []string { + s.mu.Lock() + defer s.mu.Unlock() + return append([]string(nil), s.actionLog...) +} + +func (s *workspaceRebindAgentctlServer) closeConnections() { + s.mu.Lock() + connections := append([]*websocket.Conn(nil), s.connections...) + s.mu.Unlock() + for _, connection := range connections { + _ = connection.Close() + } +} + +func workspaceSourceTestManager(t *testing.T, rawURL string, roots []string) (*Manager, *AgentExecution) { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatal(err) + } + execution := &AgentExecution{ID: "execution", SessionID: "session", WorkspaceSourceRoots: roots, agentctl: agentctl.NewClient(parsed.Hostname(), port, newTestLogger())} + store := NewExecutionStore() + if err := store.Add(execution); err != nil { + t.Fatal(err) + } + streamManager := NewStreamManager(newTestLogger(), StreamCallbacks{}, nil, nil) + t.Cleanup(streamManager.Wait) + return &Manager{executionStore: store, logger: newTestLogger(), streamManager: streamManager}, execution +} + +func sameStrings(got, want []string) bool { + if len(got) != len(want) { + return false + } + for i := range got { + if got[i] != want[i] { + return false + } + } + return true +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/types.go b/apps/backend/internal/agent/runtime/lifecycle/types.go index c06944b6de..133fc7ae82 100644 --- a/apps/backend/internal/agent/runtime/lifecycle/types.go +++ b/apps/backend/internal/agent/runtime/lifecycle/types.go @@ -40,6 +40,7 @@ type AgentExecution struct { ContainerID string ContainerIP string // IP address of the container for agentctl communication WorkspacePath string // Path to the workspace (worktree or repository path) + WorkspaceSourceRoots []string // Canonical durable source roots permitted by agentctl file operations ACPSessionID string // ACP session ID to resume, if available AgentCommand string // Command to start the agent subprocess ContinueCommand string // Command for follow-up prompts (one-shot agents like Amp) @@ -123,7 +124,8 @@ type AgentExecution struct { assistantHistoryBuffer strings.Builder // History-based context injection for agents without native session resume (e.g. Auggie). - // historyEnabled gates recording and injection; set from SessionConfig.HistoryContextInjection. + // historyEnabled gates recording and injection; it is enabled by either + // HistoryContextInjection or NewSessionOnWorkspaceRebind. // needsResumeContext is set to true when the session has history that should be injected. // resumeContextInjected is set to true after context has been injected into a prompt. historyEnabled bool @@ -501,6 +503,30 @@ type RepoLaunchSpec struct { BranchIdentitySlug string } +// WorkspaceFolderSpec is a durable host folder attachment projected into both +// fresh launches and workspace-only resume construction. +type WorkspaceFolderSpec struct { + Name string + LocalPath string +} + +// WorkspaceRepositorySpec is the durable host-side source needed to recreate +// a task's owned repository entry after a restart. +type WorkspaceRepositorySpec struct { + RepositoryID string + RepositoryPath string + RepoName string + BaseBranch string + DefaultBranch string + CheckoutBranch string + WorktreeID string + WorktreeBranchPrefix string + WorktreeBranchTemplate string + PullBeforeWorktree bool + BranchSlug string + BranchIdentitySlug string +} + // RouteOverride carries a fully resolved provider profile for one // routing-driven launch. Empty fields mean "use the base profile value" // — so when the dispatcher does NOT supply an override, launch behavior @@ -593,7 +619,8 @@ type LaunchRequest struct { // When non-empty it is the source of truth; the legacy single-repo top-level // fields above are populated from Repositories[0] for callers that have not // yet been updated. - Repositories []RepoLaunchSpec + Repositories []RepoLaunchSpec + WorkspaceFolders []WorkspaceFolderSpec // managedGoCachePath is resolved once before local preparation so setup // scripts and the runtime instance cannot observe different settings. @@ -700,14 +727,18 @@ type McpConfigProvider interface { // WorkspaceInfo contains information about a task's workspace for on-demand execution creation type WorkspaceInfo struct { - TaskID string - SessionID string // Task session ID (from task_sessions table) - TaskEnvironmentID string // Env this session belongs to (shared across sessions in same task) - WorkspacePath string // Path to the workspace/repository - AgentProfileID string // Stable Office agent identity (or the execution profile for legacy sessions) - ExecutionProfileID string // Concrete CLI profile selected for this execution - AgentID string // Agent type ID (e.g., "auggie", "codex") - required for runtime creation - ACPSessionID string // Agent's session ID for conversation resumption (from session metadata) + TaskID string + SessionID string // Task session ID (from task_sessions table) + TaskEnvironmentID string // Env this session belongs to (shared across sessions in same task) + WorkspacePath string // Path to the workspace/repository + WorkspaceFolders []WorkspaceFolderSpec + WorkspaceRepositories []WorkspaceRepositorySpec + TaskDirName string + WorkspaceID string + AgentProfileID string // Stable Office agent identity (or the execution profile for legacy sessions) + ExecutionProfileID string // Concrete CLI profile selected for this execution + AgentID string // Agent type ID (e.g., "auggie", "codex") - required for runtime creation + ACPSessionID string // Agent's session ID for conversation resumption (from session metadata) // SessionMode is the persisted session permission mode (e.g. "acceptEdits") // from session metadata, declared via the set_session_mode workflow action or // a user toggle. Applied as a mode override at ACP session init so a fresh diff --git a/apps/backend/internal/agent/runtime/lifecycle/workspace_materialization.go b/apps/backend/internal/agent/runtime/lifecycle/workspace_materialization.go new file mode 100644 index 0000000000..627751de87 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/workspace_materialization.go @@ -0,0 +1,235 @@ +package lifecycle + +import ( + "context" + "errors" + "fmt" + "sort" + "time" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" + "github.com/kandev/kandev/internal/worktree" +) + +// WorkspaceRepositoryMaterialization is one durable repository projection +// prepared for a running remote workspace. It deliberately carries only a +// credential-free locator; executor launch environments provide Git auth. +type WorkspaceRepositoryMaterialization struct { + RepositoryURL string + Destination string + BaseBranch string + CheckoutBranch string +} + +const workspaceMaterializationRollbackTimeout = 10 * time.Second + +func remoteWorkspaceProjectionFromLaunch(req *LaunchRequest) ([]WorkspaceRepositoryMaterialization, error) { + if req == nil { + return nil, fmt.Errorf("launch request is required") + } + specs := req.RepoSpecs() + projection := make([]WorkspaceRepositoryMaterialization, 0, len(specs)) + // The first durable repository is established at the workspace root by + // each remote executor's prepare path. Only sibling repositories belong in + // agentctl-managed workspace subdirectories. + for index, spec := range specs { + if index == 0 { + continue + } + if spec.RepositoryURL == "" { + return nil, fmt.Errorf("remote repository %q has no clone URL", spec.RepoName) + } + branch := spec.CheckoutBranch + if branch == "" { + branch = spec.BaseBranch + } + name, branchSlug := worktree.SanitizeRepoDirName(spec.RepoName), worktree.SanitizeBranchSlug(branch) + if name == "" || branchSlug == "" { + return nil, fmt.Errorf("remote repository %q has unsafe runtime name", spec.RepoName) + } + projection = append(projection, WorkspaceRepositoryMaterialization{RepositoryURL: spec.RepositoryURL, Destination: name + "-" + branchSlug, BaseBranch: spec.BaseBranch, CheckoutBranch: spec.CheckoutBranch}) + } + return projection, nil +} + +type workspaceRepositoryClient interface { + MaterializeRepository(context.Context, agentctl.MaterializeRepositoryRequest) (*agentctl.MaterializeRepositoryResponse, error) + RemoveMaterializedRepository(context.Context, agentctl.RemoveMaterializedRepositoryRequest) error + RescanWorkspace(context.Context, string, ...[]string) error + ReconcileWorkspace(context.Context, ...[]string) error +} + +// MaterializeRepositoriesForEnvironment reconciles the complete durable +// repository projection against the live agentctl workspace. It is intentionally +// environment-scoped so callers attach to the existing task/session execution. +func (m *Manager) MaterializeRepositoriesForEnvironment(ctx context.Context, taskEnvironmentID string, repositories []WorkspaceRepositoryMaterialization) ([]string, error) { + executions, err := m.liveWorkspaceExecutionsForEnvironment(ctx, taskEnvironmentID) + if err != nil { + return nil, err + } + clients := distinctWorkspaceRepositoryClients(executions) + if len(clients) == 0 { + return nil, fmt.Errorf("workspace execution has no agentctl client") + } + created, err := materializeWorkspaceRepositoriesWithoutRescan(ctx, clients[0].client, repositories) + if err != nil { + return nil, err + } + rescanned := make([]workspaceRepositoryExecution, 0, len(clients)) + for _, execution := range clients { + if err := execution.client.RescanWorkspace(ctx, "", execution.sourceRoots); err != nil { + cleanupErr := rollbackMaterializedWorkspaceRepositories(ctx, clients[0].client, created) + var reconcileErr error + if cleanupErr == nil { + reconcileErr = rollbackWorkspaceRepositoryRescans(ctx, rescanned) + } + return nil, fmt.Errorf("rescan materialized workspace for session %s: %w", execution.sessionID, errors.Join(err, cleanupErr, reconcileErr)) + } + rescanned = append(rescanned, execution) + } + return workspaceRepositorySessionIDs(executions), nil +} + +func materializeWorkspaceRepositories(ctx context.Context, client workspaceRepositoryClient, repositories []WorkspaceRepositoryMaterialization) error { + created, err := materializeWorkspaceRepositoriesWithoutRescan(ctx, client, repositories) + if err != nil { + return err + } + if err := client.RescanWorkspace(ctx, ""); err != nil { + cleanupErr := rollbackMaterializedWorkspaceRepositories(ctx, client, created) + var reconcileErr error + if cleanupErr == nil { + reconcileErr = reconcileWorkspaceRepositoryClient(ctx, client, nil) + } + return fmt.Errorf("rescan materialized workspace: %w", errors.Join(err, cleanupErr, reconcileErr)) + } + return nil +} + +type workspaceRepositoryExecution struct { + sessionID string + client workspaceRepositoryClient + sourceRoots []string +} + +func (m *Manager) liveWorkspaceExecutionsForEnvironment(ctx context.Context, taskEnvironmentID string) ([]*AgentExecution, error) { + if taskEnvironmentID == "" { + return nil, fmt.Errorf("task_environment_id is required") + } + executions := make([]*AgentExecution, 0) + for _, execution := range m.executionStore.List() { + if execution != nil && execution.TaskEnvironmentID == taskEnvironmentID && execution.GetAgentCtlClient() != nil { + executions = append(executions, execution) + } + } + if len(executions) == 0 { + execution, err := m.GetOrEnsureExecutionForEnvironment(ctx, taskEnvironmentID) + if err != nil { + return nil, fmt.Errorf("ensure workspace execution: %w", err) + } + if execution == nil || execution.GetAgentCtlClient() == nil { + return nil, fmt.Errorf("workspace execution has no agentctl client") + } + executions = append(executions, execution) + } + sort.Slice(executions, func(i, j int) bool { return executions[i].ID < executions[j].ID }) + return executions, nil +} + +func distinctWorkspaceRepositoryClients(executions []*AgentExecution) []workspaceRepositoryExecution { + clients := make([]workspaceRepositoryExecution, 0, len(executions)) + seen := make(map[*agentctl.Client]struct{}, len(executions)) + for _, execution := range executions { + client := execution.GetAgentCtlClient() + if client == nil { + continue + } + if _, exists := seen[client]; exists { + continue + } + seen[client] = struct{}{} + clients = append(clients, workspaceRepositoryExecution{ + sessionID: execution.SessionID, + client: client, + sourceRoots: append([]string(nil), execution.WorkspaceSourceRoots...), + }) + } + return clients +} + +func workspaceRepositorySessionIDs(executions []*AgentExecution) []string { + ids := make([]string, 0, len(executions)) + seen := make(map[string]struct{}, len(executions)) + for _, execution := range executions { + if execution.SessionID == "" { + continue + } + if _, exists := seen[execution.SessionID]; !exists { + seen[execution.SessionID] = struct{}{} + ids = append(ids, execution.SessionID) + } + } + return ids +} + +func materializeWorkspaceRepositoriesWithoutRescan(ctx context.Context, client workspaceRepositoryClient, repositories []WorkspaceRepositoryMaterialization) ([]WorkspaceRepositoryMaterialization, error) { + if client == nil { + return nil, fmt.Errorf("agentctl client is required") + } + created := make([]WorkspaceRepositoryMaterialization, 0, len(repositories)) + for _, repository := range repositories { + response, err := client.MaterializeRepository(ctx, agentctl.MaterializeRepositoryRequest{ + RepositoryURL: repository.RepositoryURL, + Destination: repository.Destination, + BaseBranch: repository.BaseBranch, + CheckoutBranch: repository.CheckoutBranch, + }) + if err != nil { + rollbackErr := rollbackMaterializedWorkspaceRepositories(ctx, client, created) + return nil, fmt.Errorf("materialize workspace repository %q: %w", repository.Destination, errors.Join(err, rollbackErr)) + } + if response == nil { + rollbackErr := rollbackMaterializedWorkspaceRepositories(ctx, client, created) + return nil, fmt.Errorf("materialize workspace repository %q: %w", repository.Destination, errors.Join(errors.New("empty response"), rollbackErr)) + } + if !response.Reused { + created = append(created, repository) + } + } + return created, nil +} + +func rollbackWorkspaceRepositoryRescans(ctx context.Context, executions []workspaceRepositoryExecution) error { + rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), workspaceMaterializationRollbackTimeout) + defer cancel() + var errs []error + for index := len(executions) - 1; index >= 0; index-- { + execution := executions[index] + if err := execution.client.ReconcileWorkspace(rollbackCtx, execution.sourceRoots); err != nil { + errs = append(errs, fmt.Errorf("reconcile workspace for session %s: %w", execution.sessionID, err)) + } + } + return errors.Join(errs...) +} + +func reconcileWorkspaceRepositoryClient(ctx context.Context, client workspaceRepositoryClient, sourceRoots []string) error { + rollbackCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), workspaceMaterializationRollbackTimeout) + defer cancel() + return client.ReconcileWorkspace(rollbackCtx, sourceRoots) +} + +func rollbackMaterializedWorkspaceRepositories(ctx context.Context, client workspaceRepositoryClient, created []WorkspaceRepositoryMaterialization) error { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), workspaceMaterializationRollbackTimeout) + defer cancel() + var errs []error + for i := len(created) - 1; i >= 0; i-- { + repository := created[i] + if err := client.RemoveMaterializedRepository(cleanupCtx, agentctl.RemoveMaterializedRepositoryRequest{ + RepositoryURL: repository.RepositoryURL, + Destination: repository.Destination, + }); err != nil { + errs = append(errs, fmt.Errorf("remove materialized workspace repository %q: %w", repository.Destination, err)) + } + } + return errors.Join(errs...) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/workspace_materialization_test.go b/apps/backend/internal/agent/runtime/lifecycle/workspace_materialization_test.go new file mode 100644 index 0000000000..1bd85d2525 --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/workspace_materialization_test.go @@ -0,0 +1,289 @@ +package lifecycle + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + agentctl "github.com/kandev/kandev/internal/agent/runtime/agentctl" +) + +type workspaceMaterializerClientStub struct { + requests []agentctl.MaterializeRepositoryRequest + removals []agentctl.RemoveMaterializedRepositoryRequest + removalContexts []context.Context + rescans []string + reconciles int + failAt int + rescanErr error + reconcileErr error +} + +func (s *workspaceMaterializerClientStub) MaterializeRepository(_ context.Context, request agentctl.MaterializeRepositoryRequest) (*agentctl.MaterializeRepositoryResponse, error) { + s.requests = append(s.requests, request) + if s.failAt > 0 && len(s.requests) == s.failAt { + return nil, context.Canceled + } + return &agentctl.MaterializeRepositoryResponse{Destination: request.Destination, Reused: request.Destination == "reused"}, nil +} + +func (s *workspaceMaterializerClientStub) RemoveMaterializedRepository(ctx context.Context, request agentctl.RemoveMaterializedRepositoryRequest) error { + s.removals = append(s.removals, request) + s.removalContexts = append(s.removalContexts, ctx) + return nil +} + +func (s *workspaceMaterializerClientStub) RescanWorkspace(_ context.Context, workdir string, _ ...[]string) error { + s.rescans = append(s.rescans, workdir) + return s.rescanErr +} + +func (s *workspaceMaterializerClientStub) ReconcileWorkspace(_ context.Context, _ ...[]string) error { + s.reconciles++ + return s.reconcileErr +} + +func TestMaterializeWorkspaceRepositories_RollsBackOnlyNewCheckoutsInReverseOrder(t *testing.T) { + client := &workspaceMaterializerClientStub{failAt: 3} + err := materializeWorkspaceRepositories(context.Background(), client, []WorkspaceRepositoryMaterialization{ + {RepositoryURL: "https://github.com/acme/reused.git", Destination: "reused", BaseBranch: "main"}, + {RepositoryURL: "https://github.com/acme/new.git", Destination: "new", BaseBranch: "main", CheckoutBranch: "feature/new"}, + {RepositoryURL: "https://github.com/acme/fails.git", Destination: "fails", BaseBranch: "main"}, + }) + if err == nil { + t.Fatal("materializeWorkspaceRepositories succeeded despite cancelled third clone") + } + if len(client.removals) != 1 || client.removals[0].Destination != "new" { + t.Fatalf("removals=%+v; want only newly-created checkout rollback", client.removals) + } + if _, ok := client.removalContexts[0].Deadline(); !ok { + t.Fatal("rollback cleanup context has no deadline") + } +} + +func TestRemoteWorkspaceProjectionFromLaunch_SkipsPrimaryWorkspaceRepository(t *testing.T) { + projection, err := remoteWorkspaceProjectionFromLaunch(&LaunchRequest{Repositories: []RepoLaunchSpec{ + {RepositoryURL: "https://github.com/acme/one.git", RepoName: "one", BaseBranch: "main"}, + {RepositoryURL: "https://github.com/acme/two.git", RepoName: "two", CheckoutBranch: "feature/next"}, + }}) + if err != nil { + t.Fatalf("remoteWorkspaceProjectionFromLaunch: %v", err) + } + if len(projection) != 1 || projection[0].Destination != "two-feature-next" || projection[0].CheckoutBranch != "feature/next" { + t.Fatalf("projection=%+v; want only the additional repository", projection) + } +} + +func TestRemoteWorkspaceProjectionFromLaunch_KeepsAdditionalBranchOfPrimaryRepository(t *testing.T) { + projection, err := remoteWorkspaceProjectionFromLaunch(&LaunchRequest{Repositories: []RepoLaunchSpec{ + {RepositoryURL: "https://github.com/acme/repository.git", RepoName: "repository", BaseBranch: "main"}, + {RepositoryURL: "https://github.com/acme/repository.git", RepoName: "repository", BaseBranch: "main", CheckoutBranch: "release/2026"}, + }}) + if err != nil { + t.Fatalf("remoteWorkspaceProjectionFromLaunch: %v", err) + } + if len(projection) != 1 || projection[0].Destination != "repository-release-2026" || projection[0].BaseBranch != "main" || projection[0].CheckoutBranch != "release/2026" { + t.Fatalf("projection=%+v; want additional branch checkout", projection) + } +} + +func TestMaterializeWorkspaceRepositories_ReconcilesAllBeforeRescan(t *testing.T) { + client := &workspaceMaterializerClientStub{} + err := materializeWorkspaceRepositories(context.Background(), client, []WorkspaceRepositoryMaterialization{ + {RepositoryURL: "https://github.com/acme/one.git", Destination: "one-main", BaseBranch: "main"}, + {RepositoryURL: "https://github.com/acme/two.git", Destination: "two-main", BaseBranch: "main"}, + }) + if err != nil { + t.Fatalf("materializeWorkspaceRepositories: %v", err) + } + if len(client.requests) != 2 || len(client.rescans) != 1 || client.rescans[0] != "" { + t.Fatalf("requests=%+v rescans=%+v; want two requests followed by one rescan", client.requests, client.rescans) + } +} + +func TestMaterializeWorkspaceRepositories_ForwardsBaseAndCheckoutBranches(t *testing.T) { + client := &workspaceMaterializerClientStub{} + if err := materializeWorkspaceRepositories(context.Background(), client, []WorkspaceRepositoryMaterialization{{ + RepositoryURL: "https://github.com/acme/repository.git", Destination: "repository-feature", BaseBranch: "main", CheckoutBranch: "feature/work", + }}); err != nil { + t.Fatal(err) + } + if got := client.requests[0]; got.BaseBranch != "main" || got.CheckoutBranch != "feature/work" { + t.Fatalf("request branches = base:%q checkout:%q", got.BaseBranch, got.CheckoutBranch) + } +} + +func TestMaterializeWorkspaceRepositories_ReturnsRollbackReconcileFailure(t *testing.T) { + rescanErr := errors.New("rescan failed") + reconcileErr := errors.New("tracker prune failed") + client := &workspaceMaterializerClientStub{rescanErr: rescanErr, reconcileErr: reconcileErr} + err := materializeWorkspaceRepositories(context.Background(), client, []WorkspaceRepositoryMaterialization{{ + RepositoryURL: "https://github.com/acme/repository.git", Destination: "repository-main", BaseBranch: "main", + }}) + if !errors.Is(err, rescanErr) || !errors.Is(err, reconcileErr) { + t.Fatalf("rollback error = %v, want rescan and exact reconciliation failures", err) + } + if client.reconciles != 1 { + t.Fatalf("reconcile calls = %d, want 1 after checkout cleanup", client.reconciles) + } +} + +func TestMaterializeRepositoriesForEnvironment_RescansEveryDistinctClient(t *testing.T) { + materializations := 0 + rescans := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/workspace/materialize-repository": + materializations++ + _, _ = w.Write([]byte(`{"destination":"added-main"}`)) + case "/api/v1/workspace/rescan": + rescans++ + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected request path %q", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer server.Close() + + clientOne := workspaceMaterializationAgentctlClient(t, server.URL) + clientTwo := workspaceMaterializationAgentctlClient(t, server.URL) + store := NewExecutionStore() + for _, execution := range []*AgentExecution{ + {ID: "execution-1", SessionID: "session-1", TaskEnvironmentID: "environment-1", agentctl: clientOne}, + {ID: "execution-2", SessionID: "session-2", TaskEnvironmentID: "environment-1", agentctl: clientTwo}, + } { + if err := store.Add(execution); err != nil { + t.Fatal(err) + } + } + manager := &Manager{executionStore: store, logger: newTestLogger()} + + _, err := manager.MaterializeRepositoriesForEnvironment(context.Background(), "environment-1", []WorkspaceRepositoryMaterialization{{ + RepositoryURL: "https://github.com/acme/added.git", + Destination: "added-main", + BaseBranch: "main", + }}) + if err != nil { + t.Fatalf("MaterializeRepositoriesForEnvironment: %v", err) + } + if materializations != 1 { + t.Fatalf("materializations = %d, want 1", materializations) + } + if rescans != 2 { + t.Fatalf("rescans = %d, want 2 for both live executions", rescans) + } +} + +func TestMaterializeRepositoriesForEnvironment_DeduplicatesSharedAgentctlClient(t *testing.T) { + rescans := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/workspace/materialize-repository": + _, _ = w.Write([]byte(`{"destination":"added-main"}`)) + case "/api/v1/workspace/rescan": + rescans++ + w.WriteHeader(http.StatusOK) + default: + t.Errorf("unexpected request path %q", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer server.Close() + + sharedClient := workspaceMaterializationAgentctlClient(t, server.URL) + store := NewExecutionStore() + for _, execution := range []*AgentExecution{ + {ID: "execution-1", SessionID: "session-1", TaskEnvironmentID: "environment-1", agentctl: sharedClient}, + {ID: "execution-2", SessionID: "session-2", TaskEnvironmentID: "environment-1", agentctl: sharedClient}, + } { + if err := store.Add(execution); err != nil { + t.Fatal(err) + } + } + manager := &Manager{executionStore: store, logger: newTestLogger()} + + ids, err := manager.MaterializeRepositoriesForEnvironment(context.Background(), "environment-1", []WorkspaceRepositoryMaterialization{{RepositoryURL: "https://github.com/acme/added.git", Destination: "added-main", BaseBranch: "main"}}) + if err != nil { + t.Fatalf("MaterializeRepositoriesForEnvironment: %v", err) + } + if rescans != 1 { + t.Fatalf("rescans = %d, want 1 for a shared agentctl client", rescans) + } + if !sameStrings(ids, []string{"session-1", "session-2"}) { + t.Fatalf("adopted session ids = %v, want both live sessions", ids) + } +} + +func TestMaterializeRepositoriesForEnvironment_RemovesCheckoutsBeforeReconcilingPriorClients(t *testing.T) { + events := make([]string, 0, 5) + first := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/api/v1/workspace/materialize-repository": + events = append(events, "materialize") + _, _ = w.Write([]byte(`{"destination":"added-main"}`)) + case "/api/v1/workspace/rescan": + events = append(events, "rescan-first") + w.WriteHeader(http.StatusOK) + case "/api/v1/workspace/reconcile": + events = append(events, "reconcile-first") + w.WriteHeader(http.StatusOK) + case "/api/v1/workspace/materialize-repository/remove": + events = append(events, "remove") + _, _ = w.Write([]byte(`{"removed":true}`)) + default: + t.Errorf("unexpected request path %q", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + } + })) + defer first.Close() + second := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/api/v1/workspace/rescan" { + t.Errorf("unexpected request path %q", r.URL.Path) + w.WriteHeader(http.StatusInternalServerError) + return + } + events = append(events, "rescan-second") + w.WriteHeader(http.StatusUnprocessableEntity) + })) + defer second.Close() + + store := NewExecutionStore() + for _, execution := range []*AgentExecution{ + {ID: "execution-1", SessionID: "session-1", TaskEnvironmentID: "environment-1", agentctl: workspaceMaterializationAgentctlClient(t, first.URL)}, + {ID: "execution-2", SessionID: "session-2", TaskEnvironmentID: "environment-1", agentctl: workspaceMaterializationAgentctlClient(t, second.URL)}, + } { + if err := store.Add(execution); err != nil { + t.Fatal(err) + } + } + manager := &Manager{executionStore: store, logger: newTestLogger()} + + ids, err := manager.MaterializeRepositoriesForEnvironment(context.Background(), "environment-1", []WorkspaceRepositoryMaterialization{{RepositoryURL: "https://github.com/acme/added.git", Destination: "added-main", BaseBranch: "main"}}) + if err == nil { + t.Fatal("MaterializeRepositoriesForEnvironment succeeded despite second rescan failure") + } + if len(ids) != 0 { + t.Fatalf("adopted session ids = %v, want none after rollback", ids) + } + if !sameStrings(events, []string{"materialize", "rescan-first", "rescan-second", "remove", "reconcile-first"}) { + t.Fatalf("events = %v, want checkout removal before exact rollback reconciliation", events) + } +} + +func workspaceMaterializationAgentctlClient(t *testing.T, rawURL string) *agentctl.Client { + t.Helper() + parsed, err := url.Parse(rawURL) + if err != nil { + t.Fatal(err) + } + port, err := strconv.Atoi(parsed.Port()) + if err != nil { + t.Fatal(err) + } + return agentctl.NewClient(parsed.Hostname(), port, newTestLogger()) +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/workspace_sources_reconcile.go b/apps/backend/internal/agent/runtime/lifecycle/workspace_sources_reconcile.go new file mode 100644 index 0000000000..bd5cff2abd --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/workspace_sources_reconcile.go @@ -0,0 +1,101 @@ +package lifecycle + +import ( + "context" + "fmt" + "os" + "path/filepath" + + "github.com/kandev/kandev/internal/worktree" +) + +// reconcileWorkspaceSources recreates Kandev-owned links from durable source +// specs before a host launch or workspace-only resume. +func reconcileWorkspaceSources(_ context.Context, root string, folders []WorkspaceFolderSpec) error { + if len(folders) == 0 { + return nil + } + if root == "" { + return fmt.Errorf("workspace root is required for durable folders") + } + for _, folder := range folders { + if folder.Name == "" || filepath.Base(folder.Name) != folder.Name || folder.LocalPath == "" { + return fmt.Errorf("invalid durable workspace folder") + } + info, err := os.Stat(folder.LocalPath) + if err != nil || !info.IsDir() { + return fmt.Errorf("workspace folder %q target is missing: %s", folder.Name, folder.LocalPath) + } + if _, _, err := worktree.EnsureOwnedDirectoryLink(root, folder.Name, folder.LocalPath); err != nil { + return fmt.Errorf("link workspace folder %q: %w", folder.Name, err) + } + } + return nil +} + +func reconcileWorkspaceRepositories(root string, repositories []WorkspaceRepositorySpec) error { + if len(repositories) == 0 { + return nil + } + if root == "" { + return fmt.Errorf("workspace root is required for durable repositories") + } + for _, repository := range repositories { + if repository.RepoName == "" || filepath.Base(repository.RepoName) != repository.RepoName || repository.RepositoryPath == "" { + return fmt.Errorf("invalid durable workspace repository") + } + info, err := os.Stat(repository.RepositoryPath) + if err != nil || !info.IsDir() { + return fmt.Errorf("workspace repository %q target is missing: %s", repository.RepoName, repository.RepositoryPath) + } + if _, _, err := worktree.EnsureOwnedDirectoryLink(root, repository.RepoName, repository.RepositoryPath); err != nil { + return fmt.Errorf("link workspace repository %q: %w", repository.RepoName, err) + } + } + return nil +} + +func workspaceRepositorySpecsFromLaunch(req *LaunchRequest) []WorkspaceRepositorySpec { + if req == nil { + return nil + } + specs := req.RepoSpecs() + result := make([]WorkspaceRepositorySpec, 0, len(specs)) + for _, spec := range specs { + result = append(result, WorkspaceRepositorySpec{ + RepositoryID: spec.RepositoryID, RepositoryPath: spec.RepositoryPath, RepoName: spec.RepoName, + BaseBranch: spec.BaseBranch, DefaultBranch: spec.DefaultBranch, CheckoutBranch: spec.CheckoutBranch, + WorktreeID: spec.WorktreeID, WorktreeBranchPrefix: spec.WorktreeBranchPrefix, + WorktreeBranchTemplate: spec.WorktreeBranchTemplate, PullBeforeWorktree: spec.PullBeforeWorktree, + BranchSlug: spec.BranchSlug, BranchIdentitySlug: spec.BranchIdentitySlug, + }) + } + return result +} + +func workspaceSourceRoots(folders []WorkspaceFolderSpec, repositories []WorkspaceRepositorySpec) []string { + roots := make([]string, 0, len(folders)+len(repositories)) + seen := make(map[string]struct{}, cap(roots)) + add := func(path string) { + resolved, err := filepath.EvalSymlinks(filepath.Clean(path)) + if err != nil { + return + } + info, err := os.Stat(resolved) + if err != nil || !info.IsDir() { + return + } + if _, ok := seen[resolved]; ok { + return + } + seen[resolved] = struct{}{} + roots = append(roots, resolved) + } + for _, folder := range folders { + add(folder.LocalPath) + } + for _, repository := range repositories { + add(repository.RepositoryPath) + } + return roots +} diff --git a/apps/backend/internal/agent/runtime/lifecycle/workspace_sources_reconcile_test.go b/apps/backend/internal/agent/runtime/lifecycle/workspace_sources_reconcile_test.go new file mode 100644 index 0000000000..40eeb3c05c --- /dev/null +++ b/apps/backend/internal/agent/runtime/lifecycle/workspace_sources_reconcile_test.go @@ -0,0 +1,31 @@ +package lifecycle + +import ( + "context" + "os" + "path/filepath" + "testing" +) + +func TestReconcileWorkspaceSources_RejectsMissingFolderTarget(t *testing.T) { + err := reconcileWorkspaceSources(context.Background(), t.TempDir(), []WorkspaceFolderSpec{{Name: "missing", LocalPath: "/definitely/not/a/kandev-folder"}}) + if err == nil { + t.Fatal("missing durable folder target was accepted") + } +} + +func TestReconcileWorkspaceRepositories_RecreatesMissingOwnedLink(t *testing.T) { + root, source := t.TempDir(), t.TempDir() + if err := reconcileWorkspaceRepositories(root, []WorkspaceRepositorySpec{{RepoName: "api", RepositoryPath: source}}); err != nil { + t.Fatalf("reconcileWorkspaceRepositories: %v", err) + } + if got, err := os.Readlink(filepath.Join(root, "api")); err != nil || got != source { + t.Fatalf("repository link = %q, %v; want %q", got, err, source) + } + if err := os.Remove(filepath.Join(root, "api")); err != nil { + t.Fatal(err) + } + if err := reconcileWorkspaceRepositories(root, []WorkspaceRepositorySpec{{RepoName: "api", RepositoryPath: source}}); err != nil { + t.Fatalf("reconcile after reset: %v", err) + } +} diff --git a/apps/backend/internal/agent/settings/controller/profile_crud.go b/apps/backend/internal/agent/settings/controller/profile_crud.go index 3f690be2fa..6178333a99 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud.go @@ -3,6 +3,7 @@ package controller import ( "context" "fmt" + "regexp" "sort" "strings" @@ -489,6 +490,8 @@ const ( reservedProfileEnvVarPrefix = "KANDEV_" ) +var posixEnvIdentifier = regexp.MustCompile(`^[A-Za-z_][A-Za-z0-9_]*$`) + func validateProfileEnvVarDTOs(in []dto.ProfileEnvVarDTO) error { if len(in) > maxProfileEnvVars { return fmt.Errorf("%w: at most %d entries allowed", ErrInvalidProfileEnvVars, maxProfileEnvVars) @@ -496,6 +499,9 @@ func validateProfileEnvVarDTOs(in []dto.ProfileEnvVarDTO) error { seen := make(map[string]int, len(in)) for i, ev := range in { key := strings.TrimSpace(ev.Key) + if key != ev.Key { + return fmt.Errorf("%w: env_vars[%d].key must be a POSIX environment identifier", ErrInvalidProfileEnvVars, i) + } if err := validateEnvVarKey(key, i, seen); err != nil { return err } @@ -514,8 +520,8 @@ func validateEnvVarKey(key string, i int, seen map[string]int) error { if len(key) > maxProfileEnvVarKeyLen { return fmt.Errorf("%w: env_vars[%d].key exceeds %d characters", ErrInvalidProfileEnvVars, i, maxProfileEnvVarKeyLen) } - if strings.ContainsAny(key, "=\x00") { - return fmt.Errorf("%w: env_vars[%d].key must not contain '=' or null bytes", ErrInvalidProfileEnvVars, i) + if !posixEnvIdentifier.MatchString(key) { + return fmt.Errorf("%w: env_vars[%d].key must be a POSIX environment identifier", ErrInvalidProfileEnvVars, i) } if strings.HasPrefix(key, reservedProfileEnvVarPrefix) || key == reservedProfileEnvVarKey { return fmt.Errorf("%w: env_vars[%d].key %q is reserved", ErrInvalidProfileEnvVars, i, key) diff --git a/apps/backend/internal/agent/settings/controller/profile_crud_test.go b/apps/backend/internal/agent/settings/controller/profile_crud_test.go index 6ce2d60186..09b4acfafc 100644 --- a/apps/backend/internal/agent/settings/controller/profile_crud_test.go +++ b/apps/backend/internal/agent/settings/controller/profile_crud_test.go @@ -170,8 +170,13 @@ func TestValidateProfileEnvVarDTOs(t *testing.T) { {name: "valid secret", envVars: []dto.ProfileEnvVarDTO{{Key: "TOKEN", SecretID: "sec-1"}}}, {name: "empty key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: ""}}, wantErr: true}, {name: "whitespace key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: " "}}, wantErr: true}, + {name: "padded key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: " FOO ", Value: "x"}}, wantErr: true}, {name: "equals key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "BAD=KEY", Value: "x"}}, wantErr: true}, {name: "null key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "BAD\x00KEY", Value: "x"}}, wantErr: true}, + {name: "shell injection keys rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "BAD; touch /tmp/pwned", Value: "x"}}, wantErr: true}, + {name: "command substitution key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "$(touch /tmp/pwned)", Value: "x"}}, wantErr: true}, + {name: "newline key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "BAD\nKEY", Value: "x"}}, wantErr: true}, + {name: "numeric first character rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "1BAD", Value: "x"}}, wantErr: true}, {name: "duplicate key rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "one"}, {Key: " FOO ", Value: "two"}}, wantErr: true}, {name: "value and secret rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "bar", SecretID: "sec-1"}}, wantErr: true}, {name: "null value rejected", envVars: []dto.ProfileEnvVarDTO{{Key: "FOO", Value: "bad\x00val"}}, wantErr: true}, diff --git a/apps/backend/internal/agentctl/server/api/server.go b/apps/backend/internal/agentctl/server/api/server.go index 5a8d25e3de..3113add732 100644 --- a/apps/backend/internal/agentctl/server/api/server.go +++ b/apps/backend/internal/agentctl/server/api/server.go @@ -113,6 +113,8 @@ func (s *Server) setupRoutes() { // Rebuilds the per-repo tracker set so the new worktree's git/file // events reach the UI without a session restart. api.POST("/workspace/rescan", s.handleRescanWorkspace) + api.POST("/workspace/reconcile", s.handleReconcileWorkspace) + api.POST("/workspace/rebind", s.handleRebindWorkspace) // Per-task base-branch map update: kandev backend hits this when // the user picks a different "Compare against" branch via the @@ -135,6 +137,8 @@ func (s *Server) setupRoutes() { // Docker, Sprites — to seed the workspace with gitignored config // after the in-container clone). api.POST("/workspace/copy-files", s.handleWorkspaceCopyFiles) + api.POST("/workspace/materialize-repository", s.handleWorkspaceMaterializeRepository) + api.POST("/workspace/materialize-repository/remove", s.handleWorkspaceRemoveMaterializedRepository) // Shell access (HTTP endpoints only - streaming is via /workspace/stream) api.GET("/shell/status", s.handleShellStatus) diff --git a/apps/backend/internal/agentctl/server/api/workspace_materialize.go b/apps/backend/internal/agentctl/server/api/workspace_materialize.go new file mode 100644 index 0000000000..772a26e93a --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/workspace_materialize.go @@ -0,0 +1,534 @@ +package api + +import ( + "context" + "crypto/rand" + "errors" + "fmt" + "net/http" + "net/url" + "os" + "os/exec" + "path/filepath" + "strings" + "unicode" + + "github.com/gin-gonic/gin" + "github.com/kandev/kandev/internal/common/securityutil" + "go.uber.org/zap" +) + +// MaterializeRepositoryRequest describes one repository checkout below the +// agentctl workspace. RepositoryURL must be a credential-free Git locator; +// destination is always a direct child of the current workspace root. +type MaterializeRepositoryRequest struct { + RepositoryURL string `json:"repository_url"` + Destination string `json:"destination"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch,omitempty"` +} + +// MaterializeRepositoryResponse deliberately contains no remote locator so a +// credential accidentally supplied by an untrusted caller cannot be echoed. +type MaterializeRepositoryResponse struct { + Destination string `json:"destination"` + Reused bool `json:"reused,omitempty"` + Error string `json:"error,omitempty"` +} + +// RemoveMaterializedRepositoryRequest identifies a previously materialized, +// credential-free checkout that may be removed during a failed batch rollback. +type RemoveMaterializedRepositoryRequest struct { + RepositoryURL string `json:"repository_url"` + Destination string `json:"destination"` +} + +type removeMaterializedRepositoryResponse struct { + Removed bool `json:"removed"` + Error string `json:"error,omitempty"` +} + +func (s *Server) handleWorkspaceMaterializeRepository(c *gin.Context) { + var req MaterializeRepositoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, MaterializeRepositoryResponse{Error: "invalid request"}) + return + } + destination, err := materializeDestination(s.procMgr.WorkDir(), req.Destination) + if err != nil { + c.JSON(http.StatusBadRequest, MaterializeRepositoryResponse{Error: "invalid destination"}) + return + } + if err := validateRepositoryLocator(req.RepositoryURL); err != nil { + c.JSON(http.StatusBadRequest, MaterializeRepositoryResponse{Error: "invalid repository locator"}) + return + } + if !securityutil.IsValidBranchName(req.BaseBranch) || (req.CheckoutBranch != "" && !securityutil.IsValidBranchName(req.CheckoutBranch)) { + c.JSON(http.StatusBadRequest, MaterializeRepositoryResponse{Error: "invalid repository branch"}) + return + } + + reused, err := materializeRepository(c.Request.Context(), req.RepositoryURL, destination, req.BaseBranch, req.CheckoutBranch) + if err != nil { + if errors.Is(err, errMaterializeCollision) { + c.JSON(http.StatusConflict, MaterializeRepositoryResponse{Error: "destination already exists"}) + return + } + if errors.Is(err, context.Canceled) || errors.Is(err, context.DeadlineExceeded) { + c.JSON(http.StatusRequestTimeout, MaterializeRepositoryResponse{Error: "repository materialization cancelled"}) + return + } + s.logger.Warn("workspace repository materialization failed", zap.String("destination", req.Destination), zap.Error(err)) + c.JSON(http.StatusUnprocessableEntity, MaterializeRepositoryResponse{Error: "repository materialization failed"}) + return + } + status := http.StatusCreated + if reused { + status = http.StatusOK + } + c.JSON(status, MaterializeRepositoryResponse{Destination: req.Destination, Reused: reused}) +} + +func (s *Server) handleWorkspaceRemoveMaterializedRepository(c *gin.Context) { + var req RemoveMaterializedRepositoryRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, removeMaterializedRepositoryResponse{Error: "invalid request"}) + return + } + destination, err := materializeDestination(s.procMgr.WorkDir(), req.Destination) + if err != nil { + c.JSON(http.StatusBadRequest, removeMaterializedRepositoryResponse{Error: "invalid destination"}) + return + } + if err := validateRemovalRepositoryLocator(req.RepositoryURL); err != nil { + c.JSON(http.StatusBadRequest, removeMaterializedRepositoryResponse{Error: "invalid repository locator"}) + return + } + removed, err := removeMaterializedRepository(c.Request.Context(), s.procMgr.WorkDir(), destination, req.RepositoryURL) + if err != nil { + if errors.Is(err, errMaterializeCollision) { + c.JSON(http.StatusConflict, removeMaterializedRepositoryResponse{Error: "destination is not the requested checkout"}) + return + } + s.logger.Warn("workspace repository cleanup failed", zap.String("destination", req.Destination), zap.Error(err)) + c.JSON(http.StatusUnprocessableEntity, removeMaterializedRepositoryResponse{Error: "repository cleanup failed"}) + return + } + c.JSON(http.StatusOK, removeMaterializedRepositoryResponse{Removed: removed}) +} + +var errMaterializeCollision = errors.New("materialize destination collision") + +var beforeMaterializeQuarantineRename = func() {} + +var afterMaterializeQuarantineOpen = func(string) {} + +func materializeDestination(workDir, destination string) (string, error) { + if destination == "" || filepath.IsAbs(destination) || filepath.Base(destination) != destination || destination == "." || destination == ".." { + return "", errors.New("unsafe destination") + } + root, err := filepath.Abs(workDir) + if err != nil { + return "", err + } + root, err = filepath.EvalSymlinks(root) + if err != nil { + return "", err + } + destination = filepath.Join(root, destination) + if filepath.Dir(destination) != root { + return "", errors.New("destination escapes workspace") + } + return destination, nil +} + +func validateRepositoryLocator(locator string) error { + if malformedRepositoryLocator(locator) { + return errors.New("empty or malformed locator") + } + if filepath.IsAbs(locator) { + return errors.New("local locator is not allowed") + } + parsed, err := url.Parse(locator) + if err != nil || parsed.User != nil || parsed.RawQuery != "" || parsed.Fragment != "" { + return errors.New("credentialed or malformed locator") + } + if isNetworkRepositoryScheme(parsed.Scheme) { + return validateNetworkRepositoryLocator(parsed) + } + // Git's SCP-like syntax permits only the conventional git user; accepting + // arbitrary users would make it impossible to distinguish credentials. + if strings.HasPrefix(locator, "git@") && strings.Count(locator, ":") == 1 && !strings.ContainsAny(locator, " \\t") { + return nil + } + return errors.New("unsupported locator") +} + +func malformedRepositoryLocator(locator string) bool { + return locator == "" || strings.TrimSpace(locator) != locator || strings.IndexFunc(locator, unicode.IsControl) >= 0 +} + +func isNetworkRepositoryScheme(scheme string) bool { + switch scheme { + case "https", "http", "ssh", "git": + return true + default: + return false + } +} + +func validateNetworkRepositoryLocator(locator *url.URL) error { + if locator.Host == "" { + return errors.New("locator host required") + } + return nil +} + +// validateRemovalRepositoryLocator keeps rollback capable of recognizing +// legacy local-test checkouts. Removal never opens the locator: it only +// compares it with an existing checkout's origin before deleting that owned +// destination, unlike the materialize endpoint which must not fetch local or +// file URLs from an HTTP caller. +func validateRemovalRepositoryLocator(locator string) error { + if filepath.IsAbs(locator) || strings.HasPrefix(locator, "file:") { + return nil + } + return validateRepositoryLocator(locator) +} + +func materializeRepository(ctx context.Context, locator, destination, baseBranch, checkoutBranch string) (bool, error) { + if reused, err := matchingCheckout(ctx, destination, locator, baseBranch, checkoutBranch); err != nil || reused { + return reused, err + } + // codeql[go/path-injection] destination is a direct child of the canonical workspace root; Lstat rejects links before use. + if _, err := os.Lstat(destination); err == nil { + return false, errMaterializeCollision + } else if !os.IsNotExist(err) { + return false, fmt.Errorf("inspect destination: %w", err) + } + parent := filepath.Dir(destination) + // codeql[go/path-injection] parent is the canonical workspace root containing the direct-child destination. + tmp, err := os.MkdirTemp(parent, ".kandev-clone-") + if err != nil { + return false, err + } + defer func() { _ = os.RemoveAll(tmp) }() + checkout := filepath.Join(tmp, "checkout") + if _, err := materializeGitOutput(ctx, "clone", "--no-checkout", "--", locator, checkout); err != nil { + return false, err + } + if err := checkoutMaterializedBranch(ctx, checkout, baseBranch, checkoutBranch); err != nil { + return false, err + } + // codeql[go/path-injection] checkout is newly created beneath the trusted workspace root; destination is its direct child. + if err := os.Rename(checkout, destination); err != nil { + if os.IsExist(err) { + return false, errMaterializeCollision + } + return false, err + } + return false, nil +} + +func checkoutMaterializedBranch(ctx context.Context, checkout, baseBranch, checkoutBranch string) error { + branch := checkoutBranch + if branch == "" { + branch = baseBranch + } + if hasGitRef(ctx, checkout, "refs/heads/"+branch) { + _, err := materializeGitOutput(ctx, "-C", checkout, "checkout", branch) + return err + } + if hasGitRef(ctx, checkout, "refs/remotes/origin/"+branch) { + _, err := materializeGitOutput(ctx, "-C", checkout, "checkout", "--track", "-b", branch, "origin/"+branch) + return err + } + if checkoutBranch == "" { + return errors.New("base branch is unavailable from origin") + } + _, err := materializeGitOutput(ctx, "-C", checkout, "checkout", "-b", checkoutBranch, "origin/"+baseBranch) + return err +} + +func hasGitRef(ctx context.Context, directory, ref string) bool { + _, err := materializeGitOutput(ctx, "-C", directory, "show-ref", "--verify", "--quiet", ref) + return err == nil +} + +func matchingCheckout(ctx context.Context, destination, locator, baseBranch, checkoutBranch string) (bool, error) { + exists, err := materializedCheckoutExists(destination) + if err != nil || !exists { + return false, err + } + branch := checkoutBranch + if branch == "" { + branch = baseBranch + } + if err := matchingCheckoutIdentity(ctx, destination, locator, branch); err != nil { + return false, err + } + return matchingCheckoutCommit(ctx, destination, baseBranch, branch) +} + +func materializedCheckoutExists(destination string) (bool, error) { + // codeql[go/path-injection] destination is a direct child of the canonical workspace root and must be a real directory before Git probes. + destinationInfo, err := os.Lstat(destination) + if os.IsNotExist(err) { + return false, nil + } + if err != nil || destinationInfo.Mode()&os.ModeSymlink != 0 || !destinationInfo.IsDir() { + return false, errMaterializeCollision + } + gitPath := filepath.Join(destination, ".git") + // codeql[go/path-injection] .git is the exact child of a real, non-symlink destination and is Lstat-checked before Git probes. + gitInfo, err := os.Lstat(gitPath) + if err != nil { + if os.IsNotExist(err) { + return false, nil + } + return false, errMaterializeCollision + } + if gitInfo.Mode()&os.ModeSymlink != 0 || !gitInfo.IsDir() { + return false, errMaterializeCollision + } + return true, nil +} + +func matchingCheckoutIdentity(ctx context.Context, destination, locator, branch string) error { + origin, err := materializeGitOutput(ctx, "-C", destination, "remote", "get-url", "origin") + if err != nil || strings.TrimSpace(origin) != locator { + return errMaterializeCollision + } + currentBranch, err := materializeGitOutput(ctx, "-C", destination, "branch", "--show-current") + if err != nil || strings.TrimSpace(currentBranch) != branch { + return errMaterializeCollision + } + return nil +} + +func matchingCheckoutCommit(ctx context.Context, destination, baseBranch, branch string) (bool, error) { + requestedRef := "origin/" + branch + if !hasGitRef(ctx, destination, "refs/remotes/"+requestedRef) { + requestedRef = "origin/" + baseBranch + } + requestedCommit, err := materializeGitOutput(ctx, "-C", destination, "rev-parse", "--verify", requestedRef+"^{commit}") + if err != nil { + return false, errMaterializeCollision + } + headCommit, err := materializeGitOutput(ctx, "-C", destination, "rev-parse", "--verify", "HEAD^{commit}") + if err != nil || strings.TrimSpace(headCommit) != strings.TrimSpace(requestedCommit) { + return false, errMaterializeCollision + } + return true, nil +} + +func removeMaterializedRepository(ctx context.Context, workDir, destination, locator string) (bool, error) { + root, destinationName, err := openMaterializeRemovalRoot(workDir, destination) + if err != nil { + return false, err + } + defer func() { _ = root.Close() }() + quarantine, err := moveToMaterializeQuarantine(root, destinationName) + if err != nil { + return false, err + } + if quarantine == "" { + return false, nil + } + quarantineRoot, capturedInfo, err := openMaterializedQuarantine(root, quarantine, destinationName) + if err != nil { + return false, err + } + defer func() { _ = quarantineRoot.Close() }() + afterMaterializeQuarantineOpen(quarantine) + if err := validateMaterializedQuarantine(quarantineRoot, locator); err != nil { + return false, restoreMaterializedQuarantine(root, capturedInfo, quarantine, destinationName) + } + if err := removeCapturedMaterializedQuarantine(root, quarantineRoot, capturedInfo, quarantine); err != nil { + return false, err + } + return true, nil +} + +func openMaterializeRemovalRoot(workDir, destination string) (*os.Root, string, error) { + canonicalWorkDir, err := filepath.EvalSymlinks(workDir) + if err != nil { + return nil, "", err + } + if filepath.Dir(destination) != canonicalWorkDir { + return nil, "", errMaterializeCollision + } + root, err := os.OpenRoot(canonicalWorkDir) + if err != nil { + return nil, "", err + } + return root, filepath.Base(destination), nil +} + +func moveToMaterializeQuarantine(root *os.Root, destination string) (string, error) { + quarantine, err := materializeQuarantine(root) + if err != nil { + return "", err + } + beforeMaterializeQuarantineRename() + // codeql[go/path-injection] Root binds both direct-child names to the canonical workspace; quarantine captures the exact object before validation. + if err := root.Rename(destination, quarantine); err != nil { + if os.IsNotExist(err) { + return "", nil + } + return "", err + } + return quarantine, nil +} + +func openMaterializedQuarantine(root *os.Root, quarantine, destination string) (*os.Root, os.FileInfo, error) { + // codeql[go/path-injection] Root Lstat rejects a link or non-directory before opening the quarantined name. + quarantineInfo, err := root.Lstat(quarantine) + if err != nil || quarantineInfo.Mode()&os.ModeSymlink != 0 || !quarantineInfo.IsDir() { + return nil, nil, restoreMaterializedQuarantine(root, quarantineInfo, quarantine, destination) + } + // codeql[go/path-injection] Root opens the exact object atomically moved into its private canonical-workdir quarantine. + quarantineRoot, err := root.OpenRoot(quarantine) + if err != nil { + return nil, nil, restoreMaterializedQuarantine(root, nil, quarantine, destination) + } + capturedInfo, err := quarantineRoot.Lstat(".") + if err != nil || capturedInfo.Mode()&os.ModeSymlink != 0 || !capturedInfo.IsDir() { + _ = quarantineRoot.Close() + return nil, nil, restoreMaterializedQuarantine(root, capturedInfo, quarantine, destination) + } + return quarantineRoot, capturedInfo, nil +} + +func validateMaterializedQuarantine(quarantineRoot *os.Root, locator string) error { + // codeql[go/path-injection] The opened quarantine root keeps .git bound to the captured directory, independent of later pathname replacement. + gitDir, err := quarantineRoot.Lstat(".git") + if err != nil || gitDir.Mode()&os.ModeSymlink != 0 || !gitDir.IsDir() { + return errMaterializeCollision + } + origin, err := materializeQuarantineOrigin(quarantineRoot) + if err != nil || strings.TrimSpace(origin) != locator { + return errMaterializeCollision + } + return nil +} + +func removeCapturedMaterializedQuarantine(root, quarantineRoot *os.Root, capturedInfo os.FileInfo, quarantine string) error { + // codeql[go/path-injection] The captured quarantine root recursively removes only its own entries after origin verification. + if err := clearMaterializedQuarantine(quarantineRoot); err != nil { + return err + } + if matches, err := quarantineMatchesCaptured(root, quarantine, capturedInfo); err != nil || !matches { + return errMaterializeCollision + } + // codeql[go/path-injection] Root non-recursively removes the quarantined parent entry only after it still matches the captured object. + if err := root.Remove(quarantine); err != nil { + return err + } + return nil +} + +func materializeQuarantine(root *os.Root) (string, error) { + for range 10 { + entropy := make([]byte, 16) + if _, err := rand.Read(entropy); err != nil { + return "", err + } + name := fmt.Sprintf(".kandev-remove-%x", entropy) + if err := root.Mkdir(name, 0o700); err == nil { + if err := root.Remove(name); err != nil { + return "", err + } + return name, nil + } else if !errors.Is(err, os.ErrExist) { + return "", err + } + } + return "", errors.New("allocate removal quarantine") +} + +func restoreMaterializedQuarantine(root *os.Root, captured os.FileInfo, quarantine, destination string) error { + if captured == nil { + return errMaterializeCollision + } + if matches, err := quarantineMatchesCaptured(root, quarantine, captured); err != nil || !matches { + return errMaterializeCollision + } + if _, err := root.Lstat(destination); err == nil { + return errMaterializeCollision + } else if !os.IsNotExist(err) { + return err + } + // codeql[go/path-injection] Root restores the exact quarantined object only when its direct-child destination remains absent. + if err := root.Rename(quarantine, destination); err != nil { + return err + } + return errMaterializeCollision +} + +func quarantineMatchesCaptured(root *os.Root, quarantine string, captured os.FileInfo) (bool, error) { + info, err := root.Lstat(quarantine) + if err != nil { + return false, err + } + return os.SameFile(info, captured), nil +} + +func materializeQuarantineOrigin(root *os.Root) (string, error) { + config, err := root.ReadFile(filepath.Join(".git", "config")) + if err != nil { + return "", err + } + return parseMaterializeOriginURL(string(config)), nil +} + +func parseMaterializeOriginURL(config string) string { + inOrigin := false + for line := range strings.SplitSeq(config, "\n") { + line = strings.TrimSpace(line) + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + fields := strings.Fields(strings.TrimSpace(line[1 : len(line)-1])) + inOrigin = len(fields) == 2 && strings.EqualFold(fields[0], "remote") && fields[1] == `"origin"` + continue + } + if !inOrigin || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + key, value, ok := strings.Cut(line, "=") + if ok && strings.EqualFold(strings.TrimSpace(key), "url") { + return strings.TrimSpace(value) + } + } + return "" +} + +func clearMaterializedQuarantine(root *os.Root) error { + directory, err := root.Open(".") + if err != nil { + return err + } + defer func() { _ = directory.Close() }() + entries, err := directory.ReadDir(-1) + if err != nil { + return err + } + for _, entry := range entries { + if err := root.RemoveAll(entry.Name()); err != nil { + return err + } + } + return nil +} + +func materializeGitOutput(ctx context.Context, args ...string) (string, error) { + // codeql[go/command-injection] git is fixed; HTTP locators and refs are validated before checkout and matching probes. + cmd := exec.CommandContext(ctx, "git", args...) // #nosec G204 -- git is fixed and arguments are validated; no shell. + output, err := cmd.CombinedOutput() + if err != nil { + if ctx.Err() != nil { + return "", ctx.Err() + } + return "", errors.New("git command failed") + } + return string(output), nil +} diff --git a/apps/backend/internal/agentctl/server/api/workspace_materialize_test.go b/apps/backend/internal/agentctl/server/api/workspace_materialize_test.go new file mode 100644 index 0000000000..b94bfaf5d2 --- /dev/null +++ b/apps/backend/internal/agentctl/server/api/workspace_materialize_test.go @@ -0,0 +1,511 @@ +package api + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/process" +) + +func TestMaterializeRepository_ClonesIntoWorkspaceDestination(t *testing.T) { + origin := createMaterializeOrigin(t) + workDir := t.TempDir() + destination := filepath.Join(workDir, "second-repo") + if _, err := materializeRepository(context.Background(), origin, destination, "main", ""); err != nil { + t.Fatalf("materialize repository: %v", err) + } + if _, err := os.Stat(filepath.Join(workDir, "second-repo", ".git")); err != nil { + t.Fatalf("cloned repository missing: %v", err) + } +} + +func TestMaterializeRepository_ChecksOutBaseAsNamedTrackingBranch(t *testing.T) { + origin := createMaterializeOrigin(t) + workDir := t.TempDir() + destination := filepath.Join(workDir, "second-repo") + if _, err := materializeRepository(context.Background(), origin, destination, "main", ""); err != nil { + t.Fatalf("materialize repository: %v", err) + } + if branch := strings.TrimSpace(materializeGitOutputForTest(t, destination, "branch", "--show-current")); branch != "main" { + t.Fatalf("current branch = %q, want main", branch) + } + if upstream := strings.TrimSpace(materializeGitOutputForTest(t, destination, "rev-parse", "--abbrev-ref", "@{upstream}")); upstream != "origin/main" { + t.Fatalf("upstream = %q, want origin/main", upstream) + } +} + +func TestMaterializeRepository_CreatesNewCheckoutBranchFromBase(t *testing.T) { + origin := createMaterializeOrigin(t) + destination := filepath.Join(t.TempDir(), "second-repo") + if _, err := materializeRepository(context.Background(), origin, destination, "main", "feature/new-work"); err != nil { + t.Fatalf("materialize repository: %v", err) + } + if branch := strings.TrimSpace(materializeGitOutputForTest(t, destination, "branch", "--show-current")); branch != "feature/new-work" { + t.Fatalf("current branch = %q, want feature/new-work", branch) + } + if got, want := strings.TrimSpace(materializeGitOutputForTest(t, destination, "rev-parse", "HEAD")), strings.TrimSpace(materializeGitOutputForTest(t, destination, "rev-parse", "origin/main")); got != want { + t.Fatalf("HEAD = %q, want base commit %q", got, want) + } +} + +func TestMaterializeRepository_ChecksOutExistingRemoteBranchWithTracking(t *testing.T) { + origin := createMaterializeOriginWithBranch(t, "feature/existing") + destination := filepath.Join(t.TempDir(), "second-repo") + if _, err := materializeRepository(context.Background(), origin, destination, "main", "feature/existing"); err != nil { + t.Fatalf("materialize repository: %v", err) + } + if upstream := strings.TrimSpace(materializeGitOutputForTest(t, destination, "rev-parse", "--abbrev-ref", "@{upstream}")); upstream != "origin/feature/existing" { + t.Fatalf("upstream = %q, want origin/feature/existing", upstream) + } +} + +func TestWorkspaceMaterializeRepository_RejectsEscapingDestination(t *testing.T) { + origin := createMaterializeOrigin(t) + workDir := t.TempDir() + log := newTestLogger() + cfg := &config.InstanceConfig{Port: 0, WorkDir: workDir, AuthToken: "test-token"} + s := NewServer(cfg, process.NewManager(cfg, log), nil, nil, log) + body, err := json.Marshal(MaterializeRepositoryRequest{ + RepositoryURL: origin, + Destination: "../escape", + BaseBranch: "main", + }) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/materialize-repository", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d (body: %s)", w.Code, w.Body.String()) + } + if _, err := os.Stat(filepath.Join(filepath.Dir(workDir), "escape")); !os.IsNotExist(err) { + t.Fatalf("escape destination was created: %v", err) + } +} + +func TestWorkspaceMaterializeRepository_RejectsCredentialedLocator(t *testing.T) { + workDir := t.TempDir() + log := newTestLogger() + cfg := &config.InstanceConfig{Port: 0, WorkDir: workDir, AuthToken: "test-token"} + s := NewServer(cfg, process.NewManager(cfg, log), nil, nil, log) + body, err := json.Marshal(MaterializeRepositoryRequest{ + RepositoryURL: "https://secret-token@github.com/kdlbs/kandev.git", + Destination: "kandev", + BaseBranch: "main", + }) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/materialize-repository", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d (body: %s)", w.Code, w.Body.String()) + } + if strings.Contains(w.Body.String(), "secret-token") { + t.Fatalf("credential leaked in response: %s", w.Body.String()) + } +} + +func TestWorkspaceMaterializeRepository_RejectsLocalLocators(t *testing.T) { + s := newMaterializeTestServer(t, t.TempDir()) + for _, locator := range []string{"/srv/repos/private.git", "file:///srv/repos/private.git"} { + body, err := json.Marshal(MaterializeRepositoryRequest{RepositoryURL: locator, Destination: "repo", BaseBranch: "main"}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/materialize-repository", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("%s: expected 400, got %d", locator, w.Code) + } + } +} + +func TestWorkspaceMaterializeRepository_RejectsUnsafeBranches(t *testing.T) { + s := newMaterializeTestServer(t, t.TempDir()) + for _, tc := range []struct { + name string + request MaterializeRepositoryRequest + }{ + {name: "base branch", request: MaterializeRepositoryRequest{RepositoryURL: "https://github.com/kdlbs/kandev.git", Destination: "repo", BaseBranch: "main^{commit}"}}, + {name: "checkout branch", request: MaterializeRepositoryRequest{RepositoryURL: "https://github.com/kdlbs/kandev.git", Destination: "repo", BaseBranch: "main", CheckoutBranch: "feature;unsafe"}}, + } { + t.Run(tc.name, func(t *testing.T) { + body, err := json.Marshal(tc.request) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/materialize-repository", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d (body: %s)", w.Code, w.Body.String()) + } + }) + } +} + +func TestMaterializeRepository_CancelledCloneLeavesNoDestination(t *testing.T) { + origin := createMaterializeOrigin(t) + workDir := t.TempDir() + destination := filepath.Join(workDir, "second-repo") + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + _, err := materializeRepository(ctx, origin, destination, "main", "") + + if !errors.Is(err, context.Canceled) { + t.Fatalf("expected cancellation, got %v", err) + } + if _, err := os.Stat(destination); !os.IsNotExist(err) { + t.Fatalf("partial destination remains after cancellation: %v", err) + } +} + +func TestMaterializeRepository_RejectsExistingCheckoutAtDifferentCommit(t *testing.T) { + origin := createMaterializeOrigin(t) + workDir := t.TempDir() + destination := filepath.Join(workDir, "second-repo") + if reused, err := materializeRepository(context.Background(), origin, destination, "main", ""); err != nil || reused { + t.Fatalf("initial materialization = reused:%t, err:%v", reused, err) + } + runMaterializeTestGit(t, destination, "checkout", "-b", "different-head") + runMaterializeTestGit(t, destination, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "different") + + reused, err := materializeRepository(context.Background(), origin, destination, "main", "") + + if reused || !errors.Is(err, errMaterializeCollision) { + t.Fatalf("mismatched checkout = reused:%t, err:%v; want collision", reused, err) + } +} + +func TestMaterializeRepository_RejectsExistingCheckoutOnWrongNamedBranch(t *testing.T) { + origin := createMaterializeOrigin(t) + destination := filepath.Join(t.TempDir(), "second-repo") + if _, err := materializeRepository(context.Background(), origin, destination, "main", "feature/work"); err != nil { + t.Fatal(err) + } + runMaterializeTestGit(t, destination, "checkout", "main") + reused, err := materializeRepository(context.Background(), origin, destination, "main", "feature/work") + if reused || !errors.Is(err, errMaterializeCollision) { + t.Fatalf("wrong branch reuse = reused:%t err:%v; want collision", reused, err) + } +} + +func TestMaterializeRepository_RejectsSymlinkedExistingCheckout(t *testing.T) { + origin := createMaterializeOrigin(t) + workDir := t.TempDir() + target := filepath.Join(workDir, "target") + if _, err := materializeRepository(context.Background(), origin, target, "main", ""); err != nil { + t.Fatal(err) + } + destination := filepath.Join(workDir, "second-repo") + if err := os.Symlink(target, destination); err != nil { + t.Fatal(err) + } + + reused, err := materializeRepository(context.Background(), origin, destination, "main", "") + + if reused || !errors.Is(err, errMaterializeCollision) { + t.Fatalf("symlinked checkout = reused:%t err:%v; want collision", reused, err) + } +} + +func TestMaterializeRepository_RejectsSymlinkedGitMetadata(t *testing.T) { + origin := createMaterializeOrigin(t) + destination := filepath.Join(t.TempDir(), "second-repo") + if _, err := materializeRepository(context.Background(), origin, destination, "main", ""); err != nil { + t.Fatal(err) + } + gitDir := filepath.Join(destination, ".git") + metadataTarget := filepath.Join(t.TempDir(), "metadata") + if err := os.Rename(gitDir, metadataTarget); err != nil { + t.Fatal(err) + } + if err := os.Symlink(metadataTarget, gitDir); err != nil { + t.Fatal(err) + } + + reused, err := materializeRepository(context.Background(), origin, destination, "main", "") + + if reused || !errors.Is(err, errMaterializeCollision) { + t.Fatalf("symlinked git metadata = reused:%t err:%v; want collision", reused, err) + } +} + +func TestWorkspaceRemoveMaterializedRepository_RemovesOwnedCheckout(t *testing.T) { + origin := createMaterializeOrigin(t) + workDir := t.TempDir() + destination := filepath.Join(workDir, "second-repo") + if _, err := materializeRepository(context.Background(), origin, destination, "main", ""); err != nil { + t.Fatal(err) + } + s := newMaterializeTestServer(t, workDir) + w := removeMaterializedRepositoryRequest(t, s, RemoveMaterializedRepositoryRequest{RepositoryURL: origin, Destination: "second-repo"}) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + if _, err := os.Lstat(destination); !os.IsNotExist(err) { + t.Fatalf("owned checkout remains: %v", err) + } +} + +func TestWorkspaceRemoveMaterializedRepository_NonexistentIsIdempotent(t *testing.T) { + s := newMaterializeTestServer(t, t.TempDir()) + w := removeMaterializedRepositoryRequest(t, s, RemoveMaterializedRepositoryRequest{RepositoryURL: "https://github.com/kdlbs/kandev.git", Destination: "missing"}) + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } +} + +func TestWorkspaceRemoveMaterializedRepository_RejectsUnownedDestination(t *testing.T) { + origin := createMaterializeOrigin(t) + otherOrigin := createMaterializeOrigin(t) + workDir := t.TempDir() + s := newMaterializeTestServer(t, workDir) + cases := []struct { + name string + destination string + prepare func(t *testing.T, path string) + requestURL string + }{ + {name: "non git", destination: "plain", prepare: func(t *testing.T, path string) { + if err := os.Mkdir(path, 0o755); err != nil { + t.Fatal(err) + } + }, requestURL: origin}, + {name: "wrong origin", destination: "other", prepare: func(t *testing.T, path string) { + if _, err := materializeRepository(context.Background(), otherOrigin, path, "main", ""); err != nil { + t.Fatal(err) + } + }, requestURL: origin}, + {name: "symlink", destination: "linked", prepare: func(t *testing.T, path string) { + target := filepath.Join(workDir, "target") + if err := os.Mkdir(target, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(target, path); err != nil { + t.Fatal(err) + } + }, requestURL: origin}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + path := filepath.Join(workDir, tc.destination) + tc.prepare(t, path) + w := removeMaterializedRepositoryRequest(t, s, RemoveMaterializedRepositoryRequest{RepositoryURL: tc.requestURL, Destination: tc.destination}) + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d (body: %s)", w.Code, w.Body.String()) + } + if _, err := os.Lstat(path); err != nil { + t.Fatalf("unowned destination was deleted: %v", err) + } + }) + } +} + +func TestWorkspaceRemoveMaterializedRepository_RejectsSymlinkedGitMetadata(t *testing.T) { + origin := createMaterializeOrigin(t) + workDir := t.TempDir() + destination := filepath.Join(workDir, "second-repo") + if _, err := materializeRepository(context.Background(), origin, destination, "main", ""); err != nil { + t.Fatal(err) + } + gitDir := filepath.Join(destination, ".git") + metadataTarget := filepath.Join(t.TempDir(), "metadata") + if err := os.Rename(gitDir, metadataTarget); err != nil { + t.Fatal(err) + } + if err := os.Symlink(metadataTarget, gitDir); err != nil { + t.Fatal(err) + } + s := newMaterializeTestServer(t, workDir) + + w := removeMaterializedRepositoryRequest(t, s, RemoveMaterializedRepositoryRequest{RepositoryURL: origin, Destination: "second-repo"}) + + if w.Code != http.StatusConflict { + t.Fatalf("expected 409, got %d (body: %s)", w.Code, w.Body.String()) + } + if _, err := os.Lstat(destination); err != nil { + t.Fatalf("checkout was removed: %v", err) + } +} + +func TestWorkspaceRemoveMaterializedRepository_RestoresSwappedReplacement(t *testing.T) { + origin := createMaterializeOrigin(t) + workDir := t.TempDir() + destination := filepath.Join(workDir, "second-repo") + if _, err := materializeRepository(context.Background(), origin, destination, "main", ""); err != nil { + t.Fatal(err) + } + ownedCheckout := filepath.Join(workDir, "owned-checkout") + beforeMaterializeQuarantineRename = func() { + if err := os.Rename(destination, ownedCheckout); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(destination, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(destination, "unrelated"), []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { beforeMaterializeQuarantineRename = func() {} }) + + removed, err := removeMaterializedRepository(context.Background(), workDir, destination, origin) + + if removed || !errors.Is(err, errMaterializeCollision) { + t.Fatalf("swapped removal = removed:%t err:%v; want collision", removed, err) + } + if contents, err := os.ReadFile(filepath.Join(destination, "unrelated")); err != nil || string(contents) != "keep" { + t.Fatalf("replacement was not restored: contents:%q err:%v", contents, err) + } + if _, err := os.Lstat(ownedCheckout); err != nil { + t.Fatalf("original checkout was deleted: %v", err) + } +} + +func TestWorkspaceRemoveMaterializedRepository_RejectsPostQuarantineReplacement(t *testing.T) { + origin := createMaterializeOrigin(t) + workDir := t.TempDir() + destination := filepath.Join(workDir, "second-repo") + if _, err := materializeRepository(context.Background(), origin, destination, "main", ""); err != nil { + t.Fatal(err) + } + capturedCheckout := filepath.Join(workDir, "captured-checkout") + var replacementPath string + afterMaterializeQuarantineOpen = func(quarantine string) { + quarantinePath := filepath.Join(workDir, quarantine) + if err := os.Rename(quarantinePath, capturedCheckout); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(quarantinePath, ".git"), 0o755); err != nil { + t.Fatal(err) + } + replacementPath = quarantinePath + config := "[remote \"origin\"]\n\turl = " + origin + "\n" + if err := os.WriteFile(filepath.Join(quarantinePath, ".git", "config"), []byte(config), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(quarantinePath, "unrelated"), []byte("keep"), 0o644); err != nil { + t.Fatal(err) + } + } + t.Cleanup(func() { afterMaterializeQuarantineOpen = func(string) {} }) + + removed, err := removeMaterializedRepository(context.Background(), workDir, destination, origin) + + if removed || !errors.Is(err, errMaterializeCollision) { + t.Fatalf("post-quarantine replacement = removed:%t err:%v; want collision", removed, err) + } + if contents, err := os.ReadFile(filepath.Join(replacementPath, "unrelated")); err != nil || string(contents) != "keep" { + t.Fatalf("replacement was validated or recursively deleted: contents:%q err:%v", contents, err) + } + if _, err := os.Lstat(capturedCheckout); err != nil { + t.Fatalf("captured checkout was removed through replacement path: %v", err) + } +} + +func TestWorkspaceRemoveMaterializedRepository_RejectsTraversal(t *testing.T) { + workDir := t.TempDir() + s := newMaterializeTestServer(t, workDir) + w := removeMaterializedRepositoryRequest(t, s, RemoveMaterializedRepositoryRequest{RepositoryURL: "https://github.com/kdlbs/kandev.git", Destination: "../escape"}) + if w.Code != http.StatusBadRequest { + t.Fatalf("expected 400, got %d (body: %s)", w.Code, w.Body.String()) + } +} + +func newMaterializeTestServer(t *testing.T, workDir string) *Server { + t.Helper() + log := newTestLogger() + cfg := &config.InstanceConfig{Port: 0, WorkDir: workDir, AuthToken: "test-token"} + return NewServer(cfg, process.NewManager(cfg, log), nil, nil, log) +} + +func removeMaterializedRepositoryRequest(t *testing.T, s *Server, removal RemoveMaterializedRepositoryRequest) *httptest.ResponseRecorder { + t.Helper() + body, err := json.Marshal(removal) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/materialize-repository/remove", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer test-token") + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + return w +} + +func createMaterializeOrigin(t *testing.T) string { + t.Helper() + root := t.TempDir() + origin := filepath.Join(root, "origin.git") + seed := filepath.Join(root, "seed") + runMaterializeTestGit(t, root, "init", "--bare", "--initial-branch=main", origin) + runMaterializeTestGit(t, root, "init", "--initial-branch=main", seed) + if err := os.WriteFile(filepath.Join(seed, "README.md"), []byte("source"), 0o644); err != nil { + t.Fatal(err) + } + runMaterializeTestGit(t, seed, "add", "README.md") + runMaterializeTestGit(t, seed, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "-m", "initial") + runMaterializeTestGit(t, seed, "remote", "add", "origin", origin) + runMaterializeTestGit(t, seed, "push", "origin", "main") + return origin +} + +func createMaterializeOriginWithBranch(t *testing.T, branch string) string { + t.Helper() + origin := createMaterializeOrigin(t) + clone := filepath.Join(t.TempDir(), "clone") + runMaterializeTestGit(t, filepath.Dir(clone), "clone", origin, clone) + runMaterializeTestGit(t, clone, "checkout", "-b", branch) + runMaterializeTestGit(t, clone, "-c", "user.email=test@example.com", "-c", "user.name=Test", "commit", "--allow-empty", "-m", "branch") + runMaterializeTestGit(t, clone, "push", "-u", "origin", branch) + return origin +} + +func runMaterializeTestGit(t *testing.T, dir string, args ...string) { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", args...) + cmd.Dir = dir + if output, err := cmd.CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, output) + } +} + +func materializeGitOutputForTest(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.CommandContext(context.Background(), "git", args...) + cmd.Dir = dir + output, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, output) + } + return string(output) +} diff --git a/apps/backend/internal/agentctl/server/api/workspace_rescan.go b/apps/backend/internal/agentctl/server/api/workspace_rescan.go index 4c82c25524..29cf489afd 100644 --- a/apps/backend/internal/agentctl/server/api/workspace_rescan.go +++ b/apps/backend/internal/agentctl/server/api/workspace_rescan.go @@ -19,7 +19,8 @@ const errKey = "error" // primary worktree". When empty, the manager rescans its current WorkDir // in place. type RescanWorkspaceRequest struct { - WorkDir string `json:"work_dir"` + WorkDir string `json:"work_dir"` + WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"` } // handleRescanWorkspace re-runs repo discovery and reconciles trackers. @@ -42,7 +43,50 @@ func (s *Server) handleRescanWorkspace(c *gin.Context) { return } } - s.procMgr.RescanRepositories(c.Request.Context(), req.WorkDir) + if err := s.procMgr.RescanRepositoriesWithSourceRoots(c.Request.Context(), req.WorkDir, req.WorkspaceSourceRoots); err != nil { + s.logger.Warn("workspace rescan failed", zap.Error(err), zap.String("work_dir", req.WorkDir)) + c.JSON(http.StatusUnprocessableEntity, gin.H{errKey: err.Error()}) + return + } s.logger.Debug("workspace rescan completed", zap.String("work_dir", req.WorkDir)) c.JSON(http.StatusOK, gin.H{"ok": true}) } + +// handleReconcileWorkspace prunes tracker state after rollback has removed +// newly-created checkouts. It deliberately has no work_dir: changing roots is +// a host-rebind concern, while rollback must retain the current root tracker +// and its existing workspace-stream subscriptions. +func (s *Server) handleReconcileWorkspace(c *gin.Context) { + var req RescanWorkspaceRequest + if c.Request.ContentLength != 0 && c.Request.Body != http.NoBody { + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{errKey: "invalid JSON body"}) + return + } + } + if err := s.procMgr.ReconcileRepositories(c.Request.Context()); err != nil { + s.logger.Warn("workspace reconcile failed", zap.Error(err)) + c.JSON(http.StatusUnprocessableEntity, gin.H{errKey: err.Error()}) + return + } + if req.WorkspaceSourceRoots != nil { + s.procMgr.SetWorkspaceSourceRoots(req.WorkspaceSourceRoots) + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} + +// handleRebindWorkspace performs the authenticated, destructive tracker +// replacement used only after lifecycle has stopped a native host child. +func (s *Server) handleRebindWorkspace(c *gin.Context) { + var req RescanWorkspaceRequest + if err := c.ShouldBindJSON(&req); err != nil || req.WorkDir == "" { + c.JSON(http.StatusBadRequest, gin.H{errKey: "work_dir is required"}) + return + } + if err := s.procMgr.RebindWorkspaceWithSourceRoots(c.Request.Context(), req.WorkDir, req.WorkspaceSourceRoots); err != nil { + s.logger.Warn("workspace rebind failed", zap.Error(err), zap.String("work_dir", req.WorkDir)) + c.JSON(http.StatusUnprocessableEntity, gin.H{errKey: err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"ok": true}) +} diff --git a/apps/backend/internal/agentctl/server/api/workspace_rescan_test.go b/apps/backend/internal/agentctl/server/api/workspace_rescan_test.go index 8aadad415a..284eabc361 100644 --- a/apps/backend/internal/agentctl/server/api/workspace_rescan_test.go +++ b/apps/backend/internal/agentctl/server/api/workspace_rescan_test.go @@ -2,10 +2,17 @@ package api import ( "bytes" + "context" "encoding/json" "net/http" "net/http/httptest" + "os" + "os/exec" + "path/filepath" "testing" + + "github.com/kandev/kandev/internal/agentctl/server/config" + "github.com/kandev/kandev/internal/agentctl/server/process" ) // TestHandleRescanWorkspace_AcceptsEmptyBody covers the materializer's @@ -26,7 +33,7 @@ func TestHandleRescanWorkspace_AcceptsEmptyBody(t *testing.T) { // TestHandleRescanWorkspace_AcceptsWorkDir covers the materializer's // transition form: a rescan that promotes WorkDir to the task root before // re-discovering repo subdirs. -func TestHandleRescanWorkspace_AcceptsWorkDir(t *testing.T) { +func TestHandleRescanWorkspace_RejectsInvalidWorkDir(t *testing.T) { s := newTestServer(t) body, _ := json.Marshal(RescanWorkspaceRequest{WorkDir: "/tmp/task-root"}) @@ -35,7 +42,138 @@ func TestHandleRescanWorkspace_AcceptsWorkDir(t *testing.T) { w := httptest.NewRecorder() s.router.ServeHTTP(w, req) + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d (body: %s)", w.Code, w.Body.String()) + } +} + +// A rejected rescan must leave the established linked-source policy intact. +// In particular, the proposed roots belong to the failed transition and must +// not become usable by the current workspace tracker. +func TestHandleRescanWorkspace_FailedRescanRetainsExistingSourceRoots(t *testing.T) { + workspace := t.TempDir() + allowedSource := t.TempDir() + proposedSource := t.TempDir() + if err := os.Symlink(allowedSource, filepath.Join(workspace, "allowed")); err != nil { + t.Skip("symlinks not supported") + } + if err := os.Symlink(proposedSource, filepath.Join(workspace, "proposed")); err != nil { + t.Skip("symlinks not supported") + } + + log := newTestLogger() + cfg := &config.InstanceConfig{WorkDir: workspace, WorkspaceSourceRoots: []string{allowedSource}} + procMgr := process.NewManager(cfg, log) + s := NewServer(cfg, procMgr, nil, nil, log) + + body, err := json.Marshal(RescanWorkspaceRequest{ + WorkDir: filepath.Join(workspace, "missing"), + WorkspaceSourceRoots: []string{proposedSource}, + }) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/rescan", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusUnprocessableEntity { + t.Fatalf("expected 422, got %d (body: %s)", w.Code, w.Body.String()) + } + tracker := procMgr.GetWorkspaceTracker() + if err := tracker.CreateFile(filepath.Join("allowed", "still-allowed.txt")); err != nil { + t.Fatalf("existing source was lost after failed rescan: %v", err) + } + if err := tracker.CreateFile(filepath.Join("proposed", "must-stay-blocked.txt")); err == nil { + t.Fatal("failed rescan installed its proposed source roots") + } +} + +func TestHandleRescanWorkspace_TracksRepositoryLinkWithProposedSourceRoots(t *testing.T) { + workspace := t.TempDir() + source := t.TempDir() + initWorkspaceGitRepo(t, workspace) + initWorkspaceGitRepo(t, source) + if err := os.Symlink(source, filepath.Join(workspace, "linked-repository")); err != nil { + t.Skip("symlinks not supported") + } + + log := newTestLogger() + cfg := &config.InstanceConfig{WorkDir: workspace} + procMgr := process.NewManager(cfg, log) + defer func() { _ = procMgr.Stop(context.Background()) }() + s := NewServer(cfg, procMgr, nil, nil, log) + + body, err := json.Marshal(RescanWorkspaceRequest{WorkspaceSourceRoots: []string{source}}) + if err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/rescan", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + if w.Code != http.StatusOK { t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) } + if got := procMgr.RepoSubpaths(); len(got) != 1 || got[0] != "linked-repository" { + t.Fatalf("RepoSubpaths = %v, want [linked-repository]", got) + } +} + +// A rollback must prune trackers for checkouts that have already been removed +// from disk. The ordinary empty-workdir rescan deliberately only appends, so +// rollback uses its own exact reconciliation endpoint. +func TestHandleReconcileWorkspace_PrunesRemovedRepositoryTracker(t *testing.T) { + workspace := t.TempDir() + source := t.TempDir() + initWorkspaceGitRepo(t, filepath.Join(workspace, "original")) + stale := filepath.Join(workspace, "rolled-back") + initWorkspaceGitRepo(t, stale) + + log := newTestLogger() + if err := os.Symlink(source, filepath.Join(workspace, "linked")); err != nil { + t.Skip("symlinks not supported") + } + cfg := &config.InstanceConfig{WorkDir: workspace, WorkspaceSourceRoots: []string{source}} + procMgr := process.NewManager(cfg, log) + defer func() { _ = procMgr.Stop(context.Background()) }() + s := NewServer(cfg, procMgr, nil, nil, log) + + if err := os.RemoveAll(stale); err != nil { + t.Fatal(err) + } + req := httptest.NewRequest(http.MethodPost, "/api/v1/workspace/reconcile", nil) + req.Header.Set("Content-Type", "application/json") + w := httptest.NewRecorder() + s.router.ServeHTTP(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected 200, got %d (body: %s)", w.Code, w.Body.String()) + } + if got := procMgr.RepoSubpaths(); len(got) != 1 || got[0] != "original" { + t.Fatalf("RepoSubpaths = %v, want [original] after rollback", got) + } + if err := procMgr.GetWorkspaceTracker().CreateFile(filepath.Join("linked", "preserved.txt")); err != nil { + t.Fatalf("reconcile dropped the existing source allowlist: %v", err) + } +} + +func initWorkspaceGitRepo(t *testing.T, path string) { + t.Helper() + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + run := func(args ...string) { + command := exec.Command("git", args...) + command.Dir = path + if output, err := command.CombinedOutput(); err != nil { + t.Fatalf("git %v in %s: %v: %s", args, path, err, output) + } + } + run("init", "-b", "main") + run("config", "user.email", "test@example.com") + run("config", "user.name", "Test User") + run("commit", "--allow-empty", "-m", "initial") } diff --git a/apps/backend/internal/agentctl/server/config/config.go b/apps/backend/internal/agentctl/server/config/config.go index 6b92f98178..0902288596 100644 --- a/apps/backend/internal/agentctl/server/config/config.go +++ b/apps/backend/internal/agentctl/server/config/config.go @@ -53,6 +53,11 @@ type Config struct { // Empty means authentication is disabled (e.g. dev/test without nonce). AuthToken string + // ListenHostOverride forces HTTP listeners to a specific host. It is used by + // SSH launches, whose controller and instance traffic stays inside explicit + // loopback SSH forwards even though bootstrap authentication is enabled. + ListenHostOverride string + // BootstrapNonce is a one-time-use nonce for the handshake protocol. // When set (via AGENTCTL_BOOTSTRAP_NONCE), agentctl generates its own AuthToken. // at startup and exposes POST /auth/handshake for the backend to retrieve it. @@ -235,6 +240,10 @@ type InstanceConfig struct { // WorkspaceTracker's baseBranch. Empty falls back to the hardcoded // origin/main → master priority list inside workspace_git_status.go. BaseBranches map[string]string + + // WorkspaceSourceRoots are canonical durable source roots permitted for + // linked workspace file operations. + WorkspaceSourceRoots []string } // Load loads the configuration from environment variables. @@ -259,6 +268,7 @@ func Load() *Config { LogFormat: getEnv("AGENTCTL_LOG_FORMAT", "json"), McpLogFile: getEnv("KANDEV_MCP_LOG_FILE", ""), VscodeCommand: getEnv("AGENTCTL_VSCODE_COMMAND", "code-server"), + ListenHostOverride: getEnv("AGENTCTL_LISTEN_HOST", ""), IdleTimeout: getEnvDuration("KANDEV_ACP_IDLE_TIMEOUT", time.Hour), IdleReaperInterval: getEnvDuration("KANDEV_ACP_IDLE_REAPER_INTERVAL", time.Minute), } @@ -285,6 +295,9 @@ func Load() *Config { // // An empty return means "all interfaces" (the historical ":port" form). func (c *Config) ListenHost() string { + if c.ListenHostOverride != "" { + return c.ListenHostOverride + } if c.AuthToken == "" { return "127.0.0.1" } @@ -414,6 +427,9 @@ func applyOverrides(cfg *InstanceConfig, overrides *InstanceOverrides) { if len(overrides.BaseBranches) > 0 { cfg.BaseBranches = overrides.BaseBranches } + if overrides.WorkspaceSourceRoots != nil { + cfg.WorkspaceSourceRoots = append([]string(nil), overrides.WorkspaceSourceRoots...) + } } // applyApprovalOverrides sets approval-related instance overrides. Env is a @@ -454,6 +470,7 @@ type InstanceOverrides struct { RequiresProcessKill bool StripEnv []string BaseBranches map[string]string + WorkspaceSourceRoots []string } // ParseCommand splits a command string into arguments diff --git a/apps/backend/internal/agentctl/server/config/config_test.go b/apps/backend/internal/agentctl/server/config/config_test.go index e84504a075..177e76ee22 100644 --- a/apps/backend/internal/agentctl/server/config/config_test.go +++ b/apps/backend/internal/agentctl/server/config/config_test.go @@ -170,6 +170,19 @@ func TestApplyOverrides_BaseBranches(t *testing.T) { // (empty AuthToken) the server must bind loopback only; when a token is // configured it binds all interfaces (empty host → ":port"). func TestListenHost(t *testing.T) { + t.Run("explicit override wins when token is configured", func(t *testing.T) { + cfg := &Config{AuthToken: "secret", ListenHostOverride: "127.0.0.1"} + if got := cfg.ListenHost(); got != "127.0.0.1" { + t.Fatalf("ListenHost() = %q, want explicit loopback override", got) + } + }) + t.Run("loads explicit override from launch environment", func(t *testing.T) { + t.Setenv("AGENTCTL_BOOTSTRAP_NONCE", "nonce") + t.Setenv("AGENTCTL_LISTEN_HOST", "127.0.0.1") + if got := Load().ListenHost(); got != "127.0.0.1" { + t.Fatalf("Load().ListenHost() = %q, want loopback override", got) + } + }) t.Run("no token binds loopback only", func(t *testing.T) { cfg := &Config{AuthToken: ""} if got := cfg.ListenHost(); got != "127.0.0.1" { diff --git a/apps/backend/internal/agentctl/server/instance/instance.go b/apps/backend/internal/agentctl/server/instance/instance.go index 2d7a6f48c5..8c41f575d1 100644 --- a/apps/backend/internal/agentctl/server/instance/instance.go +++ b/apps/backend/internal/agentctl/server/instance/instance.go @@ -177,7 +177,8 @@ type CreateRequest struct { // Each WorkspaceTracker reads its entry at startup and uses it as the // first candidate when resolving BaseCommit / Ahead / Behind. Empty // disables the override. - BaseBranches map[string]string `json:"base_branches,omitempty"` + BaseBranches map[string]string `json:"base_branches,omitempty"` + WorkspaceSourceRoots []string `json:"workspace_source_roots,omitempty"` } // CreateResponse contains the result of creating a new agent instance. diff --git a/apps/backend/internal/agentctl/server/instance/manager.go b/apps/backend/internal/agentctl/server/instance/manager.go index c9329b0a21..b62ee44f8e 100644 --- a/apps/backend/internal/agentctl/server/instance/manager.go +++ b/apps/backend/internal/agentctl/server/instance/manager.go @@ -122,6 +122,7 @@ func (m *Manager) CreateInstance(ctx context.Context, req *CreateRequest) (*Crea RequiresProcessKill: req.RequiresProcessKill, StripEnv: req.StripEnv, BaseBranches: req.BaseBranches, + WorkspaceSourceRoots: req.WorkspaceSourceRoots, } m.logger.Info("CreateInstance: applying overrides", diff --git a/apps/backend/internal/agentctl/server/process/manager.go b/apps/backend/internal/agentctl/server/process/manager.go index 95bd880ef3..509a763b4e 100644 --- a/apps/backend/internal/agentctl/server/process/manager.go +++ b/apps/backend/internal/agentctl/server/process/manager.go @@ -114,6 +114,11 @@ type Manager struct { // swap it when transitioning single→multi mode. repoTrackers []*WorkspaceTracker repoTrackersMu sync.RWMutex + // workspaceSourceRoots is the canonical durable-source allowlist used both + // by workspace operations and repository-child discovery. It is guarded by + // repoTrackersMu so a rebind snapshots its proposed policy before creating + // replacement trackers. + workspaceSourceRoots []string // rescanMu serializes RescanRepositories calls so two concurrent // rescans can't both observe an empty tracker set and double-bootstrap // (or both append duplicate trackers for the same new child). The @@ -266,10 +271,11 @@ func (m *Manager) BeginStop() { func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager { cfg.WorkDir = resolveExistingWorkDir(cfg.WorkDir, log.WithFields(zap.String("component", "process-manager"))) m := &Manager{ - cfg: cfg, - logger: log.WithFields(zap.String("component", "process-manager")), - updatesCh: make(chan adapter.AgentEvent, 100), - pendingPermissions: make(map[string]*PendingPermission), + cfg: cfg, + logger: log.WithFields(zap.String("component", "process-manager")), + updatesCh: make(chan adapter.AgentEvent, 100), + pendingPermissions: make(map[string]*PendingPermission), + workspaceSourceRoots: canonicalWorkspaceSourceRoots(cfg.WorkspaceSourceRoots), } // Multi-repo task roots hold one git worktree per repository as siblings. // In that case build a per-repo tracker for each child so each emits its @@ -277,20 +283,23 @@ func NewManager(cfg *config.InstanceConfig, log *logger.Logger) *Manager { // can show all repos. The root tracker covers the single-repo case via // preferGitRepoChildIfRootIsBare; we skip its fallback when we've already // detected a multi-repo root to avoid double-tracking the first repo. - repoChildren := scanRepositorySubdirs(cfg.WorkDir) + repoChildren := scanRepositorySubdirs(cfg.WorkDir, m.workspaceSourceRoots) if len(repoChildren) >= 2 { // Multi-repo: root tracker bound to the bare task root (no fallback, // no events), plus one tracker per repo subdir. m.workspaceTracker = NewWorkspaceTrackerForRepo(cfg.WorkDir, "", log) m.workspaceTracker.SetBaseBranch(lookupBaseBranch(cfg.BaseBranches, "")) + m.workspaceTracker.SetAllowedSourceRoots(cfg.WorkspaceSourceRoots) for _, child := range repoChildren { tr := NewWorkspaceTrackerForRepo(child.path, child.name, log) tr.SetBaseBranch(lookupBaseBranch(cfg.BaseBranches, child.name)) + tr.SetAllowedSourceRoots(cfg.WorkspaceSourceRoots) m.repoTrackers = append(m.repoTrackers, tr) } } else { m.workspaceTracker = NewWorkspaceTracker(cfg.WorkDir, log) m.workspaceTracker.SetBaseBranch(lookupBaseBranch(cfg.BaseBranches, "")) + m.workspaceTracker.SetAllowedSourceRoots(cfg.WorkspaceSourceRoots) } m.processRunner = NewProcessRunner(m.workspaceTracker, log, cfg.ProcessBufferMaxBytes) m.shellMgr = shell.NewManager(cfg.WorkDir, log) @@ -328,6 +337,33 @@ func (m *Manager) setBaseBranches(branches map[string]string) { m.cfg.BaseBranches = branches } +// SetWorkspaceSourceRoots atomically refreshes the canonical durable-source +// allowlist used by workspace file operations. Roots are set on every active +// tracker because rebind/rescan can swap the root tracker live. +func (m *Manager) SetWorkspaceSourceRoots(roots []string) { + canonical := canonicalWorkspaceSourceRoots(roots) + m.rescanMu.Lock() + defer m.rescanMu.Unlock() + m.repoTrackersMu.RLock() + trackers := append([]*WorkspaceTracker{m.workspaceTracker}, m.repoTrackers...) + m.repoTrackersMu.RUnlock() + m.repoTrackersMu.Lock() + m.workspaceSourceRoots = canonical + m.cfg.WorkspaceSourceRoots = append([]string(nil), canonical...) + m.repoTrackersMu.Unlock() + for _, tracker := range trackers { + if tracker != nil { + tracker.SetAllowedSourceRoots(canonical) + } + } +} + +func (m *Manager) currentWorkspaceSourceRoots() []string { + m.repoTrackersMu.RLock() + defer m.repoTrackersMu.RUnlock() + return append([]string(nil), m.workspaceSourceRoots...) +} + // lookupBaseBranch reads the task's recorded base branch for a given // repository name from the per-instance map. The empty key "" addresses the // single-repo / root tracker. Falls back to the empty-key entry when the @@ -365,7 +401,7 @@ type repositorySubdir struct { // that are themselves git repositories or worktrees. Returns an empty slice // when workDir doesn't exist, isn't readable, or contains zero git children. // Used to detect multi-repo task roots at Manager construction. -func scanRepositorySubdirs(workDir string) []repositorySubdir { +func scanRepositorySubdirs(workDir string, allowedSourceRoots []string) []repositorySubdir { if workDir == "" { return nil } @@ -375,18 +411,69 @@ func scanRepositorySubdirs(workDir string) []repositorySubdir { } var out []repositorySubdir for _, entry := range entries { - if !entry.IsDir() || strings.HasPrefix(entry.Name(), ".") { + if strings.HasPrefix(entry.Name(), ".") { continue } candidate := filepath.Join(workDir, entry.Name()) - if resolveGitIndexPath(candidate) == "" { + linkInfo, err := os.Lstat(candidate) + if err != nil { continue } - out = append(out, repositorySubdir{name: entry.Name(), path: candidate}) + repositoryPath := candidate + if linkInfo.Mode()&os.ModeSymlink != 0 { + resolved, err := filepath.EvalSymlinks(candidate) + if err != nil || !pathWithinSourceRoots(resolved, allowedSourceRoots) { + continue + } + repositoryPath = resolved + } + // Directory links are how a Local task exposes durable folder and + // repository attachments. DirEntry.IsDir is false for a Unix symlink; + // Stat follows only a link already proven under a registered source root. + // codeql[go/path-injection] Candidate is a child of workDir; links pass containment above. + info, err := os.Stat(repositoryPath) + if err != nil || !info.IsDir() { + continue + } + if resolveGitIndexPath(repositoryPath) == "" { + continue + } + out = append(out, repositorySubdir{name: entry.Name(), path: repositoryPath}) } return out } +func canonicalWorkspaceSourceRoots(roots []string) []string { + canonical := make([]string, 0, len(roots)) + seen := make(map[string]struct{}, len(roots)) + for _, root := range roots { + resolved, err := filepath.EvalSymlinks(filepath.Clean(root)) + if err != nil { + continue + } + info, err := os.Stat(resolved) + if err != nil || !info.IsDir() { + continue + } + if _, ok := seen[resolved]; ok { + continue + } + seen[resolved] = struct{}{} + canonical = append(canonical, resolved) + } + return canonical +} + +func pathWithinSourceRoots(path string, roots []string) bool { + for _, root := range roots { + rel, err := filepath.Rel(root, path) + if err == nil && !pathEscapesRoot(rel) { + return true + } + } + return false +} + // Status returns the current process status func (m *Manager) Status() Status { return m.status.Load().(Status) @@ -1824,8 +1911,13 @@ func (m *Manager) waitForExit() { pid := m.agentPID() err := m.cmd.Wait() + intentionalStop := m.Status() == StatusStopping - if err != nil { + switch { + case intentionalStop: + m.exitCode.Store(0) + m.logger.Info("agent process exited during intentional stop") + case err != nil: m.exitErr.Store(errorWrapper{err: err}) exitCode := -1 if exitErr, ok := err.(*exec.ExitError); ok { @@ -1856,12 +1948,12 @@ func (m *Manager) waitForExit() { default: m.logger.Warn("updates channel full, could not send exit error event") } - } else { + default: m.exitCode.Store(0) m.logger.Info("agent process exited successfully") } - if m.Status() != StatusStopping { + if !intentionalStop { if err := m.reapRemainingProcessGroup(context.Background(), pid); err != nil { m.mainReapPending.Store(true) m.logger.Warn("agent process group reap remains pending", zap.Error(err)) diff --git a/apps/backend/internal/agentctl/server/process/manager_killgroup_test.go b/apps/backend/internal/agentctl/server/process/manager_killgroup_test.go index 9c8e1b8d49..eee4a078f3 100644 --- a/apps/backend/internal/agentctl/server/process/manager_killgroup_test.go +++ b/apps/backend/internal/agentctl/server/process/manager_killgroup_test.go @@ -208,6 +208,39 @@ func TestWaitForExit_ReapsProcessGroupAfterNaturalLeaderExit(t *testing.T) { "child process %d should be reaped when the leader exits naturally", childPID) } +func TestWaitForExit_DoesNotPublishErrorForIntentionalStop(t *testing.T) { + log := newTestLogger(t) + m := &Manager{ + logger: log, + updatesCh: make(chan adapter.AgentEvent, 1), + doneCh: make(chan struct{}), + } + m.status.Store(StatusStopping) + m.cmd = fixtureCmd("sleep 30") + setProcGroup(m.cmd) + require.NoError(t, m.cmd.Start()) + parentPID := m.cmd.Process.Pid + t.Cleanup(func() { + _ = killProcessGroup(parentPID) + }) + + m.wg.Add(1) + go m.waitForExit() + require.NoError(t, killProcessGroup(parentPID)) + + select { + case <-m.doneCh: + case <-time.After(5 * time.Second): + t.Fatal("timed out waiting for intentionally stopped process") + } + require.Nil(t, m.ExitError(), "intentional stop must not be recorded as an agent failure") + select { + case event := <-m.updatesCh: + t.Fatalf("intentional stop published event %+v", event) + default: + } +} + func TestWaitForProcessExit_TerminatesBeforeParentContextDeadline(t *testing.T) { log, observed := newObservedTestLogger(t) diff --git a/apps/backend/internal/agentctl/server/process/manager_rescan.go b/apps/backend/internal/agentctl/server/process/manager_rescan.go index ff79591b90..c06d0feb61 100644 --- a/apps/backend/internal/agentctl/server/process/manager_rescan.go +++ b/apps/backend/internal/agentctl/server/process/manager_rescan.go @@ -2,6 +2,7 @@ package process import ( "context" + "fmt" "os" "path/filepath" @@ -36,11 +37,18 @@ import ( // workspace-stream subscriber so events flow without re-subscription. // // Idempotent: a rescan with no on-disk changes is a no-op. -func (m *Manager) RescanRepositories(ctx context.Context, newWorkDir string) { +func (m *Manager) RescanRepositories(ctx context.Context, newWorkDir string) error { + return m.RescanRepositoriesWithSourceRoots(ctx, newWorkDir, nil) +} + +// RescanRepositoriesWithSourceRoots discovers repository children and updates +// their tracker graph using proposed source roots as one serialized operation. +// A nil roots slice retains the existing policy for compatibility callers. +func (m *Manager) RescanRepositoriesWithSourceRoots(ctx context.Context, newWorkDir string, roots []string) error { release, err := m.admitStart() if err != nil { m.logger.Debug("workspace rescan rejected during teardown") - return + return fmt.Errorf("workspace rescan rejected during teardown: %w", err) } defer release() // Serialize the whole rescan body. Two concurrent calls could otherwise @@ -50,43 +58,32 @@ func (m *Manager) RescanRepositories(ctx context.Context, newWorkDir string) { m.rescanMu.Lock() defer m.rescanMu.Unlock() - // Resolve the candidate workDir and prove it's a readable directory - // BEFORE committing cfg.WorkDir. If newWorkDir is bogus, leaving the - // manager pointing at the existing root keeps path-based handlers - // (vscode, git, files) consistent with the trackers that never moved. + candidate, scopeChanged, err := m.rescanCandidateWorkDir(newWorkDir) + if err != nil { + return err + } + + proposedRoots := roots != nil + if roots == nil { + roots = m.currentWorkspaceSourceRoots() + } else { + roots = canonicalWorkspaceSourceRoots(roots) + } + + // Snapshot existing trackers while rescanMu serializes the graph mutation. + // cfg.WorkDir and roots are committed only with the resulting graph below. m.repoTrackersMu.RLock() - candidate := m.cfg.WorkDir + workDir := candidate + existingTrackers := len(m.repoTrackers) + trackers := append([]*WorkspaceTracker{m.workspaceTracker}, m.repoTrackers...) m.repoTrackersMu.RUnlock() - if newWorkDir != "" && newWorkDir != candidate { - resolved, ok := resolveRescanPath(newWorkDir, candidate) - if !ok { - m.logger.Warn("workspace rescan: ignoring invalid work_dir", - zap.String("work_dir", newWorkDir), - zap.String("current_work_dir", candidate)) - return - } - // resolved is derived from currentWorkDir (trusted manager config), - // not from newWorkDir, so os.Stat here doesn't see HTTP-supplied - // input. CodeQL's path-injection trace ends at resolveRescanPath. - if info, err := os.Stat(resolved); err == nil && info.IsDir() { - candidate = resolved - } else { - m.logger.Warn("workspace rescan: ignoring invalid work_dir", - zap.String("work_dir", newWorkDir), zap.Error(err)) - return + for _, tracker := range trackers { + if tracker != nil { + tracker.SetAllowedSourceRoots(roots) } } - // Read existingTrackers under the same write-lock that commits the new - // cfg.WorkDir so two concurrent rescans don't both observe 0 trackers - // and double-bootstrap the multi-repo set. - m.repoTrackersMu.Lock() - m.cfg.WorkDir = candidate - workDir := m.cfg.WorkDir - existingTrackers := len(m.repoTrackers) - m.repoTrackersMu.Unlock() - - children := scanRepositorySubdirs(workDir) + children := scanRepositorySubdirs(workDir, roots) subs := m.snapshotSubscribers() m.logger.Info("workspace rescan started", @@ -95,31 +92,187 @@ func (m *Manager) RescanRepositories(ctx context.Context, newWorkDir string) { zap.Int("existing_repo_trackers", existingTrackers), zap.Int("subscribers", len(subs))) - if len(children) < 2 { + if candidate == "" { + m.commitRescanWorkspaceState(workDir, roots) + return nil + } + // A promoted root must always replace the old single-repo file tracker, + // even when it contains just one git repository plus plain linked folders. + if len(children) < 2 && !scopeChanged && newWorkDir != "" { // Nothing to do: a non-multi-repo workspace stays on its single // tracker. The legacy preferGitRepoChildIfRootIsBare fallback // covers single-repo construct-time setup. - return + m.commitRescanWorkspaceState(workDir, roots) + return nil } - if existingTrackers == 0 { - m.transitionToMultiRepoMode(ctx, workDir, children, subs) - return + if existingTrackers == 0 && scopeChanged { + m.transitionToMultiRepoMode(ctx, workDir, children, roots, subs) + return nil } - m.appendNewRepoTrackers(ctx, children, subs) + if proposedRoots { + m.reconcileRepoTrackers(ctx, workDir, children, roots, subs) + return nil + } + m.appendNewRepoTrackers(ctx, workDir, children, roots, subs) + return nil +} + +// rescanCandidateWorkDir resolves and validates a proposed tracking root +// without committing it, so a failed rescan retains the active graph. +func (m *Manager) rescanCandidateWorkDir(newWorkDir string) (string, bool, error) { + m.repoTrackersMu.RLock() + candidate := m.cfg.WorkDir + m.repoTrackersMu.RUnlock() + if newWorkDir == "" || newWorkDir == candidate { + return candidate, false, nil + } + resolved, ok := resolveRescanPath(newWorkDir, candidate) + if !ok { + m.logger.Warn("workspace rescan: ignoring invalid work_dir", + zap.String("work_dir", newWorkDir), + zap.String("current_work_dir", candidate)) + return "", false, fmt.Errorf("invalid workspace work_dir: %s", newWorkDir) + } + // resolveRescanPath returns only the configured workspace root or its + // direct parent, so this Stat never receives an arbitrary request path. + if info, err := os.Stat(resolved); err == nil && info.IsDir() { + return resolved, true, nil + } else { + m.logger.Warn("workspace rescan: ignoring invalid work_dir", + zap.String("work_dir", newWorkDir), zap.Error(err)) + return "", false, fmt.Errorf("workspace work_dir is inaccessible: %s", newWorkDir) + } +} + +func (m *Manager) commitRescanWorkspaceState(workDir string, roots []string) { + m.repoTrackersMu.Lock() + m.cfg.WorkDir = workDir + m.workspaceSourceRoots = append([]string(nil), roots...) + m.cfg.WorkspaceSourceRoots = append([]string(nil), roots...) + m.repoTrackersMu.Unlock() +} + +// ReconcileRepositories makes the current-root repository tracker set exact. +// It is reserved for rollback after a materialized checkout has been removed: +// unlike RescanRepositories, it stops and removes trackers whose repository +// directories no longer exist. Existing trackers remain in place so their +// workspace-stream subscriptions and cached state are preserved. +func (m *Manager) ReconcileRepositories(ctx context.Context) error { + release, err := m.admitStart() + if err != nil { + return fmt.Errorf("workspace reconcile rejected during teardown: %w", err) + } + defer release() + m.rescanMu.Lock() + defer m.rescanMu.Unlock() + + m.repoTrackersMu.RLock() + workDir := m.cfg.WorkDir + m.repoTrackersMu.RUnlock() + if workDir == "" { + return fmt.Errorf("workspace work_dir is required") + } + info, err := os.Stat(workDir) + if err != nil || !info.IsDir() { + return fmt.Errorf("workspace work_dir is inaccessible: %s", workDir) + } + + roots := m.currentWorkspaceSourceRoots() + children := scanRepositorySubdirs(workDir, roots) + subs := m.snapshotSubscribers() + m.reconcileRepoTrackers(ctx, workDir, children, roots, subs) + return nil +} + +// RebindWorkspace replaces the complete workspace tracker graph after the +// owning host process has been stopped. Unlike RescanRepositories it never +// retains a tracker from the previous root: retaining one would keep file and +// git events scoped to a workspace the agent no longer executes in. Calling it +// again with the previous root is the rollback operation used by lifecycle. +func (m *Manager) RebindWorkspace(ctx context.Context, workDir string) error { + return m.RebindWorkspaceWithSourceRoots(ctx, workDir, nil) +} + +// RebindWorkspaceWithSourceRoots replaces the complete workspace tracker graph +// and installs proposed source roots before discovering linked repositories. +// A nil roots slice retains the current policy for compatibility callers. +func (m *Manager) RebindWorkspaceWithSourceRoots(ctx context.Context, workDir string, roots []string) error { + if workDir == "" || !filepath.IsAbs(workDir) { + return fmt.Errorf("workspace work_dir must be an absolute path") + } + resolved := filepath.Clean(workDir) + // codeql[go/path-injection] Rebind accepts an authenticated absolute workspace root and rejects inaccessible directories. + info, err := os.Stat(resolved) + if err != nil || !info.IsDir() { + return fmt.Errorf("workspace work_dir is inaccessible: %s", workDir) + } + release, err := m.admitStart() + if err != nil { + return fmt.Errorf("workspace rebind rejected during teardown: %w", err) + } + defer release() + m.rescanMu.Lock() + defer m.rescanMu.Unlock() + + if roots == nil { + roots = m.currentWorkspaceSourceRoots() + } else { + roots = canonicalWorkspaceSourceRoots(roots) + } + subs := m.snapshotSubscribers() + children := scanRepositorySubdirs(resolved, roots) + bare := NewWorkspaceTrackerForRepo(resolved, "", m.logger) + bare.SetBaseBranch(lookupBaseBranch(m.getBaseBranches(), "")) + bare.SetAllowedSourceRoots(roots) + bare.Start(ctx) + for _, sub := range subs { + bare.AttachWorkspaceStreamSubscriber(sub) + } + repos := make([]*WorkspaceTracker, 0, len(children)) + for _, child := range children { + tracker := NewWorkspaceTrackerForRepo(child.path, child.name, m.logger) + tracker.SetBaseBranch(lookupBaseBranch(m.getBaseBranches(), child.name)) + tracker.SetAllowedSourceRoots(roots) + tracker.Start(ctx) + for _, sub := range subs { + tracker.AttachWorkspaceStreamSubscriber(sub) + } + repos = append(repos, tracker) + } + + m.repoTrackersMu.Lock() + oldBare, oldRepos := m.workspaceTracker, m.repoTrackers + m.cfg.WorkDir, m.workspaceTracker, m.repoTrackers, m.workspaceSourceRoots = resolved, bare, repos, append([]string(nil), roots...) + m.cfg.WorkspaceSourceRoots = append([]string(nil), roots...) + m.repoTrackersMu.Unlock() + if oldBare != nil { + for _, sub := range subs { + oldBare.DetachWorkspaceStreamSubscriber(sub) + } + oldBare.Stop() + } + for _, tracker := range oldRepos { + for _, sub := range subs { + tracker.DetachWorkspaceStreamSubscriber(sub) + } + tracker.Stop() + } + return nil } // transitionToMultiRepoMode replaces the single-repo workspaceTracker with a // bare task-root tracker and stands up per-repo trackers for every detected // child. Used when the agent launched as single-repo and a sibling worktree // was added afterwards. -func (m *Manager) transitionToMultiRepoMode(ctx context.Context, workDir string, children []repositorySubdir, subs []types.WorkspaceStreamSubscriber) { +func (m *Manager) transitionToMultiRepoMode(ctx context.Context, workDir string, children []repositorySubdir, roots []string, subs []types.WorkspaceStreamSubscriber) { m.logger.Info("transitioning workspace to multi-repo mode", zap.String("work_dir", workDir), zap.Int("children", len(children))) bareRoot := NewWorkspaceTrackerForRepo(workDir, "", m.logger) bareRoot.SetBaseBranch(lookupBaseBranch(m.getBaseBranches(), "")) + bareRoot.SetAllowedSourceRoots(roots) bareRoot.Start(ctx) for _, sub := range subs { bareRoot.AttachWorkspaceStreamSubscriber(sub) @@ -129,6 +282,7 @@ func (m *Manager) transitionToMultiRepoMode(ctx context.Context, workDir string, for _, child := range children { tracker := NewWorkspaceTrackerForRepo(child.path, child.name, m.logger) tracker.SetBaseBranch(lookupBaseBranch(m.getBaseBranches(), child.name)) + tracker.SetAllowedSourceRoots(roots) tracker.Start(ctx) for _, sub := range subs { tracker.AttachWorkspaceStreamSubscriber(sub) @@ -140,6 +294,9 @@ func (m *Manager) transitionToMultiRepoMode(ctx context.Context, workDir string, old := m.workspaceTracker m.workspaceTracker = bareRoot m.repoTrackers = append(m.repoTrackers, newRepoTrackers...) + m.cfg.WorkDir = workDir + m.workspaceSourceRoots = append([]string(nil), roots...) + m.cfg.WorkspaceSourceRoots = append([]string(nil), roots...) m.repoTrackersMu.Unlock() if old != nil { @@ -153,7 +310,7 @@ func (m *Manager) transitionToMultiRepoMode(ctx context.Context, workDir string, // appendNewRepoTrackers adds trackers for child subdirs that don't already // have one. Existing trackers (matched by RepositoryName) are left running // so their cached git state and subscriber wiring stay intact. -func (m *Manager) appendNewRepoTrackers(ctx context.Context, children []repositorySubdir, subs []types.WorkspaceStreamSubscriber) { +func (m *Manager) appendNewRepoTrackers(ctx context.Context, workDir string, children []repositorySubdir, roots []string, subs []types.WorkspaceStreamSubscriber) { m.repoTrackersMu.RLock() existing := make(map[string]bool, len(m.repoTrackers)) for _, t := range m.repoTrackers { @@ -171,15 +328,13 @@ func (m *Manager) appendNewRepoTrackers(ctx context.Context, children []reposito zap.String("path", child.path)) tracker := NewWorkspaceTrackerForRepo(child.path, child.name, m.logger) tracker.SetBaseBranch(lookupBaseBranch(m.getBaseBranches(), child.name)) + tracker.SetAllowedSourceRoots(roots) tracker.Start(ctx) for _, sub := range subs { tracker.AttachWorkspaceStreamSubscriber(sub) } newTrackers = append(newTrackers, tracker) } - if len(newTrackers) == 0 { - return - } // Re-check membership inside the write-lock as a defense-in-depth // guard. rescanMu already serializes RescanRepositories callers, but // the explicit check here makes the invariant local: any tracker @@ -198,6 +353,9 @@ func (m *Manager) appendNewRepoTrackers(ctx context.Context, children []reposito } m.repoTrackers = append(m.repoTrackers, t) } + m.cfg.WorkDir = workDir + m.workspaceSourceRoots = append([]string(nil), roots...) + m.cfg.WorkspaceSourceRoots = append([]string(nil), roots...) m.repoTrackersMu.Unlock() // Stop + detach any dropped trackers outside the lock so we don't block // readers on potentially-slow Stop() teardown. @@ -209,22 +367,69 @@ func (m *Manager) appendNewRepoTrackers(ctx context.Context, children []reposito } } +func (m *Manager) reconcileRepoTrackers(ctx context.Context, workDir string, children []repositorySubdir, roots []string, subs []types.WorkspaceStreamSubscriber) { + wanted := make(map[repositoryTrackerKey]struct{}, len(children)) + for _, child := range children { + wanted[repositoryTrackerIdentity(child.name, child.path)] = struct{}{} + } + m.repoTrackersMu.Lock() + retained := make([]*WorkspaceTracker, 0, len(m.repoTrackers)) + removed := make([]*WorkspaceTracker, 0) + for _, tracker := range m.repoTrackers { + identity := repositoryTrackerIdentity(tracker.RepositoryName(), tracker.workDir) + if _, ok := wanted[identity]; ok { + retained = append(retained, tracker) + delete(wanted, identity) + continue + } + removed = append(removed, tracker) + } + m.repoTrackersMu.Unlock() + newTrackers := make([]*WorkspaceTracker, 0, len(wanted)) + for _, child := range children { + if _, needed := wanted[repositoryTrackerIdentity(child.name, child.path)]; !needed { + continue + } + tracker := NewWorkspaceTrackerForRepo(child.path, child.name, m.logger) + tracker.SetBaseBranch(lookupBaseBranch(m.getBaseBranches(), child.name)) + tracker.SetAllowedSourceRoots(roots) + tracker.Start(ctx) + for _, sub := range subs { + tracker.AttachWorkspaceStreamSubscriber(sub) + } + newTrackers = append(newTrackers, tracker) + } + m.repoTrackersMu.Lock() + retained = append(retained, newTrackers...) + m.repoTrackers = retained + m.cfg.WorkDir = workDir + m.workspaceSourceRoots = append([]string(nil), roots...) + m.cfg.WorkspaceSourceRoots = append([]string(nil), roots...) + m.repoTrackersMu.Unlock() + + for _, tracker := range removed { + for _, sub := range subs { + tracker.DetachWorkspaceStreamSubscriber(sub) + } + tracker.Stop() + } +} + +type repositoryTrackerKey struct { + name string + path string +} + +func repositoryTrackerIdentity(name, path string) repositoryTrackerKey { + return repositoryTrackerKey{name: name, path: filepath.Clean(path)} +} + // resolveRescanPath maps an externally-supplied workspace path to a known-good // path. The legitimate caller (kandev backend's branch materializer) promotes // the workdir to the task root that contains the per-repo worktrees as // siblings. Allowed transitions are: // - newPath equals currentWorkDir → no-op (return current) // - newPath equals parent of current → return derived parent -// - newPath is a different absolute directory that actually holds >=1 git -// repo subdir → return cleaned newPath (recovery path: covers envs whose -// workspace_path landed on the source repo's local_path instead of the -// primary worktree, so the parent-only check would otherwise refuse to -// ever switch the manager away from the wrong root) -// -// The third branch reintroduces the HTTP-supplied path as a Stat sink, but -// the endpoint is already authenticated via the bearer-token middleware and -// the manager verifies the path resolves to a real directory before -// committing — taint here is gated by auth, not path-shape. // // Returns ("", false) for any other input — first-launch case (currentWorkDir // empty) is handled by the caller falling back to the existing workdir. @@ -246,13 +451,6 @@ func resolveRescanPath(newPath, currentWorkDir string) (string, bool) { return parent, true } } - // Recovery path: accept any absolute directory that actually contains git - // repo subdirs. scanRepositorySubdirs reads the directory and validates - // each child has a working .git entry, so a hostile or empty path returns - // nil and the rescan stays a no-op below. - if children := scanRepositorySubdirs(clean); len(children) >= 1 { - return clean, true - } return "", false } diff --git a/apps/backend/internal/agentctl/server/process/manager_rescan_path_test.go b/apps/backend/internal/agentctl/server/process/manager_rescan_path_test.go index ec9ae1c8df..d5dd78e585 100644 --- a/apps/backend/internal/agentctl/server/process/manager_rescan_path_test.go +++ b/apps/backend/internal/agentctl/server/process/manager_rescan_path_test.go @@ -72,6 +72,17 @@ func TestResolveRescanPath(t *testing.T) { } } +func TestResolveRescanPath_RejectsUnrelatedDirectoryWithGitChild(t *testing.T) { + current := filepath.Join(t.TempDir(), "workspace", "repo") + unrelated := t.TempDir() + initGitRepoAt(t, filepath.Join(unrelated, "unrelated-repository")) + + got, ok := resolveRescanPath(unrelated, current) + if ok || got != "" { + t.Fatalf("resolveRescanPath accepted unrelated git directory: (%q, %v)", got, ok) + } +} + // join wraps filepath.Join so callers can express paths with separate // segments without thinking about platform separators. func join(root string, segs ...string) string { diff --git a/apps/backend/internal/agentctl/server/process/manager_rescan_test.go b/apps/backend/internal/agentctl/server/process/manager_rescan_test.go index ad86877c8f..3bda914f0f 100644 --- a/apps/backend/internal/agentctl/server/process/manager_rescan_test.go +++ b/apps/backend/internal/agentctl/server/process/manager_rescan_test.go @@ -13,6 +13,160 @@ import ( "github.com/kandev/kandev/internal/common/logger" ) +func TestScanRepositorySubdirs_RejectsUnregisteredRepositoryLink(t *testing.T) { + taskRoot := t.TempDir() + source := t.TempDir() + initGitRepoAt(t, source) + if err := os.Symlink(source, filepath.Join(taskRoot, "linked-repository")); err != nil { + t.Skip("symlinks not supported") + } + + if children := scanRepositorySubdirs(taskRoot, nil); len(children) != 0 { + t.Fatalf("repository links outside registered source roots = %+v, want none", children) + } +} + +func TestScanRepositorySubdirs_AcceptsRegisteredRepositoryLink(t *testing.T) { + taskRoot := t.TempDir() + source := t.TempDir() + initGitRepoAt(t, source) + canonicalSource := canonicalSourceRoot(t, source) + if err := os.Symlink(source, filepath.Join(taskRoot, "linked-repository")); err != nil { + t.Skip("symlinks not supported") + } + + children := scanRepositorySubdirs(taskRoot, []string{canonicalSource}) + if len(children) != 1 || children[0].name != "linked-repository" { + t.Fatalf("registered repository links = %+v, want linked-repository", children) + } +} + +func TestRebindWorkspaceWithSourceRoots_PinsRegisteredRepositoryLinkTarget(t *testing.T) { + oldRoot := t.TempDir() + initGitRepoAt(t, oldRoot) + newRoot := t.TempDir() + firstSource := t.TempDir() + secondSource := t.TempDir() + initGitRepoAt(t, firstSource) + initGitRepoAt(t, secondSource) + canonicalFirstSource := canonicalSourceRoot(t, firstSource) + link := filepath.Join(newRoot, "linked-repository") + if err := os.Symlink(firstSource, link); err != nil { + t.Skip("symlinks not supported") + } + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + m := NewManager(&config.InstanceConfig{WorkDir: oldRoot}, log) + defer m.stopWorkspaceTrackers() + if err := m.RebindWorkspaceWithSourceRoots(context.Background(), newRoot, []string{firstSource}); err != nil { + t.Fatalf("rebind workspace: %v", err) + } + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(secondSource, link); err != nil { + t.Fatal(err) + } + if len(m.repoTrackers) != 1 || m.repoTrackers[0].workDir != canonicalFirstSource { + t.Fatalf("repository tracker after link swap = %+v, want pinned target %q", m.repoTrackers, canonicalFirstSource) + } +} + +func TestRebindWorkspaceWithSourceRoots_UsesProposedRootsForDiscovery(t *testing.T) { + oldRoot := t.TempDir() + initGitRepoAt(t, oldRoot) + newRoot := t.TempDir() + source := t.TempDir() + initGitRepoAt(t, source) + canonicalSource := canonicalSourceRoot(t, source) + if err := os.Symlink(source, filepath.Join(newRoot, "linked-repository")); err != nil { + t.Skip("symlinks not supported") + } + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + m := NewManager(&config.InstanceConfig{WorkDir: oldRoot}, log) + defer m.stopWorkspaceTrackers() + if err := m.RebindWorkspaceWithSourceRoots(context.Background(), newRoot, []string{source}); err != nil { + t.Fatalf("rebind workspace: %v", err) + } + if len(m.repoTrackers) != 1 || m.repoTrackers[0].RepositoryName() != "linked-repository" { + t.Fatalf("rebind repository trackers = %+v, want linked-repository", m.repoTrackers) + } + if len(m.workspaceSourceRoots) != 1 || m.workspaceSourceRoots[0] != canonicalSource { + t.Fatalf("rebind source roots = %q, want %q", m.workspaceSourceRoots, canonicalSource) + } +} + +func TestRescanRepositoriesWithSourceRoots_ReplacesRepointedRepositoryLink(t *testing.T) { + workspace := t.TempDir() + firstSource := t.TempDir() + secondSource := t.TempDir() + initGitRepoAt(t, workspace) + initGitRepoAt(t, firstSource) + initGitRepoAt(t, secondSource) + canonicalSecondSource := canonicalSourceRoot(t, secondSource) + link := filepath.Join(workspace, "linked-repository") + if err := os.Symlink(firstSource, link); err != nil { + t.Skip("symlinks not supported") + } + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + m := NewManager(&config.InstanceConfig{WorkDir: workspace}, log) + defer m.stopWorkspaceTrackers() + if err := m.RescanRepositoriesWithSourceRoots(context.Background(), "", []string{firstSource}); err != nil { + t.Fatalf("initial source-root rescan: %v", err) + } + oldTracker := m.repoTrackers[0] + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Symlink(secondSource, link); err != nil { + t.Fatal(err) + } + + if err := m.RescanRepositoriesWithSourceRoots(context.Background(), "", []string{secondSource}); err != nil { + t.Fatalf("repointed source-root rescan: %v", err) + } + if len(m.repoTrackers) != 1 || m.repoTrackers[0] == oldTracker || m.repoTrackers[0].workDir != canonicalSecondSource { + t.Fatalf("repointed tracker = %+v, want replacement pinned to %q", m.repoTrackers, canonicalSecondSource) + } + select { + case <-oldTracker.stopCh: + default: + t.Fatal("repointed tracker was not stopped") + } +} + +func TestRescanRepositoriesWithSourceRoots_RemovesRevokedRepositoryLink(t *testing.T) { + workspace := t.TempDir() + source := t.TempDir() + initGitRepoAt(t, workspace) + initGitRepoAt(t, source) + if err := os.Symlink(source, filepath.Join(workspace, "linked-repository")); err != nil { + t.Skip("symlinks not supported") + } + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + m := NewManager(&config.InstanceConfig{WorkDir: workspace}, log) + defer m.stopWorkspaceTrackers() + if err := m.RescanRepositoriesWithSourceRoots(context.Background(), "", []string{source}); err != nil { + t.Fatalf("initial source-root rescan: %v", err) + } + oldTracker := m.repoTrackers[0] + + if err := m.RescanRepositoriesWithSourceRoots(context.Background(), "", []string{}); err != nil { + t.Fatalf("revoked source-root rescan: %v", err) + } + if len(m.repoTrackers) != 0 { + t.Fatalf("repository trackers after source revocation = %+v, want none", m.repoTrackers) + } + select { + case <-oldTracker.stopCh: + default: + t.Fatal("revoked tracker was not stopped") + } +} + // TestRescanRepositories_TransitionsSingleToMultiRepo simulates the // MCP add_branch flow: the agent launched as single-repo with WorkDir set // to the primary worktree, then a sibling worktree appears at the task @@ -33,7 +187,9 @@ func TestRescanRepositories_TransitionsSingleToMultiRepo(t *testing.T) { t.Fatalf("expected 0 repoTrackers in single-repo mode, got %d", len(m.repoTrackers)) } - m.RescanRepositories(context.Background(), taskRoot) + if err := m.RescanRepositories(context.Background(), taskRoot); err != nil { + t.Fatal(err) + } if len(m.repoTrackers) != 2 { t.Fatalf("expected 2 repoTrackers after transition, got %d", len(m.repoTrackers)) @@ -75,7 +231,9 @@ func TestRescanRepositories_AppendsNewRepoTrackerInMultiRepoMode(t *testing.T) { repoC := filepath.Join(taskRoot, "gamma") initGitRepoAt(t, repoC) - m.RescanRepositories(context.Background(), taskRoot) + if err := m.RescanRepositories(context.Background(), taskRoot); err != nil { + t.Fatal(err) + } if len(m.repoTrackers) != 3 { t.Fatalf("expected 3 repoTrackers after append, got %d", len(m.repoTrackers)) @@ -92,6 +250,97 @@ func TestRescanRepositories_AppendsNewRepoTrackerInMultiRepoMode(t *testing.T) { m.stopWorkspaceTrackers() } +// A live remote materialization does not change the workspace root: it asks +// agentctl to rescan the root it already tracks after adding a sibling. The +// rescan must append only the new tracker, leaving existing tracker instances +// intact so their Changes subscriptions and cached state survive. +func TestRescanRepositories_EmptyWorkDirAppendsNewTrackerAtCurrentRoot(t *testing.T) { + taskRoot := t.TempDir() + initGitRepoAt(t, taskRoot) + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + m := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, log) + defer m.stopWorkspaceTrackers() + if len(m.repoTrackers) != 0 { + t.Fatalf("initial repo trackers = %d, want 0", len(m.repoTrackers)) + } + existing := m.workspaceTracker + sub := m.SubscribeWorkspaceStream() + defer m.UnsubscribeWorkspaceStream(sub) + drainChannel(sub) + + repo := filepath.Join(taskRoot, "beta") + initGitRepoAt(t, repo) + if err := m.RescanRepositories(context.Background(), ""); err != nil { + t.Fatalf("rescan current root: %v", err) + } + + if len(m.repoTrackers) != 1 { + t.Fatalf("repo trackers after current-root rescan = %d, want 1", len(m.repoTrackers)) + } + if m.workspaceTracker != existing { + t.Fatal("current-root rescan replaced an existing tracker") + } + if m.repoTrackers[0].RepositoryName() != "beta" { + t.Fatalf("new tracker = %q, want beta", m.repoTrackers[0].RepositoryName()) + } + m.repoTrackers[0].workspaceSubMu.RLock() + _, visibleInChanges := m.repoTrackers[0].workspaceStreamSubscribers[sub] + m.repoTrackers[0].workspaceSubMu.RUnlock() + if !visibleInChanges { + t.Fatal("new child tracker did not inherit the Changes stream subscriber") + } +} + +func TestReconcileRepositories_PrunesRemovedTrackerAndPreservesSubscription(t *testing.T) { + taskRoot := t.TempDir() + original := filepath.Join(taskRoot, "original") + rolledBack := filepath.Join(taskRoot, "rolled-back") + initGitRepoAt(t, original) + initGitRepoAt(t, rolledBack) + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + m := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, log) + defer m.stopWorkspaceTrackers() + if len(m.repoTrackers) != 2 { + t.Fatalf("initial repo trackers = %d, want 2", len(m.repoTrackers)) + } + originalTracker := m.repoTrackers[0] + rolledBackTracker := m.repoTrackers[0] + if originalTracker.RepositoryName() != "original" { + originalTracker = m.repoTrackers[1] + } + if rolledBackTracker.RepositoryName() != "rolled-back" { + rolledBackTracker = m.repoTrackers[1] + } + sub := m.SubscribeWorkspaceStream() + defer m.UnsubscribeWorkspaceStream(sub) + if err := os.RemoveAll(rolledBack); err != nil { + t.Fatal(err) + } + if err := m.ReconcileRepositories(context.Background()); err != nil { + t.Fatalf("reconcile repositories: %v", err) + } + if got := m.RepoSubpaths(); len(got) != 1 || got[0] != "original" { + t.Fatalf("RepoSubpaths = %v, want [original]", got) + } + if m.repoTrackers[0] != originalTracker { + t.Fatal("reconcile replaced the retained tracker") + } + m.repoTrackers[0].workspaceSubMu.RLock() + _, subscribed := m.repoTrackers[0].workspaceStreamSubscribers[sub] + m.repoTrackers[0].workspaceSubMu.RUnlock() + if !subscribed { + t.Fatal("reconcile dropped the retained Changes subscription") + } + rolledBackTracker.workspaceSubMu.RLock() + _, staleSubscribed := rolledBackTracker.workspaceStreamSubscribers[sub] + rolledBackTracker.workspaceSubMu.RUnlock() + if staleSubscribed { + t.Fatal("removed tracker can still replay Changes to the subscription") + } +} + // TestRescanRepositories_FileTreeReturnsTaskRootContentsAfterTransition // covers the Files panel surface: after rescan flips a single-branch // workspace to multi-repo mode, GetWorkspaceTracker().GetFileTree("", n) @@ -133,7 +382,9 @@ func TestRescanRepositories_FileTreeReturnsTaskRootContentsAfterTransition(t *te t.Errorf("pre-rescan tree should not contain sibling marker yet: %v", preNames) } - m.RescanRepositories(context.Background(), taskRoot) + if err := m.RescanRepositories(context.Background(), taskRoot); err != nil { + t.Fatal(err) + } postTree, err := m.workspaceTracker.GetFileTree("", 1) if err != nil { @@ -148,6 +399,146 @@ func TestRescanRepositories_FileTreeReturnsTaskRootContentsAfterTransition(t *te } } +func TestRescanRepositories_PromotedRootTracksPlainFolderWithOneRepository(t *testing.T) { + taskRoot := t.TempDir() + primary := filepath.Join(taskRoot, "repo") + folder := filepath.Join(taskRoot, "notes") + initGitRepoAt(t, primary) + if err := os.MkdirAll(folder, 0o755); err != nil { + t.Fatal(err) + } + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + m := NewManager(&config.InstanceConfig{WorkDir: primary}, log) + defer m.stopWorkspaceTrackers() + if err := m.RescanRepositories(context.Background(), taskRoot); err != nil { + t.Fatal(err) + } + tree, err := m.workspaceTracker.GetFileTree("", 1) + if err != nil { + t.Fatal(err) + } + names := fileTreeChildNames(tree) + if !names["notes"] || !names["repo"] { + t.Fatalf("promoted root file scope = %v", names) + } + if len(m.repoTrackers) != 1 || m.repoTrackers[0].RepositoryName() != "repo" { + t.Fatalf("repo trackers = %+v", m.repoTrackers) + } +} + +// Source-root policy is installed after a successful API rescan. It must +// therefore be applied to the bare root tracker that replaces the original +// primary tracker as well as every newly-created child tracker. +func TestRescanRepositories_SourceRootsApplyToPromotedTrackers(t *testing.T) { + taskRoot := t.TempDir() + primary := filepath.Join(taskRoot, "primary") + sibling := filepath.Join(taskRoot, "sibling") + source := t.TempDir() + initGitRepoAt(t, primary) + initGitRepoAt(t, sibling) + for _, dir := range []string{taskRoot, primary, sibling} { + if err := os.Symlink(source, filepath.Join(dir, "linked")); err != nil { + t.Skip("symlinks not supported") + } + } + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + m := NewManager(&config.InstanceConfig{WorkDir: primary}, log) + defer m.stopWorkspaceTrackers() + if err := m.RescanRepositories(context.Background(), taskRoot); err != nil { + t.Fatal(err) + } + m.SetWorkspaceSourceRoots([]string{source}) + + assertLinkedSourceFileOperations(t, m.workspaceTracker, "linked/root.txt") + for _, tracker := range m.repoTrackers { + assertLinkedSourceFileOperations(t, tracker, "linked/"+tracker.RepositoryName()+".txt") + } +} + +// Empty-path rescans append a new tracker without replacing the root tracker. +// Applying policy after that reconciliation must cover both tracker kinds. +func TestRescanRepositories_SourceRootsApplyToCurrentRootAppends(t *testing.T) { + taskRoot := t.TempDir() + source := t.TempDir() + initGitRepoAt(t, taskRoot) + if err := os.Symlink(source, filepath.Join(taskRoot, "linked")); err != nil { + t.Skip("symlinks not supported") + } + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + m := NewManager(&config.InstanceConfig{WorkDir: taskRoot}, log) + defer m.stopWorkspaceTrackers() + child := filepath.Join(taskRoot, "child") + initGitRepoAt(t, child) + if err := os.Symlink(source, filepath.Join(child, "linked")); err != nil { + t.Skip("symlinks not supported") + } + if err := m.RescanRepositories(context.Background(), ""); err != nil { + t.Fatal(err) + } + m.SetWorkspaceSourceRoots([]string{source}) + + assertLinkedSourceFileOperations(t, m.workspaceTracker, "linked/root.txt") + if len(m.repoTrackers) != 1 { + t.Fatalf("repo trackers = %d, want 1", len(m.repoTrackers)) + } + assertLinkedSourceFileOperations(t, m.repoTrackers[0], "linked/child.txt") +} + +func assertLinkedSourceFileOperations(t *testing.T, tracker *WorkspaceTracker, path string) { + t.Helper() + if err := tracker.CreateFile(path); err != nil { + t.Fatalf("create %q through linked source: %v", path, err) + } + if _, _, err := tracker.ApplyFileDiff(context.Background(), path, "", "not a diff", stringPtr("updated")); err != nil { + t.Fatalf("write %q through linked source: %v", path, err) + } + content, _, _, _, err := tracker.GetFileContent(path) + if err != nil || content != "updated" { + t.Fatalf("read %q through linked source = %q, %v", path, content, err) + } + renamed := path + ".renamed" + if err := tracker.RenameFile(path, renamed); err != nil { + t.Fatalf("rename %q through linked source: %v", path, err) + } + if err := tracker.DeleteFile(renamed); err != nil { + t.Fatalf("delete %q through linked source: %v", renamed, err) + } +} + +// Rebinding is intentionally stronger than a rescan: a live host execution +// changes its process CWD, so no tracker rooted at the previous workspace may +// survive the switch (or a later rollback). +func TestRebindWorkspace_ReplacesTrackersAndSupportsReverseRollback(t *testing.T) { + oldRoot := t.TempDir() + newRoot := t.TempDir() + initGitRepoAt(t, oldRoot) + newRepo := filepath.Join(newRoot, "repo") + initGitRepoAt(t, newRepo) + + log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json"}) + m := NewManager(&config.InstanceConfig{WorkDir: oldRoot}, log) + defer m.stopWorkspaceTrackers() + + if err := m.RebindWorkspace(context.Background(), newRoot); err != nil { + t.Fatalf("rebind new root: %v", err) + } + if m.cfg.WorkDir != newRoot || m.workspaceTracker.workDir != newRoot { + t.Fatalf("new binding = workdir %q tracker %q, want %q", m.cfg.WorkDir, m.workspaceTracker.workDir, newRoot) + } + if len(m.repoTrackers) != 1 || m.repoTrackers[0].workDir != newRepo { + t.Fatalf("new repo trackers were not replaced: %+v", m.repoTrackers) + } + + if err := m.RebindWorkspace(context.Background(), oldRoot); err != nil { + t.Fatalf("reverse rebind: %v", err) + } + if m.cfg.WorkDir != oldRoot || m.workspaceTracker.workDir != oldRoot || len(m.repoTrackers) != 0 { + t.Fatalf("rollback binding = workdir %q tracker %q repos %d", m.cfg.WorkDir, m.workspaceTracker.workDir, len(m.repoTrackers)) + } +} + // fileTreeChildNames returns the set of top-level child names from a // FileTreeNode. Returns an empty map for nil / leaf nodes so the caller can // just index without nil checks. @@ -187,7 +578,9 @@ func TestRescanRepositories_PropagatesSubscriberToNewTrackers(t *testing.T) { // into our post-rescan assertions. drainChannel(sub) - m.RescanRepositories(context.Background(), taskRoot) + if err := m.RescanRepositories(context.Background(), taskRoot); err != nil { + t.Fatal(err) + } // After transition, every active tracker must hold the subscriber: // bare root + 2 per-repo trackers = 3 attach references. The exported @@ -241,3 +634,12 @@ func initGitRepoAt(t *testing.T, dir string) { runIn("git", "config", "commit.gpgsign", "false") runIn("git", "commit", "--allow-empty", "-m", "initial") } + +func canonicalSourceRoot(t *testing.T, source string) string { + t.Helper() + roots := canonicalWorkspaceSourceRoots([]string{source}) + if len(roots) != 1 { + t.Fatalf("canonical source roots for %q = %q, want one root", source, roots) + } + return roots[0] +} diff --git a/apps/backend/internal/agentctl/server/process/workspace_files.go b/apps/backend/internal/agentctl/server/process/workspace_files.go index 9cf3859d26..26fb72441a 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files.go @@ -15,6 +15,7 @@ import ( "sort" "strconv" "strings" + "sync/atomic" "time" "unicode/utf8" @@ -36,6 +37,17 @@ const maxFileSize = 10 * 1024 * 1024 // 10MB var errPathTraversal = errors.New("path traversal detected") +// workspaceMutationBarrier provides a deterministic synchronization point for +// filesystem-race regression tests. It is unset in production. +var workspaceMutationBarrier atomic.Value // func() + +func runWorkspaceMutationBarrier() { + barrier, _ := workspaceMutationBarrier.Load().(func()) + if barrier != nil { + barrier() + } +} + func isRootOwnershipMarkerPath(path string) bool { return filepath.Clean(filepath.FromSlash(path)) == storageworkspaces.OwnershipMarkerFilename } @@ -238,21 +250,65 @@ func (wt *WorkspaceTracker) resolveSafePath(reqPath string) (string, error) { } } - // Check that the real path is within the workspace - relPath, err := filepath.Rel(realWorkDir, realPath) - if err != nil { - return "", fmt.Errorf("invalid path: %w", err) - } - - // Ensure the relative path doesn't escape the workspace - if strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) || relPath == ".." { - return "", fmt.Errorf("%w: %s", errPathTraversal, reqPath) + // A path may leave the workspace only through a durable source link whose + // canonical target was registered by lifecycle. Re-resolving every request + // rejects stale or later-mutated links instead of trusting their old name. + root := realWorkDir + relPath, err := filepath.Rel(root, realPath) + if err != nil || pathEscapesRoot(relPath) { + root, relPath, err = wt.allowedSourcePath(realPath) + if err != nil { + return "", fmt.Errorf("%w: %s", errPathTraversal, reqPath) + } } // Reconstruct the absolute path from the trusted workspace root and the // validated relative path. This ensures the returned path is provably // inside the workspace, satisfying static-analysis taint checks. - return filepath.Join(realWorkDir, relPath), nil + return filepath.Join(root, relPath), nil +} + +func pathEscapesRoot(relPath string) bool { + return relPath == ".." || strings.HasPrefix(relPath, ".."+string(os.PathSeparator)) +} + +func (wt *WorkspaceTracker) allowedSourcePath(realPath string) (string, string, error) { + wt.mu.RLock() + roots := append([]string(nil), wt.allowedSourceRoots...) + wt.mu.RUnlock() + for _, root := range roots { + rel, err := filepath.Rel(root, realPath) + if err == nil && !pathEscapesRoot(rel) { + return root, rel, nil + } + } + return "", "", errPathTraversal +} + +// SetAllowedSourceRoots replaces the durable-source allowlist. Each root is +// canonicalized now and requests resolve their target again before matching, +// so a stale or mutated workspace link cannot inherit access to a new target. +func (wt *WorkspaceTracker) SetAllowedSourceRoots(roots []string) { + canonical := make([]string, 0, len(roots)) + seen := make(map[string]struct{}, len(roots)) + for _, root := range roots { + resolved, err := filepath.EvalSymlinks(filepath.Clean(root)) + if err != nil { + continue + } + info, err := os.Stat(resolved) + if err != nil || !info.IsDir() { + continue + } + if _, ok := seen[resolved]; ok { + continue + } + seen[resolved] = struct{}{} + canonical = append(canonical, resolved) + } + wt.mu.Lock() + wt.allowedSourceRoots = canonical + wt.mu.Unlock() } // resolveNonExistentPath walks up from path until it finds an existing @@ -384,6 +440,7 @@ func isHomeShorthand(path string) bool { func readFileContent(safePath string) (string, int64, bool, error) { // Check if file exists and is a regular file + // codeql[go/path-injection] safePath is canonical containment-validated by resolveSafePath; read-only external paths reach here only through absoluteReadPath validation. info, err := os.Stat(safePath) if err != nil { return "", 0, false, fmt.Errorf("file not found: %w", err) @@ -474,7 +531,7 @@ func (wt *WorkspaceTracker) ApplyFileDiff(ctx context.Context, reqPath, unifiedD currentHash := calculateSHA256(currentContent) if originalHash != "" && currentHash != originalHash { if desiredContent != nil { - return wt.writeDesiredContent(safePath, cleanWorkDir, reqPath, *desiredContent, currentHash) + return wt.writeDesiredContent(reqPath, *desiredContent, currentHash) } return "", "", fmt.Errorf("conflict detected: file has been modified (expected hash %s, got %s)", originalHash, currentHash) } @@ -506,7 +563,7 @@ func (wt *WorkspaceTracker) ApplyFileDiff(ctx context.Context, reqPath, unifiedD return "", "", fmt.Errorf("git apply cancelled: %w", err) } if desiredContent != nil { - return wt.writeDesiredContent(safePath, cleanWorkDir, reqPath, *desiredContent, currentHash) + return wt.writeDesiredContent(reqPath, *desiredContent, currentHash) } return "", "", fmt.Errorf("git apply failed: %w\nOutput: %s", err, string(output)) } @@ -564,14 +621,20 @@ func (wt *WorkspaceTracker) resolveSymlinkForDiff( // writeDesiredContent writes the desired content directly to the file as a fallback // when the diff cannot be applied. Returns the new hash and "overwritten" resolution. func (wt *WorkspaceTracker) writeDesiredContent( - safePath, cleanWorkDir, reqPath, desiredContent, oldHash string, + reqPath, desiredContent, oldHash string, ) (string, string, error) { - if err := os.WriteFile(safePath, []byte(desiredContent), 0o644); err != nil { + path, err := wt.resolveMutationPath(reqPath) + if err != nil { + return "", "", err + } + defer func() { _ = path.root.Close() }() + runWorkspaceMutationBarrier() + if err := path.root.WriteFile(path.rel, []byte(desiredContent), 0o644); err != nil { return "", "", fmt.Errorf("failed to write desired content: %w", err) } newHash := calculateSHA256(desiredContent) - relPath := strings.TrimPrefix(safePath, cleanWorkDir+string(os.PathSeparator)) + relPath := wt.mutationNotificationPath(path.safe) wt.notifyFileChange(relPath, types.FileOpWrite) wt.logger.Warn("overwrote file with desired content (conflict fallback)", @@ -612,19 +675,20 @@ func replaceDiffPath(line, prefix, oldPath, newPath string) string { // CreateFile creates a new file in the workspace func (wt *WorkspaceTracker) CreateFile(reqPath string) error { - safePath, err := wt.resolveSafePath(reqPath) + path, err := wt.resolveMutationPath(reqPath) if err != nil { return err } + defer func() { _ = path.root.Close() }() + runWorkspaceMutationBarrier() // Create intermediate directories - dir := filepath.Dir(safePath) - if err := os.MkdirAll(dir, 0o755); err != nil { + if err := path.root.MkdirAll(filepath.Dir(path.rel), 0o755); err != nil { return fmt.Errorf("failed to create directories: %w", err) } // Atomically create the file, failing if it already exists - f, err := os.OpenFile(safePath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + f, err := path.root.OpenFile(path.rel, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) if err != nil { if os.IsExist(err) { return fmt.Errorf("file already exists: %s", reqPath) @@ -634,30 +698,29 @@ func (wt *WorkspaceTracker) CreateFile(reqPath string) error { _ = f.Close() // Notify with the relative path - cleanWorkDir := filepath.Clean(wt.workDir) - relPath := strings.TrimPrefix(safePath, cleanWorkDir+string(os.PathSeparator)) - wt.notifyFileChange(relPath, types.FileOpCreate) + wt.notifyFileChange(wt.mutationNotificationPath(path.safe), types.FileOpCreate) return nil } // DeleteFile deletes a file or directory from the workspace. func (wt *WorkspaceTracker) DeleteFile(reqPath string) error { - safePath, err := wt.resolveSafePath(reqPath) + path, err := wt.resolveMutationPath(reqPath) if err != nil { return err } + defer func() { _ = path.root.Close() }() - cleanWorkDir := wt.resolvedWorkDir() - if safePath == cleanWorkDir { + if path.rel == "." { + if path.rootPath != wt.resolvedWorkDir() { + return fmt.Errorf("path outside workspace") + } return fmt.Errorf("cannot delete workspace root") } - if err := wt.validateWorkspacePaths(safePath); err != nil { - return err - } + runWorkspaceMutationBarrier() // Check if file exists - info, err := os.Stat(safePath) + info, err := path.root.Stat(path.rel) if err != nil { if os.IsNotExist(err) { return fmt.Errorf("file does not exist: %s", reqPath) @@ -666,17 +729,16 @@ func (wt *WorkspaceTracker) DeleteFile(reqPath string) error { } if info.IsDir() { - if err := os.RemoveAll(safePath); err != nil { + if err := path.root.RemoveAll(path.rel); err != nil { return fmt.Errorf("failed to delete directory: %w", err) } } else { - if err := os.Remove(safePath); err != nil { + if err := path.root.Remove(path.rel); err != nil { return fmt.Errorf("failed to delete file: %w", err) } } - relPath := strings.TrimPrefix(safePath, cleanWorkDir+string(os.PathSeparator)) - wt.notifyFileChange(relPath, types.FileOpRemove) + wt.notifyFileChange(wt.mutationNotificationPath(path.safe), types.FileOpRemove) return nil } @@ -687,63 +749,93 @@ func (wt *WorkspaceTracker) RenameFile(oldPath, newPath string) error { return fmt.Errorf("old_path and new_path are required") } - oldSafePath, err := wt.resolveSafePath(oldPath) + oldResolved, err := wt.resolveMutationPath(oldPath) if err != nil { return err } - newSafePath, err := wt.resolveSafePath(newPath) + defer func() { _ = oldResolved.root.Close() }() + newResolved, err := wt.resolveMutationPath(newPath) if err != nil { return err } + defer func() { _ = newResolved.root.Close() }() - if err := wt.validateWorkspacePaths(oldSafePath, newSafePath); err != nil { - return err - } - if oldSafePath == newSafePath { + if oldResolved.rootPath == newResolved.rootPath && oldResolved.rel == newResolved.rel { return nil } + if oldResolved.rootPath != newResolved.rootPath { + return fmt.Errorf("cannot rename across workspace roots") + } + runWorkspaceMutationBarrier() - if err := validateSourceExists(oldSafePath, oldPath); err != nil { + if err := validateSourceExistsRooted(oldResolved.root, oldResolved.rel, oldPath); err != nil { return err } - if err := validateTargetAvailable(newSafePath, newPath); err != nil { + if err := validateTargetAvailableRooted(newResolved.root, newResolved.rel, newPath); err != nil { return err } - parentDir := filepath.Dir(newSafePath) - if err := os.MkdirAll(parentDir, 0o755); err != nil { + if err := oldResolved.root.MkdirAll(filepath.Dir(newResolved.rel), 0o755); err != nil { return fmt.Errorf("failed to create target parent directories: %w", err) } - if err := os.Rename(oldSafePath, newSafePath); err != nil { + if err := oldResolved.root.Rename(oldResolved.rel, newResolved.rel); err != nil { return fmt.Errorf("failed to rename path: %w", err) } - cleanWorkDir := wt.resolvedWorkDir() - oldRelPath := strings.TrimPrefix(oldSafePath, cleanWorkDir+string(os.PathSeparator)) - newRelPath := strings.TrimPrefix(newSafePath, cleanWorkDir+string(os.PathSeparator)) + wt.notifyRename(oldResolved.safe, newResolved.safe) + + return nil +} + +func (wt *WorkspaceTracker) notifyRename(oldSafePath, newSafePath string) { + oldRelPath := wt.mutationNotificationPath(oldSafePath) + newRelPath := wt.mutationNotificationPath(newSafePath) wt.notifyFileChange(oldRelPath, types.FileOpRename) if newRelPath != oldRelPath { wt.notifyFileChange(newRelPath, types.FileOpRename) } - - return nil } -// validateWorkspacePaths checks that all provided paths are strictly inside the workspace. -func (wt *WorkspaceTracker) validateWorkspacePaths(paths ...string) error { - cleanWorkDir := wt.resolvedWorkDir() - workDirPrefix := cleanWorkDir + string(os.PathSeparator) - for _, p := range paths { - if !strings.HasPrefix(p, workDirPrefix) { - return fmt.Errorf("path outside workspace") +type rootedMutationPath struct { + root *os.Root + rootPath string + rel string + safe string +} + +// resolveMutationPath anchors a mutation at the canonical workspace or a +// registered durable-source root. Root methods retain that directory handle and +// reject symlink traversal outside it, including after this resolution returns. +func (wt *WorkspaceTracker) resolveMutationPath(reqPath string) (*rootedMutationPath, error) { + safePath, err := wt.resolveSafePath(reqPath) + if err != nil { + return nil, err + } + + rootPath := wt.resolvedWorkDir() + rel, err := filepath.Rel(rootPath, safePath) + if err != nil || pathEscapesRoot(rel) { + rootPath, rel, err = wt.allowedSourcePath(safePath) + if err != nil { + return nil, fmt.Errorf("path outside workspace") } } - return nil + + root, err := os.OpenRoot(rootPath) + if err != nil { + return nil, fmt.Errorf("failed to open workspace root: %w", err) + } + return &rootedMutationPath{root: root, rootPath: rootPath, rel: rel, safe: safePath}, nil } -func validateSourceExists(safePath, reqPath string) error { - _, err := os.Stat(safePath) +func (wt *WorkspaceTracker) mutationNotificationPath(safePath string) string { + cleanWorkDir := wt.resolvedWorkDir() + return strings.TrimPrefix(safePath, cleanWorkDir+string(os.PathSeparator)) +} + +func validateSourceExistsRooted(root *os.Root, relPath, reqPath string) error { + _, err := root.Stat(relPath) if err == nil { return nil } @@ -753,8 +845,8 @@ func validateSourceExists(safePath, reqPath string) error { return fmt.Errorf("failed to stat path: %w", err) } -func validateTargetAvailable(safePath, reqPath string) error { - _, err := os.Stat(safePath) +func validateTargetAvailableRooted(root *os.Root, relPath, reqPath string) error { + _, err := root.Stat(relPath) if err == nil { return fmt.Errorf("target already exists: %s", reqPath) } diff --git a/apps/backend/internal/agentctl/server/process/workspace_files_test.go b/apps/backend/internal/agentctl/server/process/workspace_files_test.go index 0638f0b980..993ef6df01 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_files_test.go +++ b/apps/backend/internal/agentctl/server/process/workspace_files_test.go @@ -5,10 +5,13 @@ import ( "os" "path/filepath" "runtime" + "strings" "testing" "github.com/kandev/kandev/internal/agentctl/types" + "github.com/kandev/kandev/internal/common/logger" storageworkspaces "github.com/kandev/kandev/internal/system/storage/workspaces" + "go.uber.org/zap" ) func TestResolveNonExistentPath(t *testing.T) { @@ -128,6 +131,259 @@ func TestResolveNonExistentPath(t *testing.T) { }) } +func TestWorkspaceFileOperationsAllowRegisteredLinkedSource(t *testing.T) { + workspace := t.TempDir() + source := t.TempDir() + if err := os.Symlink(source, filepath.Join(workspace, "linked")); err != nil { + t.Skip("symlinks not supported") + } + + log, err := logger.NewFromZap(zap.NewNop()) + if err != nil { + t.Fatal(err) + } + wt := &WorkspaceTracker{workDir: workspace, logger: log} + wt.SetAllowedSourceRoots([]string{source}) + resolved, err := wt.resolveSafePath(filepath.Join("linked", "created.txt")) + if err != nil { + t.Fatalf("resolveSafePath through registered link: %v", err) + } + want, err := filepath.EvalSymlinks(source) + if err != nil { + t.Fatal(err) + } + if resolved != filepath.Join(want, "created.txt") { + t.Errorf("resolved path = %q, want %q", resolved, filepath.Join(want, "created.txt")) + } + if err := wt.CreateFile(filepath.Join("linked", "created.txt")); err != nil { + t.Fatalf("CreateFile through registered link: %v", err) + } + if _, _, err := wt.ApplyFileDiff(context.Background(), filepath.Join("linked", "created.txt"), "", "not a diff", stringPtr("updated")); err != nil { + t.Fatalf("ApplyFileDiff through registered link: %v", err) + } + content, _, _, _, err := wt.GetFileContent(filepath.Join("linked", "created.txt")) + if err != nil || content != "updated" { + t.Fatalf("GetFileContent through registered link = %q, %v", content, err) + } + if err := wt.RenameFile(filepath.Join("linked", "created.txt"), filepath.Join("linked", "renamed.txt")); err != nil { + t.Fatalf("RenameFile through registered link: %v", err) + } + if err := wt.DeleteFile(filepath.Join("linked", "renamed.txt")); err != nil { + t.Fatalf("DeleteFile through registered link: %v", err) + } + + escape := t.TempDir() + if err := os.Remove(filepath.Join(workspace, "linked")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(escape, filepath.Join(workspace, "linked")); err != nil { + t.Fatal(err) + } + if err := wt.CreateFile(filepath.Join("linked", "escape.txt")); err == nil { + t.Fatal("CreateFile through mutated link unexpectedly succeeded") + } +} + +func TestWorkspaceFileMutationsRejectDescendantSymlinkSwap(t *testing.T) { + for _, tc := range []struct { + name string + prepare func(t *testing.T, workspace, external string, wt *WorkspaceTracker) error + assert func(t *testing.T, workspace, external string) + }{ + { + name: "create", + prepare: func(_ *testing.T, _ string, _ string, wt *WorkspaceTracker) error { + return wt.CreateFile(filepath.Join("switchable", "created.txt")) + }, + assert: func(t *testing.T, _ string, external string) { + if _, err := os.Stat(filepath.Join(external, "created.txt")); !os.IsNotExist(err) { + t.Fatalf("create escaped through swapped symlink: %v", err) + } + }, + }, + { + name: "write", + prepare: func(t *testing.T, workspace, _ string, wt *WorkspaceTracker) error { + path := filepath.Join(workspace, "switchable", "file.txt") + if err := os.WriteFile(path, []byte("original"), 0o644); err != nil { + t.Fatal(err) + } + _, _, err := wt.ApplyFileDiff(context.Background(), filepath.Join("switchable", "file.txt"), "", "invalid diff", stringPtr("updated")) + return err + }, + assert: func(t *testing.T, _ string, external string) { + content, err := os.ReadFile(filepath.Join(external, "file.txt")) + if err != nil { + t.Fatal(err) + } + if string(content) != "external" { + t.Fatalf("write escaped through swapped symlink = %q", content) + } + }, + }, + { + name: "delete", + prepare: func(t *testing.T, workspace, _ string, wt *WorkspaceTracker) error { + if err := os.WriteFile(filepath.Join(workspace, "switchable", "file.txt"), []byte("original"), 0o644); err != nil { + t.Fatal(err) + } + return wt.DeleteFile(filepath.Join("switchable", "file.txt")) + }, + assert: func(t *testing.T, _ string, external string) { + if _, err := os.Stat(filepath.Join(external, "file.txt")); err != nil { + t.Fatalf("delete escaped through swapped symlink: %v", err) + } + }, + }, + { + name: "rename", + prepare: func(t *testing.T, workspace, external string, wt *WorkspaceTracker) error { + if err := os.WriteFile(filepath.Join(workspace, "switchable", "from.txt"), []byte("original"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(external, "from.txt"), []byte("external"), 0o644); err != nil { + t.Fatal(err) + } + return wt.RenameFile(filepath.Join("switchable", "from.txt"), filepath.Join("switchable", "to.txt")) + }, + assert: func(t *testing.T, _ string, external string) { + if _, err := os.Stat(filepath.Join(external, "from.txt")); err != nil { + t.Fatalf("rename escaped through swapped symlink: %v", err) + } + if _, err := os.Stat(filepath.Join(external, "to.txt")); !os.IsNotExist(err) { + t.Fatalf("rename escaped through swapped symlink: %v", err) + } + }, + }, + } { + t.Run(tc.name, func(t *testing.T) { + workspace := t.TempDir() + external := t.TempDir() + switchable := filepath.Join(workspace, "switchable") + if err := os.Mkdir(switchable, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(external, "file.txt"), []byte("external"), 0o644); err != nil { + t.Fatal(err) + } + wt := &WorkspaceTracker{workDir: workspace} + workspaceMutationBarrier.Store(func() { + if err := os.Rename(switchable, filepath.Join(workspace, "original")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(external, switchable); err != nil { + t.Fatal(err) + } + }) + t.Cleanup(func() { workspaceMutationBarrier.Store((func())(nil)) }) + + if err := tc.prepare(t, workspace, external, wt); err == nil { + t.Fatal("mutation unexpectedly succeeded after descendant directory became an external symlink") + } + tc.assert(t, workspace, external) + }) + } +} + +func TestRenameFileRejectsCrossRootFileMove(t *testing.T) { + workspace := t.TempDir() + source := t.TempDir() + if err := os.Symlink(source, filepath.Join(workspace, "linked")); err != nil { + t.Skip("symlinks not supported") + } + if err := os.WriteFile(filepath.Join(source, "source.txt"), []byte("source"), 0o644); err != nil { + t.Fatal(err) + } + wt := &WorkspaceTracker{workDir: workspace} + wt.SetAllowedSourceRoots([]string{source}) + + if err := wt.RenameFile(filepath.Join("linked", "source.txt"), "workspace.txt"); err == nil || !strings.Contains(err.Error(), "across workspace roots") { + t.Fatalf("RenameFile cross-root error = %v", err) + } + assertFileContent(t, filepath.Join(source, "source.txt"), "source") + if _, err := os.Stat(filepath.Join(workspace, "workspace.txt")); !os.IsNotExist(err) { + t.Fatalf("cross-root move created destination: %v", err) + } +} + +func TestRenameFileRejectsCrossRootSameRelativePath(t *testing.T) { + workspace := t.TempDir() + source := t.TempDir() + if err := os.Symlink(source, filepath.Join(workspace, "linked")); err != nil { + t.Skip("symlinks not supported") + } + if err := os.WriteFile(filepath.Join(source, "foo"), []byte("source"), 0o644); err != nil { + t.Fatal(err) + } + wt := &WorkspaceTracker{workDir: workspace} + wt.SetAllowedSourceRoots([]string{source}) + + if err := wt.RenameFile(filepath.Join("linked", "foo"), "foo"); err == nil || !strings.Contains(err.Error(), "across workspace roots") { + t.Fatalf("RenameFile cross-root same-relative error = %v", err) + } + assertFileContent(t, filepath.Join(source, "foo"), "source") + if _, err := os.Stat(filepath.Join(workspace, "foo")); !os.IsNotExist(err) { + t.Fatalf("cross-root same-relative move created destination: %v", err) + } +} + +func TestRenameFileRejectsCrossRootDirectoryMove(t *testing.T) { + workspace := t.TempDir() + source := t.TempDir() + if err := os.Symlink(source, filepath.Join(workspace, "linked")); err != nil { + t.Skip("symlinks not supported") + } + if err := os.MkdirAll(filepath.Join(source, "directory", "nested"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "directory", "nested", "file.txt"), []byte("content"), 0o640); err != nil { + t.Fatal(err) + } + wt := &WorkspaceTracker{workDir: workspace} + wt.SetAllowedSourceRoots([]string{source}) + + if err := wt.RenameFile(filepath.Join("linked", "directory"), "moved"); err == nil || !strings.Contains(err.Error(), "across workspace roots") { + t.Fatalf("RenameFile cross-root directory error = %v", err) + } + assertFileContent(t, filepath.Join(source, "directory", "nested", "file.txt"), "content") + if _, err := os.Stat(filepath.Join(workspace, "moved")); !os.IsNotExist(err) { + t.Fatalf("cross-root directory move created destination: %v", err) + } +} + +func TestRenameFileCrossRootCollisionLeavesBothPathsUntouched(t *testing.T) { + workspace := t.TempDir() + source := t.TempDir() + if err := os.Symlink(source, filepath.Join(workspace, "linked")); err != nil { + t.Skip("symlinks not supported") + } + if err := os.WriteFile(filepath.Join(source, "source.txt"), []byte("source"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "destination.txt"), []byte("destination"), 0o644); err != nil { + t.Fatal(err) + } + wt := &WorkspaceTracker{workDir: workspace} + wt.SetAllowedSourceRoots([]string{source}) + + err := wt.RenameFile(filepath.Join("linked", "source.txt"), "destination.txt") + if err == nil || !strings.Contains(err.Error(), "across workspace roots") { + t.Fatalf("RenameFile collision error = %v", err) + } + assertFileContent(t, filepath.Join(source, "source.txt"), "source") + assertFileContent(t, filepath.Join(workspace, "destination.txt"), "destination") +} + +func assertFileContent(t *testing.T, path, want string) { + t.Helper() + content, err := os.ReadFile(path) + if err != nil || string(content) != want { + t.Fatalf("file %q = %q, %v", path, content, err) + } +} + +func stringPtr(value string) *string { return &value } + // requireChild finds a child node by name in the tree, failing the test if not found. func requireChild(t *testing.T, node *types.FileTreeNode, name string) *types.FileTreeNode { t.Helper() diff --git a/apps/backend/internal/agentctl/server/process/workspace_tracker.go b/apps/backend/internal/agentctl/server/process/workspace_tracker.go index 69955cc53d..63e8bf52e8 100644 --- a/apps/backend/internal/agentctl/server/process/workspace_tracker.go +++ b/apps/backend/internal/agentctl/server/process/workspace_tracker.go @@ -38,6 +38,10 @@ type WorkspaceTracker struct { workDir string gitIndexPath string // Cached, validated path to git index file (works with worktrees) logger *logger.Logger + // allowedSourceRoots are canonical roots of durable sources explicitly + // attached to this workspace. They are deliberately separate from workDir: + // a link may point outside the workspace, but only to one of these roots. + allowedSourceRoots []string // repositoryName identifies the repository this tracker covers when the // agent's workspace is a multi-repo task root. Stamped onto every emitted // GitStatusUpdate / FileListUpdate so the frontend can key per-repo state. diff --git a/apps/backend/internal/backendapp/adapters.go b/apps/backend/internal/backendapp/adapters.go index 6036e409d8..5fad4244e1 100644 --- a/apps/backend/internal/backendapp/adapters.go +++ b/apps/backend/internal/backendapp/adapters.go @@ -154,6 +154,9 @@ func (a *lifecycleAdapter) LaunchAgent(ctx context.Context, req *executor.Launch BranchSlug: req.BranchSlug, BranchIdentitySlug: req.BranchIdentitySlug, } + for _, f := range req.WorkspaceFolders { + launchReq.WorkspaceFolders = append(launchReq.WorkspaceFolders, lifecycle.WorkspaceFolderSpec{Name: f.Name, LocalPath: f.LocalPath}) + } if req.RouteOverride != nil { launchReq.RouteOverride = &lifecycle.RouteOverride{ diff --git a/apps/backend/internal/backendapp/branch_materializer.go b/apps/backend/internal/backendapp/branch_materializer.go index c07b2fc7d0..c6f49cfac3 100644 --- a/apps/backend/internal/backendapp/branch_materializer.go +++ b/apps/backend/internal/backendapp/branch_materializer.go @@ -35,7 +35,7 @@ type branchMaterializerRepo interface { // Defined as an interface so the materializer stays decoupled from the // lifecycle package. type agentctlRescanner interface { - RescanWorkspaceForSession(ctx context.Context, sessionID, workDir string) error + RescanWorkspaceForSession(ctx context.Context, sessionID, workDir string, sourceRoots ...[]string) error NotifyWorktreeMaterialized(ctx context.Context, wt lifecycle.MaterializedWorktree) } @@ -54,6 +54,15 @@ type branchMaterializer struct { logger *logger.Logger } +type branchMaterialization struct { + environment *models.TaskEnvironment + session *models.TaskSession + worktree *worktree.Worktree + repositoryID string + slug string + taskID string +} + func newBranchMaterializer(repo branchMaterializerRepo, mgr *worktree.Manager, lc *lifecycle.Manager, log *logger.Logger) *branchMaterializer { var rescanner agentctlRescanner if lc != nil { @@ -73,19 +82,32 @@ func newBranchMaterializer(repo branchMaterializerRepo, mgr *worktree.Manager, l // rerun for the same (session, repo, branch_slug) triple and returns the // existing worktree. func (b *branchMaterializer) MaterializeBranch(ctx context.Context, taskID, taskRepositoryID string) error { + materialization, err := b.materializeUnfinalized(ctx, taskID, taskRepositoryID) + if err != nil || materialization == nil { + return err + } + b.finalize(materialization, ctx) + return nil +} + +// materializeUnfinalized creates the worktree but deliberately does not +// promote, rescan, or notify. Workspace-source batches call this phase for +// every repository and publish only after their folders and live sessions have +// all adopted the new root. +func (b *branchMaterializer) materializeUnfinalized(ctx context.Context, taskID, taskRepositoryID string) (*branchMaterialization, error) { if b == nil || b.worktreeMgr == nil { - return nil + return nil, nil } req, env, session, slug, ok, err := b.prepareMaterializeRequest(ctx, taskID, taskRepositoryID) if err != nil { - return err + return nil, err } if !ok { - return nil + return nil, nil } wt, err := b.worktreeMgr.Create(ctx, req) if err != nil { - return fmt.Errorf("create worktree: %w", err) + return nil, fmt.Errorf("create worktree: %w", err) } b.logger.Info("materialized branch worktree", zap.String("task_id", taskID), @@ -93,8 +115,11 @@ func (b *branchMaterializer) MaterializeBranch(ctx context.Context, taskID, task zap.String("worktree_id", wt.ID), zap.String("path", wt.Path), zap.String("branch", wt.Branch)) - b.finalizeMaterialize(ctx, env, session, wt, req.RepositoryID, slug, taskID) - return nil + return &branchMaterialization{environment: env, session: session, worktree: wt, repositoryID: req.RepositoryID, slug: slug, taskID: taskID}, nil +} + +func (b *branchMaterializer) finalize(materialization *branchMaterialization, ctx context.Context) { + b.finalizeMaterialize(ctx, materialization.environment, materialization.session, materialization.worktree, materialization.repositoryID, materialization.slug, materialization.taskID) } // prepareMaterializeRequest builds the worktree.CreateRequest plus the diff --git a/apps/backend/internal/backendapp/branch_materializer_test.go b/apps/backend/internal/backendapp/branch_materializer_test.go index 8bc699a611..de3719df7e 100644 --- a/apps/backend/internal/backendapp/branch_materializer_test.go +++ b/apps/backend/internal/backendapp/branch_materializer_test.go @@ -32,7 +32,7 @@ type stubRescanCall struct { workDir string } -func (s *stubRescanner) RescanWorkspaceForSession(_ context.Context, sessionID, workDir string) error { +func (s *stubRescanner) RescanWorkspaceForSession(_ context.Context, sessionID, workDir string, _ ...[]string) error { s.calls = append(s.calls, stubRescanCall{sessionID: sessionID, workDir: workDir}) return nil } diff --git a/apps/backend/internal/backendapp/main.go b/apps/backend/internal/backendapp/main.go index 21805cc260..62dc5b13b9 100644 --- a/apps/backend/internal/backendapp/main.go +++ b/apps/backend/internal/backendapp/main.go @@ -435,6 +435,8 @@ func startAgentInfrastructure( zap.Bool("enabled", cfg.Worktree.Enabled)) services.Task.SetBranchMaterializer(newBranchMaterializer(repos.Task, worktreeMgr, lifecycleMgr, log)) + workspaceSourceMaterializer := newWorkspaceSourceMaterializer(repos.Task, worktreeMgr, lifecycleMgr, log) + services.Task.SetWorkspaceSourceMaterializer(workspaceSourceMaterializer) services.Task.SetAgentBaseBranchPusher(lifecycleMgr) lifecycleMgr.SetWorkspaceInfoProvider(services.Task) @@ -542,6 +544,9 @@ func startAgentInfrastructure( addCleanup(func() error { glPoller.Stop(); return nil }) log.Info("GitLab poller started") } + // Bind only the path-returning orchestrator seam after its clone pipeline + // and workspace-scoped GitLab credential resolver are both configured. + workspaceSourceMaterializer.SetHostRepositoryCloner(orchestratorSvc) // Azure DevOps v1 owns only connection-health polling. PR summaries are // refreshed explicitly through their task association routes. diff --git a/apps/backend/internal/backendapp/services.go b/apps/backend/internal/backendapp/services.go index f08494168b..2f6c2ba99c 100644 --- a/apps/backend/internal/backendapp/services.go +++ b/apps/backend/internal/backendapp/services.go @@ -61,20 +61,22 @@ func provideServices(cfg *config.Config, log *logger.Logger, repos *Repositories workflowSvc := workflowservice.NewService(repos.Workflow, log) taskSvc := taskservice.NewService( taskservice.Repos{ - Workspaces: repos.Task, - Tasks: repos.Task, - TaskRepos: repos.Task, - Workflows: repos.Task, - Messages: repos.Task, - Turns: repos.Task, - Sessions: repos.Task, - GitSnapshots: repos.Task, - RepoEntities: repos.Task, - Executors: repos.Task, - Environments: repos.Task, - TaskEnvironments: repos.Task, - Reviews: repos.Task, - ResourceCleanups: repos.Task, + Workspaces: repos.Task, + Tasks: repos.Task, + TaskRepos: repos.Task, + WorkspaceFolders: repos.Task, + Workflows: repos.Task, + Messages: repos.Task, + Turns: repos.Task, + Sessions: repos.Task, + GitSnapshots: repos.Task, + RepoEntities: repos.Task, + RepositoryCleanup: repos.Task, + Executors: repos.Task, + Environments: repos.Task, + TaskEnvironments: repos.Task, + Reviews: repos.Task, + ResourceCleanups: repos.Task, }, eventBus, log, diff --git a/apps/backend/internal/backendapp/workspace_source_materializer.go b/apps/backend/internal/backendapp/workspace_source_materializer.go new file mode 100644 index 0000000000..b147a6c14c --- /dev/null +++ b/apps/backend/internal/backendapp/workspace_source_materializer.go @@ -0,0 +1,654 @@ +package backendapp + +import ( + "context" + "errors" + "fmt" + "net/url" + "os" + "path/filepath" + "strings" + "time" + + "go.uber.org/zap" + + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/common/logger" + "github.com/kandev/kandev/internal/orchestrator" + "github.com/kandev/kandev/internal/repoclone" + "github.com/kandev/kandev/internal/task/models" + taskservice "github.com/kandev/kandev/internal/task/service" + "github.com/kandev/kandev/internal/worktree" +) + +// ErrRemoteRepositoryLocatorUnavailable prevents host-local repository paths +// from crossing an executor boundary that can only clone remote locators. +var ErrRemoteRepositoryLocatorUnavailable = errors.New("remote repository locator unavailable") + +const legacyLocalPCExecutor = "local_pc" + +// workspaceSourceMaterializerRepo is intentionally limited to the durable +// state needed after the service has transactionally persisted a batch. +type workspaceSourceMaterializerRepo interface { + GetTaskEnvironmentByTaskID(context.Context, string) (*models.TaskEnvironment, error) + UpdateTaskEnvironment(context.Context, *models.TaskEnvironment) error + ListTaskSessions(context.Context, string) ([]*models.TaskSession, error) + ListTaskRepositories(context.Context, string) ([]*models.TaskRepository, error) + ListTaskWorkspaceFolders(context.Context, string) ([]*models.TaskWorkspaceFolder, error) + GetRepository(context.Context, string) (*models.Repository, error) +} + +// workspaceSourceMaterializer is the host-only materialization boundary. It +// creates only Kandev-owned directory entries; each source itself remains a +// live user-owned directory and is never copied, removed, or modified. +type workspaceSourceMaterializer struct { + repo workspaceSourceMaterializerRepo + worktreeMgr *worktree.Manager + branches *branchMaterializer + rescanner interface { + RebindWorkspaceForSession(context.Context, string, string, ...[]string) error + } + remoteMaterializer interface { + MaterializeRepositoriesForEnvironment(context.Context, string, []lifecycle.WorkspaceRepositoryMaterialization) ([]string, error) + } + hostCloner orchestrator.RepositoryHostCloner + logger *logger.Logger +} + +type workspaceSourceMaterializationState struct { + environment *models.TaskEnvironment + sessions []*models.TaskSession + repositories []*models.TaskRepository + folders []*models.TaskWorkspaceFolder + entities map[string]*models.Repository +} + +type hostWorkspaceMaterialization struct { + root string + oldPath string + rootExisted bool + knownWorktree map[string]bool + priorRoots []string + postRoots []string +} + +func newWorkspaceSourceMaterializer(repo workspaceSourceMaterializerRepo, mgr *worktree.Manager, lc *lifecycle.Manager, log *logger.Logger) *workspaceSourceMaterializer { + var rescanner interface { + RebindWorkspaceForSession(context.Context, string, string, ...[]string) error + } + if lc != nil { + rescanner = lc + } + materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, rescanner: rescanner, remoteMaterializer: lc, logger: log.WithFields(zap.String("component", "workspace_source_materializer"))} + if branchRepo, ok := repo.(branchMaterializerRepo); ok { + materializer.branches = newBranchMaterializer(branchRepo, mgr, lc, log) + } + return materializer +} + +// SetHostRepositoryCloner wires the authenticated orchestrator clone seam +// after its repository and workspace credential dependencies are configured. +func (m *workspaceSourceMaterializer) SetHostRepositoryCloner(cloner orchestrator.RepositoryHostCloner) { + if m != nil { + m.hostCloner = cloner + } +} + +func (m *workspaceSourceMaterializer) MaterializeWorkspaceSources(ctx context.Context, taskID string, batch *models.WorkspaceSourceBatch) (*taskservice.WorkspaceSourceMaterializationResult, error) { + if m == nil || m.repo == nil || m.worktreeMgr == nil || batch == nil { + return &taskservice.WorkspaceSourceMaterializationResult{}, nil + } + state, err := m.loadMaterializationState(ctx, taskID) + if err != nil { + return nil, err + } + if state == nil || state.environment == nil { + return &taskservice.WorkspaceSourceMaterializationResult{}, nil + } + if !isHostWorkspaceExecutor(state.environment.ExecutorType) { + return m.materializeRemoteWorkspaceSources(ctx, taskID, state, batch) + } + return m.materializeHostWorkspaceSources(ctx, taskID, state, batch) +} + +func (m *workspaceSourceMaterializer) materializeHostWorkspaceSources(ctx context.Context, taskID string, state *workspaceSourceMaterializationState, batch *models.WorkspaceSourceBatch) (_ *taskservice.WorkspaceSourceMaterializationResult, err error) { + if err := m.ensureHostRepositoryPaths(ctx, state); err != nil { + return nil, err + } + materialization, err := m.prepareHostWorkspaceMaterialization(ctx, taskID, state, batch) + if err != nil { + return nil, err + } + created := make([]string, 0, len(batch.Sources)) + adoptedSessions := make([]*models.TaskSession, 0, len(state.sessions)) + defer func() { + if err == nil { + return + } + if rollbackErr := m.rollbackHostWorkspaceMaterialization(ctx, taskID, state, materialization, adoptedSessions, created); rollbackErr != nil { + err = fmt.Errorf("%w; restore adopted session workspaces: %v", err, rollbackErr) + } + }() + branchMaterializations, materialized, materializeErr := m.materializeHostRuntime(ctx, taskID, materialization.root, state, batch) + created = append(created, materialized...) + if materializeErr != nil { + return nil, materializeErr + } + if state.environment.WorkspacePath != materialization.root { + state.environment.WorkspacePath, state.environment.UpdatedAt = materialization.root, time.Now().UTC() + if err = m.repo.UpdateTaskEnvironment(ctx, state.environment); err != nil { + return nil, fmt.Errorf("persist task workspace path: %w", err) + } + } + ids, adopted, adoptErr := m.adoptSessionWorkspaces(ctx, state.sessions, materialization.root, materialization.postRoots) + adoptedSessions = append(adoptedSessions, adopted...) + if adoptErr != nil { + return nil, adoptErr + } + for _, materialization := range branchMaterializations { + m.branches.finalize(materialization, ctx) + } + return &taskservice.WorkspaceSourceMaterializationResult{WorkspacePath: materialization.root, SessionIDs: ids}, nil +} + +func (m *workspaceSourceMaterializer) prepareHostWorkspaceMaterialization(ctx context.Context, taskID string, state *workspaceSourceMaterializationState, batch *models.WorkspaceSourceBatch) (*hostWorkspaceMaterialization, error) { + postRoots, err := canonicalWorkspaceSourceRoots(state, batch, true) + if err != nil { + return nil, err + } + priorRoots, err := canonicalWorkspaceSourceRoots(state, batch, false) + if err != nil { + return nil, err + } + root, err := m.worktreeMgr.TaskRoot(state.environment.TaskDirName) + if err != nil { + return nil, fmt.Errorf("resolve owned task root: %w", err) + } + worktrees, err := m.worktreeMgr.GetAllByTaskID(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("snapshot task worktrees: %w", err) + } + return &hostWorkspaceMaterialization{ + root: root, oldPath: state.environment.WorkspacePath, rootExisted: pathExists(root), + knownWorktree: worktreeIDs(worktrees), priorRoots: priorRoots, postRoots: postRoots, + }, nil +} + +func worktreeIDs(worktrees []*worktree.Worktree) map[string]bool { + ids := make(map[string]bool, len(worktrees)) + for _, wt := range worktrees { + if wt != nil { + ids[wt.ID] = true + } + } + return ids +} + +func (m *workspaceSourceMaterializer) materializeHostRuntime(ctx context.Context, taskID, root string, state *workspaceSourceMaterializationState, batch *models.WorkspaceSourceBatch) ([]*branchMaterialization, []string, error) { + if state.environment.ExecutorType == string(models.ExecutorTypeWorktree) { + created, materializations, err := m.materializeWorktreeSources(ctx, taskID, root, batch, state.folders) + return materializations, created, err + } + entries, err := localWorkspaceEntries(state.repositories, state.folders, state.entities, batch) + if err != nil { + return nil, nil, err + } + created, err := materializeDirectoryLinks(root, entries, "workspace source") + return nil, created, err +} + +func materializeDirectoryLinks(root string, entries map[string]string, description string) ([]string, error) { + created := make([]string, 0, len(entries)) + for name, target := range entries { + entry, wasCreated, err := worktree.EnsureOwnedDirectoryLink(root, name, target) + if err != nil { + return created, fmt.Errorf("link %s %q: %w", description, name, err) + } + if wasCreated { + created = append(created, entry) + } + } + return created, nil +} + +func (m *workspaceSourceMaterializer) adoptSessionWorkspaces(ctx context.Context, sessions []*models.TaskSession, root string, sourceRoots []string) ([]string, []*models.TaskSession, error) { + ids := make([]string, 0, len(sessions)) + adopted := make([]*models.TaskSession, 0, len(sessions)) + for _, session := range sessions { + if m.rescanner != nil { + if err := m.rescanner.RebindWorkspaceForSession(ctx, session.ID, root, sourceRoots); err != nil { + return nil, adopted, fmt.Errorf("adopt workspace for session %s: %w", session.ID, err) + } + adopted = append(adopted, session) + } + ids = append(ids, session.ID) + } + return ids, adopted, nil +} + +func (m *workspaceSourceMaterializer) rollbackHostWorkspaceMaterialization(ctx context.Context, taskID string, state *workspaceSourceMaterializationState, materialization *hostWorkspaceMaterialization, adopted []*models.TaskSession, created []string) error { + rollbackErr := m.restoreSessionWorkspaces(ctx, adopted, materialization.oldPath, materialization.priorRoots) + for index := len(created) - 1; index >= 0; index-- { + _ = os.Remove(created[index]) + } + m.cleanupNewWorktrees(ctx, taskID, materialization.knownWorktree) + if !materialization.rootExisted { + _ = os.Remove(materialization.root) + } + if state.environment.WorkspacePath != materialization.oldPath { + state.environment.WorkspacePath = materialization.oldPath + state.environment.UpdatedAt = time.Now().UTC() + _ = m.repo.UpdateTaskEnvironment(context.WithoutCancel(ctx), state.environment) + } + return rollbackErr +} + +func (m *workspaceSourceMaterializer) cleanupNewWorktrees(ctx context.Context, taskID string, known map[string]bool) { + worktrees, err := m.worktreeMgr.GetAllByTaskID(context.WithoutCancel(ctx), taskID) + if err != nil { + return + } + created := make([]*worktree.Worktree, 0) + for _, wt := range worktrees { + if wt != nil && !known[wt.ID] { + created = append(created, wt) + } + } + if len(created) > 0 { + _ = m.worktreeMgr.CleanupWorktrees(context.WithoutCancel(ctx), created) + } +} + +func (m *workspaceSourceMaterializer) restoreSessionWorkspaces(ctx context.Context, sessions []*models.TaskSession, workspacePath string, sourceRoots []string) error { + if m.rescanner == nil || len(sessions) == 0 { + return nil + } + rollbackCtx := context.WithoutCancel(ctx) + var rollbackErr error + for index := len(sessions) - 1; index >= 0; index-- { + session := sessions[index] + if err := m.rescanner.RebindWorkspaceForSession(rollbackCtx, session.ID, workspacePath, sourceRoots); err != nil { + rollbackErr = errors.Join(rollbackErr, fmt.Errorf("session %s: %w", session.ID, err)) + } + } + return rollbackErr +} + +func canonicalWorkspaceSourceRoots(state *workspaceSourceMaterializationState, batch *models.WorkspaceSourceBatch, includeBatch bool) ([]string, error) { + batchRepositories, batchFolders := workspaceSourceBatchIDs(batch) + collector := newWorkspaceSourceRootCollector(len(state.repositories) + len(state.folders)) + if err := collector.addRepositories(state.repositories, state.entities, batchRepositories, includeBatch); err != nil { + return nil, err + } + if err := collector.addFolders(state.folders, batchFolders, includeBatch); err != nil { + return nil, err + } + if includeBatch { + if err := collector.addUnpersistedBatchFolders(batch, batchFolders); err != nil { + return nil, err + } + } + return collector.roots, nil +} + +type workspaceSourceRootCollector struct { + roots []string + seen map[string]struct{} +} + +func newWorkspaceSourceRootCollector(capacity int) *workspaceSourceRootCollector { + return &workspaceSourceRootCollector{roots: make([]string, 0, capacity), seen: make(map[string]struct{}, capacity)} +} + +func (c *workspaceSourceRootCollector) addRepositories(repositories []*models.TaskRepository, entities map[string]*models.Repository, batchIDs map[string]bool, includeBatch bool) error { + for _, taskRepository := range repositories { + if !includeBatch && batchIDs[taskRepository.ID] { + continue + } + if repository := entities[taskRepository.RepositoryID]; repository != nil { + if err := c.add(repository.LocalPath); err != nil { + return err + } + } + } + return nil +} + +func (c *workspaceSourceRootCollector) addFolders(folders []*models.TaskWorkspaceFolder, batchIDs map[string]bool, includeBatch bool) error { + for _, folder := range folders { + if !includeBatch && batchIDs[folder.ID] { + continue + } + if err := c.add(folder.LocalPath); err != nil { + return err + } + } + return nil +} + +func (c *workspaceSourceRootCollector) addUnpersistedBatchFolders(batch *models.WorkspaceSourceBatch, batchIDs map[string]bool) error { + for _, source := range batch.Sources { + if source.Folder != nil && !batchIDs[source.Folder.ID] { + if err := c.add(source.Folder.LocalPath); err != nil { + return err + } + } + } + return nil +} + +func (c *workspaceSourceRootCollector) add(path string) error { + resolved, err := filepath.EvalSymlinks(filepath.Clean(path)) + if err != nil { + return fmt.Errorf("resolve workspace source root %q: %w", path, err) + } + info, err := os.Stat(resolved) + if err != nil || !info.IsDir() { + return fmt.Errorf("workspace source root is not a directory: %s", path) + } + if _, exists := c.seen[resolved]; !exists { + c.seen[resolved] = struct{}{} + c.roots = append(c.roots, resolved) + } + return nil +} + +func workspaceSourceBatchIDs(batch *models.WorkspaceSourceBatch) (map[string]bool, map[string]bool) { + repositories, folders := map[string]bool{}, map[string]bool{} + if batch == nil { + return repositories, folders + } + for _, source := range batch.Sources { + if source.Repository != nil && source.Repository.ID != "" { + repositories[source.Repository.ID] = true + } + if source.Folder != nil && source.Folder.ID != "" { + folders[source.Folder.ID] = true + } + } + return repositories, folders +} + +func (m *workspaceSourceMaterializer) ensureHostRepositoryPaths(ctx context.Context, state *workspaceSourceMaterializationState) error { + for _, taskRepository := range state.repositories { + repository := state.entities[taskRepository.RepositoryID] + if repository == nil || repository.LocalPath != "" { + continue + } + if repository.ProviderOwner == "" || repository.ProviderName == "" { + return fmt.Errorf("repository %q source path is missing", taskRepository.RepositoryID) + } + if m.hostCloner == nil { + return fmt.Errorf("host repository cloner is unavailable for %q", repository.Name) + } + path, err := m.hostCloner.EnsureRepositoryCloned(ctx, repository) + if err != nil { + return fmt.Errorf("clone repository %q: %w", repository.Name, err) + } + if path == "" { + return fmt.Errorf("repository %q clone produced no local path", repository.Name) + } + repository.LocalPath = path + } + return nil +} + +func (m *workspaceSourceMaterializer) loadMaterializationState(ctx context.Context, taskID string) (*workspaceSourceMaterializationState, error) { + environment, err := m.repo.GetTaskEnvironmentByTaskID(ctx, taskID) + if err != nil || environment == nil { + return nil, err + } + sessions, err := m.repo.ListTaskSessions(ctx, taskID) + if err != nil { + return nil, err + } + repositories, err := m.repo.ListTaskRepositories(ctx, taskID) + if err != nil { + return nil, err + } + folders, err := m.repo.ListTaskWorkspaceFolders(ctx, taskID) + if err != nil { + return nil, err + } + entities := make(map[string]*models.Repository, len(repositories)) + for _, taskRepository := range repositories { + entity, err := m.repo.GetRepository(ctx, taskRepository.RepositoryID) + if err != nil { + return nil, fmt.Errorf("resolve repository source: %w", err) + } + if entity == nil { + return nil, fmt.Errorf("resolve repository source: repository %q not found", taskRepository.RepositoryID) + } + entities[taskRepository.RepositoryID] = entity + } + return &workspaceSourceMaterializationState{environment: environment, sessions: sessions, repositories: repositories, folders: folders, entities: entities}, nil +} + +func (m *workspaceSourceMaterializer) materializeRemoteWorkspaceSources(ctx context.Context, taskID string, state *workspaceSourceMaterializationState, batch *models.WorkspaceSourceBatch) (*taskservice.WorkspaceSourceMaterializationResult, error) { + if m.remoteMaterializer == nil { + return nil, fmt.Errorf("remote workspace materializer is unavailable") + } + projection, err := buildRemoteWorkspaceRepositoryBatch(batch, state.entities) + if err != nil { + return nil, err + } + ids, err := m.remoteMaterializer.MaterializeRepositoriesForEnvironment(ctx, state.environment.ID, projection) + if err != nil { + return nil, err + } + return &taskservice.WorkspaceSourceMaterializationResult{WorkspacePath: state.environment.WorkspacePath, SessionIDs: ids}, nil +} + +// buildRemoteWorkspaceRepositoryBatch projects only the sources created by +// this attachment operation. Existing durable siblings belong in a fresh +// launch/resume projection, but revalidating them in a live execution would +// reject normal agent commits that advance their HEAD after attachment. +func buildRemoteWorkspaceRepositoryBatch(batch *models.WorkspaceSourceBatch, repositoryByID map[string]*models.Repository) ([]lifecycle.WorkspaceRepositoryMaterialization, error) { + if batch == nil { + return nil, nil + } + projection := make([]lifecycle.WorkspaceRepositoryMaterialization, 0, len(batch.Sources)) + for _, source := range batch.Sources { + if source.Repository == nil { + continue + } + repository := repositoryByID[source.Repository.RepositoryID] + if repository == nil { + return nil, fmt.Errorf("%w: repository %q", ErrRemoteRepositoryLocatorUnavailable, source.Repository.RepositoryID) + } + locator := remoteRepositoryLocator(repository) + if locator == "" { + return nil, fmt.Errorf("%w: repository %q", ErrRemoteRepositoryLocatorUnavailable, repository.Name) + } + branch := source.Repository.CheckoutBranch + if branch == "" { + branch = source.Repository.BaseBranch + } + if branch == "" { + return nil, fmt.Errorf("%w: repository %q has no checkout ref", ErrRemoteRepositoryLocatorUnavailable, repository.Name) + } + name, err := taskservice.WorkspaceSourceRuntimeEntryName("remote", repository, source.Repository) + if err != nil { + return nil, fmt.Errorf("%w: repository %q has unsafe runtime name", ErrRemoteRepositoryLocatorUnavailable, repository.Name) + } + projection = append(projection, lifecycle.WorkspaceRepositoryMaterialization{RepositoryURL: locator, Destination: name, BaseBranch: source.Repository.BaseBranch, CheckoutBranch: source.Repository.CheckoutBranch}) + } + return projection, nil +} + +func buildRemoteWorkspaceRepositories(taskRepositories []*models.TaskRepository, repositoryByID map[string]*models.Repository) ([]lifecycle.WorkspaceRepositoryMaterialization, error) { + projection := make([]lifecycle.WorkspaceRepositoryMaterialization, 0, len(taskRepositories)) + // The primary repository is already represented by the executor workspace + // root. Materialize only durable siblings in their collision-checked names. + for index, taskRepository := range taskRepositories { + if index == 0 { + continue + } + repository := repositoryByID[taskRepository.RepositoryID] + if repository == nil { + return nil, fmt.Errorf("%w: repository %q", ErrRemoteRepositoryLocatorUnavailable, taskRepository.RepositoryID) + } + locator := remoteRepositoryLocator(repository) + if locator == "" { + return nil, fmt.Errorf("%w: repository %q", ErrRemoteRepositoryLocatorUnavailable, repository.Name) + } + branch := taskRepository.CheckoutBranch + if branch == "" { + branch = taskRepository.BaseBranch + } + if branch == "" { + return nil, fmt.Errorf("%w: repository %q has no checkout ref", ErrRemoteRepositoryLocatorUnavailable, repository.Name) + } + name, err := taskservice.WorkspaceSourceRuntimeEntryName("remote", repository, taskRepository) + if err != nil { + return nil, fmt.Errorf("%w: repository %q has unsafe runtime name", ErrRemoteRepositoryLocatorUnavailable, repository.Name) + } + projection = append(projection, lifecycle.WorkspaceRepositoryMaterialization{RepositoryURL: locator, Destination: name, BaseBranch: taskRepository.BaseBranch, CheckoutBranch: taskRepository.CheckoutBranch}) + } + return projection, nil +} + +func remoteRepositoryLocator(repository *models.Repository) string { + locator := strings.TrimSpace(repository.RemoteURL) + if locator == "" { + locator = providerRepositoryLocator(repository) + } + if locator == "" || strings.HasPrefix(locator, "/") || strings.HasPrefix(locator, "file:") { + return "" + } + if strings.HasPrefix(locator, "git@") { + return locator + } + parsed, err := url.Parse(locator) + if err != nil || parsed.Host == "" || (parsed.Scheme != "https" && parsed.Scheme != "http" && parsed.Scheme != "ssh" && parsed.Scheme != "git") { + return "" + } + return locator +} + +func providerRepositoryLocator(repository *models.Repository) string { + if repository.ProviderOwner == "" || repository.ProviderName == "" { + return "" + } + if strings.EqualFold(repository.Provider, "gitlab") && strings.TrimSpace(repository.ProviderHost) == "" { + return "" + } + locator, err := repoclone.CloneURLWithHost(repository.Provider, repository.ProviderHost, repository.ProviderOwner, repository.ProviderName, repoclone.ProtocolHTTPS) + if err != nil { + return "" + } + return locator +} + +func isHostWorkspaceExecutor(executorType string) bool { + return executorType == string(models.ExecutorTypeLocal) || executorType == legacyLocalPCExecutor || executorType == string(models.ExecutorTypeWorktree) +} + +func (m *workspaceSourceMaterializer) materializeWorktreeSources(ctx context.Context, taskID, root string, batch *models.WorkspaceSourceBatch, folders []*models.TaskWorkspaceFolder) ([]string, []*branchMaterialization, error) { + materializations := make([]*branchMaterialization, 0, len(batch.Sources)) + for _, source := range batch.Sources { + if source.Repository != nil { + if m.branches == nil { + return nil, nil, fmt.Errorf("worktree repository materializer is unavailable") + } + materialization, err := m.branches.materializeUnfinalized(ctx, taskID, source.Repository.ID) + if err != nil { + return nil, nil, err + } + if materialization != nil { + materializations = append(materializations, materialization) + } + } + } + entries, err := workspaceFolderEntries(folders, batch) + if err != nil { + return nil, nil, err + } + created := make([]string, 0, len(entries)) + for name, target := range entries { + entry, wasCreated, err := worktree.EnsureOwnedDirectoryLink(root, name, target) + if err != nil { + return created, nil, fmt.Errorf("link worktree folder %q: %w", name, err) + } + if wasCreated { + created = append(created, entry) + } + } + return created, materializations, nil +} + +func localWorkspaceEntries(repos []*models.TaskRepository, folders []*models.TaskWorkspaceFolder, entities map[string]*models.Repository, batch *models.WorkspaceSourceBatch) (map[string]string, error) { + entries := map[string]string{} + add := func(name, target string) error { + if _, exists := entries[name]; exists { + return fmt.Errorf("workspace runtime entry %q collides", name) + } + entries[name] = target + return nil + } + for _, tr := range repos { + repository := entities[tr.RepositoryID] + name, err := taskservice.WorkspaceSourceRuntimeEntryName(string(models.ExecutorTypeLocal), repository, tr) + if err != nil { + return nil, err + } + if err := add(name, repository.LocalPath); err != nil { + return nil, err + } + } + folderEntries, err := workspaceFolderEntries(folders, batch) + if err != nil { + return nil, err + } + for name, target := range folderEntries { + if err := add(name, target); err != nil { + return nil, err + } + } + return entries, nil +} + +// workspaceFolderEntries treats durable folder rows as the source of truth. +// The attachment service persists a batch before materializing it, so the +// matching batch row is already represented in folders and must not collide +// with itself. Unpersisted batch folders remain included and collision-checked. +func workspaceFolderEntries(folders []*models.TaskWorkspaceFolder, batch *models.WorkspaceSourceBatch) (map[string]string, error) { + entries := make(map[string]string, len(folders)) + persistedIDs := make(map[string]struct{}, len(folders)) + add := func(folder *models.TaskWorkspaceFolder) error { + if _, exists := entries[folder.DisplayName]; exists { + return fmt.Errorf("workspace runtime entry %q collides", folder.DisplayName) + } + entries[folder.DisplayName] = folder.LocalPath + return nil + } + for _, folder := range folders { + if folder == nil { + continue + } + if folder.ID != "" { + persistedIDs[folder.ID] = struct{}{} + } + if err := add(folder); err != nil { + return nil, err + } + } + if batch == nil { + return entries, nil + } + for _, source := range batch.Sources { + folder := source.Folder + if folder == nil { + continue + } + if _, persisted := persistedIDs[folder.ID]; persisted && folder.ID != "" { + continue + } + if err := add(folder); err != nil { + return nil, err + } + } + return entries, nil +} + +func pathExists(path string) bool { _, err := os.Lstat(path); return err == nil } diff --git a/apps/backend/internal/backendapp/workspace_source_materializer_test.go b/apps/backend/internal/backendapp/workspace_source_materializer_test.go new file mode 100644 index 0000000000..670888310c --- /dev/null +++ b/apps/backend/internal/backendapp/workspace_source_materializer_test.go @@ -0,0 +1,613 @@ +package backendapp + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "testing" + "time" + + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/task/models" + sqliterepo "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +type remoteWorkspaceMaterializerStub struct { + calls [][]lifecycle.WorkspaceRepositoryMaterialization + err error + ids []string +} + +type hostRepositoryClonerStub struct { + path string + calls []*models.Repository + err error +} + +func (s *hostRepositoryClonerStub) EnsureRepositoryCloned(_ context.Context, repository *models.Repository) (string, error) { + s.calls = append(s.calls, repository) + return s.path, s.err +} + +func (s *remoteWorkspaceMaterializerStub) MaterializeRepositoriesForEnvironment(_ context.Context, _ string, repositories []lifecycle.WorkspaceRepositoryMaterialization) ([]string, error) { + s.calls = append(s.calls, append([]lifecycle.WorkspaceRepositoryMaterialization(nil), repositories...)) + return append([]string(nil), s.ids...), s.err +} + +type failingSessionMaterializerRepo struct { + workspaceSourceMaterializerRepo + err error +} + +func (r failingSessionMaterializerRepo) ListTaskSessions(context.Context, string) ([]*models.TaskSession, error) { + return nil, r.err +} + +func TestBuildRemoteWorkspaceRepositories_SkipsPrimaryAndUsesDurableRuntimeNames(t *testing.T) { + repositories, err := buildRemoteWorkspaceRepositories([]*models.TaskRepository{ + {RepositoryID: "primary", BaseBranch: "main"}, + {RepositoryID: "repo-1", BaseBranch: "main", CheckoutBranch: "feature/add-source"}, + }, map[string]*models.Repository{ + "primary": {ID: "primary", Name: "primary", RemoteURL: "https://github.com/acme/primary.git"}, + "repo-1": {ID: "repo-1", Name: "API", RemoteURL: "https://github.com/acme/api.git"}, + }) + if err != nil { + t.Fatalf("buildRemoteWorkspaceRepositories: %v", err) + } + want := lifecycle.WorkspaceRepositoryMaterialization{RepositoryURL: "https://github.com/acme/api.git", Destination: "API-feature-add-source", BaseBranch: "main", CheckoutBranch: "feature/add-source"} + if len(repositories) != 1 || repositories[0] != want { + t.Fatalf("repositories=%+v, want [%+v]", repositories, want) + } + _, err = buildRemoteWorkspaceRepositories([]*models.TaskRepository{{RepositoryID: "primary", BaseBranch: "main"}, {RepositoryID: "local", BaseBranch: "main"}}, map[string]*models.Repository{ + "primary": {ID: "primary", Name: "primary", RemoteURL: "https://github.com/acme/primary.git"}, + "local": {ID: "local", Name: "local", LocalPath: "/host/only/repository"}, + }) + if err == nil { + t.Fatal("local-only repository was accepted for remote materialization") + } +} + +func TestLocalWorkspaceEntries_RejectsFolderRepositoryNameCollision(t *testing.T) { + _, err := localWorkspaceEntries( + []*models.TaskRepository{{RepositoryID: "repo-1", BaseBranch: "main"}}, + []*models.TaskWorkspaceFolder{{DisplayName: "docs", LocalPath: "/folders/docs"}}, + map[string]*models.Repository{"repo-1": {ID: "repo-1", Name: "docs", LocalPath: "/repositories/docs"}}, + nil, + ) + if err == nil { + t.Fatal("local workspace entries accepted colliding folder and repository names") + } +} + +func TestWorkspaceSourceMaterializer_RemoteLoadsSessionsBeforeFilesystemMutation(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + seedWorkspaceSourceTask(t, repo, t.TempDir()) + seedRemoteMaterializerRepositories(t, repo) + env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1") + if err != nil { + t.Fatal(err) + } + env.ExecutorType = string(models.ExecutorTypeLocalDocker) + if err := repo.UpdateTaskEnvironment(ctx, env); err != nil { + t.Fatal(err) + } + remote := &remoteWorkspaceMaterializerStub{} + materializer := &workspaceSourceMaterializer{ + repo: failingSessionMaterializerRepo{workspaceSourceMaterializerRepo: repo, err: errors.New("sessions unavailable")}, + worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-1")), + remoteMaterializer: remote, + logger: newTestLogger(), + } + _, err = materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1"}) + if err == nil { + t.Fatal("MaterializeWorkspaceSources succeeded despite session lookup failure") + } + if len(remote.calls) != 0 { + t.Fatalf("remote materialization ran before session lookup: %+v", remote.calls) + } +} + +func TestWorkspaceSourceMaterializer_PrelaunchReturnsExplicitDeferredResult(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-prelaunch", Name: "Prelaunch"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateTask(ctx, &models.Task{ID: "task-prelaunch", WorkspaceID: "ws-prelaunch", WorkflowID: "wf", WorkflowStepID: "step", Title: "Task"}); err != nil { + t.Fatal(err) + } + materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-prelaunch")), logger: newTestLogger()} + + result, err := materializer.MaterializeWorkspaceSources(ctx, "task-prelaunch", &models.WorkspaceSourceBatch{TaskID: "task-prelaunch"}) + if err != nil { + t.Fatal(err) + } + if result == nil || result.WorkspacePath != "" || len(result.SessionIDs) != 0 { + t.Fatalf("prelaunch materialization result = %#v, want explicit empty deferred result", result) + } +} + +func TestWorkspaceSourceMaterializer_RemoteMaterializesAdditionalRepositories(t *testing.T) { + for _, executorType := range []models.ExecutorType{models.ExecutorTypeLocalDocker, models.ExecutorTypeSSH, models.ExecutorTypeSprites} { + t.Run(string(executorType), func(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + seedWorkspaceSourceTask(t, repo, t.TempDir()) + seedRemoteMaterializerRepositories(t, repo) + env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1") + if err != nil { + t.Fatal(err) + } + env.ExecutorType = string(executorType) + if err := repo.UpdateTaskEnvironment(ctx, env); err != nil { + t.Fatal(err) + } + remote := &remoteWorkspaceMaterializerStub{ids: []string{"session-1"}} + materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-1")), remoteMaterializer: remote, logger: newTestLogger()} + result, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Repository: &models.TaskRepository{RepositoryID: "repo-added", BaseBranch: "main"}}}}) + if err != nil { + t.Fatal(err) + } + if result.WorkspacePath != env.WorkspacePath || len(result.SessionIDs) != 1 || result.SessionIDs[0] != "session-1" { + t.Fatalf("materialization result = %#v", result) + } + if len(remote.calls) != 1 || len(remote.calls[0]) != 1 || remote.calls[0][0].Destination != "added-main" { + t.Fatalf("remote projection=%+v; want only additional repository", remote.calls) + } + }) + } +} + +func TestWorkspaceSourceMaterializer_RemoteReturnsOnlyLifecycleAdoptedSessions(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + seedWorkspaceSourceTask(t, repo, t.TempDir()) + seedRemoteMaterializerRepositories(t, repo) + env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1") + if err != nil { + t.Fatal(err) + } + env.ExecutorType = string(models.ExecutorTypeSSH) + if err := repo.UpdateTaskEnvironment(ctx, env); err != nil { + t.Fatal(err) + } + remote := &remoteWorkspaceMaterializerStub{ids: []string{"session-live"}} + materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-1")), remoteMaterializer: remote, logger: newTestLogger()} + + result, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Repository: &models.TaskRepository{RepositoryID: "repo-added", BaseBranch: "main"}}}}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(result.SessionIDs, []string{"session-live"}) { + t.Fatalf("session ids = %v, want only lifecycle-adopted session", result.SessionIDs) + } +} + +// A live attachment batch must not revalidate durable siblings. An agent may +// have committed in the first sibling between attachments, making its HEAD +// intentionally differ from origin; only the newly submitted destination is +// eligible for materialization on this live execution. +func TestWorkspaceSourceMaterializer_RemoteMaterializesOnlySubmittedRepositoryBatch(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + seedWorkspaceSourceTask(t, repo, t.TempDir()) + seedRemoteMaterializerRepositories(t, repo) + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-later", WorkspaceID: "ws-1", Name: "later", RemoteURL: "https://github.com/acme/later.git"}); err != nil { + t.Fatal(err) + } + later := &models.TaskRepository{ID: "task-repo-later", TaskID: "task-1", RepositoryID: "repo-later", BaseBranch: "main", Position: 2, Metadata: map[string]interface{}{}} + if err := repo.CreateTaskRepository(ctx, later); err != nil { + t.Fatal(err) + } + env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1") + if err != nil { + t.Fatal(err) + } + env.ExecutorType = string(models.ExecutorTypeSSH) + if err := repo.UpdateTaskEnvironment(ctx, env); err != nil { + t.Fatal(err) + } + remote := &remoteWorkspaceMaterializerStub{} + materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-1")), remoteMaterializer: remote, logger: newTestLogger()} + _, err = materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Repository: later}}}) + if err != nil { + t.Fatal(err) + } + if len(remote.calls) != 1 || len(remote.calls[0]) != 1 || remote.calls[0][0].Destination != "later-main" { + t.Fatalf("live remote projection=%+v; want only later-main", remote.calls) + } +} + +func TestWorkspaceSourceMaterializer_RemoteFailure(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + seedWorkspaceSourceTask(t, repo, t.TempDir()) + seedRemoteMaterializerRepositories(t, repo) + env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1") + if err != nil { + t.Fatal(err) + } + env.ExecutorType = string(models.ExecutorTypeSprites) + if err := repo.UpdateTaskEnvironment(ctx, env); err != nil { + t.Fatal(err) + } + materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-1")), remoteMaterializer: &remoteWorkspaceMaterializerStub{err: errors.New("agentctl unavailable")}, logger: newTestLogger()} + if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1"}); err == nil { + t.Fatal("MaterializeWorkspaceSources succeeded despite remote materialization failure") + } +} + +func TestWorkspaceSourceMaterializer_RemoteRejectsLocalOnlyAdditionalRepositoryBeforeCall(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + seedWorkspaceSourceTask(t, repo, t.TempDir()) + seedRemoteMaterializerRepositories(t, repo) + if err := repo.UpdateRepository(ctx, &models.Repository{ID: "repo-added", WorkspaceID: "ws-1", Name: "added", LocalPath: "/host/only/repository"}); err != nil { + t.Fatal(err) + } + env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1") + if err != nil { + t.Fatal(err) + } + env.ExecutorType = string(models.ExecutorTypeSSH) + if err := repo.UpdateTaskEnvironment(ctx, env); err != nil { + t.Fatal(err) + } + remote := &remoteWorkspaceMaterializerStub{} + materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, filepath.Join(t.TempDir(), "task-1")), remoteMaterializer: remote, logger: newTestLogger()} + if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Repository: &models.TaskRepository{RepositoryID: "repo-added", BaseBranch: "main"}}}}); !errors.Is(err, ErrRemoteRepositoryLocatorUnavailable) { + t.Fatalf("error=%v; want local-only remote locator error", err) + } + if len(remote.calls) != 0 { + t.Fatalf("remote materialization called for local-only repository: %+v", remote.calls) + } +} + +func TestWorkspaceSourceMaterializer_LocalFolderCreatesLiveTaskEntryAndRebindsSessions(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + tasksBase := filepath.Join(t.TempDir(), "tasks") + mgr := newMaterializerWorktreeMgr(t, filepath.Join(tasksBase, "task-1")) + source := filepath.Join(t.TempDir(), "notes") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "note.txt"), []byte("before"), 0o644); err != nil { + t.Fatal(err) + } + seedWorkspaceSourceTask(t, repo, source) + env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1") + if err != nil { + t.Fatal(err) + } + env.ExecutorType = "local_pc" + if err := repo.UpdateTaskEnvironment(ctx, env); err != nil { + t.Fatal(err) + } + rescan := &workspaceSourceRescanStub{} + materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, rescanner: rescan, logger: newTestLogger()} + + result, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: source}}}}) + if err != nil { + t.Fatalf("MaterializeWorkspaceSources: %v", err) + } + root := filepath.Join(tasksBase, "task-1") + if got, err := os.ReadFile(filepath.Join(root, "notes", "note.txt")); err != nil || string(got) != "before" { + t.Fatalf("live entry = %q, %v", got, err) + } + if err := os.WriteFile(filepath.Join(source, "note.txt"), []byte("after"), 0o644); err != nil { + t.Fatal(err) + } + if got, _ := os.ReadFile(filepath.Join(root, "notes", "note.txt")); string(got) != "after" { + t.Fatalf("entry is not live: %q", got) + } + env, err = repo.GetTaskEnvironmentByTaskID(ctx, "task-1") + if err != nil || env.WorkspacePath != root { + t.Fatalf("workspace path = %q, %v; want %q", env.WorkspacePath, err, root) + } + if len(rescan.calls) != 1 || rescan.calls[0].workDir != root { + t.Fatalf("rescan calls = %+v", rescan.calls) + } + if result.WorkspacePath != root || len(result.SessionIDs) != 1 || result.SessionIDs[0] != "session-1" { + t.Fatalf("materialization result = %#v", result) + } +} + +func TestWorkspaceSourceMaterializer_LocalFolderUsesPersistedBatchSourceOnlyOnce(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + tasksBase := filepath.Join(t.TempDir(), "tasks") + mgr := newMaterializerWorktreeMgr(t, filepath.Join(tasksBase, "task-1")) + source := filepath.Join(t.TempDir(), "notes") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(source, "note.txt"), []byte("persisted"), 0o644); err != nil { + t.Fatal(err) + } + seedWorkspaceSourceTask(t, repo, source) + env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1") + if err != nil { + t.Fatal(err) + } + env.ExecutorType = "local_pc" + if err := repo.UpdateTaskEnvironment(ctx, env); err != nil { + t.Fatal(err) + } + batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{ + Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: source}, + }}} + if err := repo.CreateWorkspaceSourceBatch(ctx, batch); err != nil { + t.Fatal(err) + } + + materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, logger: newTestLogger()} + if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", batch); err != nil { + t.Fatalf("MaterializeWorkspaceSources after persistence: %v", err) + } + if got, err := os.ReadFile(filepath.Join(tasksBase, "task-1", "notes", "note.txt")); err != nil || string(got) != "persisted" { + t.Fatalf("persisted folder entry = %q, %v", got, err) + } +} + +func TestWorkspaceSourceMaterializer_LocalPromotionPreservesPrimaryRepository(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + tasksBase := filepath.Join(t.TempDir(), "tasks") + mgr := newMaterializerWorktreeMgr(t, filepath.Join(tasksBase, "task-1")) + primary := filepath.Join(t.TempDir(), "primary") + folder := filepath.Join(t.TempDir(), "notes") + for _, path := range []string{primary, folder} { + if err := os.MkdirAll(path, 0o755); err != nil { + t.Fatal(err) + } + } + if err := os.WriteFile(filepath.Join(primary, "repo.txt"), []byte("repo"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(folder, "note.txt"), []byte("note"), 0o644); err != nil { + t.Fatal(err) + } + seedWorkspaceSourceTask(t, repo, primary) + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-1", WorkspaceID: "ws-1", Name: "primary", LocalPath: primary, DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-primary", TaskID: "task-1", RepositoryID: "repo-1", BaseBranch: "main", Metadata: map[string]interface{}{}}); err != nil { + t.Fatal(err) + } + rescan := &workspaceSourceRescanStub{} + m := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, rescanner: rescan, logger: newTestLogger()} + batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: folder}}}} + if _, err := m.MaterializeWorkspaceSources(ctx, "task-1", batch); err != nil { + t.Fatal(err) + } + root := filepath.Join(tasksBase, "task-1") + for _, file := range []string{"primary/repo.txt", "notes/note.txt"} { + if _, err := os.ReadFile(filepath.Join(root, file)); err != nil { + t.Fatalf("missing promoted source %s: %v", file, err) + } + } + env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1") + if err != nil || env.WorkspacePath != root { + t.Fatalf("workspace path = %q, %v", env.WorkspacePath, err) + } + if len(rescan.calls) != 1 || rescan.calls[0].workDir != root { + t.Fatalf("rescan calls = %+v", rescan.calls) + } +} + +func TestWorkspaceSourceMaterializer_LocalClonesProviderRepositoryBeforeLinking(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + tasksBase := filepath.Join(t.TempDir(), "tasks") + mgr := newMaterializerWorktreeMgr(t, filepath.Join(tasksBase, "task-1")) + seedWorkspaceSourceTask(t, repo, t.TempDir()) + clonePath := filepath.Join(t.TempDir(), "cloned") + if err := os.MkdirAll(clonePath, 0o755); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-remote", WorkspaceID: "ws-1", Name: "remote", Provider: "github", ProviderOwner: "acme", ProviderName: "remote"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "task-repo-remote", TaskID: "task-1", RepositoryID: "repo-remote", BaseBranch: "main", Position: 0, Metadata: map[string]interface{}{}}); err != nil { + t.Fatal(err) + } + cloner := &hostRepositoryClonerStub{path: clonePath} + materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, hostCloner: cloner, logger: newTestLogger()} + if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", &models.WorkspaceSourceBatch{TaskID: "task-1"}); err != nil { + t.Fatalf("MaterializeWorkspaceSources: %v", err) + } + if len(cloner.calls) != 1 || cloner.calls[0].ID != "repo-remote" { + t.Fatalf("clone calls = %+v", cloner.calls) + } + if got, err := os.Readlink(filepath.Join(tasksBase, "task-1", "remote")); err != nil || got != clonePath { + t.Fatalf("repository link = %q, %v; want %q", got, err, clonePath) + } +} + +func TestWorkspaceSourceMaterializer_WorktreeAddsLiveFolderAtTaskRoot(t *testing.T) { + ctx := context.Background() + repoPath, taskRoot, primary := setupMaterializerScenario(t) + repo := newMaterializerRepo(t) + seedMaterializerTask(t, ctx, repo, repoPath, taskRoot, primary) + folder := filepath.Join(t.TempDir(), "notes") + if err := os.MkdirAll(folder, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(folder, "note.txt"), []byte("live"), 0o644); err != nil { + t.Fatal(err) + } + rescan := &workspaceSourceRescanStub{} + m := &workspaceSourceMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, taskRoot), rescanner: rescan, logger: newTestLogger()} + batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: folder}}}} + if _, err := m.MaterializeWorkspaceSources(ctx, "task-1", batch); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(taskRoot, "notes", "note.txt")); err != nil || string(got) != "live" { + t.Fatalf("worktree folder link = %q, %v", got, err) + } + if len(rescan.calls) != 1 || rescan.calls[0].workDir != taskRoot { + t.Fatalf("rescan calls = %+v", rescan.calls) + } +} + +func TestWorkspaceSourceMaterializer_RollsBackLinkAndPathWhenAdoptionFails(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + tasksBase := filepath.Join(t.TempDir(), "tasks") + mgr := newMaterializerWorktreeMgr(t, filepath.Join(tasksBase, "task-1")) + source := filepath.Join(t.TempDir(), "notes") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + seedWorkspaceSourceTask(t, repo, source) + materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, rescanner: &workspaceSourceRescanStub{err: os.ErrPermission}, logger: newTestLogger()} + batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: source}}}} + if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", batch); err == nil { + t.Fatal("MaterializeWorkspaceSources succeeded despite failed adoption") + } + if _, err := os.Lstat(filepath.Join(tasksBase, "task-1", "notes")); !os.IsNotExist(err) { + t.Fatalf("created link remains after rollback: %v", err) + } + env, err := repo.GetTaskEnvironmentByTaskID(ctx, "task-1") + if err != nil || env.WorkspacePath != source { + t.Fatalf("workspace path = %q, %v; want original %q", env.WorkspacePath, err, source) + } +} + +func TestWorkspaceSourceMaterializer_RebindFailureRestoresEarlierSessionsInReverseOrder(t *testing.T) { + ctx := context.Background() + repo := newMaterializerRepo(t) + tasksBase := filepath.Join(t.TempDir(), "tasks") + mgr := newMaterializerWorktreeMgr(t, filepath.Join(tasksBase, "task-1")) + source := filepath.Join(t.TempDir(), "notes") + if err := os.MkdirAll(source, 0o755); err != nil { + t.Fatal(err) + } + seedWorkspaceSourceTask(t, repo, source) + now := time.Now().UTC() + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-2", TaskID: "task-1", State: models.TaskSessionStateWaitingForInput, StartedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + rescan := &orderedWorkspaceRebindStub{failOnCall: 2} + materializer := &workspaceSourceMaterializer{repo: repo, worktreeMgr: mgr, rescanner: rescan, logger: newTestLogger()} + + batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{DisplayName: "notes", LocalPath: source}}}} + if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", batch); err == nil { + t.Fatal("MaterializeWorkspaceSources succeeded despite second rebind failure") + } + root := filepath.Join(tasksBase, "task-1") + if len(rescan.calls) != 3 { + t.Fatalf("rebind calls = %+v, want two adoptions and one restore", rescan.calls) + } + if rescan.calls[0].workDir != root || rescan.calls[1].workDir != root || rescan.calls[0].sessionID == rescan.calls[1].sessionID { + t.Fatalf("adoption calls = %+v, want distinct sessions at %q", rescan.calls[:2], root) + } + if !reflect.DeepEqual(rescan.calls[2], stubRescanCall{sessionID: rescan.calls[0].sessionID, workDir: source}) { + t.Fatalf("restore call = %+v, want reverse restoration to %q", rescan.calls[2], source) + } + if len(rescan.roots) != 3 || !reflect.DeepEqual(rescan.roots[0], []string{source}) || !reflect.DeepEqual(rescan.roots[1], []string{source}) || len(rescan.roots[2]) != 0 { + t.Fatalf("authoritative roots = %+v, want post-state roots followed by prior roots", rescan.roots) + } +} + +func TestWorkspaceSourceMaterializer_WorktreeLateFolderFailureEmitsNoMaterializedEvent(t *testing.T) { + ctx := context.Background() + repoPath, taskRoot, primaryPath := setupMaterializerScenario(t) + repo := newMaterializerRepo(t) + seedMaterializerTask(t, ctx, repo, repoPath, taskRoot, primaryPath) + branch := &models.TaskRepository{ID: "tr-branch-2", TaskID: "task-1", RepositoryID: "repo-1", BaseBranch: "main", CheckoutBranch: "branch-2", Position: 1, Metadata: map[string]interface{}{}} + if err := repo.CreateTaskRepository(ctx, branch); err != nil { + t.Fatal(err) + } + rescanner := &stubRescanner{} + materializer := &workspaceSourceMaterializer{ + repo: repo, + worktreeMgr: newMaterializerWorktreeMgr(t, taskRoot), + branches: &branchMaterializer{repo: repo, worktreeMgr: newMaterializerWorktreeMgr(t, taskRoot), rescanner: rescanner, logger: newTestLogger()}, + logger: newTestLogger(), + } + batch := &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{ + {Repository: branch}, + {Folder: &models.TaskWorkspaceFolder{DisplayName: "duplicate", LocalPath: t.TempDir()}}, + {Folder: &models.TaskWorkspaceFolder{DisplayName: "duplicate", LocalPath: t.TempDir()}}, + }} + if _, err := materializer.MaterializeWorkspaceSources(ctx, "task-1", batch); err == nil { + t.Fatal("MaterializeWorkspaceSources succeeded despite late folder failure") + } + if len(rescanner.notifyCalls) != 0 { + t.Fatalf("materialized events = %+v, want none", rescanner.notifyCalls) + } +} + +type workspaceSourceRescanStub struct { + calls []stubRescanCall + err error +} + +type orderedWorkspaceRebindStub struct { + calls []stubRescanCall + roots [][]string + failOnCall int +} + +func (s *orderedWorkspaceRebindStub) RebindWorkspaceForSession(_ context.Context, id, dir string, sourceRoots ...[]string) error { + s.calls = append(s.calls, stubRescanCall{sessionID: id, workDir: dir}) + if len(sourceRoots) == 1 { + s.roots = append(s.roots, append([]string(nil), sourceRoots[0]...)) + } + if len(s.calls) == s.failOnCall { + return errors.New("rebind failed") + } + return nil +} + +func (s *workspaceSourceRescanStub) RebindWorkspaceForSession(_ context.Context, id, dir string, _ ...[]string) error { + s.calls = append(s.calls, stubRescanCall{sessionID: id, workDir: dir}) + return s.err +} + +func seedWorkspaceSourceTask(t *testing.T, repo *sqliterepo.Repository, source string) { + t.Helper() + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "WS"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-1", WorkspaceID: "ws-1", Name: "WF"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", Title: "Sources", Priority: "medium"}); err != nil { + t.Fatal(err) + } + now := time.Now().UTC() + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-1", TaskID: "task-1", State: models.TaskSessionStateWaitingForInput, StartedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ID: "env-1", TaskID: "task-1", ExecutorType: string(models.ExecutorTypeLocal), Status: models.TaskEnvironmentStatusReady, TaskDirName: "task-1", WorkspacePath: source, CreatedAt: now, UpdatedAt: now}); err != nil { + t.Fatal(err) + } +} + +func seedRemoteMaterializerRepositories(t *testing.T, repo *sqliterepo.Repository) { + t.Helper() + ctx := context.Background() + for _, repository := range []*models.Repository{ + {ID: "repo-primary", WorkspaceID: "ws-1", Name: "primary", RemoteURL: "https://github.com/acme/primary.git"}, + {ID: "repo-added", WorkspaceID: "ws-1", Name: "added", RemoteURL: "https://github.com/acme/added.git"}, + } { + if err := repo.CreateRepository(ctx, repository); err != nil { + t.Fatal(err) + } + } + for _, taskRepository := range []*models.TaskRepository{ + {ID: "task-repo-primary", TaskID: "task-1", RepositoryID: "repo-primary", BaseBranch: "main", Position: 0, Metadata: map[string]interface{}{}}, + {ID: "task-repo-added", TaskID: "task-1", RepositoryID: "repo-added", BaseBranch: "main", Position: 1, Metadata: map[string]interface{}{}}, + } { + if err := repo.CreateTaskRepository(ctx, taskRepository); err != nil { + t.Fatal(err) + } + } +} diff --git a/apps/backend/internal/backendapp/worktree.go b/apps/backend/internal/backendapp/worktree.go index 3ca83c0a3d..1773500ccb 100644 --- a/apps/backend/internal/backendapp/worktree.go +++ b/apps/backend/internal/backendapp/worktree.go @@ -49,9 +49,11 @@ type bootMsgAdapter struct { } func (a *bootMsgAdapter) CreateMessage(ctx context.Context, req *lifecycle.BootMessageRequest) (*models.Message, error) { + isResuming, _ := req.Metadata["is_resuming"].(bool) return a.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ TaskSessionID: req.TaskSessionID, TaskID: req.TaskID, + CompletedTurn: isResuming, Content: req.Content, AuthorType: req.AuthorType, Type: req.Type, diff --git a/apps/backend/internal/backendapp/worktree_test.go b/apps/backend/internal/backendapp/worktree_test.go index 85e37814c4..ed027995fc 100644 --- a/apps/backend/internal/backendapp/worktree_test.go +++ b/apps/backend/internal/backendapp/worktree_test.go @@ -5,8 +5,74 @@ import ( "os" "os/exec" "testing" + + "github.com/kandev/kandev/internal/agent/runtime/lifecycle" + "github.com/kandev/kandev/internal/task/models" ) +func TestBootMessageAdapterResumedMessageDoesNotLeaveActiveTurn(t *testing.T) { + harness := newBootStateTestHarness(t) + ctx := context.Background() + + if err := harness.taskRepo.CreateWorkspace(ctx, &models.Workspace{ + ID: "workspace-1", + Name: "Workspace", + }); err != nil { + t.Fatalf("CreateWorkspace: %v", err) + } + if err := harness.taskRepo.CreateTask(ctx, &models.Task{ + ID: "task-1", + WorkspaceID: "workspace-1", + Title: "Task", + Priority: "medium", + }); err != nil { + t.Fatalf("CreateTask: %v", err) + } + if err := harness.taskRepo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-1", + TaskID: "task-1", + State: models.TaskSessionStateWaitingForInput, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + message, err := (&bootMsgAdapter{svc: harness.taskSvc}).CreateMessage( + ctx, + &lifecycle.BootMessageRequest{ + TaskSessionID: "session-1", + TaskID: "task-1", + AuthorType: "agent", + Type: "script_execution", + Metadata: map[string]interface{}{ + "script_type": "agent_boot", + "is_resuming": true, + }, + }, + ) + if err != nil { + t.Fatalf("CreateMessage: %v", err) + } + if message == nil { + t.Fatal("CreateMessage returned nil") + } + + activeTurn, err := harness.taskSvc.GetActiveTurn(ctx, "session-1") + if err != nil { + t.Fatalf("GetActiveTurn: %v", err) + } + if activeTurn != nil { + t.Fatalf("active turn = %q, want none", activeTurn.ID) + } + + turn, err := harness.taskSvc.GetTurn(ctx, message.TurnID) + if err != nil { + t.Fatalf("GetTurn: %v", err) + } + if turn.CompletedAt == nil { + t.Fatalf("resume message turn %q is not completed", turn.ID) + } +} + // TestDetectBranchRemote_ReturnsConfiguredUpstream covers the happy path: a // branch with an explicit `branch..remote` config returns that remote // (covers fork-workflow repos whose primary remote is named "upstream", diff --git a/apps/backend/internal/events/types.go b/apps/backend/internal/events/types.go index 24a4e58caf..6b9240b6c3 100644 --- a/apps/backend/internal/events/types.go +++ b/apps/backend/internal/events/types.go @@ -3,11 +3,12 @@ package events // Event types for tasks const ( - TaskCreated = "task.created" - TaskUpdated = "task.updated" - TaskStateChanged = "task.state_changed" - TaskDeleted = "task.deleted" - TaskMoved = "task.moved" // Manual step change via MoveTask + TaskCreated = "task.created" + TaskUpdated = "task.updated" + TaskStateChanged = "task.state_changed" + TaskDeleted = "task.deleted" + TaskMoved = "task.moved" // Manual step change via MoveTask + SessionWorkspaceSourcesUpdated = "session.workspace_sources.updated" ) // Event types for office task tree controls. diff --git a/apps/backend/internal/gateway/websocket/task_notifications.go b/apps/backend/internal/gateway/websocket/task_notifications.go index 640b8d5831..7135dd30fd 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications.go +++ b/apps/backend/internal/gateway/websocket/task_notifications.go @@ -39,6 +39,7 @@ func RegisterTaskNotifications(ctx context.Context, eventBus bus.EventBus, hub * b.subscribe(eventBus, events.AgentProfileDeleted, ws.ActionAgentProfileDeleted) b.subscribe(eventBus, events.TaskCreated, ws.ActionTaskCreated) b.subscribe(eventBus, events.TaskUpdated, ws.ActionTaskUpdated) + b.subscribe(eventBus, events.SessionWorkspaceSourcesUpdated, ws.ActionSessionWorkspaceSourcesUpdated) b.subscribe(eventBus, events.TaskDeleted, ws.ActionTaskDeleted) b.subscribe(eventBus, events.TaskStateChanged, ws.ActionTaskStateChanged) b.subscribe(eventBus, events.TaskPlanCreated, ws.ActionTaskPlanCreated) @@ -157,6 +158,11 @@ func (b *TaskEventBroadcaster) subscribe(eventBus bus.EventBus, subject, action b.hub.BroadcastToSession(sessionID, msg) return nil } + case ws.ActionSessionWorkspaceSourcesUpdated: + if sessionID != "" { + b.hub.BroadcastToSession(sessionID, msg) + return nil + } case ws.ActionMessageQueueStatusChanged: if sessionID != "" { b.hub.BroadcastToSession(sessionID, msg) diff --git a/apps/backend/internal/gateway/websocket/task_notifications_test.go b/apps/backend/internal/gateway/websocket/task_notifications_test.go index 6f4b753bc8..32899a00c6 100644 --- a/apps/backend/internal/gateway/websocket/task_notifications_test.go +++ b/apps/backend/internal/gateway/websocket/task_notifications_test.go @@ -39,7 +39,7 @@ func TestTaskEventBroadcaster_NoDuplicateSubscriptions(t *testing.T) { // // Update this number when adding or removing event subscriptions in // RegisterTaskNotifications — it is intentionally exact. - const wantSubscriptions = 56 + const wantSubscriptions = 57 if got := len(b.subscriptions); got != wantSubscriptions { t.Errorf("RegisterTaskNotifications created %d subscriptions, want %d — "+ "did an event get subscribed twice?", got, wantSubscriptions) diff --git a/apps/backend/internal/mcp/handlers/handlers.go b/apps/backend/internal/mcp/handlers/handlers.go index 5bb2db66ba..37b134165c 100644 --- a/apps/backend/internal/mcp/handlers/handlers.go +++ b/apps/backend/internal/mcp/handlers/handlers.go @@ -25,6 +25,7 @@ import ( "github.com/kandev/kandev/internal/sysprompt" "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" + taskrepository "github.com/kandev/kandev/internal/task/repository" taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" "github.com/kandev/kandev/internal/task/service" usermodels "github.com/kandev/kandev/internal/user/models" @@ -43,6 +44,21 @@ type ClarificationService interface { CancelRequest(pendingID string) bool } +type workspaceSourceJSON struct { + Kind string `json:"kind"` + RepositoryID string `json:"repository_id"` + LocalPath string `json:"local_path"` + GitHubURL string `json:"github_url"` + RemoteURL string `json:"remote_url"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch"` + DisplayName string `json:"display_name"` +} + // SessionCanceller detaches in-memory clarification waiters while keeping DB // messages pending. Used by the MCP-timeout handler. type SessionCanceller interface { @@ -281,6 +297,7 @@ func (h *Handlers) RegisterHandlers(d *ws.Dispatcher) { d.RegisterFunc(ws.ActionMCPCreateTask, h.handleCreateTask) d.RegisterFunc(ws.ActionMCPUpdateTask, h.handleUpdateTask) d.RegisterFunc(ws.ActionMCPAddBranchToTask, h.handleAddBranchToTask) + d.RegisterFunc(ws.ActionMCPAddWorkspaceSources, h.handleAddWorkspaceSources) d.RegisterFunc(ws.ActionMCPUpdateRepositoryBaseBranch, h.handleUpdateRepositoryBaseBranch) d.RegisterFunc(ws.ActionMCPStepComplete, h.handleStepComplete) d.RegisterFunc(ws.ActionMCPMessageTask, h.handleMessageTask) @@ -1326,6 +1343,84 @@ func (h *Handlers) handleAddBranchToTask(ctx context.Context, msg *ws.Message) ( }) } +// handleAddWorkspaceSources forwards the documented discriminated source +// union to the shared attachment service. It deliberately rejects fields from +// the other variant so callers cannot get a silently ambiguous attachment. +func (h *Handlers) handleAddWorkspaceSources(ctx context.Context, msg *ws.Message) (*ws.Message, error) { + var req struct { + TaskID string `json:"task_id"` + Sources []json.RawMessage `json:"sources"` + } + if err := json.Unmarshal(msg.Payload, &req); err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeBadRequest, "Invalid payload: "+err.Error(), nil) + } + if req.TaskID == "" || len(req.Sources) == 0 { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, "task_id and at least one source are required", nil) + } + sources, err := parseWorkspaceSources(req.Sources) + if err != nil { + return ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, err.Error(), nil) + } + result, err := h.taskSvc.AttachWorkspaceSources(ctx, service.AttachWorkspaceSourcesRequest{TaskID: req.TaskID, Sources: sources}) + if err != nil { + return ws.NewError(msg.ID, msg.Action, classifyWorkspaceSourceError(err), "Failed to attach workspace sources: "+err.Error(), nil) + } + return ws.NewResponse(msg.ID, msg.Action, map[string]interface{}{ + "task_id": result.Task.ID, "repositories": result.Task.Repositories, "workspace_folders": result.Task.WorkspaceFolders, "workspace_path": result.WorkspacePath, "session_ids": result.SessionIDs, + }) +} + +func parseWorkspaceSources(raw []json.RawMessage) ([]service.WorkspaceSourceInput, error) { + sources := make([]service.WorkspaceSourceInput, 0, len(raw)) + for _, item := range raw { + var fields map[string]json.RawMessage + if err := json.Unmarshal(item, &fields); err != nil { + return nil, fmt.Errorf("source must be an object") + } + var kind string + if err := json.Unmarshal(fields["kind"], &kind); err != nil { + return nil, fmt.Errorf("source kind is required") + } + allowed := map[string]bool{"kind": true, "local_path": true} + switch kind { + case string(service.WorkspaceSourceRepository): + for _, key := range []string{"repository_id", "remote_url", "github_url", "provider", "provider_repo_id", "provider_owner", "provider_name", "base_branch", "checkout_branch"} { + allowed[key] = true + } + case string(service.WorkspaceSourceFolder): + allowed["display_name"] = true + default: + return nil, fmt.Errorf("unsupported workspace source kind %q", kind) + } + for key := range fields { + if !allowed[key] { + return nil, fmt.Errorf("field %q is not allowed for %s source", key, kind) + } + } + var source workspaceSourceJSON + if err := json.Unmarshal(item, &source); err != nil { + return nil, err + } + sources = append(sources, service.WorkspaceSourceInput{Kind: service.WorkspaceSourceKind(source.Kind), RepositoryID: source.RepositoryID, LocalPath: source.LocalPath, GitHubURL: source.GitHubURL, RemoteURL: source.RemoteURL, Provider: source.Provider, ProviderRepoID: source.ProviderRepoID, ProviderOwner: source.ProviderOwner, ProviderName: source.ProviderName, BaseBranch: source.BaseBranch, CheckoutBranch: source.CheckoutBranch, DisplayName: source.DisplayName}) + } + return sources, nil +} + +func classifyWorkspaceSourceError(err error) string { + switch { + case errors.Is(err, service.ErrInvalidWorkspaceSource): + return ws.ErrorCodeValidation + case errors.Is(err, taskrepo.ErrTaskNotFound), errors.Is(err, taskrepository.ErrRepositoryNotFound), errors.Is(err, service.ErrTaskRepositoryNotFound): + return ws.ErrorCodeNotFound + case errors.Is(err, service.ErrWorkspaceSourceConflict), errors.Is(err, service.ErrWorkspaceSourceActive): + return ws.ErrorCodeConflict + case errors.Is(err, service.ErrUnsupportedWorkspaceSource), errors.Is(err, service.ErrWorkspaceSourceMaterialize): + return ws.ErrorCodeValidation + default: + return ws.ErrorCodeInternalError + } +} + // handleUpdateRepositoryBaseBranch updates the base_branch on a single // task_repositories row. The agentctl side is notified live via the service's // AgentBaseBranchPusher hook so the changes-panel diff stats reflect the new diff --git a/apps/backend/internal/mcp/handlers/handlers_test.go b/apps/backend/internal/mcp/handlers/handlers_test.go index 5715475d78..0e510e3d8a 100644 --- a/apps/backend/internal/mcp/handlers/handlers_test.go +++ b/apps/backend/internal/mcp/handlers/handlers_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "path/filepath" "sync" "testing" @@ -190,6 +191,22 @@ func TestHandleAddBranchToTask_RejectsMultipleLocators(t *testing.T) { assertWSError(t, resp, ws.ErrorCodeValidation) } +func TestHandleAddWorkspaceSourcesRejectsUnknownKind(t *testing.T) { + h := &Handlers{} + msg := makeWSMessage(t, ws.ActionMCPAddWorkspaceSources, map[string]interface{}{ + "task_id": "task-1", + "sources": []interface{}{map[string]interface{}{"kind": "unknown"}}, + }) + + resp, err := h.handleAddWorkspaceSources(context.Background(), msg) + require.NoError(t, err) + assertWSError(t, resp, ws.ErrorCodeValidation) +} + +func TestClassifyWorkspaceSourceErrorMapsRepositoryNotFound(t *testing.T) { + assert.Equal(t, ws.ErrorCodeNotFound, classifyWorkspaceSourceError(fmt.Errorf("wrapped: %w", repository.ErrRepositoryNotFound))) +} + func TestHandleCreateTask_MissingTitle(t *testing.T) { h := &Handlers{} msg := makeWSMessage(t, ws.ActionMCPCreateTask, map[string]interface{}{ diff --git a/apps/backend/internal/mcp/server/handlers_test.go b/apps/backend/internal/mcp/server/handlers_test.go index 2d5b427fc8..0b4fc34f0a 100644 --- a/apps/backend/internal/mcp/server/handlers_test.go +++ b/apps/backend/internal/mcp/server/handlers_test.go @@ -402,6 +402,7 @@ func TestAddBranchToTask_ForwardsRepositoryURL(t *testing.T) { s := newTaskModeServer(t, backend, "task-current") result := callTool(t, s, "add_branch_to_task_kandev", map[string]interface{}{ + "task_id": "task-current", "repository_url": "https://github.com/acme/widgets", "checkout_branch": "feature/x", }) @@ -418,6 +419,52 @@ func TestAddBranchToTask_ForwardsRepositoryURL(t *testing.T) { assert.Equal(t, "", payload["repository_id"]) } +func TestAddBranchToTask_RejectsAnotherTask(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "add_branch_to_task_kandev", map[string]interface{}{ + "task_id": "task-other", + "repository_id": "repo-1", + "checkout_branch": "feature/x", + }) + + assert.True(t, result.IsError) + assert.Empty(t, backend.lastAction) +} + +func TestAddWorkspaceSourcesDefaultsTaskAndForwardsMixedSources(t *testing.T) { + backend := &testBackend{response: map[string]interface{}{"task_id": "task-current"}} + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "add_workspace_sources_kandev", map[string]interface{}{ + "sources": []interface{}{ + map[string]interface{}{"kind": "repository", "repository_id": "repo-1", "base_branch": "main"}, + map[string]interface{}{"kind": "folder", "local_path": "/tmp/docs", "display_name": "docs"}, + }, + }) + + assert.False(t, result.IsError) + assert.Equal(t, ws.ActionMCPAddWorkspaceSources, backend.lastAction) + payload, ok := backend.lastPayload.(map[string]interface{}) + require.True(t, ok) + assert.Equal(t, "task-current", payload["task_id"]) + assert.Len(t, payload["sources"], 2) +} + +func TestAddWorkspaceSourcesRejectsAnotherTask(t *testing.T) { + backend := &testBackend{} + s := newTaskModeServer(t, backend, "task-current") + + result := callTool(t, s, "add_workspace_sources_kandev", map[string]interface{}{ + "task_id": "task-other", + "sources": []interface{}{map[string]interface{}{"kind": "folder", "local_path": "/tmp/docs"}}, + }) + + assert.True(t, result.IsError) + assert.Empty(t, backend.lastAction) +} + // TestAddBranchToTask_ForwardsLocalPath verifies local_path is plumbed through // to the WS payload so the backend can find-or-create the repo in the task's // workspace. diff --git a/apps/backend/internal/mcp/server/server.go b/apps/backend/internal/mcp/server/server.go index e6fa4bc0ad..752ebc15fc 100644 --- a/apps/backend/internal/mcp/server/server.go +++ b/apps/backend/internal/mcp/server/server.go @@ -376,8 +376,9 @@ func (s *Server) registerTools() { // Task-mode only: requires a live session to attach the new // (repository, branch) to. External mode has no such context. s.registerAddBranchToTaskTool() + s.registerAddWorkspaceSourcesTool() s.registerUpdateRepositoryBaseBranchTool() - count += 2 + count += 3 // Task-mode only: ADR 0015 explicit step-completion signal. The // tool targets the current (task, session, step) the MCP server // was bound to, so it has no meaningful semantics outside a task @@ -641,12 +642,12 @@ Use this when the task should open more than one PR — same repo with different IMPORTANT: - Only works on tasks running the WORKTREE executor. Tasks on docker / sprites / local-pc / SSH / remote_docker reject this tool because sibling worktrees are a git-worktree-specific layout — other executors bind one workspace path per task and the new branch would silently never appear on disk. -- task_id defaults to your CURRENT task when omitted — pass it explicitly only to target a different task. +- task_id defaults to your CURRENT task when omitted and must match that task when provided. - Repository selection (matches create_task_kandev): pass exactly one of repository_id / repository_url / local_path. For single-repo tasks all three are optional — the service auto-resolves to the task's only repository. Multi-repo tasks must identify the target repo explicitly. - checkout_branch is the branch the new worktree will check out. Leave empty to create a fresh feature branch from base_branch. - base_branch is optional; defaults to the repository's default_branch. - The (task_id, repository_id, base_branch, checkout_branch) tuple must be unique on the task — re-adding the same combination is an error, not a no-op.`), - mcp.WithString("task_id", mcp.Description("The task to attach the branch to. Defaults to the current task when omitted.")), + mcp.WithString("task_id", mcp.Description("The current task. Defaults to the current task when omitted.")), mcp.WithString("repository_id", mcp.Description("Repository UUID. Optional for single-repo tasks (auto-resolved). Required for multi-repo tasks unless repository_url or local_path is supplied.")), mcp.WithString("repository_url", mcp.Description("GitHub repository URL (e.g. 'https://github.com/owner/repo'). Alternative to repository_id when you don't have the UUID handy. The repository is found-or-created in the task's workspace.")), mcp.WithString("local_path", mcp.Description("Local repository folder path (e.g. '/Users/me/projects/myrepo'). Alternative to repository_id for the local worktree flow. The repository is found-or-created in the task's workspace.")), @@ -657,6 +658,56 @@ IMPORTANT: ) } +// registerAddWorkspaceSourcesTool attaches a mixed repository/folder batch to +// the current task. Runtime adoption remains a backend concern; this tool only +// forwards the documented union unchanged to the shared mutation boundary. +func (s *Server) registerAddWorkspaceSourcesTool() { + s.mcpServer.AddTool( + mcp.NewTool("add_workspace_sources_kandev", + mcp.WithDescription("Attach repository and folder workspace sources to an idle task. task_id defaults to the current task."), + mcp.WithString(mcpKeyTaskID, mcp.Description("Task to update. Defaults to the current task.")), + mcp.WithArray("sources", mcp.Required(), mcp.MinItems(1), + mcp.Description("Ordered source objects. Each has kind repository or folder and the documented fields for that kind."), + mcp.Items(map[string]any{"type": "object"}), + ), + ), + s.wrapHandler("add_workspace_sources_kandev", s.addWorkspaceSourcesHandler()), + ) +} + +func (s *Server) addWorkspaceSourcesHandler() server.ToolHandlerFunc { + return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { + taskID := req.GetString(mcpKeyTaskID, "") + if taskID == "" { + taskID = s.taskID + } + if taskID == "" { + return mcp.NewToolResultError("task_id is required (no current task context to default to)"), nil + } + // A task-mode MCP server is bound to one live task. Never let an agent + // use this mutation tool as a cross-task capability: the backend tunnel + // has no authenticated user principal to authorize arbitrary task IDs. + if s.taskID == "" || taskID != s.taskID { + return mcp.NewToolResultError("task_id is not available in this session"), nil + } + arguments, ok := req.Params.Arguments.(map[string]interface{}) + if !ok { + return mcp.NewToolResultError("sources is required"), nil + } + sources, ok := arguments["sources"] + if !ok { + return mcp.NewToolResultError("sources is required"), nil + } + payload := map[string]interface{}{mcpKeyTaskID: taskID, "sources": sources} + var result map[string]interface{} + if err := s.backend.RequestPayload(ctx, ws.ActionMCPAddWorkspaceSources, payload, &result); err != nil { + return mcp.NewToolResultError(err.Error()), nil + } + data, _ := json.MarshalIndent(result, "", " ") + return mcp.NewToolResultText(string(data)), nil + } +} + func (s *Server) addBranchToTaskHandler() server.ToolHandlerFunc { return func(ctx context.Context, req mcp.CallToolRequest) (*mcp.CallToolResult, error) { taskID := req.GetString(mcpKeyTaskID, "") @@ -666,6 +717,12 @@ func (s *Server) addBranchToTaskHandler() server.ToolHandlerFunc { if taskID == "" { return mcp.NewToolResultError("task_id is required (no current task context to default to)"), nil } + // A task-mode MCP server is bound to one live task. Never let an agent + // use this mutation tool as a cross-task capability: the backend tunnel + // has no authenticated user principal to authorize arbitrary task IDs. + if s.taskID == "" || taskID != s.taskID { + return mcp.NewToolResultError("task_id is not available in this session"), nil + } // Mutual-exclusion gate at the MCP tier so the error names the // agent-facing alias (repository_url) instead of the WS wire field // (github_url). The WS handler still re-validates for direct WS diff --git a/apps/backend/internal/mcp/server/server_test.go b/apps/backend/internal/mcp/server/server_test.go index ae44f5f904..db939eac02 100644 --- a/apps/backend/internal/mcp/server/server_test.go +++ b/apps/backend/internal/mcp/server/server_test.go @@ -215,14 +215,15 @@ func TestServerModeTask_ToolCount(t *testing.T) { s := New(backend, "test-session", "test-task", 10005, log, "", false, ModeTask) tools := getRegisteredToolNames(s) // 15 kanban (incl. delete + archive task + stop_task + spawn_session) + - // 1 add_branch_to_task + 1 update_repository_base_branch + + // 1 add_branch_to_task + 1 add_workspace_sources + 1 update_repository_base_branch + // 1 step_complete (ADR 0015) + 1 interaction + 4 plan + 3 walkthrough + // 1 related-tasks = 27. // Task-document tools (list/get/write) are office-only. assert.Contains(t, tools, "step_complete_kandev", "ADR 0015 explicit-completion signal must be registered in task mode") assert.Contains(t, tools, "show_walkthrough_kandev", "walkthrough tool must be registered in task mode") assert.Contains(t, tools, "spawn_session_kandev", "spawn_session must be registered in task mode") - assert.Equal(t, 27, len(tools)) + assert.Contains(t, tools, "add_workspace_sources_kandev") + assert.Equal(t, 28, len(tools)) } func TestServerStepCompleteTool_TaskOnlyAndDiscoverable(t *testing.T) { diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming.go b/apps/backend/internal/orchestrator/event_handlers_streaming.go index 00ac25bbc8..0a3abe14a1 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming.go @@ -202,9 +202,13 @@ func (s *Service) handleSessionStatusEvent(ctx context.Context, payload *lifecyc if payload.Data.SessionStatus == "resumed" { statusMsg = "Session resumed" } + turnID := s.currentTurnIDForSession(ctx, sessionID) + if turnID == "" { + return + } if err := s.messageCreator.CreateSessionMessage( ctx, taskID, statusMsg, sessionID, - string(v1.MessageTypeStatus), s.getActiveTurnID(sessionID), nil, false, + string(v1.MessageTypeStatus), turnID, nil, false, ); err != nil { s.logger.Error("failed to create session status message", zap.String("task_id", taskID), diff --git a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go index 91b7217df3..f2c5677790 100644 --- a/apps/backend/internal/orchestrator/event_handlers_streaming_test.go +++ b/apps/backend/internal/orchestrator/event_handlers_streaming_test.go @@ -147,6 +147,25 @@ type serviceBackedMessageCreator struct { svc *taskservice.Service } +func (m *serviceBackedMessageCreator) CreateSessionMessage( + ctx context.Context, + taskID, content, sessionID, messageType, turnID string, + metadata map[string]interface{}, + requestsInput bool, +) error { + _, err := m.svc.CreateMessage(ctx, &taskservice.CreateMessageRequest{ + TaskSessionID: sessionID, + TaskID: taskID, + TurnID: turnID, + Content: content, + AuthorType: "agent", + Type: messageType, + Metadata: metadata, + RequestsInput: requestsInput, + }) + return err +} + func (m *serviceBackedMessageCreator) UpdateToolCallMessage( ctx context.Context, taskID, toolCallID, parentToolCallID, status, result, agentSessionID, title, turnID, msgType string, @@ -827,6 +846,72 @@ func TestStatuslessToolUpdateDoesNotCreateTurn(t *testing.T) { require.Zero(t, openTurnCount(t, repo, "session1")) } +func TestResumedSessionStatusDoesNotCreateTurnForWaitingSession(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + session, err := repo.GetTaskSession(ctx, "session1") + require.NoError(t, err) + session.State = models.TaskSessionStateWaitingForInput + require.NoError(t, repo.UpdateTaskSession(ctx, session)) + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = newServiceBackedMessageCreator(repo) + + svc.handleSessionStatusEvent(ctx, &lifecycle.AgentStreamEventPayload{ + TaskID: "task1", + SessionID: "session1", + Data: &lifecycle.AgentStreamEventData{ + Type: "session_status", + SessionStatus: streams.SessionStatusResumed, + }, + }) + + require.Zero(t, openTurnCount(t, repo, "session1"), + "resume status metadata must not create a phantom active turn") + messages, err := repo.ListMessages(ctx, "session1") + require.NoError(t, err) + require.Empty(t, messages, "resume status without an active turn must not create a message") +} + +func TestResumedSessionStatusUsesExistingActiveTurn(t *testing.T) { + ctx := context.Background() + repo := setupTestRepo(t) + seedSession(t, repo, "task1", "session1", "step1") + + now := time.Now().UTC() + require.NoError(t, repo.CreateTurn(ctx, &models.Turn{ + ID: "turn1", + TaskSessionID: "session1", + TaskID: "task1", + StartedAt: now, + CreatedAt: now, + UpdatedAt: now, + })) + + svc := createTestService(repo, newMockStepGetter(), newMockTaskRepo()) + svc.turnService = &repoTurnService{repo: repo} + svc.messageCreator = newServiceBackedMessageCreator(repo) + + svc.handleSessionStatusEvent(ctx, &lifecycle.AgentStreamEventPayload{ + TaskID: "task1", + SessionID: "session1", + Data: &lifecycle.AgentStreamEventData{ + Type: "session_status", + SessionStatus: streams.SessionStatusResumed, + }, + }) + + require.Equal(t, 1, openTurnCount(t, repo, "session1")) + messages, err := repo.ListMessages(ctx, "session1") + require.NoError(t, err) + require.Len(t, messages, 1) + require.Equal(t, "turn1", messages[0].TurnID) + require.Equal(t, "Session resumed", messages[0].Content) +} + func TestToolUpdateFromCompletedExecutionDoesNotCreateMessage(t *testing.T) { ctx := context.Background() repo := setupTestRepo(t) diff --git a/apps/backend/internal/orchestrator/executor/executor.go b/apps/backend/internal/orchestrator/executor/executor.go index b9b6f413c0..1b8229c92d 100644 --- a/apps/backend/internal/orchestrator/executor/executor.go +++ b/apps/backend/internal/orchestrator/executor/executor.go @@ -44,6 +44,7 @@ type executorStore interface { // Task↔repo junction GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) + ListTaskWorkspaceFolders(ctx context.Context, taskID string) ([]*models.TaskWorkspaceFolder, error) // Session CreateTaskSession(ctx context.Context, session *models.TaskSession) error GetTaskSession(ctx context.Context, id string) (*models.TaskSession, error) @@ -372,7 +373,8 @@ type LaunchAgentRequest struct { // When non-empty it is the source of truth and the legacy single-repo // top-level fields above are populated from Repositories[0] for backwards // compatibility with code paths that have not yet been updated. - Repositories []RepoSpec + Repositories []RepoSpec + WorkspaceFolders []WorkspaceFolderSpec // RouteOverride carries a provider-routing override resolved by the // office scheduler. nil when routing is disabled or this is a kanban @@ -380,6 +382,8 @@ type LaunchAgentRequest struct { RouteOverride *RouteOverride } +type WorkspaceFolderSpec struct{ Name, LocalPath string } + // RepoSpec describes one repository for a multi-repo task launch from the // orchestrator. Mirrors lifecycle.RepoLaunchSpec; kept as a separate type so // the orchestrator package does not need to import lifecycle types into its diff --git a/apps/backend/internal/orchestrator/executor/executor_environment_test.go b/apps/backend/internal/orchestrator/executor/executor_environment_test.go index 97411dde2f..88b7b89d3d 100644 --- a/apps/backend/internal/orchestrator/executor/executor_environment_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_environment_test.go @@ -580,3 +580,55 @@ func TestApplyRepositoryConfig_PropagatesRepositoryID(t *testing.T) { t.Errorf("req.RepositoryID = %q, want %q", req.RepositoryID, "repo-abc") } } + +func TestApplyRepositoryConfig_DirectLocalSetsFilesystemSafeRepoName(t *testing.T) { + e := newEnvTestExecutor(t) + req := &LaunchAgentRequest{TaskID: "task-1"} + task := &v1.Task{ID: "task-1", WorkspaceID: "workspace-1", Title: "Some task"} + info := &repoInfo{ + RepositoryID: "repo-abc", + RepositoryPath: "/repos/widget", + Repository: &models.Repository{ + ID: "repo-abc", + Name: "acme/widget", + }, + } + execCfg := executorConfig{ExecutorID: "exec-1", ExecutorType: string(models.ExecutorTypeLocal)} + + if _, err := e.applyRepositoryConfig(req, task, info, execCfg, nil); err != nil { + t.Fatalf("applyRepositoryConfig: %v", err) + } + + if req.RepoName != "acme-widget" { + t.Errorf("req.RepoName = %q, want %q", req.RepoName, "acme-widget") + } + if req.TaskDirName != "" { + t.Errorf("req.TaskDirName = %q, want empty for direct-local launch", req.TaskDirName) + } +} + +func TestApplyRepositoryConfig_DirectLocalFallsBackToRepositoryIDForUnsafeRepoName(t *testing.T) { + e := newEnvTestExecutor(t) + req := &LaunchAgentRequest{TaskID: "task-1"} + task := &v1.Task{ID: "task-1", WorkspaceID: "workspace-1", Title: "Some task"} + info := &repoInfo{ + RepositoryID: "repo_123", + RepositoryPath: "/repos/widget", + Repository: &models.Repository{ + ID: "repo_123", + Name: "日本語!!!", + }, + } + execCfg := executorConfig{ExecutorID: "exec-1", ExecutorType: string(models.ExecutorTypeLocal)} + + if _, err := e.applyRepositoryConfig(req, task, info, execCfg, nil); err != nil { + t.Fatalf("applyRepositoryConfig: %v", err) + } + + if req.RepoName != "repo_123" { + t.Errorf("req.RepoName = %q, want stable repository ID fallback %q", req.RepoName, "repo_123") + } + if req.TaskDirName != "" { + t.Errorf("req.TaskDirName = %q, want empty for direct-local launch", req.TaskDirName) + } +} diff --git a/apps/backend/internal/orchestrator/executor/executor_execute.go b/apps/backend/internal/orchestrator/executor/executor_execute.go index 16197377a2..994b467eea 100644 --- a/apps/backend/internal/orchestrator/executor/executor_execute.go +++ b/apps/backend/internal/orchestrator/executor/executor_execute.go @@ -4,11 +4,9 @@ import ( "context" "errors" "fmt" - "hash/fnv" "maps" "os/exec" "regexp" - "sort" "strings" "sync" "time" @@ -1156,6 +1154,15 @@ func (e *Executor) buildLaunchAgentRequest(ctx context.Context, task *v1.Task, s req.Repositories[i].WorktreeBranchTicket = req.WorktreeBranchTicket } } + if folders, folderErr := e.repo.ListTaskWorkspaceFolders(ctx, task.ID); folderErr != nil { + return nil, execConfig, folderErr + } else { + for _, f := range folders { + if f != nil { + req.WorkspaceFolders = append(req.WorkspaceFolders, WorkspaceFolderSpec{Name: f.DisplayName, LocalPath: f.LocalPath}) + } + } + } // Activate config-mode MCP tools when config_mode is set in session metadata. if isConfigModeSession(session) { @@ -1239,116 +1246,29 @@ type repoBranchPlan struct { } func buildRepoBranchPlans(allRepos []*repoInfo) map[*repoInfo]repoBranchPlan { - groups := make(map[string][]*repoInfo, len(allRepos)) + plans := make(map[*repoInfo]repoBranchPlan, len(allRepos)) + inputs := make([]worktree.BranchIdentityInput, 0, len(allRepos)) + infos := make([]*repoInfo, 0, len(allRepos)) for _, info := range allRepos { if info == nil || info.RepositoryID == "" { continue } - groups[info.RepositoryID] = append(groups[info.RepositoryID], info) + defaultBranch := "" + if info.Repository != nil { + defaultBranch = info.Repository.DefaultBranch + } + inputs = append(inputs, worktree.BranchIdentityInput{ + RepositoryID: info.RepositoryID, BaseBranch: info.BaseBranch, CheckoutBranch: info.CheckoutBranch, + DefaultBranch: defaultBranch, PRNumber: info.PRNumber, Position: info.Position, + }) + infos = append(infos, info) } - - plans := make(map[*repoInfo]repoBranchPlan, len(allRepos)) - for repoID, group := range groups { - identities := branchIdentitySlugsForGroup(repoID, group) - if len(group) < 2 { - for _, info := range group { - plans[info] = repoBranchPlan{identitySlug: identities[info]} - } - continue - } - flatIdentity := selectFlatBranchIdentity(group, identities) - for _, info := range group { - identity := identities[info] - pathSlug := identity - if identity == flatIdentity { - pathSlug = "" - } - plans[info] = repoBranchPlan{identitySlug: identity, pathSlug: pathSlug} - } + for index, plan := range worktree.BuildBranchIdentityPlans(inputs) { + plans[infos[index]] = repoBranchPlan{identitySlug: plan.IdentitySlug, pathSlug: plan.PathSlug} } return plans } -func branchIdentitySlugsForGroup(repoID string, group []*repoInfo) map[*repoInfo]string { - raw := make(map[*repoInfo]string, len(group)) - counts := make(map[string]int, len(group)) - for _, info := range group { - slug := preferredBranchIdentitySlug(info) - if slug == "" { - slug = "branch-" + branchIdentityHash(repoID, info) - } - raw[info] = slug - counts[slug]++ - } - - out := make(map[*repoInfo]string, len(group)) - for _, info := range group { - slug := raw[info] - if counts[slug] > 1 { - slug += "-" + branchIdentityHash(repoID, info) - } - slug = worktree.SanitizeBranchSlug(slug) - if slug == "" { - slug = "branch-" + branchIdentityHash(repoID, info) - } - out[info] = slug - } - return out -} - -func preferredBranchIdentitySlug(info *repoInfo) string { - branch := info.CheckoutBranch - if branch == "" { - branch = info.BaseBranch - } - if branch == "" && info.Repository != nil { - branch = info.Repository.DefaultBranch - } - return worktree.SanitizeBranchSlug(branch) -} - -func branchIdentityHash(repoID string, info *repoInfo) string { - seed := strings.Join([]string{ - repoID, - info.BaseBranch, - info.CheckoutBranch, - fmt.Sprintf("%d", info.PRNumber), - }, "\x00") - h := fnv.New32a() - _, _ = h.Write([]byte(seed)) - return fmt.Sprintf("%08x", h.Sum32()) -} - -func selectFlatBranchIdentity(group []*repoInfo, identities map[*repoInfo]string) string { - candidates := make([]*repoInfo, 0, len(group)) - candidates = append(candidates, group...) - sort.SliceStable(candidates, func(i, j int) bool { - if candidates[i].Position != candidates[j].Position { - return candidates[i].Position < candidates[j].Position - } - leftRank := flatBranchRank(candidates[i]) - rightRank := flatBranchRank(candidates[j]) - if leftRank != rightRank { - return leftRank < rightRank - } - return identities[candidates[i]] < identities[candidates[j]] - }) - return identities[candidates[0]] -} - -func flatBranchRank(info *repoInfo) int { - if info.CheckoutBranch != "" { - return 3 - } - if info.Repository != nil && info.Repository.DefaultBranch != "" && info.BaseBranch == info.Repository.DefaultBranch { - return 0 - } - if info.BaseBranch == defaultBaseBranch { - return 1 - } - return 2 -} - // applyRepositoryConfig sets repository-related fields on the request and resolves clone URLs. func (e *Executor) applyRepositoryConfig(req *LaunchAgentRequest, task *v1.Task, repoInfo *repoInfo, execConfig executorConfig, metadata map[string]interface{}) (map[string]interface{}, error) { if repoInfo.RepositoryPath != "" { @@ -1363,11 +1283,18 @@ func (e *Executor) applyRepositoryConfig(req *LaunchAgentRequest, task *v1.Task, req.PullBeforeWorktree = repoInfo.PullBeforeWorktree if repoInfo.Repository != nil { req.DefaultBranch = repoInfo.Repository.DefaultBranch + if req.UseWorktree { + req.RepoName = repoInfo.Repository.Name + } else { + req.RepoName = worktree.SanitizeRepoDirName(repoInfo.Repository.Name) + if req.RepoName == "" { + req.RepoName = worktree.SanitizeRepoDirName(repoInfo.RepositoryID) + } + } } // Task directory mode: place worktree inside per-task directory if req.UseWorktree && repoInfo.Repository != nil && repoInfo.Repository.Name != "" { req.TaskDirName = worktree.SemanticWorktreeName(task.Title, worktree.SmallSuffix(3)) - req.RepoName = repoInfo.Repository.Name } if repoInfo.Repository != nil && repoInfo.Repository.SetupScript != "" { if metadata == nil { diff --git a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go index 4e5bf8314b..31c312d1a7 100644 --- a/apps/backend/internal/orchestrator/executor/executor_mocks_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_mocks_test.go @@ -690,6 +690,9 @@ func (m *mockRepository) ListTaskRepositories(ctx context.Context, taskID string } return out, nil } +func (m *mockRepository) ListTaskWorkspaceFolders(context.Context, string) ([]*models.TaskWorkspaceFolder, error) { + return nil, nil +} func (m *mockRepository) ListTaskRepositoriesByTaskIDs(_ context.Context, _ []string) (map[string][]*models.TaskRepository, error) { return make(map[string][]*models.TaskRepository), nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_resume.go b/apps/backend/internal/orchestrator/executor/executor_resume.go index fec77f3ec2..4fd1c22c86 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume.go @@ -240,6 +240,26 @@ func (e *Executor) ensureRepoCloned(ctx context.Context, repo *models.Repository return localPath, nil } +// EnsureRepositoryCloned is the host-materialization seam for provider-backed +// repositories. Authentication and clone-url construction deliberately remain +// inside Executor so callers never receive credentials or construct git calls. +func (e *Executor) EnsureRepositoryCloned(ctx context.Context, repo *models.Repository) (string, error) { + if repo == nil { + return "", errors.New("repository is required") + } + if repo.LocalPath != "" { + return repo.LocalPath, nil + } + path, err := e.ensureRepoCloned(ctx, repo) + if err != nil { + return "", err + } + if path != "" { + repo.LocalPath = path + } + return path, nil +} + // backfillRepoDefaultBranch populates repo.DefaultBranch from the local clone // (in memory + DB) when it's empty. Best-effort: on any failure we log and // continue, since the launch still has the legacy worktree-manager fallback @@ -625,6 +645,15 @@ func (e *Executor) buildResumeRequest(ctx context.Context, task *v1.Task, sessio existingRunning := e.applyRunningRecordToResumeRequest(ctx, req, task, session, startAgent) e.injectGitLabWorkspaceCredentials(ctx, req) + if folders, folderErr := e.repo.ListTaskWorkspaceFolders(ctx, task.ID); folderErr != nil { + return nil, "", execConfig, existingEnv, nil, folderErr + } else { + for _, f := range folders { + if f != nil { + req.WorkspaceFolders = append(req.WorkspaceFolders, WorkspaceFolderSpec{Name: f.DisplayName, LocalPath: f.LocalPath}) + } + } + } return req, repositoryID, execConfig, existingEnv, existingRunning, nil } diff --git a/apps/backend/internal/orchestrator/executor/executor_resume_clone_default_branch_test.go b/apps/backend/internal/orchestrator/executor/executor_resume_clone_default_branch_test.go index 2a96dca7bc..b349737779 100644 --- a/apps/backend/internal/orchestrator/executor/executor_resume_clone_default_branch_test.go +++ b/apps/backend/internal/orchestrator/executor/executor_resume_clone_default_branch_test.go @@ -62,6 +62,21 @@ func TestResolveTaskRepoInfo_BackfillsDefaultBranchAfterClone(t *testing.T) { } } +func TestEnsureRepositoryCloned_ReturnsOnlyLocalPath(t *testing.T) { + clonePath := filepath.Join(t.TempDir(), "clone") + exec := newTestExecutor(t, &mockAgentManager{}, newMockRepository()) + exec.SetRepoCloner(&fakeRepoCloner{returnPath: clonePath}, &recordingRepoUpdater{}) + repository := &models.Repository{ID: "repo-1", Provider: "github", ProviderOwner: "acme", ProviderName: "thing"} + + path, err := exec.EnsureRepositoryCloned(context.Background(), repository) + if err != nil { + t.Fatalf("EnsureRepositoryCloned: %v", err) + } + if path != clonePath || repository.LocalPath != clonePath { + t.Fatalf("path = %q, repository.LocalPath = %q; want %q", path, repository.LocalPath, clonePath) + } +} + // TestResolveTaskRepoInfo_BackfillsDefaultBranchForAlreadyClonedRepo guards // against the second-launch regression: a previous failed attempt populated // repositories.local_path but left default_branch empty (because the backfill diff --git a/apps/backend/internal/orchestrator/service.go b/apps/backend/internal/orchestrator/service.go index a0b14b4130..128d20337a 100644 --- a/apps/backend/internal/orchestrator/service.go +++ b/apps/backend/internal/orchestrator/service.go @@ -153,6 +153,7 @@ type repoStore interface { UpdateTaskStateIfNotArchived(ctx context.Context, id string, state v1.TaskState) (v1.TaskState, bool, error) GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) ListTaskRepositories(ctx context.Context, taskID string) ([]*models.TaskRepository, error) + ListTaskWorkspaceFolders(ctx context.Context, taskID string) ([]*models.TaskWorkspaceFolder, error) CreateTaskSession(ctx context.Context, session *models.TaskSession) error UpdateTaskSession(ctx context.Context, session *models.TaskSession) error ListActiveTaskSessions(ctx context.Context) ([]*models.TaskSession, error) @@ -682,6 +683,19 @@ func (s *Service) SetRepoCloner(cloner executor.RepoCloner, updater executor.Rep s.executor.SetRepoCloner(cloner, updater) } +// RepositoryHostCloner is the narrow host-materialization contract. It returns +// only the persisted local path; clone credentials remain private to the +// orchestrator executor pipeline. +type RepositoryHostCloner interface { + EnsureRepositoryCloned(context.Context, *models.Repository) (string, error) +} + +// EnsureRepositoryCloned delegates host cloning through the executor's +// authenticated clone pipeline. +func (s *Service) EnsureRepositoryCloned(ctx context.Context, repository *models.Repository) (string, error) { + return s.executor.EnsureRepositoryCloned(ctx, repository) +} + // SetTurnService sets the turn service for tracking conversation turns. // // A "turn" represents a single conversation round-trip: user prompt → agent response. diff --git a/apps/backend/internal/task/dto/converters_test.go b/apps/backend/internal/task/dto/converters_test.go index 54044d414c..cc28e2e00c 100644 --- a/apps/backend/internal/task/dto/converters_test.go +++ b/apps/backend/internal/task/dto/converters_test.go @@ -1,7 +1,9 @@ package dto import ( + "encoding/json" "testing" + "time" "github.com/kandev/kandev/internal/task/models" wfmodels "github.com/kandev/kandev/internal/workflow/models" @@ -39,6 +41,27 @@ func TestFromWorkflowStep_PreservesGenericEvents(t *testing.T) { } } +func TestFromTaskSerializesWorkspaceFolders(t *testing.T) { + now := time.Now().UTC() + got := FromTask(&models.Task{ + ID: "task-folders", + WorkspaceFolders: []*models.TaskWorkspaceFolder{{ + ID: "folder-1", TaskID: "task-folders", LocalPath: "/canonical/docs", DisplayName: "docs", Position: 2, CreatedAt: now, UpdatedAt: now, + }}, + }) + payload, err := json.Marshal(got) + if err != nil { + t.Fatalf("marshal task DTO: %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(payload, &fields); err != nil { + t.Fatalf("decode task DTO: %v", err) + } + if _, ok := fields["workspace_folders"]; !ok { + t.Fatalf("workspace_folders missing from task DTO: %s", payload) + } +} + func TestFromWorkflowStep_PreservesWIPFields(t *testing.T) { step := &wfmodels.WorkflowStep{ ID: "step-1", diff --git a/apps/backend/internal/task/dto/dto.go b/apps/backend/internal/task/dto/dto.go index 4e554ee6b4..97e5b8b168 100644 --- a/apps/backend/internal/task/dto/dto.go +++ b/apps/backend/internal/task/dto/dto.go @@ -133,27 +133,28 @@ type EnvironmentDTO struct { } type TaskDTO struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - Title string `json:"title"` - Description string `json:"description"` - State v1.TaskState `json:"state"` - Priority string `json:"priority"` - Repositories []TaskRepositoryDTO `json:"repositories,omitempty"` - Position int `json:"position"` - PrimarySessionID *string `json:"primary_session_id,omitempty"` - SessionCount *int `json:"session_count,omitempty"` - ReviewStatus models.ReviewStatus `json:"review_status,omitempty"` - PrimaryExecutorID *string `json:"primary_executor_id,omitempty"` - PrimaryExecutorType *string `json:"primary_executor_type,omitempty"` - PrimaryExecutorName *string `json:"primary_executor_name,omitempty"` - PrimaryAgentName *string `json:"primary_agent_name,omitempty"` - PrimaryWorkingDirectory *string `json:"primary_working_directory,omitempty"` - PrimarySessionState *string `json:"primary_session_state,omitempty"` - PrimarySessionPendingAction *string `json:"primary_session_pending_action"` - TaskPendingAction *string `json:"task_pending_action"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Title string `json:"title"` + Description string `json:"description"` + State v1.TaskState `json:"state"` + Priority string `json:"priority"` + Repositories []TaskRepositoryDTO `json:"repositories,omitempty"` + WorkspaceFolders []TaskWorkspaceFolderDTO `json:"workspace_folders,omitempty"` + Position int `json:"position"` + PrimarySessionID *string `json:"primary_session_id,omitempty"` + SessionCount *int `json:"session_count,omitempty"` + ReviewStatus models.ReviewStatus `json:"review_status,omitempty"` + PrimaryExecutorID *string `json:"primary_executor_id,omitempty"` + PrimaryExecutorType *string `json:"primary_executor_type,omitempty"` + PrimaryExecutorName *string `json:"primary_executor_name,omitempty"` + PrimaryAgentName *string `json:"primary_agent_name,omitempty"` + PrimaryWorkingDirectory *string `json:"primary_working_directory,omitempty"` + PrimarySessionState *string `json:"primary_session_state,omitempty"` + PrimarySessionPendingAction *string `json:"primary_session_pending_action"` + TaskPendingAction *string `json:"task_pending_action"` // ForegroundActivity is the task-level MOST-ACTIVE-WINS activity aggregate // across the task's sessions: "generating" when // any session is generating, "background" when none is generating but at @@ -203,6 +204,17 @@ type TaskRepositoryDTO struct { UpdatedAt time.Time `json:"updated_at"` } +// TaskWorkspaceFolderDTO is the API projection of a durable non-Git source. +type TaskWorkspaceFolderDTO struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + LocalPath string `json:"local_path"` + DisplayName string `json:"display_name"` + Position int `json:"position"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + type TaskSessionDTO struct { ID string `json:"id"` TaskID string `json:"task_id"` @@ -624,6 +636,18 @@ func FromTaskWithSessionInfo( UpdatedAt: repo.UpdatedAt, }) } + var workspaceFolders []TaskWorkspaceFolderDTO + for _, folder := range task.WorkspaceFolders { + workspaceFolders = append(workspaceFolders, TaskWorkspaceFolderDTO{ + ID: folder.ID, + TaskID: folder.TaskID, + LocalPath: folder.LocalPath, + DisplayName: folder.DisplayName, + Position: folder.Position, + CreatedAt: folder.CreatedAt, + UpdatedAt: folder.UpdatedAt, + }) + } return TaskDTO{ ID: task.ID, @@ -635,6 +659,7 @@ func FromTaskWithSessionInfo( State: task.State, Priority: task.Priority, Repositories: repositories, + WorkspaceFolders: workspaceFolders, Position: task.Position, PrimarySessionID: primarySessionID, SessionCount: sessionCount, diff --git a/apps/backend/internal/task/handlers/repository_handlers_test.go b/apps/backend/internal/task/handlers/repository_handlers_test.go index 6e88304469..0487eea3ec 100644 --- a/apps/backend/internal/task/handlers/repository_handlers_test.go +++ b/apps/backend/internal/task/handlers/repository_handlers_test.go @@ -222,6 +222,32 @@ func TestHTTPUpdateRepositoryIgnoresRemoteURL(t *testing.T) { } } +func TestHTTPListRepositoriesIncludesRemoteURL(t *testing.T) { + router, repo := newRepositoryHTTPTestRouter(t) + if err := repo.CreateRepository(context.Background(), &models.Repository{ + ID: "remote-repository", WorkspaceID: "ws-1", Name: "api", RemoteURL: "https://git.example.com/acme/api.git", + }); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + request := httptest.NewRequest(http.MethodGet, "/api/v1/workspaces/ws-1/repositories", nil) + response := httptest.NewRecorder() + router.ServeHTTP(response, request) + if response.Code != http.StatusOK { + t.Fatalf("status = %d, want %d; body = %s", response.Code, http.StatusOK, response.Body.String()) + } + var body struct { + Repositories []struct { + RemoteURL string `json:"remote_url"` + } `json:"repositories"` + } + if err := json.Unmarshal(response.Body.Bytes(), &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if len(body.Repositories) != 1 || body.Repositories[0].RemoteURL != "https://git.example.com/acme/api.git" { + t.Fatalf("remote_url missing from response: %s", response.Body.String()) + } +} + type repositoryHandlerRemoteLister struct { calls int } diff --git a/apps/backend/internal/task/handlers/task_handlers.go b/apps/backend/internal/task/handlers/task_handlers.go index d6a518010f..c44ff2b30c 100644 --- a/apps/backend/internal/task/handlers/task_handlers.go +++ b/apps/backend/internal/task/handlers/task_handlers.go @@ -135,6 +135,7 @@ func (h *TaskHandlers) registerHTTP(router *gin.Engine) { api.POST("/tasks", h.httpCreateTask) api.PATCH("/tasks/:id", h.httpUpdateTask) api.POST("/tasks/:id/detach", h.httpDetachTask) + api.POST("/tasks/:id/workspace-sources", h.httpAttachWorkspaceSources) api.PATCH("/tasks/:id/repositories/:repo_id", h.httpUpdateTaskRepository) api.POST("/tasks/:id/move", h.httpMoveTask) api.DELETE("/tasks/:id", h.httpDeleteTask) diff --git a/apps/backend/internal/task/handlers/task_http_handlers.go b/apps/backend/internal/task/handlers/task_http_handlers.go index fd01534b58..a11fceae82 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers.go +++ b/apps/backend/internal/task/handlers/task_http_handlers.go @@ -2,6 +2,7 @@ package handlers import ( "context" + "encoding/json" "errors" "fmt" "net/http" @@ -16,6 +17,8 @@ import ( storageworkspaces "github.com/kandev/kandev/internal/system/storage/workspaces" "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" + taskrepository "github.com/kandev/kandev/internal/task/repository" + taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" "github.com/kandev/kandev/internal/task/service" usermodels "github.com/kandev/kandev/internal/user/models" "github.com/kandev/kandev/internal/worktree" @@ -23,6 +26,108 @@ import ( "go.uber.org/zap" ) +type httpWorkspaceSourcesRequest struct { + Sources []json.RawMessage `json:"sources"` +} + +type workspaceSourceJSON struct { + Kind string `json:"kind"` + RepositoryID string `json:"repository_id"` + LocalPath string `json:"local_path"` + GitHubURL string `json:"github_url"` + RemoteURL string `json:"remote_url"` + Provider string `json:"provider"` + ProviderRepoID string `json:"provider_repo_id"` + ProviderOwner string `json:"provider_owner"` + ProviderName string `json:"provider_name"` + BaseBranch string `json:"base_branch"` + CheckoutBranch string `json:"checkout_branch"` + DisplayName string `json:"display_name"` +} + +func (h *TaskHandlers) httpAttachWorkspaceSources(c *gin.Context) { + var body httpWorkspaceSourcesRequest + decoder := json.NewDecoder(c.Request.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&body); err != nil || len(body.Sources) == 0 { + c.JSON(http.StatusBadRequest, gin.H{"error": "sources is required"}) + return + } + sources, err := parseHTTPWorkspaceSources(body.Sources) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + result, err := h.service.AttachWorkspaceSources(c.Request.Context(), service.AttachWorkspaceSourcesRequest{TaskID: c.Param("id"), Sources: sources}) + if err != nil { + h.writeWorkspaceSourceError(c, err) + return + } + response := gin.H{"task_id": result.Task.ID, "repositories": result.Task.Repositories, "workspace_folders": result.Task.WorkspaceFolders, "workspace_path": result.WorkspacePath, "session_ids": result.SessionIDs} + c.JSON(http.StatusOK, response) +} + +func parseHTTPWorkspaceSources(raw []json.RawMessage) ([]service.WorkspaceSourceInput, error) { + sources := make([]service.WorkspaceSourceInput, 0, len(raw)) + for _, item := range raw { + var fields map[string]json.RawMessage + if err := json.Unmarshal(item, &fields); err != nil { + return nil, fmt.Errorf("source must be an object") + } + var kind string + if err := json.Unmarshal(fields["kind"], &kind); err != nil { + return nil, fmt.Errorf("source kind is required") + } + allowed := map[string]bool{"kind": true, "local_path": true} + switch kind { + case string(service.WorkspaceSourceRepository): + for _, key := range []string{"repository_id", "remote_url", "github_url", "provider", "provider_repo_id", "provider_owner", "provider_name", "base_branch", "checkout_branch"} { + allowed[key] = true + } + case string(service.WorkspaceSourceFolder): + allowed["display_name"] = true + default: + return nil, fmt.Errorf("unsupported workspace source kind %q", kind) + } + for key := range fields { + if !allowed[key] { + return nil, fmt.Errorf("field %q is not allowed for %s source", key, kind) + } + } + var source workspaceSourceJSON + if err := json.Unmarshal(item, &source); err != nil { + return nil, err + } + sources = append(sources, service.WorkspaceSourceInput{Kind: service.WorkspaceSourceKind(source.Kind), RepositoryID: source.RepositoryID, LocalPath: source.LocalPath, GitHubURL: source.GitHubURL, RemoteURL: source.RemoteURL, Provider: source.Provider, ProviderRepoID: source.ProviderRepoID, ProviderOwner: source.ProviderOwner, ProviderName: source.ProviderName, BaseBranch: source.BaseBranch, CheckoutBranch: source.CheckoutBranch, DisplayName: source.DisplayName}) + } + return sources, nil +} + +func workspaceSourceHTTPStatus(err error) int { + switch { + case errors.Is(err, service.ErrInvalidWorkspaceSource): + return http.StatusBadRequest + case errors.Is(err, taskrepo.ErrTaskNotFound), errors.Is(err, taskrepository.ErrRepositoryNotFound), errors.Is(err, service.ErrTaskRepositoryNotFound): + return http.StatusNotFound + case errors.Is(err, service.ErrWorkspaceSourceConflict), errors.Is(err, service.ErrWorkspaceSourceActive): + return http.StatusConflict + case errors.Is(err, service.ErrUnsupportedWorkspaceSource), errors.Is(err, service.ErrWorkspaceSourceMaterialize): + return http.StatusUnprocessableEntity + default: + return http.StatusInternalServerError + } +} + +func (h *TaskHandlers) writeWorkspaceSourceError(c *gin.Context, err error) { + status := workspaceSourceHTTPStatus(err) + if status == http.StatusInternalServerError { + h.logger.Error("attach workspace sources failed", zap.Error(err)) + c.JSON(status, gin.H{"error": "request failed"}) + return + } + c.JSON(status, gin.H{"error": err.Error()}) +} + func (h *TaskHandlers) httpListTasks(c *gin.Context) { tasks, err := h.service.ListTasks(c.Request.Context(), c.Param("id")) if err != nil { diff --git a/apps/backend/internal/task/handlers/task_http_handlers_test.go b/apps/backend/internal/task/handlers/task_http_handlers_test.go index 9c94dc89cc..633a060ca1 100644 --- a/apps/backend/internal/task/handlers/task_http_handlers_test.go +++ b/apps/backend/internal/task/handlers/task_http_handlers_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "fmt" "net/http" "net/http/httptest" "os" @@ -23,6 +24,7 @@ import ( "github.com/kandev/kandev/internal/orchestrator" "github.com/kandev/kandev/internal/task/dto" "github.com/kandev/kandev/internal/task/models" + taskrepository "github.com/kandev/kandev/internal/task/repository" "github.com/kandev/kandev/internal/task/service" usermodels "github.com/kandev/kandev/internal/user/models" v1 "github.com/kandev/kandev/pkg/api/v1" @@ -76,6 +78,35 @@ func TestQuickChatRequestBuildRepositoriesAcceptsPluralInput(t *testing.T) { assert.Equal(t, service.TaskRepositoryInput{RepositoryID: "repo-2", BaseBranch: "develop"}, repos[1]) } +func TestWorkspaceSourceHTTPStatusUsesTypedErrors(t *testing.T) { + tests := []struct { + err error + want int + }{ + {service.ErrInvalidWorkspaceSource, http.StatusBadRequest}, + {service.ErrWorkspaceSourceConflict, http.StatusConflict}, + {service.ErrWorkspaceSourceActive, http.StatusConflict}, + {service.ErrUnsupportedWorkspaceSource, http.StatusUnprocessableEntity}, + {service.ErrWorkspaceSourceMaterialize, http.StatusUnprocessableEntity}, + } + for _, tt := range tests { + assert.Equal(t, tt.want, workspaceSourceHTTPStatus(tt.err)) + } +} + +func TestWorkspaceSourceHTTPStatusMapsRepositoryNotFound(t *testing.T) { + assert.Equal(t, http.StatusNotFound, workspaceSourceHTTPStatus(fmt.Errorf("wrapped: %w", taskrepository.ErrRepositoryNotFound))) +} + +func TestParseHTTPWorkspaceSourcesPreservesSnakeCaseFields(t *testing.T) { + sources, err := parseHTTPWorkspaceSources([]json.RawMessage{json.RawMessage(`{"kind":"repository","repository_id":"repo-1","base_branch":"main","checkout_branch":"feature/x"}`)}) + require.NoError(t, err) + require.Len(t, sources, 1) + assert.Equal(t, "repo-1", sources[0].RepositoryID) + assert.Equal(t, "main", sources[0].BaseBranch) + assert.Equal(t, "feature/x", sources[0].CheckoutBranch) +} + func TestTaskPendingActionPtrAggregatesInputCapableSessions(t *testing.T) { sessions := []*models.TaskSession{ {ID: "running", State: models.TaskSessionStateRunning}, diff --git a/apps/backend/internal/task/models/models.go b/apps/backend/internal/task/models/models.go index df7f37b091..7a249e55e6 100644 --- a/apps/backend/internal/task/models/models.go +++ b/apps/backend/internal/task/models/models.go @@ -424,20 +424,21 @@ const ( // Task represents a task in the database type Task struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - WorkflowID string `json:"workflow_id"` - WorkflowStepID string `json:"workflow_step_id"` - Title string `json:"title"` - Description string `json:"description"` - State v1.TaskState `json:"state"` - Priority string `json:"priority"` - Position int `json:"position"` // Order within workflow step - Metadata map[string]interface{} `json:"metadata,omitempty"` - Repositories []*TaskRepository `json:"repositories,omitempty"` - IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat - ParentID string `json:"parent_id,omitempty"` // FK to parent task for subtasks - ArchivedAt *time.Time `json:"archived_at,omitempty"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + WorkflowStepID string `json:"workflow_step_id"` + Title string `json:"title"` + Description string `json:"description"` + State v1.TaskState `json:"state"` + Priority string `json:"priority"` + Position int `json:"position"` // Order within workflow step + Metadata map[string]interface{} `json:"metadata,omitempty"` + Repositories []*TaskRepository `json:"repositories,omitempty"` + WorkspaceFolders []*TaskWorkspaceFolder `json:"workspace_folders,omitempty"` + IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat + ParentID string `json:"parent_id,omitempty"` // FK to parent task for subtasks + ArchivedAt *time.Time `json:"archived_at,omitempty"` // ArchivedByCascadeID is set when the task was archived as part of an // office task-handoffs cascade (phase 6). UnarchiveTaskByCascade uses // it to scope restoration to exactly the descendants this cascade @@ -586,6 +587,43 @@ type TaskRepository struct { UpdatedAt time.Time `json:"updated_at"` } +// TaskWorkspaceFolder is a canonical host-folder attachment owned by a task. +// It stays separate from TaskRepository because folders are not Git sources. +type TaskWorkspaceFolder struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + LocalPath string `json:"local_path"` + DisplayName string `json:"display_name"` + Position int `json:"position"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +// WorkspaceSourceBatch identifies exactly the durable rows created by one +// attachment operation, so a later materialization failure can compensate +// without touching pre-existing sources. +type WorkspaceSourceBatch struct { + TaskID string `json:"task_id"` + Sources []WorkspaceSource `json:"sources,omitempty"` + RepositoryUpdates []WorkspaceSourceRepositoryUpdate `json:"repository_updates,omitempty"` +} + +// WorkspaceSourceRepositoryUpdate records a legacy association branch derived +// during workspace-source validation. PreviousBaseBranch permits compensation +// if later runtime materialization fails. +type WorkspaceSourceRepositoryUpdate struct { + TaskRepositoryID string `json:"task_repository_id"` + BaseBranch string `json:"base_branch"` + PreviousBaseBranch string `json:"previous_base_branch"` +} + +// WorkspaceSource carries exactly one durable source kind. Sources preserve +// the caller's mixed order for global position allocation. +type WorkspaceSource struct { + Repository *TaskRepository `json:"repository,omitempty"` + Folder *TaskWorkspaceFolder `json:"folder,omitempty"` +} + // MessageAuthorType represents who authored a message type MessageAuthorType string diff --git a/apps/backend/internal/task/repository/cleanup_repository_test.go b/apps/backend/internal/task/repository/cleanup_repository_test.go new file mode 100644 index 0000000000..1efe2e11c4 --- /dev/null +++ b/apps/backend/internal/task/repository/cleanup_repository_test.go @@ -0,0 +1,10 @@ +package repository_test + +import ( + "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/task/repository/sqlite" +) + +// The workspace-source rollback path needs the guarded cleanup operation, but +// consumers of RepositoryEntityRepository must not be forced to provide it. +var _ repository.RepositoryCleanupRepository = (*sqlite.Repository)(nil) diff --git a/apps/backend/internal/task/repository/interface.go b/apps/backend/internal/task/repository/interface.go index f12312aead..de04b58200 100644 --- a/apps/backend/internal/task/repository/interface.go +++ b/apps/backend/internal/task/repository/interface.go @@ -136,6 +136,16 @@ type TaskRepoRepository interface { GetPrimaryTaskRepository(ctx context.Context, taskID string) (*models.TaskRepository, error) } +// TaskWorkspaceFolderRepository handles canonical non-Git folder attachments. +// It is intentionally separate from TaskRepoRepository to preserve existing +// repository payload and Git-consumer contracts. +type TaskWorkspaceFolderRepository interface { + ListTaskWorkspaceFolders(ctx context.Context, taskID string) ([]*models.TaskWorkspaceFolder, error) + ListTaskWorkspaceFoldersByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]*models.TaskWorkspaceFolder, error) + CreateWorkspaceSourceBatch(ctx context.Context, batch *models.WorkspaceSourceBatch) error + CompensateWorkspaceSourceBatch(ctx context.Context, batch *models.WorkspaceSourceBatch) error +} + // WorkflowRepository handles workflow CRUD. type WorkflowRepository interface { CreateWorkflow(ctx context.Context, workflow *models.Workflow) error @@ -282,6 +292,15 @@ type RepositoryEntityRepository interface { GetRepositoryByLocalPath(ctx context.Context, workspaceID, localPath string) (*models.Repository, error) } +// RepositoryCleanupRepository performs guarded deletion of repositories +// created during workspace-source attachment rollback. +type RepositoryCleanupRepository interface { + // DeleteRepositoryIfUnreferenced soft-deletes a repository only when no + // task_repositories row currently adopts it. The predicate is part of the + // mutation so rollback cleanup cannot delete a repository another task won. + DeleteRepositoryIfUnreferenced(ctx context.Context, id string) (bool, error) +} + // ExecutorRepository handles executor CRUD, executor profiles, and running state. type ExecutorRepository interface { CreateExecutor(ctx context.Context, executor *models.Executor) error diff --git a/apps/backend/internal/task/repository/sqlite/base_migrations.go b/apps/backend/internal/task/repository/sqlite/base_migrations.go index b1844db0cf..c7a369ace9 100644 --- a/apps/backend/internal/task/repository/sqlite/base_migrations.go +++ b/apps/backend/internal/task/repository/sqlite/base_migrations.go @@ -44,6 +44,9 @@ func (r *Repository) migrateSessionsAddCostColumns() { // runMigrations applies idempotent ALTER TABLE migrations for schema evolution. func (r *Repository) runMigrations() error { + if err := r.ensureTaskWorkspaceFoldersSchema(); err != nil { + return err + } r.migrate.Apply("task_sessions.execution_profile_id", `ALTER TABLE task_sessions ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`) r.migrate.Apply("executors_running.execution_profile_id", `ALTER TABLE executors_running ADD COLUMN execution_profile_id TEXT NOT NULL DEFAULT ''`) r.migrate.Apply("executors_running.last_message_uuid", `ALTER TABLE executors_running ADD COLUMN last_message_uuid TEXT DEFAULT ''`) @@ -178,6 +181,31 @@ func (r *Repository) runMigrations() error { return nil } +// ensureTaskWorkspaceFoldersSchema upgrades databases created before durable +// folder attachments existed. CREATE TABLE/INDEX IF NOT EXISTS is replay-safe +// on SQLite and Postgres. +func (r *Repository) ensureTaskWorkspaceFoldersSchema() error { + if _, err := r.db.Exec(` + CREATE TABLE IF NOT EXISTS task_workspace_folders ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + local_path TEXT NOT NULL, + display_name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + UNIQUE(task_id, local_path), + UNIQUE(task_id, display_name) + ); + CREATE INDEX IF NOT EXISTS idx_task_workspace_folders_task_position + ON task_workspace_folders(task_id, position); + `); err != nil { + return fmt.Errorf("create task workspace folders schema: %w", err) + } + return nil +} + // recreateTable checks whether tableName's DDL contains triggerPhrase and, if so, // runs statements inside a transaction with FK enforcement disabled. // This is the standard SQLite pattern for dropping columns or FK constraints, diff --git a/apps/backend/internal/task/repository/sqlite/base_schema.go b/apps/backend/internal/task/repository/sqlite/base_schema.go index 423c881a23..ccc91a6590 100644 --- a/apps/backend/internal/task/repository/sqlite/base_schema.go +++ b/apps/backend/internal/task/repository/sqlite/base_schema.go @@ -322,6 +322,19 @@ func (r *Repository) initTaskSchema() error { UNIQUE(task_id, repository_id, base_branch, checkout_branch) ); + CREATE TABLE IF NOT EXISTS task_workspace_folders ( + id TEXT PRIMARY KEY, + task_id TEXT NOT NULL, + local_path TEXT NOT NULL, + display_name TEXT NOT NULL, + position INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMP NOT NULL, + updated_at TIMESTAMP NOT NULL, + FOREIGN KEY (task_id) REFERENCES tasks(id) ON DELETE CASCADE, + UNIQUE(task_id, local_path), + UNIQUE(task_id, display_name) + ); + CREATE TABLE IF NOT EXISTS repository_scripts ( id TEXT PRIMARY KEY, repository_id TEXT NOT NULL, @@ -343,6 +356,7 @@ func (r *Repository) initCoreIndexes() error { CREATE INDEX IF NOT EXISTS idx_tasks_archived_at ON tasks(archived_at); CREATE INDEX IF NOT EXISTS idx_task_repositories_task_id ON task_repositories(task_id); CREATE INDEX IF NOT EXISTS idx_task_repositories_repository_id ON task_repositories(repository_id); + CREATE INDEX IF NOT EXISTS idx_task_workspace_folders_task_position ON task_workspace_folders(task_id, position); CREATE INDEX IF NOT EXISTS idx_repositories_workspace_id ON repositories(workspace_id); CREATE INDEX IF NOT EXISTS idx_repository_scripts_repo_id ON repository_scripts(repository_id); `) diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity.go b/apps/backend/internal/task/repository/sqlite/repository_entity.go index d3a650f96b..0fab4c6f20 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity.go @@ -94,6 +94,29 @@ func (r *Repository) DeleteRepository(ctx context.Context, id string) error { return nil } +// DeleteRepositoryIfUnreferenced soft-deletes only an unadopted repository. +// Keeping the reference check inside UPDATE closes the cleanup/adoption race. +func (r *Repository) DeleteRepositoryIfUnreferenced(ctx context.Context, id string) (bool, error) { + now := time.Now().UTC() + result, err := r.db.ExecContext(ctx, r.db.Rebind(` + UPDATE repositories + SET deleted_at = ?, updated_at = ? + WHERE id = ? + AND deleted_at IS NULL + AND NOT EXISTS ( + SELECT 1 FROM task_repositories WHERE repository_id = repositories.id + ) + `), now, now, id) + if err != nil { + return false, err + } + rows, err := result.RowsAffected() + if err != nil { + return false, err + } + return rows > 0, nil +} + // DeleteRepositoryIfNoActiveTaskSessions soft-deletes a repository only when // no live task linked to it has a session that blocks deletion. Keeping the // predicate in the UPDATE prevents a session from becoming active between a diff --git a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go index 3aecdb810b..33634edbeb 100644 --- a/apps/backend/internal/task/repository/sqlite/repository_entity_test.go +++ b/apps/backend/internal/task/repository/sqlite/repository_entity_test.go @@ -37,6 +37,35 @@ func seedWorkspace(t *testing.T, repo *Repository, id string) { } } +func TestDeleteRepositoryIfUnreferenced_PreservesTaskAdoptedRepository(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "ws-cleanup-reference") + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-cleanup-reference", WorkspaceID: "ws-cleanup-reference", Name: "WF"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-cleanup-reference", WorkspaceID: "ws-cleanup-reference", Name: "app"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateTask(ctx, &models.Task{ID: "task-cleanup-reference", WorkspaceID: "ws-cleanup-reference", WorkflowID: "wf-cleanup-reference", Title: "Task"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "tr-cleanup-reference", TaskID: "task-cleanup-reference", RepositoryID: "repo-cleanup-reference", BaseBranch: "main"}); err != nil { + t.Fatal(err) + } + + deleted, err := repo.DeleteRepositoryIfUnreferenced(ctx, "repo-cleanup-reference") + if err != nil { + t.Fatal(err) + } + if deleted { + t.Fatal("cleanup deleted repository adopted by a task") + } + if _, err := repo.GetRepository(ctx, "repo-cleanup-reference"); err != nil { + t.Fatalf("repository after rejected cleanup: %v", err) + } +} + // TestRepositoryCopyFiles_RoundTrip writes a repository with a non-empty // CopyFiles, fetches it back via GetRepository and ListRepositories, and // asserts the value survived both code paths. diff --git a/apps/backend/internal/task/repository/sqlite/workspace_folder.go b/apps/backend/internal/task/repository/sqlite/workspace_folder.go new file mode 100644 index 0000000000..d2b0e074c8 --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/workspace_folder.go @@ -0,0 +1,233 @@ +package sqlite + +import ( + "context" + "database/sql" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jmoiron/sqlx" + + "github.com/kandev/kandev/internal/task/models" +) + +// ListTaskWorkspaceFoldersByTaskIDs loads folders for a task batch in one query. +func (r *Repository) ListTaskWorkspaceFoldersByTaskIDs(ctx context.Context, taskIDs []string) (map[string][]*models.TaskWorkspaceFolder, error) { + result := make(map[string][]*models.TaskWorkspaceFolder, len(taskIDs)) + if len(taskIDs) == 0 { + return result, nil + } + placeholders := make([]string, len(taskIDs)) + args := make([]interface{}, len(taskIDs)) + for i, id := range taskIDs { + placeholders[i], args[i] = "?", id + } + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(fmt.Sprintf(` + SELECT id, task_id, local_path, display_name, position, created_at, updated_at + FROM task_workspace_folders WHERE task_id IN (%s) + ORDER BY position ASC, created_at ASC + `, strings.Join(placeholders, ","))), args...) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + for rows.Next() { + folder, scanErr := scanTaskWorkspaceFolder(rows) + if scanErr != nil { + return nil, scanErr + } + result[folder.TaskID] = append(result[folder.TaskID], folder) + } + return result, rows.Err() +} + +// ListTaskWorkspaceFolders returns task-owned folders in durable source order. +func (r *Repository) ListTaskWorkspaceFolders(ctx context.Context, taskID string) ([]*models.TaskWorkspaceFolder, error) { + rows, err := r.ro.QueryContext(ctx, r.ro.Rebind(` + SELECT id, task_id, local_path, display_name, position, created_at, updated_at + FROM task_workspace_folders + WHERE task_id = ? + ORDER BY position ASC, created_at ASC + `), taskID) + if err != nil { + return nil, err + } + defer func() { _ = rows.Close() }() + + folders := make([]*models.TaskWorkspaceFolder, 0) + for rows.Next() { + folder, err := scanTaskWorkspaceFolder(rows) + if err != nil { + return nil, err + } + folders = append(folders, folder) + } + return folders, rows.Err() +} + +// CreateWorkspaceSourceBatch atomically writes repository and folder links. +// It owns a single position sequence across both relations so later callers +// can project an unambiguous source order. +func (r *Repository) CreateWorkspaceSourceBatch(ctx context.Context, batch *models.WorkspaceSourceBatch) error { + if batch == nil || batch.TaskID == "" { + return fmt.Errorf("workspace source batch requires task_id") + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + if err := r.createWorkspaceSourceBatchTx(ctx, tx, batch); err != nil { + if rollbackErr := tx.Rollback(); rollbackErr != nil { + return fmt.Errorf("rollback workspace source batch: %w", rollbackErr) + } + return err + } + return tx.Commit() +} + +func (r *Repository) createWorkspaceSourceBatchTx(ctx context.Context, tx *sqlx.Tx, batch *models.WorkspaceSourceBatch) error { + if err := r.updateWorkspaceSourceBranchesTx(ctx, tx, batch.RepositoryUpdates); err != nil { + return err + } + nextPosition, err := r.nextWorkspaceSourcePositionTx(ctx, tx, batch.TaskID) + if err != nil { + return err + } + now := time.Now().UTC() + for _, source := range batch.Sources { + nextPosition, err = r.persistWorkspaceSourceTx(ctx, tx, batch.TaskID, source, nextPosition, now) + if err != nil { + return err + } + } + return nil +} + +func (r *Repository) updateWorkspaceSourceBranchesTx(ctx context.Context, tx *sqlx.Tx, updates []models.WorkspaceSourceRepositoryUpdate) error { + for _, update := range updates { + if _, err := tx.ExecContext(ctx, r.db.Rebind(`UPDATE task_repositories SET base_branch = ?, updated_at = ? WHERE id = ?`), update.BaseBranch, time.Now().UTC(), update.TaskRepositoryID); err != nil { + return err + } + } + return nil +} + +func (r *Repository) persistWorkspaceSourceTx(ctx context.Context, tx *sqlx.Tx, taskID string, source models.WorkspaceSource, position int, now time.Time) (int, error) { + if source.Folder != nil && source.Repository != nil { + return position, fmt.Errorf("workspace source must contain exactly one kind") + } + if source.Folder == nil && source.Repository == nil { + return position, fmt.Errorf("workspace source kind is required") + } + if folder := source.Folder; folder != nil { + if err := r.insertWorkspaceFolderTx(ctx, tx, taskID, folder, position, now); err != nil { + return position, err + } + return position + 1, nil + } + if err := r.insertWorkspaceRepositoryTx(ctx, tx, taskID, source.Repository, position, now); err != nil { + return position, err + } + return position + 1, nil +} + +func (r *Repository) insertWorkspaceFolderTx(ctx context.Context, tx *sqlx.Tx, taskID string, folder *models.TaskWorkspaceFolder, position int, now time.Time) error { + if folder.ID == "" { + folder.ID = uuid.New().String() + } + folder.TaskID, folder.Position = taskID, position + folder.CreatedAt, folder.UpdatedAt = now, now + _, err := tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO task_workspace_folders + (id, task_id, local_path, display_name, position, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `), folder.ID, folder.TaskID, folder.LocalPath, folder.DisplayName, folder.Position, folder.CreatedAt, folder.UpdatedAt) + return err +} + +func (r *Repository) insertWorkspaceRepositoryTx(ctx context.Context, tx *sqlx.Tx, taskID string, taskRepo *models.TaskRepository, position int, now time.Time) error { + if taskRepo.ID == "" { + taskRepo.ID = uuid.New().String() + } + taskRepo.TaskID, taskRepo.Position = taskID, position + taskRepo.CreatedAt, taskRepo.UpdatedAt = now, now + metadata, err := json.Marshal(taskRepo.Metadata) + if err != nil { + metadata = []byte("{}") + } + _, err = tx.ExecContext(ctx, r.db.Rebind(` + INSERT INTO task_repositories + (id, task_id, repository_id, base_branch, checkout_branch, position, metadata, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + `), taskRepo.ID, taskRepo.TaskID, taskRepo.RepositoryID, taskRepo.BaseBranch, taskRepo.CheckoutBranch, taskRepo.Position, string(metadata), taskRepo.CreatedAt, taskRepo.UpdatedAt) + return err +} + +func (r *Repository) nextWorkspaceSourcePositionTx(ctx context.Context, tx *sqlx.Tx, taskID string) (int, error) { + var maxPosition sql.NullInt64 + if err := tx.QueryRowxContext(ctx, r.db.Rebind(` + SELECT MAX(position) FROM ( + SELECT position FROM task_repositories WHERE task_id = ? + UNION ALL + SELECT position FROM task_workspace_folders WHERE task_id = ? + ) + `), taskID, taskID).Scan(&maxPosition); err != nil { + return 0, err + } + if !maxPosition.Valid { + return 0, nil + } + return int(maxPosition.Int64) + 1, nil +} + +// CompensateWorkspaceSourceBatch removes only rows created by batch. It is +// idempotent so retrying cleanup after a partial runtime failure is safe. +func (r *Repository) CompensateWorkspaceSourceBatch(ctx context.Context, batch *models.WorkspaceSourceBatch) error { + if batch == nil { + return nil + } + tx, err := r.db.BeginTxx(ctx, nil) + if err != nil { + return err + } + for _, source := range batch.Sources { + if folder := source.Folder; folder != nil && folder.ID != "" { + if _, err := tx.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_workspace_folders WHERE id = ?`), folder.ID); err != nil { + _ = tx.Rollback() + return err + } + } + if taskRepo := source.Repository; taskRepo != nil && taskRepo.ID != "" { + if _, err := tx.ExecContext(ctx, r.db.Rebind(`DELETE FROM task_repositories WHERE id = ?`), taskRepo.ID); err != nil { + _ = tx.Rollback() + return err + } + } + } + for _, update := range batch.RepositoryUpdates { + if _, err := tx.ExecContext(ctx, r.db.Rebind(`UPDATE task_repositories SET base_branch = ?, updated_at = ? WHERE id = ?`), update.PreviousBaseBranch, time.Now().UTC(), update.TaskRepositoryID); err != nil { + _ = tx.Rollback() + return err + } + } + return tx.Commit() +} + +func scanTaskWorkspaceFolder(scanner interface{ Scan(...any) error }) (*models.TaskWorkspaceFolder, error) { + folder := &models.TaskWorkspaceFolder{} + if err := scanner.Scan( + &folder.ID, + &folder.TaskID, + &folder.LocalPath, + &folder.DisplayName, + &folder.Position, + &folder.CreatedAt, + &folder.UpdatedAt, + ); err != nil { + return nil, err + } + return folder, nil +} diff --git a/apps/backend/internal/task/repository/sqlite/workspace_folder_test.go b/apps/backend/internal/task/repository/sqlite/workspace_folder_test.go new file mode 100644 index 0000000000..2601eea00f --- /dev/null +++ b/apps/backend/internal/task/repository/sqlite/workspace_folder_test.go @@ -0,0 +1,175 @@ +package sqlite + +import ( + "context" + "testing" + "time" + + "github.com/kandev/kandev/internal/task/models" +) + +func TestTaskWorkspaceFoldersSchemaPersistsCanonicalFolders(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + now := time.Now().UTC() + + if _, err := repo.DB().ExecContext(ctx, ` + INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) + VALUES (?, ?, ?, ?, ?) + `, "task-folders", "workspace-folders", "Folders", now, now); err != nil { + t.Fatalf("seed task: %v", err) + } + if _, err := repo.DB().ExecContext(ctx, ` + INSERT INTO task_workspace_folders + (id, task_id, local_path, display_name, position, created_at, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?) + `, "folder-1", "task-folders", "/canonical/support", "support", 0, now, now); err != nil { + t.Fatalf("insert workspace folder: %v", err) + } + + var path, name string + if err := repo.DB().QueryRowContext(ctx, ` + SELECT local_path, display_name FROM task_workspace_folders WHERE id = ? + `, "folder-1").Scan(&path, &name); err != nil { + t.Fatalf("read persisted workspace folder: %v", err) + } + if path != "/canonical/support" || name != "support" { + t.Fatalf("persisted folder = (%q, %q), want canonical path and display name", path, name) + } +} + +func TestListTaskWorkspaceFoldersByTaskIDsGroupsFolders(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + now := time.Now().UTC() + for _, taskID := range []string{"task-folders-a", "task-folders-b"} { + if _, err := repo.DB().ExecContext(ctx, `INSERT INTO tasks (id, workspace_id, title, created_at, updated_at) VALUES (?, ?, ?, ?, ?)`, taskID, "workspace-folders", taskID, now, now); err != nil { + t.Fatalf("seed task %s: %v", taskID, err) + } + } + for _, folder := range []struct { + id, taskID, name string + position int + }{{"folder-a", "task-folders-a", "docs", 1}, {"folder-b", "task-folders-b", "support", 0}} { + if _, err := repo.DB().ExecContext(ctx, `INSERT INTO task_workspace_folders (id, task_id, local_path, display_name, position, created_at, updated_at) VALUES (?, ?, ?, ?, ?, ?, ?)`, folder.id, folder.taskID, "/canonical/"+folder.name, folder.name, folder.position, now, now); err != nil { + t.Fatalf("seed folder %s: %v", folder.id, err) + } + } + folders, err := repo.ListTaskWorkspaceFoldersByTaskIDs(ctx, []string{"task-folders-a", "task-folders-b"}) + if err != nil { + t.Fatalf("ListTaskWorkspaceFoldersByTaskIDs: %v", err) + } + if len(folders["task-folders-a"]) != 1 || folders["task-folders-a"][0].DisplayName != "docs" || len(folders["task-folders-b"]) != 1 || folders["task-folders-b"][0].DisplayName != "support" { + t.Fatalf("grouped folders = %#v", folders) + } +} + +func TestCreateWorkspaceSourceBatchAllocatesMixedPositionsAndCompensates(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "workspace-sources") + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repository-existing", WorkspaceID: "workspace-sources", Name: "existing", + }); err != nil { + t.Fatalf("seed existing repository: %v", err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repository-new", WorkspaceID: "workspace-sources", Name: "new", + }); err != nil { + t.Fatalf("seed new repository: %v", err) + } + if err := repo.CreateTask(ctx, &models.Task{ + ID: "task-sources", WorkspaceID: "workspace-sources", Title: "Sources", Priority: "medium", + }); err != nil { + t.Fatalf("seed task: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repository-existing", TaskID: "task-sources", RepositoryID: "repository-existing", BaseBranch: "main", Position: 3, + }); err != nil { + t.Fatalf("seed task repository: %v", err) + } + + batch := &models.WorkspaceSourceBatch{ + TaskID: "task-sources", + Sources: []models.WorkspaceSource{ + {Repository: &models.TaskRepository{RepositoryID: "repository-new", BaseBranch: "main"}}, + {Folder: &models.TaskWorkspaceFolder{LocalPath: "/canonical/docs", DisplayName: "docs"}}, + }, + } + if err := repo.CreateWorkspaceSourceBatch(ctx, batch); err != nil { + t.Fatalf("create workspace source batch: %v", err) + } + if batch.Sources[0].Repository.Position != 4 || batch.Sources[1].Folder.Position != 5 { + t.Fatalf("allocated positions = repository %d, folder %d; want 4, 5", batch.Sources[0].Repository.Position, batch.Sources[1].Folder.Position) + } + if err := repo.CompensateWorkspaceSourceBatch(ctx, batch); err != nil { + t.Fatalf("compensate workspace source batch: %v", err) + } + folders, err := repo.ListTaskWorkspaceFolders(ctx, "task-sources") + if err != nil { + t.Fatalf("list folders after compensation: %v", err) + } + if len(folders) != 0 { + t.Fatalf("folders after compensation = %#v, want none", folders) + } + repos, err := repo.ListTaskRepositories(ctx, "task-sources") + if err != nil { + t.Fatalf("list repositories after compensation: %v", err) + } + if len(repos) != 1 || repos[0].ID != "task-repository-existing" { + t.Fatalf("repositories after compensation = %#v, want only existing source", repos) + } +} + +func TestTaskWorkspaceFolderRejectsDuplicatePathOrDisplayNameAndCascades(t *testing.T) { + repo := newRepoForEntityTests(t) + ctx := context.Background() + seedWorkspace(t, repo, "workspace-folder-constraints") + if err := repo.CreateTask(ctx, &models.Task{ + ID: "task-folder-constraints", WorkspaceID: "workspace-folder-constraints", Title: "Folders", Priority: "medium", + }); err != nil { + t.Fatalf("seed task: %v", err) + } + if err := repo.CreateWorkspaceSourceBatch(ctx, &models.WorkspaceSourceBatch{TaskID: "task-folder-constraints", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{ + LocalPath: "/canonical/docs", DisplayName: "docs", + }}}}); err != nil { + t.Fatalf("create first folder: %v", err) + } + for _, folder := range []*models.TaskWorkspaceFolder{ + {TaskID: "task-folder-constraints", LocalPath: "/canonical/docs", DisplayName: "other"}, + {TaskID: "task-folder-constraints", LocalPath: "/canonical/other", DisplayName: "docs"}, + } { + if err := repo.CreateWorkspaceSourceBatch(ctx, &models.WorkspaceSourceBatch{TaskID: folder.TaskID, Sources: []models.WorkspaceSource{{Folder: folder}}}); err == nil { + t.Fatalf("CreateWorkspaceSourceBatch(%q, %q) succeeded, want uniqueness error", folder.LocalPath, folder.DisplayName) + } + } + if err := repo.DeleteTask(ctx, "task-folder-constraints"); err != nil { + t.Fatalf("delete task: %v", err) + } + var count int + if err := repo.DB().QueryRowContext(ctx, `SELECT COUNT(*) FROM task_workspace_folders WHERE task_id = ?`, "task-folder-constraints").Scan(&count); err != nil { + t.Fatalf("count cascaded folders: %v", err) + } + if count != 0 { + t.Fatalf("folders after task delete = %d, want cascade deletion", count) + } +} + +func TestTaskWorkspaceFoldersMigrationReplaysAfterLegacyTableIsMissing(t *testing.T) { + repo := newRepoForEntityTests(t) + if _, err := repo.DB().Exec(`DROP TABLE task_workspace_folders`); err != nil { + t.Fatalf("simulate legacy schema: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("migrate legacy schema: %v", err) + } + if err := repo.runMigrations(); err != nil { + t.Fatalf("replay workspace-folder migration: %v", err) + } + var tableName string + if err := repo.DB().QueryRow(` + SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'task_workspace_folders' + `).Scan(&tableName); err != nil { + t.Fatalf("workspace-folder table after migration: %v", err) + } +} diff --git a/apps/backend/internal/task/service/service.go b/apps/backend/internal/task/service/service.go index 993ac769ed..efe12bb5fb 100644 --- a/apps/backend/internal/task/service/service.go +++ b/apps/backend/internal/task/service/service.go @@ -147,6 +147,14 @@ var ( ErrActiveTaskSessions = errors.New("active agent sessions exist") ErrInvalidRepositorySettings = errors.New("invalid repository settings") ErrInvalidExecutorConfig = errors.New("invalid executor config") + // Workspace-source sentinels are the service boundary consumed by the HTTP + // and MCP adapters. Keep categories stable rather than making callers parse + // a validation or runtime error string. + ErrInvalidWorkspaceSource = errors.New("invalid workspace source") + ErrWorkspaceSourceConflict = errors.New("workspace source conflict") + ErrWorkspaceSourceActive = errors.New("workspace source task is active") + ErrUnsupportedWorkspaceSource = errors.New("unsupported workspace source") + ErrWorkspaceSourceMaterialize = errors.New("workspace source materialization failed") ) func validateExecutorConfig(config map[string]string) error { @@ -169,62 +177,69 @@ func validateExecutorConfig(config map[string]string) error { // Repos holds the repository sub-interfaces used by the task service. type Repos struct { - Workspaces repository.WorkspaceRepository - Tasks repository.TaskRepository - TaskRepos repository.TaskRepoRepository - Workflows repository.WorkflowRepository - Messages repository.MessageRepository - Turns repository.TurnRepository - Sessions repository.SessionRepository - GitSnapshots repository.GitSnapshotRepository - RepoEntities repository.RepositoryEntityRepository - Executors repository.ExecutorRepository - Environments repository.EnvironmentRepository - TaskEnvironments repository.TaskEnvironmentRepository - Reviews repository.ReviewRepository - ResourceCleanups repository.TaskResourceCleanupRepository + Workspaces repository.WorkspaceRepository + Tasks repository.TaskRepository + TaskRepos repository.TaskRepoRepository + WorkspaceFolders repository.TaskWorkspaceFolderRepository + Workflows repository.WorkflowRepository + Messages repository.MessageRepository + Turns repository.TurnRepository + Sessions repository.SessionRepository + GitSnapshots repository.GitSnapshotRepository + RepoEntities repository.RepositoryEntityRepository + RepositoryCleanup repository.RepositoryCleanupRepository + Executors repository.ExecutorRepository + Environments repository.EnvironmentRepository + TaskEnvironments repository.TaskEnvironmentRepository + Reviews repository.ReviewRepository + ResourceCleanups repository.TaskResourceCleanupRepository } // Service provides task business logic type Service struct { - workspaces repository.WorkspaceRepository - tasks repository.TaskRepository - taskRepos repository.TaskRepoRepository - workflows repository.WorkflowRepository - messages repository.MessageRepository - turns repository.TurnRepository - sessions repository.SessionRepository - gitSnapshots repository.GitSnapshotRepository - repoEntities repository.RepositoryEntityRepository - executors repository.ExecutorRepository - environments repository.EnvironmentRepository - taskEnvironments repository.TaskEnvironmentRepository - reviews repository.ReviewRepository - resourceCleanups repository.TaskResourceCleanupRepository - eventBus bus.EventBus - logger *logger.Logger - discoveryConfig RepositoryDiscoveryConfig - worktreeCleanup WorktreeCleanup - executionStopper TaskExecutionStopper - cleanupActivity TaskResourceCleanupActivityGate - branchMaterializer BranchMaterializer - providerProber ProviderDefaultBranchProber - gitArchiveCapture GitArchiveCapture - workflowStepCreator WorkflowStepCreator - workspaceBootstrapper WorkspaceBootstrapper - workflowStepGetter WorkflowStepGetter - startStepResolver StartStepResolver - prTaskResolver PRTaskResolver - quickChatDir string // Directory for quick-chat workspaces (e.g., ~/.kandev/quick-chat) - branchFetcher *branchFetcher - envDestroyer EnvironmentDestroyer - sessionRunningChecker SessionRunningChecker - remoteBranchLister RemoteBranchLister - repoCloneLocation RepoCloneLocation - blockers BlockerRepository - comments CommentRepository - baseBranchPusher AgentBaseBranchPusher - runtimeOverridesMu sync.Mutex + workspaces repository.WorkspaceRepository + tasks repository.TaskRepository + taskRepos repository.TaskRepoRepository + workspaceFolders repository.TaskWorkspaceFolderRepository + workflows repository.WorkflowRepository + messages repository.MessageRepository + turns repository.TurnRepository + sessions repository.SessionRepository + gitSnapshots repository.GitSnapshotRepository + repoEntities repository.RepositoryEntityRepository + repositoryCleanup repository.RepositoryCleanupRepository + executors repository.ExecutorRepository + environments repository.EnvironmentRepository + taskEnvironments repository.TaskEnvironmentRepository + reviews repository.ReviewRepository + resourceCleanups repository.TaskResourceCleanupRepository + eventBus bus.EventBus + logger *logger.Logger + discoveryConfig RepositoryDiscoveryConfig + worktreeCleanup WorktreeCleanup + executionStopper TaskExecutionStopper + cleanupActivity TaskResourceCleanupActivityGate + branchMaterializer BranchMaterializer + workspaceSourceMaterializer WorkspaceSourceMaterializer + workspaceSourceLocksMu sync.Mutex + workspaceSourceLocks map[string]*sync.Mutex + providerProber ProviderDefaultBranchProber + gitArchiveCapture GitArchiveCapture + workflowStepCreator WorkflowStepCreator + workspaceBootstrapper WorkspaceBootstrapper + workflowStepGetter WorkflowStepGetter + startStepResolver StartStepResolver + prTaskResolver PRTaskResolver + quickChatDir string // Directory for quick-chat workspaces (e.g., ~/.kandev/quick-chat) + branchFetcher *branchFetcher + envDestroyer EnvironmentDestroyer + sessionRunningChecker SessionRunningChecker + remoteBranchLister RemoteBranchLister + repoCloneLocation RepoCloneLocation + blockers BlockerRepository + comments CommentRepository + baseBranchPusher AgentBaseBranchPusher + runtimeOverridesMu sync.Mutex // foregroundActivity resolves the live fine-grained busy substate of a RUNNING // session (satisfied by the orchestrator). Used to compute the task-level // MOST-ACTIVE-WINS activity aggregate carried on task.updated events. Optional. @@ -260,25 +275,27 @@ type Service struct { // NewService creates a new task service func NewService(repos Repos, eventBus bus.EventBus, log *logger.Logger, discoveryConfig RepositoryDiscoveryConfig) *Service { return &Service{ - workspaces: repos.Workspaces, - tasks: repos.Tasks, - taskRepos: repos.TaskRepos, - workflows: repos.Workflows, - messages: repos.Messages, - turns: repos.Turns, - sessions: repos.Sessions, - gitSnapshots: repos.GitSnapshots, - repoEntities: repos.RepoEntities, - executors: repos.Executors, - environments: repos.Environments, - taskEnvironments: repos.TaskEnvironments, - reviews: repos.Reviews, - resourceCleanups: repos.ResourceCleanups, - eventBus: eventBus, - logger: log, - discoveryConfig: discoveryConfig, - branchFetcher: newBranchFetcher(log.Zap()), - lastTaskActivity: make(map[string]v1.ForegroundActivity), + workspaces: repos.Workspaces, + tasks: repos.Tasks, + taskRepos: repos.TaskRepos, + workspaceFolders: repos.WorkspaceFolders, + workflows: repos.Workflows, + messages: repos.Messages, + turns: repos.Turns, + sessions: repos.Sessions, + gitSnapshots: repos.GitSnapshots, + repoEntities: repos.RepoEntities, + repositoryCleanup: repos.RepositoryCleanup, + executors: repos.Executors, + environments: repos.Environments, + taskEnvironments: repos.TaskEnvironments, + reviews: repos.Reviews, + resourceCleanups: repos.ResourceCleanups, + eventBus: eventBus, + logger: log, + discoveryConfig: discoveryConfig, + branchFetcher: newBranchFetcher(log.Zap()), + lastTaskActivity: make(map[string]v1.ForegroundActivity), } } @@ -298,6 +315,10 @@ func (s *Service) SetBranchMaterializer(m BranchMaterializer) { s.branchMaterializer = m } +func (s *Service) SetWorkspaceSourceMaterializer(m WorkspaceSourceMaterializer) { + s.workspaceSourceMaterializer = m +} + // SetAgentBaseBranchPusher wires the live-update push for // UpdateRepositoryBaseBranch. Optional — when unset, the persisted DB value // is the source of truth and the new base branch takes effect at next diff --git a/apps/backend/internal/task/service/service_branches.go b/apps/backend/internal/task/service/service_branches.go index c0810f0e52..3403928523 100644 --- a/apps/backend/internal/task/service/service_branches.go +++ b/apps/backend/internal/task/service/service_branches.go @@ -6,8 +6,8 @@ import ( "go.uber.org/zap" - "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" + taskrepository "github.com/kandev/kandev/internal/task/repository" taskrepo "github.com/kandev/kandev/internal/task/repository/sqlite" "github.com/kandev/kandev/internal/worktree" ) @@ -55,10 +55,15 @@ type AddBranchToTaskRequest struct { // LocalPath / GitHubURL flows; for the RepositoryID fast path the // workspace-membership check happens in resolveBranchRepo below. func (s *Service) AddBranchToTask(ctx context.Context, req AddBranchToTaskRequest) (*models.TaskRepository, error) { + unlock := s.lockWorkspaceSources(req.TaskID) + defer unlock() task, existing, cleanupOrphanRepo, err := s.prepareBranchAdd(ctx, &req) if err != nil { return nil, err } + if err := s.workspaceSourcesIdle(ctx, task.ID); err != nil { + return nil, err + } repo, err := s.resolveBranchRepo(ctx, task, req.RepositoryID) if err != nil { cleanupOrphanRepo() @@ -78,58 +83,53 @@ func (s *Service) AddBranchToTask(ctx context.Context, req AddBranchToTaskReques return nil, fmt.Errorf("cannot resolve base_branch for repository %q: pass base_branch explicitly", repoLabelOrID(repo, req.RepositoryID)) } checkoutBranch := resolveCheckoutBranchOrAutoName(req.CheckoutBranch, existing, req.RepositoryID, baseBranch) + if err := validateWorkspaceSourceBranches(baseBranch, checkoutBranch); err != nil { + cleanupOrphanRepo() + return nil, err + } - nextPosition, dupErr := scanForBranchAddDuplicate(existing, req.RepositoryID, baseBranch, checkoutBranch, repo) + _, dupErr := scanForBranchAddDuplicate(existing, req.RepositoryID, baseBranch, checkoutBranch, repo) if dupErr != nil { cleanupOrphanRepo() return nil, dupErr } taskRepo := &models.TaskRepository{ - TaskID: req.TaskID, RepositoryID: req.RepositoryID, BaseBranch: baseBranch, CheckoutBranch: checkoutBranch, - Position: nextPosition, Metadata: map[string]interface{}{}, } - if err := s.taskRepos.CreateTaskRepository(ctx, taskRepo); err != nil { - s.logger.Error("AddBranchToTask: failed to create task repository", - zap.String("task_id", req.TaskID), - zap.String("repository_id", req.RepositoryID), - zap.String("checkout_branch", req.CheckoutBranch), - zap.Error(err)) - cleanupOrphanRepo() - return nil, fmt.Errorf("create task repository: %w", err) - } - - if err := s.materializeBranch(ctx, req.TaskID, taskRepo.ID); err != nil { - // Roll back the task_repositories row so a failed materialize doesn't - // leave a dangling association the user can't see on disk. Pre-launch - // tasks short-circuit inside materializeBranch and never reach this - // branch — their worktree is built at next session launch. - // - // Detach from ctx via WithoutCancel: a caller-side timeout or cancel - // can fire mid-materialize, and the rollback must still run on the - // now-dead ctx or we'd leak the row we tried to delete. - rollbackCtx := context.WithoutCancel(ctx) - if delErr := s.taskRepos.DeleteTaskRepository(rollbackCtx, taskRepo.ID); delErr != nil { - s.logger.Error("AddBranchToTask: failed to roll back task repository after materialize failure", - zap.String("task_repository_id", taskRepo.ID), - zap.Error(delErr)) - } + batch := &models.WorkspaceSourceBatch{TaskID: task.ID, Sources: []models.WorkspaceSource{{Repository: taskRepo}}} + if err := s.rejectRuntimeNameCollisions(ctx, append(existing, taskRepo), batch); err != nil { cleanupOrphanRepo() return nil, err } - - // Publish task.updated only after the row is durable AND the worktree - // materialized. Emitting before materialize would push a phantom row to - // WS clients on the rollback path; emitting after keeps event truthiness - // aligned with persisted state. - s.publishTaskEvent(ctx, events.TaskUpdated, task, nil) + materialize := s.materializeLegacyBranch + if s.workspaceSourceMaterializer != nil { + materialize = s.materializeWorkspaceSources + } + _, err = s.commitWorkspaceSourceBatch(ctx, task, batch, func(context.Context) { cleanupOrphanRepo() }, materialize) + if err != nil { + return nil, err + } return taskRepo, nil } +// materializeLegacyBranch adapts the legacy one-row worktree capability to +// the batch commit path. The existing materializer owns its live/pre-launch +// distinction, preserving add_branch's historical no-op-before-launch rule. +func (s *Service) materializeLegacyBranch(ctx context.Context, taskID string, batch *models.WorkspaceSourceBatch) (*WorkspaceSourceMaterializationResult, error) { + for _, source := range batch.Sources { + if source.Repository != nil { + if err := s.materializeBranch(ctx, taskID, source.Repository.ID); err != nil { + return nil, err + } + } + } + return &WorkspaceSourceMaterializationResult{}, nil +} + // prepareBranchAdd runs the input validation, executor-shield check, and // repository_id defaulting that AddBranchToTask needs before it can pick a // branch name and write the row. Returns the loaded task plus the current @@ -197,7 +197,7 @@ func (s *Service) resolveBranchRepo(ctx context.Context, task *models.Task, repo return nil, fmt.Errorf("get repository: %w", err) } if repo == nil || repo.WorkspaceID != task.WorkspaceID { - return nil, fmt.Errorf("repository %q does not belong to task's workspace", repositoryID) + return nil, fmt.Errorf("%w: repository %q does not belong to task's workspace", taskrepository.ErrRepositoryNotFound, repositoryID) } return repo, nil } diff --git a/apps/backend/internal/task/service/service_branches_test.go b/apps/backend/internal/task/service/service_branches_test.go index 0048480946..4e7e79c97a 100644 --- a/apps/backend/internal/task/service/service_branches_test.go +++ b/apps/backend/internal/task/service/service_branches_test.go @@ -2,6 +2,7 @@ package service import ( "context" + "errors" "fmt" "strings" "testing" @@ -928,3 +929,39 @@ func TestAddBranchToTask_MaterializeSkippedPreLaunchStillSucceeds(t *testing.T) t.Errorf("expected row to remain after pre-launch materialize failure; got %d rows", len(rows)) } } + +func TestAddBranchToTask_RejectsActiveTaskBeforePersisting(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-add-idle", Name: "WS"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-add-idle", WorkspaceID: "ws-add-idle", Name: "WF"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-add-idle", WorkspaceID: "ws-add-idle", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-add-idle", WorkflowID: "wf-add-idle", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-add-idle", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-add-idle", TaskID: task.ID}); err != nil { + t.Fatal(err) + } + if _, err := svc.StartTurn(ctx, "session-add-idle"); err != nil { + t.Fatal(err) + } + + _, err = svc.AddBranchToTask(ctx, AddBranchToTaskRequest{TaskID: task.ID, RepositoryID: "repo-add-idle", BaseBranch: "main", CheckoutBranch: "feature/source"}) + if !errors.Is(err, ErrWorkspaceSourceActive) { + t.Fatalf("error = %v, want active-task rejection", err) + } + rows, err := repo.ListTaskRepositories(ctx, task.ID) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("task repositories = %#v, want no added branch", rows) + } +} diff --git a/apps/backend/internal/task/service/service_events.go b/apps/backend/internal/task/service/service_events.go index 8a73294244..c48fa8068c 100644 --- a/apps/backend/internal/task/service/service_events.go +++ b/apps/backend/internal/task/service/service_events.go @@ -49,6 +49,25 @@ func (s *Service) PublishTaskUpdated(ctx context.Context, task *models.Task, old s.publishTaskEvent(ctx, events.TaskUpdated, task, nil, oldWorkflowIDs...) } +// PublishWorkspaceSourcesAdopted publishes the session refresh boundary after +// a runtime has adopted the materialized workspace. Materializers must call it +// only after agentctl adoption succeeds; protocol handlers never publish an +// optimistic success event. +func (s *Service) PublishWorkspaceSourcesAdopted(ctx context.Context, taskID, workspacePath string, sessionIDs []string) { + if s.eventBus == nil { + return + } + for _, sessionID := range sessionIDs { + if sessionID == "" { + continue + } + data := map[string]interface{}{"task_id": taskID, "session_id": sessionID, "workspace_path": workspacePath} + if err := s.eventBus.Publish(ctx, events.SessionWorkspaceSourcesUpdated, bus.NewEvent(events.SessionWorkspaceSourcesUpdated, "task-service", data)); err != nil { + s.logger.Error("failed to publish workspace source adoption", zap.String("task_id", taskID), zap.String("session_id", sessionID), zap.Error(err)) + } + } +} + // PublishTaskStateChanged publishes a task.state_changed event for callers // that mutate task state outside the normal task service update path. func (s *Service) PublishTaskStateChanged(ctx context.Context, task *models.Task, oldState v1.TaskState) { @@ -354,6 +373,7 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas data["repository_id"] = repos[0].RepositoryID } } + s.addTaskWorkspaceFoldersToEvent(ctx, task, data) if task.Metadata != nil { data["metadata"] = task.Metadata } @@ -387,6 +407,12 @@ func (s *Service) publishTaskEventNow(ctx context.Context, eventType string, tas s.logTaskLifecycleEventPublished(eventType, task, data) } +func (s *Service) addTaskWorkspaceFoldersToEvent(ctx context.Context, task *models.Task, data map[string]interface{}) { + if folders, ok := taskWorkspaceFoldersForEvent(ctx, s, task); ok { + data["workspace_folders"] = serializeTaskWorkspaceFolders(folders) + } +} + func (s *Service) logTaskLifecycleEventPublished(eventType string, task *models.Task, data map[string]interface{}) { switch eventType { case events.TaskCreated, events.TaskUpdated, events.TaskStateChanged, events.TaskDeleted: @@ -596,6 +622,36 @@ func serializeTaskRepositories(repos []*models.TaskRepository) []map[string]inte return out } +func taskWorkspaceFoldersForEvent(ctx context.Context, s *Service, task *models.Task) ([]*models.TaskWorkspaceFolder, bool) { + if len(task.WorkspaceFolders) > 0 { + return task.WorkspaceFolders, true + } + if s.workspaceFolders == nil { + return nil, false + } + folders, err := s.workspaceFolders.ListTaskWorkspaceFolders(ctx, task.ID) + if err != nil { + return nil, false + } + return folders, true +} + +func serializeTaskWorkspaceFolders(folders []*models.TaskWorkspaceFolder) []map[string]interface{} { + out := make([]map[string]interface{}, 0, len(folders)) + for _, folder := range folders { + out = append(out, map[string]interface{}{ + "id": folder.ID, + "task_id": folder.TaskID, + "local_path": folder.LocalPath, + "display_name": folder.DisplayName, + "position": folder.Position, + "created_at": folder.CreatedAt.Format(time.RFC3339), + "updated_at": folder.UpdatedAt.Format(time.RFC3339), + }) + } + return out +} + // publishTaskMovedEvent publishes a task.moved event so the orchestrator can process // on_exit/on_enter actions for the new workflow step. func (s *Service) publishTaskMovedEvent(ctx context.Context, task *models.Task, fromWorkflowID, fromStepID, toStepID, sessionID string) { diff --git a/apps/backend/internal/task/service/service_events_test.go b/apps/backend/internal/task/service/service_events_test.go index acafddcb15..41b748cea1 100644 --- a/apps/backend/internal/task/service/service_events_test.go +++ b/apps/backend/internal/task/service/service_events_test.go @@ -505,6 +505,18 @@ func TestPublishTaskUpdated_FallbackRepositoryID(t *testing.T) { } } +func TestPublishWorkspaceSourcesAdoptedPublishesSessionScopedPayload(t *testing.T) { + svc, eventBus, _ := createTestService(t) + eventBus.ClearEvents() + + svc.PublishWorkspaceSourcesAdopted(context.Background(), "task-1", "/workspaces/task-1", []string{"session-1"}) + + data := singlePublishedEventData(t, eventBus) + if data["task_id"] != "task-1" || data["session_id"] != "session-1" || data["workspace_path"] != "/workspaces/task-1" { + t.Fatalf("unexpected source-adoption payload: %#v", data) + } +} + func TestPublishTaskUpdated_EmitsEmptyRepositories(t *testing.T) { svc, eventBus, repo := createTestService(t) ctx := context.Background() @@ -529,6 +541,33 @@ func TestPublishTaskUpdated_EmitsEmptyRepositories(t *testing.T) { } } +func TestPublishTaskUpdated_EmitsWorkspaceFolders(t *testing.T) { + svc, eventBus, repo := createTestService(t) + ctx := context.Background() + + createTaskWithoutRepositories(t, ctx, repo) + if err := repo.CreateWorkspaceSourceBatch(ctx, &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{ + LocalPath: "/canonical/docs", DisplayName: "docs", + }}}}); err != nil { + t.Fatalf("create workspace folder: %v", err) + } + svc.workspaceFolders = repo + eventBus.ClearEvents() + + svc.PublishTaskUpdated(ctx, &models.Task{ + ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-1", WorkflowStepID: "step-1", + }) + + data := singlePublishedEventData(t, eventBus) + folders, ok := data["workspace_folders"].([]map[string]interface{}) + if !ok { + t.Fatalf("workspace_folders missing from payload or wrong type: %#v", data["workspace_folders"]) + } + if len(folders) != 1 || folders[0]["local_path"] != "/canonical/docs" { + t.Fatalf("workspace_folders = %#v, want canonical docs folder", folders) + } +} + func TestPublishTaskUpdated_EmitsNullPrimarySessionFieldsWhenNoPrimaryExists(t *testing.T) { svc, eventBus, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/task/service/service_messages.go b/apps/backend/internal/task/service/service_messages.go index 33cf325033..ac014c94ce 100644 --- a/apps/backend/internal/task/service/service_messages.go +++ b/apps/backend/internal/task/service/service_messages.go @@ -50,13 +50,18 @@ func (s *Service) CreateMessage(ctx context.Context, req *CreateMessageRequest) // Ensure we have a turn ID - get active turn or start a new one turnID := req.TurnID if turnID == "" { - turn, err := s.getOrStartTurnWithRetry( - ctx, - req.TaskSessionID, - messageID, - messageCreateMaxRetries, - messageCreateRetryDelay, - ) + var turn *models.Turn + if req.CompletedTurn { + turn, err = s.createCompletedTurn(ctx, session) + } else { + turn, err = s.getOrStartTurnWithRetry( + ctx, + req.TaskSessionID, + messageID, + messageCreateMaxRetries, + messageCreateRetryDelay, + ) + } if err != nil { s.logger.Warn("failed to get or start turn for message", zap.String("session_id", req.TaskSessionID), diff --git a/apps/backend/internal/task/service/service_requests.go b/apps/backend/internal/task/service/service_requests.go index c1546ea539..9ee19b7caa 100644 --- a/apps/backend/internal/task/service/service_requests.go +++ b/apps/backend/internal/task/service/service_requests.go @@ -265,6 +265,7 @@ type CreateMessageRequest struct { TaskSessionID string `json:"session_id"` TaskID string `json:"task_id,omitempty"` TurnID string `json:"turn_id"` + CompletedTurn bool `json:"-"` Content string `json:"content"` AuthorType string `json:"author_type,omitempty"` // "user" or "agent", defaults to "user" AuthorID string `json:"author_id,omitempty"` diff --git a/apps/backend/internal/task/service/service_resources.go b/apps/backend/internal/task/service/service_resources.go index a20d424525..176a629b73 100644 --- a/apps/backend/internal/task/service/service_resources.go +++ b/apps/backend/internal/task/service/service_resources.go @@ -738,6 +738,11 @@ func (s *Service) createRepository( if resolveProvider { resolveRepositoryProviderIdentity(repository) } + if repository.LocalPath != "" && repository.RemoteURL == "" { + if origin := canonicalCloneOrigin(repository.LocalPath); origin != "" { + repository.RemoteURL = origin + } + } if err := s.repoEntities.CreateRepository(ctx, repository); err != nil { s.logger.Error("failed to create repository", zap.Error(err)) @@ -765,6 +770,21 @@ func resolveRepositoryProviderIdentity(repository *models.Repository) { } } +// canonicalCloneOrigin returns a credential-free clone URL for a local +// checkout's origin. Invalid and unsupported origins deliberately remain +// empty so host-local-only repositories cannot leak credentials into storage. +func canonicalCloneOrigin(localPath string) string { + raw, err := readGitRemoteOriginURL(localPath) + if err != nil || raw == "" { + return "" + } + _, _, _, canonical, err := parseRemoteRepositoryURL(raw, "") + if err != nil { + return "" + } + return canonical +} + func (s *Service) GetRepository(ctx context.Context, id string) (*models.Repository, error) { repo, err := s.repoEntities.GetRepository(ctx, id) if err != nil { diff --git a/apps/backend/internal/task/service/service_tasks.go b/apps/backend/internal/task/service/service_tasks.go index 2f592b14bb..af5ac6ee45 100644 --- a/apps/backend/internal/task/service/service_tasks.go +++ b/apps/backend/internal/task/service/service_tasks.go @@ -1,3 +1,4 @@ +//revive:disable:file-length-limit // Legacy task operations remain cohesive; workspace-source additions live in focused files. package service import ( @@ -201,6 +202,9 @@ func (s *Service) validateCreateTaskRequest(req *CreateTaskRequest) error { if req.IsEphemeral && req.WorkflowID != "" { return fmt.Errorf("workflow_id must be empty for ephemeral tasks") } + if err := validateTaskRepositoryBranches(req.Repositories); err != nil { + return err + } return nil } @@ -721,6 +725,9 @@ func (s *Service) resolveRepoInputRemote( defaultBranch = s.probeProviderDefaultBranchIfMissing(ctx, workspaceID, provider, owner, name) } providerHost := remoteProviderHost(provider, canonicalURL) + if provider == providerGitLab && providerHost != "https://gitlab.com" { + return "", "", false, fmt.Errorf("untrusted GitLab origin %q", providerHost) + } repo, repoCreated, createErr := s.FindOrCreateRepository(ctx, &FindOrCreateRepositoryRequest{ WorkspaceID: workspaceID, Provider: provider, @@ -994,6 +1001,7 @@ func (s *Service) GetTask(ctx context.Context, id string) (*models.Task, error) } else { task.Repositories = repos } + s.hydrateTaskWorkspaceFolders(ctx, task) return task, nil } @@ -2392,6 +2400,7 @@ func (s *Service) ListTasks(ctx context.Context, workflowID string) ([]*models.T if err := s.loadTaskRepositoriesBatch(ctx, tasks); err != nil { s.logger.Error("failed to batch-load task repositories", zap.Error(err)) } + s.hydrateTaskWorkspaceFoldersBatch(ctx, tasks) return tasks, nil } @@ -2424,6 +2433,7 @@ func (s *Service) ListTasksByWorkspace(ctx context.Context, workspaceID, workflo if err := s.loadTaskRepositoriesBatch(ctx, tasks); err != nil { s.logger.Error("failed to batch-load task repositories", zap.Error(err)) } + s.hydrateTaskWorkspaceFoldersBatch(ctx, tasks) return tasks, total, nil } diff --git a/apps/backend/internal/task/service/service_test.go b/apps/backend/internal/task/service/service_test.go index 5effe6fb2d..30239f9e38 100644 --- a/apps/backend/internal/task/service/service_test.go +++ b/apps/backend/internal/task/service/service_test.go @@ -131,20 +131,21 @@ func createTestServiceWithSessionsRepo( eventBus := NewMockEventBus() log, _ := logger.NewLogger(logger.LoggingConfig{Level: "error", Format: "json", OutputPath: "stdout"}) svc := NewService(Repos{ - Workspaces: repo, - Tasks: repo, - TaskRepos: repo, - Workflows: repo, - Messages: repo, - Turns: repo, - Sessions: wrapSessions(repo), - GitSnapshots: repo, - RepoEntities: repo, - Executors: repo, - Environments: repo, - TaskEnvironments: repo, - Reviews: repo, - ResourceCleanups: repo, + Workspaces: repo, + Tasks: repo, + TaskRepos: repo, + Workflows: repo, + Messages: repo, + Turns: repo, + Sessions: wrapSessions(repo), + GitSnapshots: repo, + RepoEntities: repo, + RepositoryCleanup: repo, + Executors: repo, + Environments: repo, + TaskEnvironments: repo, + Reviews: repo, + ResourceCleanups: repo, }, eventBus, log, RepositoryDiscoveryConfig{}) svc.SetWorkspaceBootstrapper(repo) if err := svc.StartTaskResourceCleanupWorker(context.Background()); err != nil { @@ -2482,12 +2483,14 @@ func TestService_CleanupTaskResourcesFailsClosedWhenRuntimeInventoryFails(t *tes func TestService_ListTasks(t *testing.T) { svc, _, repo := createTestService(t) + svc.workspaceFolders = repo ctx := context.Background() _ = repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-1", Name: "Workspace"}) _ = repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-123", WorkspaceID: "ws-1", Name: "Workflow"}) _ = repo.CreateTask(ctx, &models.Task{ID: "task-1", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 1", Priority: "medium"}) _ = repo.CreateTask(ctx, &models.Task{ID: "task-2", WorkspaceID: "ws-1", WorkflowID: "wf-123", WorkflowStepID: "step-123", Title: "Task 2", Priority: "medium"}) + _ = repo.CreateWorkspaceSourceBatch(ctx, &models.WorkspaceSourceBatch{TaskID: "task-1", Sources: []models.WorkspaceSource{{Folder: &models.TaskWorkspaceFolder{LocalPath: "/canonical/docs", DisplayName: "docs"}}}}) tasks, err := svc.ListTasks(ctx, "wf-123") if err != nil { @@ -2496,6 +2499,9 @@ func TestService_ListTasks(t *testing.T) { if len(tasks) != 2 { t.Errorf("expected 2 tasks, got %d", len(tasks)) } + if len(tasks[0].WorkspaceFolders) != 1 || tasks[0].WorkspaceFolders[0].DisplayName != "docs" { + t.Fatalf("list workspace folders = %#v, want hydrated docs folder", tasks[0].WorkspaceFolders) + } } type stopExecutionCall struct { diff --git a/apps/backend/internal/task/service/service_turns.go b/apps/backend/internal/task/service/service_turns.go index a030473da8..1da3b06831 100644 --- a/apps/backend/internal/task/service/service_turns.go +++ b/apps/backend/internal/task/service/service_turns.go @@ -19,6 +19,7 @@ import ( "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/events/bus" "github.com/kandev/kandev/internal/task/models" + "github.com/kandev/kandev/internal/worktree" ) // Turn operations @@ -32,6 +33,8 @@ func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn if err != nil { return nil, fmt.Errorf("failed to get session: %w", err) } + unlock := s.lockWorkspaceSources(session.TaskID) + defer unlock() turn := &models.Turn{ ID: uuid.New().String(), @@ -59,6 +62,30 @@ func (s *Service) StartTurn(ctx context.Context, sessionID string) (*models.Turn return turn, nil } +// createCompletedTurn persists a synthetic turn that is never observable as +// active. It is used for lifecycle messages that must belong to a turn without +// making an idle task appear to have an active agent turn. +func (s *Service) createCompletedTurn(ctx context.Context, session *models.TaskSession) (*models.Turn, error) { + if session == nil { + return nil, errors.New("cannot create completed turn without a session") + } + now := time.Now().UTC() + turn := &models.Turn{ + ID: uuid.New().String(), + TaskSessionID: session.ID, + TaskID: session.TaskID, + StartedAt: now, + CompletedAt: &now, + Metadata: runtimeConfigSnapshotMetadata(session), + CreatedAt: now, + UpdatedAt: now, + } + if err := s.turns.CreateTurn(ctx, turn); err != nil { + return nil, fmt.Errorf("failed to create completed turn: %w", err) + } + return turn, nil +} + func runtimeConfigSnapshotMetadata(session *models.TaskSession) map[string]interface{} { if session == nil { return nil @@ -582,6 +609,19 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio RuntimeConfigOptions: runtimeConfig.ConfigOptions, RuntimeConfigOptionsSet: runtimeConfigOptionsSet, } + // Durable folder attachments are replayed by lifecycle for both fresh + // launch and workspace-only session recovery. + if s.workspaceFolders != nil { + folders, err := s.workspaceFolders.ListTaskWorkspaceFolders(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("list task workspace folders: %w", err) + } + for _, folder := range folders { + if folder != nil { + info.WorkspaceFolders = append(info.WorkspaceFolders, lifecycle.WorkspaceFolderSpec{Name: folder.DisplayName, LocalPath: folder.LocalPath}) + } + } + } var taskEnv *models.TaskEnvironment if session.TaskEnvironmentID != "" { @@ -608,6 +648,10 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio } if taskEnv != nil { applyTaskEnvironmentToWorkspaceInfo(info, taskEnv) + info.TaskDirName = taskEnv.TaskDirName + } + if err := s.populateWorkspaceRepositorySpecs(ctx, taskID, session.Worktrees, info); err != nil { + return nil, err } // Populate executor info for correct runtime selection and remote reconnection @@ -660,6 +704,114 @@ func (s *Service) GetWorkspaceInfoForSession(ctx context.Context, taskID, sessio return info, nil } +type workspaceWorktreeKey struct { + repositoryID string + branchSlug string +} + +type workspaceRepositoryProjection struct { + taskRepository *models.TaskRepository + repository *models.Repository + repoName string +} + +func (s *Service) populateWorkspaceRepositorySpecs(ctx context.Context, taskID string, sessionWorktrees []*models.TaskSessionWorktree, info *lifecycle.WorkspaceInfo) error { + if taskID == "" || s.taskRepos == nil || s.repoEntities == nil { + return nil + } + if task, err := s.tasks.GetTask(ctx, taskID); err != nil { + return fmt.Errorf("get workspace task: %w", err) + } else if task != nil { + info.WorkspaceID = task.WorkspaceID + } + worktreesByIdentity := make(map[workspaceWorktreeKey]*models.TaskSessionWorktree, len(sessionWorktrees)) + for _, worktree := range sessionWorktrees { + if worktree != nil && worktree.RepositoryID != "" { + worktreesByIdentity[workspaceWorktreeKey{repositoryID: worktree.RepositoryID, branchSlug: worktree.BranchSlug}] = worktree + } + } + projections, err := s.workspaceRepositoryProjections(ctx, taskID) + if err != nil { + return err + } + branchPlans := worktree.BuildBranchIdentityPlans(workspaceBranchIdentityInputs(projections)) + for index, projection := range projections { + taskRepository, repository := projection.taskRepository, projection.repository + spec := lifecycle.WorkspaceRepositorySpec{ + RepositoryID: taskRepository.RepositoryID, RepositoryPath: repository.LocalPath, RepoName: projection.repoName, + BaseBranch: taskRepository.BaseBranch, DefaultBranch: repository.DefaultBranch, + CheckoutBranch: taskRepository.CheckoutBranch, WorktreeBranchPrefix: repository.WorktreeBranchPrefix, + WorktreeBranchTemplate: repository.WorktreeBranchTemplate, PullBeforeWorktree: repository.PullBeforeWorktree, + } + if worktree := worktreesByIdentity[workspaceWorktreeKey{repositoryID: taskRepository.RepositoryID, branchSlug: branchPlans[index].IdentitySlug}]; worktree != nil { + spec.WorktreeID = worktree.WorktreeID + spec.BranchSlug = worktree.BranchSlug + spec.BranchIdentitySlug = worktree.BranchSlug + } + info.WorkspaceRepositories = append(info.WorkspaceRepositories, spec) + } + return nil +} + +func (s *Service) workspaceRepositoryProjections(ctx context.Context, taskID string) ([]workspaceRepositoryProjection, error) { + taskRepositories, err := s.taskRepos.ListTaskRepositories(ctx, taskID) + if err != nil { + return nil, fmt.Errorf("list task repositories: %w", err) + } + projections := make([]workspaceRepositoryProjection, 0, len(taskRepositories)) + for _, taskRepository := range taskRepositories { + if taskRepository == nil { + continue + } + repository, err := s.repoEntities.GetRepository(ctx, taskRepository.RepositoryID) + if err != nil { + return nil, fmt.Errorf("get workspace repository: %w", err) + } + if repository == nil { + return nil, fmt.Errorf("workspace repository %q not found", taskRepository.RepositoryID) + } + repoName := worktree.SanitizeRepoDirName(repository.Name) + if repoName == "" || repository.LocalPath == "" { + continue + } + projections = append(projections, workspaceRepositoryProjection{taskRepository: taskRepository, repository: repository, repoName: repoName}) + } + return projections, nil +} + +func workspaceBranchIdentityInputs(projections []workspaceRepositoryProjection) []worktree.BranchIdentityInput { + inputs := make([]worktree.BranchIdentityInput, 0, len(projections)) + for _, projection := range projections { + taskRepository, repository := projection.taskRepository, projection.repository + inputs = append(inputs, worktree.BranchIdentityInput{ + RepositoryID: taskRepository.RepositoryID, BaseBranch: taskRepository.BaseBranch, CheckoutBranch: taskRepository.CheckoutBranch, + DefaultBranch: repository.DefaultBranch, PRNumber: taskRepositoryPRNumber(taskRepository.Metadata), Position: taskRepository.Position, + }) + } + return inputs +} + +func taskRepositoryPRNumber(metadata map[string]interface{}) int { + if metadata == nil { + return 0 + } + switch number := metadata["pr_number"].(type) { + case float64: + if number > 0 { + return int(number) + } + case int: + if number > 0 { + return number + } + case int64: + if number > 0 { + return int(number) + } + } + return 0 +} + // PersistSessionRuntimeModel records the session's selected ACP model. func (s *Service) PersistSessionRuntimeModel(ctx context.Context, sessionID, modelID string) error { if modelID == "" { diff --git a/apps/backend/internal/task/service/service_turns_test.go b/apps/backend/internal/task/service/service_turns_test.go index 6c705bea51..8056d0c8e0 100644 --- a/apps/backend/internal/task/service/service_turns_test.go +++ b/apps/backend/internal/task/service/service_turns_test.go @@ -3,6 +3,7 @@ package service import ( "context" "fmt" + "path/filepath" "sync" "testing" "time" @@ -12,6 +13,7 @@ import ( "github.com/kandev/kandev/internal/events" "github.com/kandev/kandev/internal/task/models" "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/worktree" "github.com/stretchr/testify/require" ) @@ -218,6 +220,239 @@ func TestGetWorkspaceInfoForSession_BasicFields(t *testing.T) { } } +func TestGetWorkspaceInfoForSession_ProjectsPersistedWorktreeIdentity(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + now := time.Now().UTC() + + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-recovery", WorkspaceID: "ws-1", Name: "api", LocalPath: "/source/api", DefaultBranch: "main", + }); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: "task-repo-recovery", TaskID: "task-123", RepositoryID: "repo-recovery", BaseBranch: "main", CheckoutBranch: "feature/recovery", + }); err != nil { + t.Fatalf("CreateTaskRepository: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-recovery", TaskID: "task-123", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: "session-worktree-recovery", SessionID: "session-recovery", WorktreeID: "worktree-recovery", RepositoryID: "repo-recovery", + BranchSlug: "feature-recovery", WorktreePath: "/tasks/task-recovery/api-feature-recovery", CreatedAt: now, + }); err != nil { + t.Fatalf("CreateTaskSessionWorktree: %v", err) + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-recovery") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession: %v", err) + } + if len(info.WorkspaceRepositories) != 1 { + t.Fatalf("WorkspaceRepositories = %#v, want one repository", info.WorkspaceRepositories) + } + got := info.WorkspaceRepositories[0] + if got.WorktreeID != "worktree-recovery" || got.BranchSlug != "feature-recovery" || got.BranchIdentitySlug != "feature-recovery" { + t.Fatalf("recovery repository identity = %+v, want persisted worktree ID and branch slug", got) + } +} + +func TestGetWorkspaceInfoForSession_ProjectsDistinctWorktreesForSameRepositoryBranches(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + now := time.Now().UTC() + + if err := repo.CreateRepository(ctx, &models.Repository{ + ID: "repo-multi-recovery", WorkspaceID: "ws-1", Name: "api", LocalPath: "/source/api", DefaultBranch: "main", + }); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + for position, branch := range []string{"feature/one", "feature/two"} { + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ + ID: fmt.Sprintf("task-repo-multi-%d", position), TaskID: "task-123", RepositoryID: "repo-multi-recovery", + BaseBranch: "main", CheckoutBranch: branch, Position: position, + }); err != nil { + t.Fatalf("CreateTaskRepository %q: %v", branch, err) + } + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ + ID: "session-multi-recovery", TaskID: "task-123", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now, + }); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + for _, worktree := range []*models.TaskSessionWorktree{ + {ID: "session-worktree-one", SessionID: "session-multi-recovery", WorktreeID: "worktree-one", RepositoryID: "repo-multi-recovery", BranchSlug: "feature-one", CreatedAt: now}, + {ID: "session-worktree-two", SessionID: "session-multi-recovery", WorktreeID: "worktree-two", RepositoryID: "repo-multi-recovery", BranchSlug: "feature-two", CreatedAt: now}, + } { + if err := repo.CreateTaskSessionWorktree(ctx, worktree); err != nil { + t.Fatalf("CreateTaskSessionWorktree %q: %v", worktree.BranchSlug, err) + } + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-multi-recovery") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession: %v", err) + } + if len(info.WorkspaceRepositories) != 2 { + t.Fatalf("WorkspaceRepositories = %#v, want two branch-specific repositories", info.WorkspaceRepositories) + } + for _, got := range info.WorkspaceRepositories { + wantWorktreeID := map[string]string{"feature/one": "worktree-one", "feature/two": "worktree-two"}[got.CheckoutBranch] + if got.WorktreeID != wantWorktreeID || got.BranchSlug != got.BranchIdentitySlug { + t.Fatalf("recovery repository for %q = %+v, want worktree %q and matching branch identity", got.CheckoutBranch, got, wantWorktreeID) + } + } +} + +func TestGetWorkspaceInfoForSession_LeavesLegacyRepositoryIdentityEmpty(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-legacy", WorkspaceID: "ws-1", Name: "api", LocalPath: "/source/api"}); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "task-repo-legacy", TaskID: "task-123", RepositoryID: "repo-legacy", BaseBranch: "main"}); err != nil { + t.Fatalf("CreateTaskRepository: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-legacy", TaskID: "task-123", State: models.TaskSessionStateCompleted}); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-legacy") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession: %v", err) + } + got := info.WorkspaceRepositories[0] + if got.WorktreeID != "" || got.BranchSlug != "" || got.BranchIdentitySlug != "" { + t.Fatalf("legacy recovery identity = %+v, want empty persisted worktree fields", got) + } +} + +func TestGetWorkspaceInfoForSession_UsesRepositoryDefaultBranchIdentity(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + now := time.Now().UTC() + + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-default-branch", WorkspaceID: "ws-1", Name: "api", LocalPath: "/source/api", DefaultBranch: "main"}); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "task-repo-default-branch", TaskID: "task-123", RepositoryID: "repo-default-branch"}); err != nil { + t.Fatalf("CreateTaskRepository: %v", err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-default-branch", TaskID: "task-123", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ID: "session-worktree-default-branch", SessionID: "session-default-branch", WorktreeID: "worktree-default-branch", RepositoryID: "repo-default-branch", BranchSlug: "main", CreatedAt: now}); err != nil { + t.Fatalf("CreateTaskSessionWorktree: %v", err) + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-default-branch") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession: %v", err) + } + if got := info.WorkspaceRepositories[0]; got.WorktreeID != "worktree-default-branch" || got.BranchIdentitySlug != "main" { + t.Fatalf("default branch recovery identity = %+v, want persisted main worktree", got) + } +} + +func TestGetWorkspaceInfoForSession_UsesHashDisambiguatedBranchIdentities(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + now := time.Now().UTC() + + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-hash-branches", WorkspaceID: "ws-1", Name: "api", LocalPath: "/source/api", DefaultBranch: "main"}); err != nil { + t.Fatalf("CreateRepository: %v", err) + } + taskRepos := []*models.TaskRepository{ + {ID: "task-repo-hash-one", TaskID: "task-123", RepositoryID: "repo-hash-branches", BaseBranch: "feature/a", Position: 0, Metadata: map[string]interface{}{"pr_number": 101}}, + {ID: "task-repo-hash-two", TaskID: "task-123", RepositoryID: "repo-hash-branches", BaseBranch: "feature-a", Position: 1, Metadata: map[string]interface{}{"pr_number": 202}}, + } + for _, taskRepo := range taskRepos { + if err := repo.CreateTaskRepository(ctx, taskRepo); err != nil { + t.Fatalf("CreateTaskRepository %q: %v", taskRepo.ID, err) + } + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-hash-branches", TaskID: "task-123", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + plans := worktree.BuildBranchIdentityPlans([]worktree.BranchIdentityInput{ + {RepositoryID: "repo-hash-branches", BaseBranch: "feature/a", DefaultBranch: "main", PRNumber: 101, Position: 0}, + {RepositoryID: "repo-hash-branches", BaseBranch: "feature-a", DefaultBranch: "main", PRNumber: 202, Position: 1}, + }) + for index, plan := range plans { + if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ + ID: fmt.Sprintf("session-worktree-hash-%d", index), SessionID: "session-hash-branches", WorktreeID: fmt.Sprintf("worktree-hash-%d", index), + RepositoryID: "repo-hash-branches", BranchSlug: plan.IdentitySlug, CreatedAt: now, + }); err != nil { + t.Fatalf("CreateTaskSessionWorktree %q: %v", plan.IdentitySlug, err) + } + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-hash-branches") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession: %v", err) + } + if len(info.WorkspaceRepositories) != len(plans) { + t.Fatalf("WorkspaceRepositories = %#v, want %d branch-specific repositories", info.WorkspaceRepositories, len(plans)) + } + for index, got := range info.WorkspaceRepositories { + if got.WorktreeID != fmt.Sprintf("worktree-hash-%d", index) || got.BranchSlug != plans[index].IdentitySlug || got.BranchIdentitySlug != plans[index].IdentitySlug { + t.Fatalf("hash-disambiguated recovery repository %d = %+v, want worktree %q and identity %q", index, got, fmt.Sprintf("worktree-hash-%d", index), plans[index].IdentitySlug) + } + } +} + +func TestGetWorkspaceInfoForSession_ProjectsOnlyLifecycleValidRepositories(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + setupTestTask(t, repo) + now := time.Now().UTC() + validPath := t.TempDir() + + for _, repository := range []*models.Repository{ + {ID: "repo-provider-recovery", WorkspaceID: "ws-1", Name: "kdlbs/kandev", LocalPath: validPath, DefaultBranch: "main"}, + {ID: "repo-empty-path-recovery", WorkspaceID: "ws-1", Name: "ignored", DefaultBranch: "main"}, + } { + if err := repo.CreateRepository(ctx, repository); err != nil { + t.Fatalf("CreateRepository %q: %v", repository.ID, err) + } + } + for position, repositoryID := range []string{"repo-provider-recovery", "repo-empty-path-recovery"} { + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: fmt.Sprintf("task-repo-validity-%d", position), TaskID: "task-123", RepositoryID: repositoryID, BaseBranch: "main", Position: position}); err != nil { + t.Fatalf("CreateTaskRepository %q: %v", repositoryID, err) + } + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-projection-validity", TaskID: "task-123", State: models.TaskSessionStateCompleted, StartedAt: now, UpdatedAt: now}); err != nil { + t.Fatalf("CreateTaskSession: %v", err) + } + if err := repo.CreateTaskSessionWorktree(ctx, &models.TaskSessionWorktree{ID: "session-worktree-projection-validity", SessionID: "session-projection-validity", WorktreeID: "worktree-projection-validity", RepositoryID: "repo-provider-recovery", BranchSlug: "main", CreatedAt: now}); err != nil { + t.Fatalf("CreateTaskSessionWorktree: %v", err) + } + + info, err := svc.GetWorkspaceInfoForSession(ctx, "task-123", "session-projection-validity") + if err != nil { + t.Fatalf("GetWorkspaceInfoForSession: %v", err) + } + if len(info.WorkspaceRepositories) != 1 { + t.Fatalf("WorkspaceRepositories = %#v, want only the valid repository", info.WorkspaceRepositories) + } + got := info.WorkspaceRepositories[0] + if got.RepositoryPath != validPath || got.RepoName != "kdlbs-kandev" || filepath.Base(got.RepoName) != got.RepoName { + t.Fatalf("durable repository projection = %+v, want safe name and valid path", got) + } + if got.WorktreeID != "worktree-projection-validity" || got.BranchSlug != "main" || got.BranchIdentitySlug != "main" { + t.Fatalf("durable repository identity = %+v, want persisted worktree identity", got) + } +} + func TestGetWorkspaceInfoForSession_RuntimeConfigOptionsSetOnlyWhenOptionsPresent(t *testing.T) { svc, _, repo := createTestService(t) ctx := context.Background() diff --git a/apps/backend/internal/task/service/service_workspace_folders.go b/apps/backend/internal/task/service/service_workspace_folders.go new file mode 100644 index 0000000000..556d09c485 --- /dev/null +++ b/apps/backend/internal/task/service/service_workspace_folders.go @@ -0,0 +1,38 @@ +package service + +import ( + "context" + + "github.com/kandev/kandev/internal/task/models" + "go.uber.org/zap" +) + +func (s *Service) hydrateTaskWorkspaceFolders(ctx context.Context, task *models.Task) { + if s.workspaceFolders == nil { + return + } + folders, err := s.workspaceFolders.ListTaskWorkspaceFolders(ctx, task.ID) + if err != nil { + s.logger.Error("failed to list task workspace folders", zap.Error(err)) + return + } + task.WorkspaceFolders = folders +} + +func (s *Service) hydrateTaskWorkspaceFoldersBatch(ctx context.Context, tasks []*models.Task) { + if len(tasks) == 0 || s.workspaceFolders == nil { + return + } + taskIDs := make([]string, len(tasks)) + for i, task := range tasks { + taskIDs[i] = task.ID + } + folders, err := s.workspaceFolders.ListTaskWorkspaceFoldersByTaskIDs(ctx, taskIDs) + if err != nil { + s.logger.Error("failed to batch-load task workspace folders", zap.Error(err)) + return + } + for _, task := range tasks { + task.WorkspaceFolders = folders[task.ID] + } +} diff --git a/apps/backend/internal/task/service/service_workspace_sources.go b/apps/backend/internal/task/service/service_workspace_sources.go new file mode 100644 index 0000000000..a97f177a06 --- /dev/null +++ b/apps/backend/internal/task/service/service_workspace_sources.go @@ -0,0 +1,615 @@ +package service + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "sync" + "time" + + "github.com/kandev/kandev/internal/common/securityutil" + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" + taskrepository "github.com/kandev/kandev/internal/task/repository" + "github.com/kandev/kandev/internal/worktree" +) + +type WorkspaceSourceKind string + +const ( + WorkspaceSourceRepository WorkspaceSourceKind = "repository" + WorkspaceSourceFolder WorkspaceSourceKind = "folder" +) + +type WorkspaceSourceInput struct { + Kind WorkspaceSourceKind + RepositoryID, LocalPath, GitHubURL, RemoteURL, Provider, ProviderRepoID, ProviderOwner, ProviderName string + BaseBranch, CheckoutBranch, DisplayName string +} + +type AttachWorkspaceSourcesRequest struct { + TaskID string + Sources []WorkspaceSourceInput +} + +// AttachWorkspaceSourcesResult is the authoritative post-materialization +// projection shared by HTTP and MCP callers. Every field is loaded before the +// durable commit is declared successful and task.updated is published. +type AttachWorkspaceSourcesResult struct { + Task *models.Task + WorkspacePath string + SessionIDs []string +} + +// WorkspaceSourceMaterializationResult is returned by the runtime boundary +// after it has successfully adopted the durable source batch. Its values are +// authoritative: callers must not perform fallible hydration after this point. +type WorkspaceSourceMaterializationResult struct { + WorkspacePath string + SessionIDs []string +} + +// WorkspaceSourceMaterializer applies durable sources to the active runtime. +type WorkspaceSourceMaterializer interface { + MaterializeWorkspaceSources(context.Context, string, *models.WorkspaceSourceBatch) (*WorkspaceSourceMaterializationResult, error) +} + +func (s *Service) AttachWorkspaceSources(ctx context.Context, req AttachWorkspaceSourcesRequest) (*AttachWorkspaceSourcesResult, error) { + if req.TaskID == "" || len(req.Sources) == 0 { + return nil, fmt.Errorf("%w: task_id and at least one source are required", ErrInvalidWorkspaceSource) + } + unlock := s.lockWorkspaceSources(req.TaskID) + defer unlock() + task, err := s.tasks.GetTask(ctx, req.TaskID) + if err != nil { + return nil, fmt.Errorf("get task: %w", err) + } + if task == nil { + return nil, fmt.Errorf("%w: %s", taskrepository.ErrTaskNotFound, req.TaskID) + } + if err := s.workspaceSourcesIdle(ctx, task.ID); err != nil { + return nil, err + } + if err := s.rejectUnsupportedFolderSources(ctx, task.ID, req.Sources); err != nil { + return nil, err + } + existing, err := s.taskRepos.ListTaskRepositories(ctx, task.ID) + if err != nil { + return nil, err + } + if len(existing) == 0 { + return nil, fmt.Errorf("%w: task must have a repository before attaching workspace sources", ErrInvalidWorkspaceSource) + } + store := s.workspaceSourceStore() + if store == nil { + return nil, fmt.Errorf("%w: workspace source persistence is unavailable", ErrWorkspaceSourceMaterialize) + } + folders, err := store.ListTaskWorkspaceFolders(ctx, task.ID) + if err != nil { + return nil, err + } + batch, cleanupCreated, err := s.prepareWorkspaceSourceBatch(ctx, task, existing, folders, req.Sources) + if err != nil { + return nil, err + } + return s.commitWorkspaceSourceBatch(ctx, task, batch, cleanupCreated, s.materializeWorkspaceSources) +} + +func (s *Service) prepareWorkspaceSourceBatch(ctx context.Context, task *models.Task, existing []*models.TaskRepository, folders []*models.TaskWorkspaceFolder, inputs []WorkspaceSourceInput) (*models.WorkspaceSourceBatch, func(context.Context), error) { + batch := &models.WorkspaceSourceBatch{TaskID: task.ID} + created := make([]string, 0) + cleanup := func(cleanupCtx context.Context) { s.cleanupCreatedWorkspaceRepositories(cleanupCtx, created) } + keepCreatedRepositories := false + defer func() { + if !keepCreatedRepositories { + cleanup(context.WithoutCancel(ctx)) + } + }() + seenPaths, seenNames := map[string]bool{}, map[string]bool{} + for _, folder := range folders { + seenPaths[folder.LocalPath], seenNames[folder.DisplayName] = true, true + } + prospective := cloneTaskRepositories(existing) + updates, err := s.deriveLegacyWorkspaceSourceBranches(ctx, task.ID, prospective) + if err != nil { + return nil, nil, err + } + batch.RepositoryUpdates = updates + for _, input := range inputs { + switch input.Kind { + case WorkspaceSourceFolder: + folder, err := prepareFolderWorkspaceSource(input, seenPaths, seenNames) + if err != nil { + return nil, nil, err + } + batch.Sources = append(batch.Sources, models.WorkspaceSource{Folder: folder}) + case WorkspaceSourceRepository: + taskRepository, createdByUs, err := s.prepareRepositoryWorkspaceSource(ctx, task, input, prospective) + if err != nil { + return nil, nil, err + } + if createdByUs != "" { + created = append(created, createdByUs) + } + prospective = append(prospective, taskRepository) + batch.Sources = append(batch.Sources, models.WorkspaceSource{Repository: taskRepository}) + default: + return nil, nil, fmt.Errorf("%w: unsupported workspace source kind %q", ErrInvalidWorkspaceSource, input.Kind) + } + } + if err := s.rejectRuntimeNameCollisions(ctx, prospective, batch); err != nil { + return nil, nil, err + } + keepCreatedRepositories = true + return batch, cleanup, nil +} + +func cloneTaskRepositories(repositories []*models.TaskRepository) []*models.TaskRepository { + clones := make([]*models.TaskRepository, 0, len(repositories)) + for _, repository := range repositories { + if repository == nil { + clones = append(clones, nil) + continue + } + clone := *repository + clones = append(clones, &clone) + } + return clones +} + +func (s *Service) deriveLegacyWorkspaceSourceBranches(ctx context.Context, taskID string, repositories []*models.TaskRepository) ([]models.WorkspaceSourceRepositoryUpdate, error) { + executorType, err := s.workspaceSourceExecutorType(ctx, taskID) + if err != nil || isLocalWorkspaceExecutor(executorType) { + return nil, err + } + updates := make([]models.WorkspaceSourceRepositoryUpdate, 0) + for _, taskRepository := range repositories { + if taskRepository == nil || taskRepository.BaseBranch != "" || taskRepository.CheckoutBranch != "" { + continue + } + repository, err := s.repoEntities.GetRepository(ctx, taskRepository.RepositoryID) + if err != nil { + return nil, classifyWorkspaceRepositoryError(err) + } + if repository == nil || repository.LocalPath == "" { + continue + } + branch, err := s.RepositoryCurrentBranch(ctx, repository.ID) + if err != nil || !securityutil.IsValidBranchName(branch) { + return nil, fmt.Errorf("%w: repository %q has no safe runtime branch", ErrInvalidWorkspaceSource, repository.Name) + } + taskRepository.BaseBranch = branch + updates = append(updates, models.WorkspaceSourceRepositoryUpdate{TaskRepositoryID: taskRepository.ID, BaseBranch: branch}) + } + return updates, nil +} + +func prepareFolderWorkspaceSource(input WorkspaceSourceInput, seenPaths, seenNames map[string]bool) (*models.TaskWorkspaceFolder, error) { + path, err := canonicalFolder(input.LocalPath) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrInvalidWorkspaceSource, err) + } + name := input.DisplayName + if name == "" { + name = filepath.Base(path) + } + if filepath.Base(name) != name || name == "." || name == ".." || worktree.SanitizeRepoDirName(name) != name { + return nil, fmt.Errorf("%w: folder display_name %q is not a safe workspace entry", ErrInvalidWorkspaceSource, name) + } + if seenPaths[path] || seenNames[name] { + return nil, fmt.Errorf("%w: workspace folder %q is already attached", ErrWorkspaceSourceConflict, name) + } + seenPaths[path], seenNames[name] = true, true + return &models.TaskWorkspaceFolder{LocalPath: path, DisplayName: name}, nil +} + +func (s *Service) prepareRepositoryWorkspaceSource(ctx context.Context, task *models.Task, input WorkspaceSourceInput, existing []*models.TaskRepository) (*models.TaskRepository, string, error) { + if err := s.validateRepositoryWorkspaceSourceInput(ctx, task, input); err != nil { + return nil, "", err + } + id, base, createdID, err := s.resolveRepositoryWorkspaceSource(ctx, task, input) + if err != nil { + return nil, createdID, err + } + repo, base, err := s.resolveWorkspaceSourceBaseBranch(ctx, task, id, base) + if err != nil { + return nil, createdID, err + } + if err := validateWorkspaceSourceBranches(base, input.CheckoutBranch); err != nil { + return nil, createdID, err + } + if input.LocalPath != "" { + if err := s.requireCloneableLocalRepository(ctx, task.ID, repo); err != nil { + return nil, createdID, err + } + } + checkout := input.CheckoutBranch + if _, duplicate := scanForBranchAddDuplicate(existing, id, base, checkout, repo); duplicate != nil { + return nil, createdID, fmt.Errorf("%w: %v", ErrWorkspaceSourceConflict, duplicate) + } + return &models.TaskRepository{RepositoryID: id, BaseBranch: base, CheckoutBranch: checkout, Metadata: map[string]interface{}{}}, createdID, nil +} + +func (s *Service) validateRepositoryWorkspaceSourceInput(ctx context.Context, task *models.Task, input WorkspaceSourceInput) error { + if repositoryLocatorCount(input) != 1 { + return fmt.Errorf("%w: repository source requires exactly one locator", ErrInvalidWorkspaceSource) + } + if err := validateWorkspaceSourceBranches(input.BaseBranch, input.CheckoutBranch); err != nil { + return err + } + if input.RepositoryID == "" { + return nil + } + if _, err := s.resolveBranchRepo(ctx, task, input.RepositoryID); err != nil { + return classifyWorkspaceRepositoryError(err) + } + return nil +} + +func (s *Service) resolveRepositoryWorkspaceSource(ctx context.Context, task *models.Task, input WorkspaceSourceInput) (string, string, string, error) { + id, base, created, err := s.ResolveRepositoryRef(ctx, task.WorkspaceID, TaskRepositoryInput{RepositoryID: input.RepositoryID, LocalPath: input.LocalPath, GitHubURL: input.GitHubURL, RemoteURL: input.RemoteURL, Provider: input.Provider, ProviderRepoID: input.ProviderRepoID, ProviderOwner: input.ProviderOwner, ProviderName: input.ProviderName, BaseBranch: input.BaseBranch, ResolveProviderDefaults: true}) + if err != nil { + return "", "", "", classifyWorkspaceRepositoryError(err) + } + createdID := "" + if created { + createdID = id + } + if base == "" { + base = input.BaseBranch + } + return id, base, createdID, nil +} + +func (s *Service) resolveWorkspaceSourceBaseBranch(ctx context.Context, task *models.Task, repositoryID, base string) (*models.Repository, string, error) { + repo, err := s.resolveBranchRepo(ctx, task, repositoryID) + if err != nil { + return nil, "", classifyWorkspaceRepositoryError(err) + } + if base == "" && repo.LocalPath != "" { + base, err = s.RepositoryCurrentBranch(ctx, repo.ID) + if err != nil { + return nil, "", fmt.Errorf("%w: resolve local repository branch: %v", ErrInvalidWorkspaceSource, err) + } + } + if base == "" { + return nil, "", fmt.Errorf("%w: repository source requires base_branch", ErrInvalidWorkspaceSource) + } + return repo, base, nil +} + +func validateWorkspaceSourceBranches(baseBranch, checkoutBranch string) error { + if !securityutil.IsValidBranchName(baseBranch) { + return fmt.Errorf("%w: base_branch %q is not a safe git branch", ErrInvalidWorkspaceSource, baseBranch) + } + if checkoutBranch != "" && !securityutil.IsValidBranchName(checkoutBranch) { + return fmt.Errorf("%w: checkout_branch %q is not a safe git branch", ErrInvalidWorkspaceSource, checkoutBranch) + } + return nil +} + +func validateTaskRepositoryBranches(repositories []TaskRepositoryInput) error { + for _, repository := range repositories { + if repository.BaseBranch != "" && !securityutil.IsValidBranchName(repository.BaseBranch) { + return fmt.Errorf("base_branch %q is not a safe git branch", repository.BaseBranch) + } + if repository.CheckoutBranch != "" && !securityutil.IsValidBranchName(repository.CheckoutBranch) { + return fmt.Errorf("checkout_branch %q is not a safe git branch", repository.CheckoutBranch) + } + } + return nil +} + +func (s *Service) requireCloneableLocalRepository(ctx context.Context, taskID string, repository *models.Repository) error { + executorType, err := s.workspaceSourceExecutorType(ctx, taskID) + if err != nil || executorType == "" || isLocalWorkspaceExecutor(executorType) || executorType == string(models.ExecutorTypeWorktree) { + return err + } + if repository == nil || repository.RemoteURL == "" { + return fmt.Errorf("%w: local repository has no safe cloneable origin", ErrUnsupportedWorkspaceSource) + } + return nil +} + +func classifyWorkspaceRepositoryError(err error) error { + if errors.Is(err, taskrepository.ErrRepositoryNotFound) { + return fmt.Errorf("repository source: %w", err) + } + return fmt.Errorf("%w: %v", ErrInvalidWorkspaceSource, err) +} + +func (s *Service) rejectRuntimeNameCollisions(ctx context.Context, repositories []*models.TaskRepository, batch *models.WorkspaceSourceBatch) error { + executorType, err := s.workspaceSourceExecutorType(ctx, batch.TaskID) + if err != nil { + return err + } + names := make(map[string]bool, len(repositories)) + for _, taskRepository := range repositories { + repo, err := s.repoEntities.GetRepository(ctx, taskRepository.RepositoryID) + if err != nil { + return classifyWorkspaceRepositoryError(err) + } + if repo == nil || repo.WorkspaceID == "" { + return fmt.Errorf("%w: repository %q", taskrepository.ErrRepositoryNotFound, taskRepository.RepositoryID) + } + name, err := WorkspaceSourceRuntimeEntryName(executorType, repo, taskRepository) + if err != nil { + return err + } + if names[name] { + return fmt.Errorf("%w: workspace runtime name %q collides", ErrWorkspaceSourceConflict, name) + } + names[name] = true + } + for _, source := range batch.Sources { + if source.Folder == nil { + continue + } + if _, exists := names[source.Folder.DisplayName]; exists { + return fmt.Errorf("%w: folder %q collides with repository runtime name", ErrWorkspaceSourceConflict, source.Folder.DisplayName) + } + names[source.Folder.DisplayName] = true + } + return nil +} + +// WorkspaceSourceRuntimeEntryName derives the owned workspace entry name used +// by both service collision checks and runtime materialization. Local runtimes +// link repositories under bare repository names; branch-capable runtimes use +// the repository and effective ref so independent checkouts cannot collide. +func WorkspaceSourceRuntimeEntryName(executorType string, repository *models.Repository, taskRepository *models.TaskRepository) (string, error) { + if repository == nil || taskRepository == nil { + return "", fmt.Errorf("%w: missing repository runtime source", ErrInvalidWorkspaceSource) + } + name := worktree.SanitizeRepoDirName(repository.Name) + if name == "" { + return "", fmt.Errorf("%w: repository %q has unsafe runtime name", ErrInvalidWorkspaceSource, repository.Name) + } + if isLocalWorkspaceExecutor(executorType) { + if taskRepository.CheckoutBranch != "" { + return "", fmt.Errorf("%w: checkout_branch is unsupported by local executor", ErrInvalidWorkspaceSource) + } + return name, nil + } + branch := worktree.SanitizeBranchSlug(taskRepository.CheckoutBranch) + if branch == "" { + branch = worktree.SanitizeBranchSlug(taskRepository.BaseBranch) + } + if branch == "" { + return "", fmt.Errorf("%w: repository %q has no safe runtime branch", ErrInvalidWorkspaceSource, repository.Name) + } + return name + "-" + branch, nil +} + +func isLocalWorkspaceExecutor(executorType string) bool { + return executorType == string(models.ExecutorTypeLocal) || executorType == "local_pc" +} + +func (s *Service) workspaceSourceExecutorType(ctx context.Context, taskID string) (string, error) { + if s.taskEnvironments == nil { + return "", nil + } + environment, err := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID) + if errors.Is(err, sql.ErrNoRows) || environment == nil { + return "", nil + } + if err != nil { + return "", err + } + return environment.ExecutorType, nil +} + +func (s *Service) commitWorkspaceSourceBatch(ctx context.Context, task *models.Task, batch *models.WorkspaceSourceBatch, cleanupCreated func(context.Context), materialize func(context.Context, string, *models.WorkspaceSourceBatch) (*WorkspaceSourceMaterializationResult, error)) (*AttachWorkspaceSourcesResult, error) { + store := s.workspaceSourceStore() + if store == nil { + return nil, fmt.Errorf("%w: workspace source persistence is unavailable", ErrWorkspaceSourceMaterialize) + } + if err := store.CreateWorkspaceSourceBatch(ctx, batch); err != nil { + cleanupCreated(context.WithoutCancel(ctx)) + return nil, err + } + succeeded := false + defer func() { + if !succeeded { + s.compensateWorkspaceSources(ctx, batch, nil) + cleanupCreated(context.WithoutCancel(ctx)) + } + }() + result, err := s.hydrateWorkspaceSourceResult(ctx, task, store) + if err != nil { + return nil, err + } + if err := ctx.Err(); err != nil { + return nil, fmt.Errorf("workspace source request canceled: %w", err) + } + var materialized *WorkspaceSourceMaterializationResult + if materialize != nil { + var err error + materialized, err = materialize(ctx, task.ID, batch) + if err != nil { + return nil, fmt.Errorf("%w: %v", ErrWorkspaceSourceMaterialize, err) + } + if s.workspaceSourceMaterializer != nil && materialized == nil { + return nil, fmt.Errorf("%w: empty materialization result", ErrWorkspaceSourceMaterialize) + } + } + if materialized != nil { + result.WorkspacePath = materialized.WorkspacePath + result.SessionIDs = materialized.SessionIDs + } + s.publishTaskEvent(context.WithoutCancel(ctx), events.TaskUpdated, result.Task, nil) + if materialized != nil { + s.PublishWorkspaceSourcesAdopted(context.WithoutCancel(ctx), task.ID, result.WorkspacePath, result.SessionIDs) + } + succeeded = true + return result, nil +} + +func (s *Service) hydrateWorkspaceSourceResult(ctx context.Context, task *models.Task, store taskrepository.TaskWorkspaceFolderRepository) (*AttachWorkspaceSourcesResult, error) { + projected := *task + var err error + if projected.Repositories, err = s.taskRepos.ListTaskRepositories(ctx, task.ID); err != nil { + return nil, fmt.Errorf("hydrate repositories: %w", err) + } + if projected.WorkspaceFolders, err = store.ListTaskWorkspaceFolders(ctx, task.ID); err != nil { + return nil, fmt.Errorf("hydrate workspace folders: %w", err) + } + result := &AttachWorkspaceSourcesResult{Task: &projected, SessionIDs: []string{}} + if s.taskEnvironments != nil { + env, err := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, task.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("hydrate task environment: %w", err) + } + if env != nil { + result.WorkspacePath = env.WorkspacePath + } + } + sessions, err := s.sessions.ListTaskSessions(ctx, task.ID) + if err != nil { + return nil, fmt.Errorf("hydrate task sessions: %w", err) + } + result.SessionIDs = make([]string, 0, len(sessions)) + for _, session := range sessions { + result.SessionIDs = append(result.SessionIDs, session.ID) + } + return result, nil +} + +func (s *Service) materializeWorkspaceSources(ctx context.Context, taskID string, batch *models.WorkspaceSourceBatch) (*WorkspaceSourceMaterializationResult, error) { + if s.workspaceSourceMaterializer == nil { + return nil, nil + } + return s.workspaceSourceMaterializer.MaterializeWorkspaceSources(ctx, taskID, batch) +} + +func repositoryLocatorCount(in WorkspaceSourceInput) int { + n := 0 + if in.RepositoryID != "" { + n++ + } + if in.LocalPath != "" { + n++ + } + if in.GitHubURL != "" || in.RemoteURL != "" { + n++ + } + return n +} + +func canonicalFolder(path string) (string, error) { + if path == "" { + return "", fmt.Errorf("folder source requires local_path") + } + p, err := filepath.Abs(path) + if err != nil { + return "", err + } + p, err = filepath.EvalSymlinks(p) + if err != nil { + return "", err + } + // codeql[go/path-injection] Explicit local-folder selection resolves to a canonical directory for the trusted local-user model; remote executors are rejected before this path is reached. + info, err := os.Stat(p) + if err != nil { + return "", err + } + if !info.IsDir() { + return "", fmt.Errorf("folder source %q is not a directory", p) + } + return p, nil +} + +func (s *Service) workspaceSourcesIdle(ctx context.Context, taskID string) error { + sessions, err := s.sessions.ListTaskSessions(ctx, taskID) + if err != nil { + return err + } + for _, session := range sessions { + turn, err := s.turns.GetActiveTurnBySessionID(ctx, session.ID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if turn != nil { + return fmt.Errorf("%w: task has an active turn", ErrWorkspaceSourceActive) + } + } + return nil +} + +func (s *Service) rejectUnsupportedFolderSources(ctx context.Context, taskID string, sources []WorkspaceSourceInput) error { + for _, source := range sources { + if source.Kind == WorkspaceSourceFolder { + return s.requireFolderSourceExecutor(ctx, taskID) + } + } + return nil +} + +func (s *Service) requireFolderSourceExecutor(ctx context.Context, taskID string) error { + if s.taskEnvironments == nil { + return nil + } + env, err := s.taskEnvironments.GetTaskEnvironmentByTaskID(ctx, taskID) + if err != nil && !errors.Is(err, sql.ErrNoRows) { + return err + } + if env == nil || env.ExecutorType == "" || env.ExecutorType == string(models.ExecutorTypeLocal) || env.ExecutorType == "local_pc" || env.ExecutorType == string(models.ExecutorTypeWorktree) { + return nil + } + return fmt.Errorf("%w: folder sources are not supported by executor %q", ErrUnsupportedWorkspaceSource, env.ExecutorType) +} + +func (s *Service) compensateWorkspaceSources(ctx context.Context, batch *models.WorkspaceSourceBatch, created []string) { + cleanupCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 10*time.Second) + defer cancel() + if store := s.workspaceSourceStore(); store != nil { + _ = store.CompensateWorkspaceSourceBatch(cleanupCtx, batch) + } + s.cleanupCreatedWorkspaceRepositories(cleanupCtx, created) +} + +// workspaceSourceStore keeps the shared commit path compatible with service +// tests and older construction sites that supplied the SQLite repository only +// through TaskRepos before folder attachments existed. +func (s *Service) workspaceSourceStore() taskrepository.TaskWorkspaceFolderRepository { + if s.workspaceFolders != nil { + return s.workspaceFolders + } + store, _ := s.taskRepos.(taskrepository.TaskWorkspaceFolderRepository) + return store +} + +func (s *Service) cleanupCreatedWorkspaceRepositories(ctx context.Context, ids []string) { + if s.repositoryCleanup == nil { + return + } + for _, id := range ids { + repository, err := s.repoEntities.GetRepository(ctx, id) + if err != nil { + continue + } + deleted, err := s.repositoryCleanup.DeleteRepositoryIfUnreferenced(ctx, id) + if err != nil || !deleted { + continue + } + s.publishRepositoryEvent(ctx, events.RepositoryDeleted, repository) + } +} + +func (s *Service) lockWorkspaceSources(taskID string) func() { + s.workspaceSourceLocksMu.Lock() + if s.workspaceSourceLocks == nil { + s.workspaceSourceLocks = map[string]*sync.Mutex{} + } + lock := s.workspaceSourceLocks[taskID] + if lock == nil { + lock = &sync.Mutex{} + s.workspaceSourceLocks[taskID] = lock + } + s.workspaceSourceLocksMu.Unlock() + lock.Lock() + return lock.Unlock +} diff --git a/apps/backend/internal/task/service/service_workspace_sources_test.go b/apps/backend/internal/task/service/service_workspace_sources_test.go new file mode 100644 index 0000000000..fa6af600cf --- /dev/null +++ b/apps/backend/internal/task/service/service_workspace_sources_test.go @@ -0,0 +1,758 @@ +package service + +import ( + "context" + "errors" + "path/filepath" + "testing" + "time" + + "github.com/kandev/kandev/internal/events" + "github.com/kandev/kandev/internal/task/models" + taskrepository "github.com/kandev/kandev/internal/task/repository" +) + +func TestAttachWorkspaceSourcesDerivesLegacyPrimaryBranchForWorktreeProjection(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-legacy-primary", Name: "Legacy"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-legacy-primary", WorkspaceID: "ws-legacy-primary", Name: "WF"}); err != nil { + t.Fatal(err) + } + primaryPath := t.TempDir() + seedBareGitDir(t, primaryPath, "ref: refs/heads/main\n") + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-legacy-primary", WorkspaceID: "ws-legacy-primary", Name: "primary", LocalPath: primaryPath}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-legacy-primary", WorkflowID: "wf-legacy-primary", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-legacy-primary"}}}) + if err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ID: "env-legacy-primary", TaskID: task.ID, ExecutorType: string(models.ExecutorTypeWorktree), WorkspacePath: t.TempDir()}); err != nil { + t.Fatal(err) + } + + addedPath := filepath.Join(t.TempDir(), "added") + seedBareGitDir(t, addedPath, "ref: refs/heads/main\n") + _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{ + {Kind: WorkspaceSourceRepository, LocalPath: addedPath, BaseBranch: "main"}, + {Kind: WorkspaceSourceFolder, LocalPath: t.TempDir(), DisplayName: "notes"}, + }}) + if err != nil { + t.Fatalf("AttachWorkspaceSources: %v", err) + } + links, err := repo.ListTaskRepositories(ctx, task.ID) + if err != nil { + t.Fatal(err) + } + if links[0].BaseBranch != "main" { + t.Fatalf("legacy primary base branch = %q, want main", links[0].BaseBranch) + } +} + +func TestAttachWorkspaceSourcesRejectsDetachedLegacyPrimaryAtomically(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-detached-primary", Name: "Detached"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-detached-primary", WorkspaceID: "ws-detached-primary", Name: "WF"}); err != nil { + t.Fatal(err) + } + primaryPath := t.TempDir() + seedBareGitDir(t, primaryPath, "c0ffee\n") + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-detached-primary", WorkspaceID: "ws-detached-primary", Name: "primary", LocalPath: primaryPath}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-detached-primary", WorkflowID: "wf-detached-primary", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-detached-primary"}}}) + if err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ID: "env-detached-primary", TaskID: task.ID, ExecutorType: string(models.ExecutorTypeWorktree), WorkspacePath: t.TempDir()}); err != nil { + t.Fatal(err) + } + + _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: t.TempDir(), DisplayName: "notes"}}}) + if !errors.Is(err, ErrInvalidWorkspaceSource) { + t.Fatalf("error = %v, want invalid workspace source", err) + } + links, err := repo.ListTaskRepositories(ctx, task.ID) + if err != nil { + t.Fatal(err) + } + if links[0].BaseBranch != "" { + t.Fatalf("legacy primary base branch = %q, want unchanged empty branch", links[0].BaseBranch) + } + folders, err := repo.ListTaskWorkspaceFolders(ctx, task.ID) + if err != nil { + t.Fatal(err) + } + if len(folders) != 0 { + t.Fatalf("folders = %#v, want no attachment mutation", folders) + } +} + +type failingWorkspaceSourceMaterializer struct{} + +func (failingWorkspaceSourceMaterializer) MaterializeWorkspaceSources(context.Context, string, *models.WorkspaceSourceBatch) (*WorkspaceSourceMaterializationResult, error) { + return nil, errors.New("materialize failed") +} + +type cancelingWorkspaceSourceMaterializer struct{ cancel context.CancelFunc } + +func (m cancelingWorkspaceSourceMaterializer) MaterializeWorkspaceSources(context.Context, string, *models.WorkspaceSourceBatch) (*WorkspaceSourceMaterializationResult, error) { + m.cancel() + return &WorkspaceSourceMaterializationResult{}, nil +} + +type resultWorkspaceSourceMaterializer struct { + result *WorkspaceSourceMaterializationResult +} + +func (m resultWorkspaceSourceMaterializer) MaterializeWorkspaceSources(context.Context, string, *models.WorkspaceSourceBatch) (*WorkspaceSourceMaterializationResult, error) { + return m.result, nil +} + +type recordingWorkspaceSourceMaterializer struct{ called bool } + +func (m *recordingWorkspaceSourceMaterializer) MaterializeWorkspaceSources(context.Context, string, *models.WorkspaceSourceBatch) (*WorkspaceSourceMaterializationResult, error) { + m.called = true + return &WorkspaceSourceMaterializationResult{}, nil +} + +type blockingWorkspaceSourceMaterializer struct { + started chan struct{} + release chan struct{} +} + +func (m blockingWorkspaceSourceMaterializer) MaterializeWorkspaceSources(context.Context, string, *models.WorkspaceSourceBatch) (*WorkspaceSourceMaterializationResult, error) { + close(m.started) + <-m.release + return &WorkspaceSourceMaterializationResult{}, nil +} + +func TestAttachWorkspaceSources_CancellationBeforeMaterializationPreventsMutation(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-cancel-before-materialize", Name: "Cancel"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-cancel-before-materialize", WorkspaceID: "ws-cancel-before-materialize", Name: "Workflow"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-cancel-before-materialize", WorkflowID: "wf-cancel-before-materialize", WorkflowStepID: "step", Title: "Task"}) + if err != nil { + t.Fatal(err) + } + materializer := &recordingWorkspaceSourceMaterializer{} + svc.SetWorkspaceSourceMaterializer(materializer) + canceled, cancel := context.WithCancel(ctx) + cancel() + if _, err := svc.AttachWorkspaceSources(canceled, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: t.TempDir(), DisplayName: "docs"}}}); err == nil { + t.Fatal("AttachWorkspaceSources succeeded with canceled context") + } + if materializer.called { + t.Fatal("materializer ran after cancellation") + } + folders, err := repo.ListTaskWorkspaceFolders(ctx, task.ID) + if err != nil { + t.Fatal(err) + } + if len(folders) != 0 { + t.Fatalf("folders after canceled request = %#v", folders) + } +} + +func TestAttachWorkspaceSources_RejectsRepositorylessTaskBeforePersistingFolders(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-repositoryless", Name: "Repositoryless"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-repositoryless", WorkspaceID: "ws-repositoryless", Name: "Workflow"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-repositoryless", WorkflowID: "wf-repositoryless", WorkflowStepID: "step", Title: "Task"}) + if err != nil { + t.Fatal(err) + } + + _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: t.TempDir(), DisplayName: "docs"}}}) + if !errors.Is(err, ErrInvalidWorkspaceSource) { + t.Fatalf("error = %v, want invalid workspace source", err) + } + folders, err := repo.ListTaskWorkspaceFolders(ctx, task.ID) + if err != nil { + t.Fatal(err) + } + if len(folders) != 0 { + t.Fatalf("folders after rejected attachment = %#v", folders) + } +} + +func TestAttachWorkspaceSources_RejectsUnsafeBranchBeforeCreatingRepository(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-unsafe-source", Name: "Unsafe"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-unsafe-source", WorkspaceID: "ws-unsafe-source", Name: "Workflow"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-primary", WorkspaceID: "ws-unsafe-source", Name: "primary", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-unsafe-source", WorkflowID: "wf-unsafe-source", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-primary", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceRepository, LocalPath: t.TempDir(), BaseBranch: "--upload-pack=evil"}}}) + if !errors.Is(err, ErrInvalidWorkspaceSource) { + t.Fatalf("error = %v, want invalid workspace source", err) + } + repositories, listErr := repo.ListRepositories(ctx, "ws-unsafe-source") + if listErr != nil { + t.Fatal(listErr) + } + if len(repositories) != 1 { + t.Fatalf("repositories = %#v, want only primary repository", repositories) + } + links, listErr := repo.ListTaskRepositories(ctx, task.ID) + if listErr != nil { + t.Fatal(listErr) + } + if len(links) != 1 { + t.Fatalf("task repository links = %#v, want only primary link", links) + } +} + +func TestResolveRepositoryRef_RejectsUntrustedGitLabOrigins(t *testing.T) { + svc, _, repo := createTestService(t) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-gitlab-origin", Name: "Origin"}); err != nil { + t.Fatal(err) + } + for _, remoteURL := range []string{ + "http://gitlab.com/acme/app.git", + "https://localhost/acme/app.git", + "https://127.0.0.1/acme/app.git", + "https://10.0.0.1/acme/app.git", + } { + t.Run(remoteURL, func(t *testing.T) { + _, _, _, err := svc.ResolveRepositoryRef(ctx, "ws-gitlab-origin", TaskRepositoryInput{RemoteURL: remoteURL, Provider: "gitlab", BaseBranch: "main"}) + if err == nil { + t.Fatalf("ResolveRepositoryRef accepted untrusted GitLab origin %q", remoteURL) + } + }) + } +} + +func TestAttachWorkspaceSourcesReturnsAuthoritativeMaterializationResult(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-authoritative-result", Name: "Result"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-authoritative-result", WorkspaceID: "ws-authoritative-result", Name: "Workflow"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-authoritative-result", WorkflowID: "wf-authoritative-result", WorkflowStepID: "step", Title: "Task"}) + if err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ID: "env-authoritative-result", TaskID: task.ID, WorkspacePath: "/stale/workspace"}); err != nil { + t.Fatal(err) + } + initialRepository := t.TempDir() + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-authoritative-result", WorkspaceID: "ws-authoritative-result", Name: "initial", LocalPath: initialRepository}); err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskRepository(ctx, &models.TaskRepository{ID: "task-repo-authoritative-result", TaskID: task.ID, RepositoryID: "repo-authoritative-result", BaseBranch: "main", Position: 0, Metadata: map[string]interface{}{}}); err != nil { + t.Fatal(err) + } + svc.SetWorkspaceSourceMaterializer(resultWorkspaceSourceMaterializer{result: &WorkspaceSourceMaterializationResult{WorkspacePath: "/adopted/workspace", SessionIDs: []string{"adopted-session"}}}) + result, err := svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: t.TempDir(), DisplayName: "docs"}}}) + if err != nil { + t.Fatal(err) + } + if result.WorkspacePath != "/adopted/workspace" || len(result.SessionIDs) != 1 || result.SessionIDs[0] != "adopted-session" { + t.Fatalf("result = %#v", result) + } +} + +func TestAttachWorkspaceSources_PublishesTaskBeforeSessionAdoption(t *testing.T) { + svc, eventBus, repo := createTestService(t) + svc.workspaceFolders = repo + svc.SetWorkspaceSourceMaterializer(resultWorkspaceSourceMaterializer{result: &WorkspaceSourceMaterializationResult{WorkspacePath: "/workspace/task", SessionIDs: []string{"session-1"}}}) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-event-order", Name: "Events"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-event-order", WorkspaceID: "ws-event-order", Name: "Workflow"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-event-order", WorkspaceID: "ws-event-order", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-event-order", WorkflowID: "wf-event-order", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-event-order", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + eventBus.ClearEvents() + + if _, err := svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: t.TempDir(), DisplayName: "docs"}}}); err != nil { + t.Fatal(err) + } + published := eventBus.GetPublishedEvents() + if len(published) != 2 { + t.Fatalf("published events = %#v, want task.updated then workspace adoption", published) + } + if published[0].Type != events.TaskUpdated || published[1].Type != events.SessionWorkspaceSourcesUpdated { + t.Fatalf("published event types = %q, %q", published[0].Type, published[1].Type) + } +} + +func TestAttachWorkspaceSources_HoldsTaskMutationGateThroughMaterialization(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + materializer := blockingWorkspaceSourceMaterializer{started: make(chan struct{}), release: make(chan struct{})} + svc.SetWorkspaceSourceMaterializer(materializer) + released := false + defer func() { + if !released { + close(materializer.release) + } + }() + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-mutation-gate", Name: "Gate"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-mutation-gate", WorkspaceID: "ws-mutation-gate", Name: "Workflow"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-mutation-gate", WorkspaceID: "ws-mutation-gate", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-mutation-gate", WorkflowID: "wf-mutation-gate", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-mutation-gate", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskSession(ctx, &models.TaskSession{ID: "session-mutation-gate", TaskID: task.ID}); err != nil { + t.Fatal(err) + } + folder := t.TempDir() + attachDone := make(chan error, 1) + go func() { + _, attachErr := svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: folder, DisplayName: "docs"}}}) + attachDone <- attachErr + }() + select { + case <-materializer.started: + case attachErr := <-attachDone: + t.Fatalf("AttachWorkspaceSources before materialization: %v", attachErr) + case <-time.After(time.Second): + t.Fatal("AttachWorkspaceSources did not reach materialization") + } + turnDone := make(chan error, 1) + go func() { + _, turnErr := svc.StartTurn(ctx, "session-mutation-gate") + turnDone <- turnErr + }() + select { + case err := <-turnDone: + t.Fatalf("StartTurn completed during source materialization: %v", err) + case <-time.After(100 * time.Millisecond): + } + close(materializer.release) + released = true + if err := <-attachDone; err != nil { + t.Fatalf("AttachWorkspaceSources: %v", err) + } + if err := <-turnDone; err != nil { + t.Fatalf("StartTurn: %v", err) + } +} + +func TestAttachWorkspaceSources_CommitsWhenCancellationArrivesAfterMaterialization(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx, cancel := context.WithCancel(context.Background()) + svc.SetWorkspaceSourceMaterializer(cancelingWorkspaceSourceMaterializer{cancel: cancel}) + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-cancel-after-materialize", Name: "Cancel"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-cancel-after-materialize", WorkspaceID: "ws-cancel-after-materialize", Name: "Workflow"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-cancel-after-materialize", WorkspaceID: "ws-cancel-after-materialize", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-cancel-after-materialize", WorkflowID: "wf-cancel-after-materialize", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-cancel-after-materialize", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + if _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: t.TempDir(), DisplayName: "docs"}}}); err != nil { + t.Fatalf("AttachWorkspaceSources: %v", err) + } + folders, err := repo.ListTaskWorkspaceFolders(context.Background(), task.ID) + if err != nil { + t.Fatal(err) + } + if len(folders) != 1 { + t.Fatalf("folders after committed materialization = %#v", folders) + } +} + +func TestAttachWorkspaceSourcesPersistsMixedSourcesInRequestOrder(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-sources", Name: "Sources"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-sources", WorkspaceID: "ws-sources", Name: "Sources"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-sources", WorkspaceID: "ws-sources", Name: "app", LocalPath: t.TempDir(), DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-sources", WorkflowID: "wf-sources", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-sources", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ID: "env-sources", TaskID: task.ID, WorkspacePath: "/workspace/task"}); err != nil { + t.Fatal(err) + } + + got, err := svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: t.TempDir(), DisplayName: "docs"}, {Kind: WorkspaceSourceRepository, RepositoryID: "repo-sources", BaseBranch: "release", CheckoutBranch: "feature/x"}}}) + if err != nil { + t.Fatalf("AttachWorkspaceSources: %v", err) + } + if len(got.Task.WorkspaceFolders) != 1 || len(got.Task.Repositories) != 2 { + t.Fatalf("sources = folders %d repos %d", len(got.Task.WorkspaceFolders), len(got.Task.Repositories)) + } + if got.Task.WorkspaceFolders[0].Position != 1 || got.Task.Repositories[1].Position != 2 { + t.Fatalf("positions = folder %d repo %d, want 1,2", got.Task.WorkspaceFolders[0].Position, got.Task.Repositories[1].Position) + } + if got.WorkspacePath != "/workspace/task" || len(got.SessionIDs) != 0 { + t.Fatalf("result workspace/session projection = %#v", got) + } +} + +func TestAttachWorkspaceSourcesCompensatesOnMaterializationFailure(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + svc.SetWorkspaceSourceMaterializer(failingWorkspaceSourceMaterializer{}) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-rollback", Name: "Rollback"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-rollback", WorkspaceID: "ws-rollback", Name: "Workflow"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-rollback", WorkspaceID: "ws-rollback", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-rollback", WorkflowID: "wf-rollback", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-rollback", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + if _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: t.TempDir(), DisplayName: "docs"}}}); err == nil { + t.Fatal("AttachWorkspaceSources succeeded, want materialization error") + } + folders, err := repo.ListTaskWorkspaceFolders(ctx, task.ID) + if err != nil { + t.Fatal(err) + } + if len(folders) != 0 { + t.Fatalf("folders after rollback = %#v", folders) + } +} + +func TestAttachWorkspaceSourcesAllowsFolderForLocalPCAlias(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-local-pc", Name: "Local PC"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-local-pc", WorkspaceID: "ws-local-pc", Name: "WF"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-local-pc", WorkspaceID: "ws-local-pc", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-local-pc", WorkflowID: "wf-local-pc", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-local-pc", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ID: "env-local-pc", TaskID: task.ID, ExecutorType: "local_pc"}); err != nil { + t.Fatal(err) + } + + if _, err := svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: t.TempDir(), DisplayName: "docs"}}}); err != nil { + t.Fatalf("AttachWorkspaceSources for local_pc: %v", err) + } +} + +func TestAttachWorkspaceSourcesRejectsFolderForRemoteExecutorBeforeResolvingPath(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-remote-folder", Name: "Remote Folder"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-remote-folder", WorkspaceID: "ws-remote-folder", Name: "WF"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-remote-folder", WorkspaceID: "ws-remote-folder", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-remote-folder", WorkflowID: "wf-remote-folder", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-remote-folder", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ID: "env-remote-folder", TaskID: task.ID, ExecutorType: "remote_docker"}); err != nil { + t.Fatal(err) + } + + _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: filepath.Join(t.TempDir(), "not-present"), DisplayName: "docs"}}}) + if !errors.Is(err, ErrUnsupportedWorkspaceSource) { + t.Fatalf("error = %v, want unsupported workspace source", err) + } +} + +func TestAttachWorkspaceSources_PersistsPrelaunchSourcesWithExplicitDeferredResult(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + svc.SetWorkspaceSourceMaterializer(resultWorkspaceSourceMaterializer{result: &WorkspaceSourceMaterializationResult{}}) + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-prelaunch", Name: "Prelaunch"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-prelaunch", WorkspaceID: "ws-prelaunch", Name: "Workflow"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-prelaunch", WorkspaceID: "ws-prelaunch", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-prelaunch", WorkflowID: "wf-prelaunch", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-prelaunch", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + + if _, err := svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: t.TempDir(), DisplayName: "docs"}}}); err != nil { + t.Fatalf("AttachWorkspaceSources before launch: %v", err) + } + folders, err := repo.ListTaskWorkspaceFolders(ctx, task.ID) + if err != nil { + t.Fatal(err) + } + if len(folders) != 1 || folders[0].DisplayName != "docs" { + t.Fatalf("deferred folders = %#v", folders) + } +} + +func TestAttachWorkspaceSources_RejectsCheckoutBranchForLocalRuntime(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-local-checkout", Name: "Local"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-local-checkout", WorkspaceID: "ws-local-checkout", Name: "WF"}); err != nil { + t.Fatal(err) + } + for _, repository := range []*models.Repository{ + {ID: "repo-local-primary", WorkspaceID: "ws-local-checkout", Name: "primary", DefaultBranch: "main"}, + {ID: "repo-local-added", WorkspaceID: "ws-local-checkout", Name: "added", DefaultBranch: "main"}, + } { + if err := repo.CreateRepository(ctx, repository); err != nil { + t.Fatal(err) + } + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-local-checkout", WorkflowID: "wf-local-checkout", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-local-primary", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ID: "env-local-checkout", TaskID: task.ID, ExecutorType: string(models.ExecutorTypeLocal)}); err != nil { + t.Fatal(err) + } + + _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceRepository, RepositoryID: "repo-local-added", BaseBranch: "main", CheckoutBranch: "feature/source"}}}) + if !errors.Is(err, ErrInvalidWorkspaceSource) { + t.Fatalf("error = %v, want invalid workspace source", err) + } +} + +func TestAttachWorkspaceSources_RejectsSameRepositoryDifferentBaseOnLocalRuntime(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-local-base", Name: "Local"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-local-base", WorkspaceID: "ws-local-base", Name: "WF"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-local-base", WorkspaceID: "ws-local-base", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-local-base", WorkflowID: "wf-local-base", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-local-base", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + if err := repo.CreateTaskEnvironment(ctx, &models.TaskEnvironment{ID: "env-local-base", TaskID: task.ID, ExecutorType: "local_pc"}); err != nil { + t.Fatal(err) + } + + _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceRepository, RepositoryID: "repo-local-base", BaseBranch: "release"}}}) + if !errors.Is(err, ErrWorkspaceSourceConflict) { + t.Fatalf("error = %v, want local runtime-name conflict", err) + } +} + +func TestAttachWorkspaceSourcesRejectsDuplicateRepositoryWithinBatch(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-dup", Name: "Dup"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-dup", WorkspaceID: "ws-dup", Name: "WF"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-dup", WorkspaceID: "ws-dup", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-dup", WorkflowID: "wf-dup", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-dup", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceRepository, RepositoryID: "repo-dup", BaseBranch: "release", CheckoutBranch: "feature/x"}, {Kind: WorkspaceSourceRepository, RepositoryID: "repo-dup", BaseBranch: "release", CheckoutBranch: "feature/x"}}}) + if err == nil { + t.Fatal("AttachWorkspaceSources succeeded, want duplicate error") + } +} + +func TestAttachWorkspaceSourcesRejectsSanitizedBranchCollisionWithinBatch(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-slug", Name: "Slug"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-slug", WorkspaceID: "ws-slug", Name: "WF"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-slug", WorkspaceID: "ws-slug", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-slug", WorkflowID: "wf-slug", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-slug", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + + _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{ + {Kind: WorkspaceSourceRepository, RepositoryID: "repo-slug", BaseBranch: "main", CheckoutBranch: "feature/a"}, + {Kind: WorkspaceSourceRepository, RepositoryID: "repo-slug", BaseBranch: "release", CheckoutBranch: "feature-a"}, + }}) + if err == nil { + t.Fatal("AttachWorkspaceSources succeeded, want sanitized worktree-path conflict") + } +} + +func TestAttachWorkspaceSourcesClassifiesCrossWorkspaceRepositoryAsNotFound(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + for _, workspace := range []*models.Workspace{{ID: "ws-owner", Name: "Owner"}, {ID: "ws-other", Name: "Other"}} { + if err := repo.CreateWorkspace(ctx, workspace); err != nil { + t.Fatal(err) + } + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-owner", WorkspaceID: "ws-owner", Name: "WF"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-owner", WorkspaceID: "ws-owner", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-other", WorkspaceID: "ws-other", Name: "other", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-owner", WorkflowID: "wf-owner", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-owner", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + + _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceRepository, RepositoryID: "repo-other", BaseBranch: "main"}}}) + if !errors.Is(err, taskrepository.ErrRepositoryNotFound) { + t.Fatalf("error = %v, want errors.Is(ErrRepositoryNotFound)", err) + } +} + +func TestAttachWorkspaceSourcesRejectsFolderNameCollidingWithRepositoryRuntimeDirectory(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-runtime", Name: "Runtime"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-runtime", WorkspaceID: "ws-runtime", Name: "WF"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-runtime", WorkspaceID: "ws-runtime", Name: "app", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-runtime", WorkflowID: "wf-runtime", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-runtime", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + + _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{{Kind: WorkspaceSourceFolder, LocalPath: t.TempDir(), DisplayName: "app-main"}}}) + if !errors.Is(err, ErrWorkspaceSourceConflict) { + t.Fatalf("error = %v, want runtime-name conflict", err) + } +} + +func TestAttachWorkspaceSourcesRejectsNewRepositoriesWithSameRuntimeName(t *testing.T) { + svc, _, repo := createTestService(t) + svc.workspaceFolders = repo + ctx := context.Background() + if err := repo.CreateWorkspace(ctx, &models.Workspace{ID: "ws-new-runtime", Name: "Runtime"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateWorkflow(ctx, &models.Workflow{ID: "wf-new-runtime", WorkspaceID: "ws-new-runtime", Name: "WF"}); err != nil { + t.Fatal(err) + } + if err := repo.CreateRepository(ctx, &models.Repository{ID: "repo-primary-runtime", WorkspaceID: "ws-new-runtime", Name: "primary", DefaultBranch: "main"}); err != nil { + t.Fatal(err) + } + task, err := svc.CreateTask(ctx, &CreateTaskRequest{WorkspaceID: "ws-new-runtime", WorkflowID: "wf-new-runtime", WorkflowStepID: "step", Title: "Task", Repositories: []TaskRepositoryInput{{RepositoryID: "repo-primary-runtime", BaseBranch: "main"}}}) + if err != nil { + t.Fatal(err) + } + + _, err = svc.AttachWorkspaceSources(ctx, AttachWorkspaceSourcesRequest{TaskID: task.ID, Sources: []WorkspaceSourceInput{ + {Kind: WorkspaceSourceRepository, GitHubURL: "https://github.com/a-b/repo", BaseBranch: "main"}, + {Kind: WorkspaceSourceRepository, GitHubURL: "https://github.com/a/b-repo", BaseBranch: "main"}, + }}) + if !errors.Is(err, ErrWorkspaceSourceConflict) { + t.Fatalf("error = %v, want runtime-name conflict", err) + } + rows, err := repo.ListTaskRepositories(ctx, task.ID) + if err != nil { + t.Fatal(err) + } + if len(rows) != 1 { + t.Fatalf("task repositories = %d, want no durable attachment mutation", len(rows)) + } +} diff --git a/apps/backend/internal/worktree/branch_plan.go b/apps/backend/internal/worktree/branch_plan.go new file mode 100644 index 0000000000..92006443cd --- /dev/null +++ b/apps/backend/internal/worktree/branch_plan.go @@ -0,0 +1,133 @@ +package worktree + +import ( + "fmt" + "hash/fnv" + "sort" + "strings" +) + +// BranchIdentityInput contains the durable branch fields used to plan a +// repository worktree's stable identity and path slug. +type BranchIdentityInput struct { + RepositoryID string + BaseBranch string + CheckoutBranch string + DefaultBranch string + PRNumber int + Position int +} + +// BranchIdentityPlan is the stable identity and optional path suffix for one +// BranchIdentityInput at the same index in BuildBranchIdentityPlans' result. +type BranchIdentityPlan struct { + IdentitySlug string + PathSlug string +} + +// BuildBranchIdentityPlans derives stable identities for repository branches. +// The lowest-ranked branch for a repeated repository keeps the legacy flat +// path; every plan still has an identity for durable worktree reuse. +func BuildBranchIdentityPlans(inputs []BranchIdentityInput) []BranchIdentityPlan { + plans := make([]BranchIdentityPlan, len(inputs)) + groups := make(map[string][]int, len(inputs)) + for index, input := range inputs { + if input.RepositoryID != "" { + groups[input.RepositoryID] = append(groups[input.RepositoryID], index) + } + } + for repositoryID, group := range groups { + identities := branchIdentitySlugsForGroup(repositoryID, inputs, group) + if len(group) == 1 { + index := group[0] + plans[index] = BranchIdentityPlan{IdentitySlug: identities[index]} + continue + } + flatIdentity := selectFlatBranchIdentity(inputs, group, identities) + for _, index := range group { + pathSlug := identities[index] + if pathSlug == flatIdentity { + pathSlug = "" + } + plans[index] = BranchIdentityPlan{IdentitySlug: identities[index], PathSlug: pathSlug} + } + } + return plans +} + +func branchIdentitySlugsForGroup(repositoryID string, inputs []BranchIdentityInput, group []int) map[int]string { + raw := make(map[int]string, len(group)) + counts := make(map[string]int, len(group)) + for _, index := range group { + slug := preferredBranchIdentitySlug(inputs[index]) + if slug == "" { + slug = "branch-" + branchIdentityHash(repositoryID, inputs[index]) + } + raw[index] = slug + counts[slug]++ + } + identities := make(map[int]string, len(group)) + for _, index := range group { + slug := raw[index] + if counts[slug] > 1 { + slug += "-" + branchIdentityHash(repositoryID, inputs[index]) + } + slug = SanitizeBranchSlug(slug) + if slug == "" { + slug = "branch-" + branchIdentityHash(repositoryID, inputs[index]) + } + identities[index] = slug + } + return identities +} + +func preferredBranchIdentitySlug(input BranchIdentityInput) string { + branch := input.CheckoutBranch + if branch == "" { + branch = effectiveBaseBranch(input) + } + return SanitizeBranchSlug(branch) +} + +func branchIdentityHash(repositoryID string, input BranchIdentityInput) string { + seed := strings.Join([]string{repositoryID, effectiveBaseBranch(input), input.CheckoutBranch, fmt.Sprintf("%d", input.PRNumber)}, "\x00") + hash := fnv.New32a() + _, _ = hash.Write([]byte(seed)) + return fmt.Sprintf("%08x", hash.Sum32()) +} + +func effectiveBaseBranch(input BranchIdentityInput) string { + if input.BaseBranch != "" { + return input.BaseBranch + } + return input.DefaultBranch +} + +func selectFlatBranchIdentity(inputs []BranchIdentityInput, group []int, identities map[int]string) string { + candidates := append([]int(nil), group...) + sort.SliceStable(candidates, func(i, j int) bool { + left, right := inputs[candidates[i]], inputs[candidates[j]] + if left.Position != right.Position { + return left.Position < right.Position + } + if leftRank, rightRank := flatBranchRank(left), flatBranchRank(right); leftRank != rightRank { + return leftRank < rightRank + } + return identities[candidates[i]] < identities[candidates[j]] + }) + return identities[candidates[0]] +} + +func flatBranchRank(input BranchIdentityInput) int { + if input.CheckoutBranch != "" { + return 3 + } + baseBranch := effectiveBaseBranch(input) + if input.DefaultBranch != "" && baseBranch == input.DefaultBranch { + return 0 + } + if baseBranch == "main" { + return 1 + } + return 2 +} diff --git a/apps/backend/internal/worktree/directory_link.go b/apps/backend/internal/worktree/directory_link.go new file mode 100644 index 0000000000..c1e0c9b7f6 --- /dev/null +++ b/apps/backend/internal/worktree/directory_link.go @@ -0,0 +1,190 @@ +package worktree + +import ( + "fmt" + "os" + "path/filepath" + "strings" +) + +// CreateOwnedDirectoryLink creates a live directory reference below root. +// root is created only through real (non-symlink) ancestors, and name must be +// a single path segment. The caller owns root; this function never alters the +// target or an existing entry. +func CreateOwnedDirectoryLink(root, name, target string) (string, error) { + if !isOwnedDirectoryLinkPath(root, name) { + return "", fmt.Errorf("invalid owned link path") + } + canonicalTarget, err := canonicalDirectoryLinkTarget(target) + if err != nil { + return "", err + } + if err := mkdirOwned(root); err != nil { + return "", err + } + link, err := ownedDirectoryLinkPath(root, name) + if err != nil { + return "", err + } + _, err = os.Lstat(link) + if err == nil { + return "", fmt.Errorf("owned link entry already exists: %s", name) + } + if !os.IsNotExist(err) { + return "", fmt.Errorf("inspect owned link entry: %w", err) + } + if err := createPlatformDirectoryLink(canonicalTarget, link); err != nil { + return "", fmt.Errorf("create directory link: %w", err) + } + if err := verifyCreatedOwnedDirectoryLink(root, link); err != nil { + return "", err + } + return link, nil +} + +func isOwnedDirectoryLinkPath(root, name string) bool { + return root != "" && filepath.IsAbs(root) && name != "" && filepath.Base(name) == name && name != "." && name != ".." +} + +func canonicalDirectoryLinkTarget(target string) (string, error) { + canonicalTarget, err := filepath.EvalSymlinks(target) + if err != nil { + return "", fmt.Errorf("canonicalize link target: %w", err) + } + info, err := os.Stat(canonicalTarget) + if err != nil || !info.IsDir() { + return "", fmt.Errorf("link target is not a directory: %w", err) + } + return canonicalTarget, nil +} + +func ownedDirectoryLinkPath(root, name string) (string, error) { + link := filepath.Join(root, name) + if filepath.Dir(link) != filepath.Clean(root) { + return "", fmt.Errorf("link escapes owned root") + } + return link, nil +} + +func verifyCreatedOwnedDirectoryLink(root, link string) error { + if err := requireRealDir(root); err != nil { + _ = os.Remove(link) + return err + } + if err := requirePlatformDirectoryLink(link); err != nil { + _ = os.Remove(link) + return fmt.Errorf("owned link changed during creation") + } + return nil +} + +// EnsureOwnedDirectoryLink returns an existing matching live link or creates +// it. A non-link/collision, or a link to another canonical directory, fails +// closed and is never replaced. +func EnsureOwnedDirectoryLink(root, name, target string) (string, bool, error) { + link := filepath.Join(root, name) + info, err := os.Lstat(link) + if err == nil { + if !isPlatformDirectoryLink(info, link) { + return "", false, fmt.Errorf("owned link entry already exists: %s", name) + } + actual, err := filepath.EvalSymlinks(link) + if err != nil { + return "", false, fmt.Errorf("resolve owned link: %w", err) + } + expected, err := filepath.EvalSymlinks(target) + if err != nil { + return "", false, fmt.Errorf("canonicalize link target: %w", err) + } + if filepath.Clean(actual) != filepath.Clean(expected) { + return "", false, fmt.Errorf("owned link target mismatch: %s", name) + } + return link, false, nil + } + if !os.IsNotExist(err) { + return "", false, fmt.Errorf("inspect owned link entry: %w", err) + } + created, err := CreateOwnedDirectoryLink(root, name, target) + return created, err == nil, err +} + +func mkdirOwned(root string) error { + parent := filepath.Dir(root) + if err := requireNoSymlinkAncestors(parent); err != nil { + return err + } + if err := os.MkdirAll(parent, 0o755); err != nil { + return fmt.Errorf("create owned parent: %w", err) + } + if err := requireRealDir(parent); err != nil { + return err + } + if err := os.Mkdir(root, 0o755); err != nil && !os.IsExist(err) { + return fmt.Errorf("create owned root: %w", err) + } + return requireRealDir(root) +} + +// requireNoSymlinkAncestors checks every existing control-path component +// before MkdirAll can traverse it. It deliberately uses Lstat so a junction +// or symlink is rejected rather than resolved. +func requireNoSymlinkAncestors(path string) error { + clean := filepath.Clean(path) + volume := filepath.VolumeName(clean) + rest := strings.TrimPrefix(clean, volume) + rest = strings.TrimPrefix(rest, string(filepath.Separator)) + current := volume + string(filepath.Separator) + for _, part := range strings.Split(rest, string(filepath.Separator)) { + if part == "" { + continue + } + current = filepath.Join(current, part) + info, err := os.Lstat(current) + if os.IsNotExist(err) { + return nil + } + if err != nil { + return fmt.Errorf("inspect owned ancestor: %w", err) + } + if isPlatformDirectoryLink(info, current) { + return fmt.Errorf("owned control ancestor is symlink: %s", current) + } + } + return nil +} + +func requireRealDir(path string) error { + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect owned path: %w", err) + } + if isPlatformDirectoryLink(info, path) || !info.IsDir() { + return fmt.Errorf("owned path is not a real directory: %s", path) + } + return nil +} + +// TaskRoot returns the canonical Kandev-owned root for a task directory name. +// It deliberately accepts only the same single-segment names used by worktree +// creation, preventing a persisted environment row from selecting another +// directory beneath (or outside) the configured tasks base. +func (m *Manager) TaskRoot(taskDirName string) (string, error) { + if taskDirName == "" || filepath.Base(taskDirName) != taskDirName || taskDirName == "." || taskDirName == ".." { + return "", fmt.Errorf("invalid task directory name") + } + base, err := m.config.ExpandedTasksBasePath() + if err != nil { + return "", err + } + base, err = filepath.Abs(base) + if err != nil { + return "", err + } + if err := requireRealDir(base); err != nil { + return "", err + } + if err := requireNoSymlinkAncestors(base); err != nil { + return "", err + } + return filepath.Join(base, taskDirName), nil +} diff --git a/apps/backend/internal/worktree/directory_link_test.go b/apps/backend/internal/worktree/directory_link_test.go new file mode 100644 index 0000000000..9c3ad0e509 --- /dev/null +++ b/apps/backend/internal/worktree/directory_link_test.go @@ -0,0 +1,57 @@ +package worktree + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCreateOwnedDirectoryLinkCreatesLiveLinkInsideOwnedRoot(t *testing.T) { + root := filepath.Join(t.TempDir(), "tasks", "task-1") + target := filepath.Join(t.TempDir(), "source") + if err := os.MkdirAll(target, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(target, "live.txt"), []byte("one"), 0o644); err != nil { + t.Fatal(err) + } + + link, err := CreateOwnedDirectoryLink(root, "source", target) + if err != nil { + t.Fatalf("CreateOwnedDirectoryLink: %v", err) + } + if got, err := os.ReadFile(filepath.Join(link, "live.txt")); err != nil || string(got) != "one" { + t.Fatalf("read through link = %q, %v", got, err) + } + if err := os.WriteFile(filepath.Join(target, "live.txt"), []byte("two"), 0o644); err != nil { + t.Fatal(err) + } + if got, err := os.ReadFile(filepath.Join(link, "live.txt")); err != nil || string(got) != "two" { + t.Fatalf("link is not live: %q, %v", got, err) + } +} + +func TestCreateOwnedDirectoryLinkRejectsCollision(t *testing.T) { + root := filepath.Join(t.TempDir(), "tasks", "task-1") + target := t.TempDir() + if err := os.MkdirAll(root, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(root, "source"), 0o755); err != nil { + t.Fatal(err) + } + if _, err := CreateOwnedDirectoryLink(root, "source", target); err == nil { + t.Fatal("CreateOwnedDirectoryLink succeeded for collision") + } +} + +func TestCreateOwnedDirectoryLinkRejectsSymlinkedControlAncestor(t *testing.T) { + realBase := t.TempDir() + linkBase := filepath.Join(t.TempDir(), "tasks") + if err := os.Symlink(realBase, linkBase); err != nil { + t.Skipf("symlink unsupported: %v", err) + } + if _, err := CreateOwnedDirectoryLink(filepath.Join(linkBase, "task-1"), "source", t.TempDir()); err == nil { + t.Fatal("CreateOwnedDirectoryLink accepted symlinked control ancestor") + } +} diff --git a/apps/backend/internal/worktree/directory_link_unix.go b/apps/backend/internal/worktree/directory_link_unix.go new file mode 100644 index 0000000000..bcb2b547ba --- /dev/null +++ b/apps/backend/internal/worktree/directory_link_unix.go @@ -0,0 +1,18 @@ +//go:build !windows + +package worktree + +import "os" + +func createPlatformDirectoryLink(target, link string) error { return os.Symlink(target, link) } +func isPlatformDirectoryLink(info os.FileInfo, _ string) bool { return info.Mode()&os.ModeSymlink != 0 } +func requirePlatformDirectoryLink(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !isPlatformDirectoryLink(info, path) { + return os.ErrInvalid + } + return nil +} diff --git a/apps/backend/internal/worktree/directory_link_windows.go b/apps/backend/internal/worktree/directory_link_windows.go new file mode 100644 index 0000000000..1ad91e96c1 --- /dev/null +++ b/apps/backend/internal/worktree/directory_link_windows.go @@ -0,0 +1,88 @@ +//go:build windows + +package worktree + +import ( + "encoding/binary" + "fmt" + "golang.org/x/sys/windows" + "os" + "strings" + "unsafe" +) + +const ioReparseTagMountPoint = 0xA0000003 + +func createPlatformDirectoryLink(target, link string) error { + if err := os.Mkdir(link, 0o755); err != nil { + return err + } + h, err := windows.CreateFile(windows.StringToUTF16Ptr(link), windows.GENERIC_WRITE, 0, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + _ = os.Remove(link) + return err + } + defer windows.CloseHandle(h) + b := mountPointBuffer(target) + var n uint32 + if err := windows.DeviceIoControl(h, windows.FSCTL_SET_REPARSE_POINT, &b[0], uint32(len(b)), nil, 0, &n, nil); err != nil { + _ = os.Remove(link) + return err + } + return nil +} + +func mountPointBuffer(target string) []byte { + target = strings.TrimPrefix(target, `\\?\`) + sub := `\??\` + target + su, pr := windows.StringToUTF16(sub), windows.StringToUTF16(target) + dataLen := 8 + len(su)*2 + len(pr)*2 + b := make([]byte, 8+dataLen) + binary.LittleEndian.PutUint32(b, ioReparseTagMountPoint) + binary.LittleEndian.PutUint16(b[4:], uint16(dataLen)) + binary.LittleEndian.PutUint16(b[8:], 0) + binary.LittleEndian.PutUint16(b[10:], uint16(len(su)*2-2)) + binary.LittleEndian.PutUint16(b[12:], uint16(len(su)*2)) + binary.LittleEndian.PutUint16(b[14:], uint16(len(pr)*2-2)) + off := 16 + for _, v := range su { + binary.LittleEndian.PutUint16(b[off:], v) + off += 2 + } + for _, v := range pr { + binary.LittleEndian.PutUint16(b[off:], v) + off += 2 + } + return b +} +func isPlatformDirectoryLink(info os.FileInfo, path string) bool { + if info.Mode()&os.ModeSymlink != 0 { + return true + } + return hasMountPoint(path) +} +func requirePlatformDirectoryLink(path string) error { + info, err := os.Lstat(path) + if err != nil { + return err + } + if !isPlatformDirectoryLink(info, path) { + return fmt.Errorf("not directory link") + } + return nil +} +func hasMountPoint(path string) bool { + h, err := windows.CreateFile(windows.StringToUTF16Ptr(path), windows.GENERIC_READ, 0, nil, windows.OPEN_EXISTING, windows.FILE_FLAG_OPEN_REPARSE_POINT|windows.FILE_FLAG_BACKUP_SEMANTICS, 0) + if err != nil { + return false + } + defer windows.CloseHandle(h) + b := make([]byte, windows.MAXIMUM_REPARSE_DATA_BUFFER_SIZE) + var n uint32 + if windows.DeviceIoControl(h, windows.FSCTL_GET_REPARSE_POINT, nil, 0, &b[0], uint32(len(b)), &n, nil) != nil { + return false + } + return binary.LittleEndian.Uint32(b) == ioReparseTagMountPoint +} + +var _ = unsafe.Sizeof(0) diff --git a/apps/backend/pkg/api/v1/task.go b/apps/backend/pkg/api/v1/task.go index 177fbc4adb..4777ce3f6d 100644 --- a/apps/backend/pkg/api/v1/task.go +++ b/apps/backend/pkg/api/v1/task.go @@ -120,25 +120,37 @@ type TaskRepository struct { UpdatedAt time.Time `json:"updated_at"` } +// TaskWorkspaceFolder represents a non-Git folder associated with a task. +type TaskWorkspaceFolder struct { + ID string `json:"id"` + TaskID string `json:"task_id"` + LocalPath string `json:"local_path"` + DisplayName string `json:"display_name"` + Position int `json:"position"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + // Task represents a Kanban task type Task struct { - ID string `json:"id"` - WorkspaceID string `json:"workspace_id"` - WorkflowID string `json:"workflow_id"` - Title string `json:"title"` - Description string `json:"description"` - State TaskState `json:"state"` - Priority string `json:"priority"` - Repositories []TaskRepository `json:"repositories,omitempty"` - CreatedBy string `json:"created_by"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` - StartedAt *time.Time `json:"started_at,omitempty"` - CompletedAt *time.Time `json:"completed_at,omitempty"` - Metadata map[string]interface{} `json:"metadata,omitempty"` - IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat - ParentID string `json:"parent_id,omitempty"` // FK to parent task for subtasks - Identifier string `json:"identifier,omitempty"` + ID string `json:"id"` + WorkspaceID string `json:"workspace_id"` + WorkflowID string `json:"workflow_id"` + Title string `json:"title"` + Description string `json:"description"` + State TaskState `json:"state"` + Priority string `json:"priority"` + Repositories []TaskRepository `json:"repositories,omitempty"` + WorkspaceFolders []TaskWorkspaceFolder `json:"workspace_folders,omitempty"` + CreatedBy string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + StartedAt *time.Time `json:"started_at,omitempty"` + CompletedAt *time.Time `json:"completed_at,omitempty"` + Metadata map[string]interface{} `json:"metadata,omitempty"` + IsEphemeral bool `json:"is_ephemeral"` // Ephemeral tasks are not shown in kanban, used for quick chat + ParentID string `json:"parent_id,omitempty"` // FK to parent task for subtasks + Identifier string `json:"identifier,omitempty"` } // TaskRepositoryInput for creating/updating task repositories diff --git a/apps/backend/pkg/api/v1/task_test.go b/apps/backend/pkg/api/v1/task_test.go index ec24a0ffbc..2345a1769c 100644 --- a/apps/backend/pkg/api/v1/task_test.go +++ b/apps/backend/pkg/api/v1/task_test.go @@ -1,6 +1,9 @@ package v1 -import "testing" +import ( + "encoding/json" + "testing" +) func TestMessageAttachmentHasValidDeliveryMode(t *testing.T) { tests := []struct { @@ -24,6 +27,22 @@ func TestMessageAttachmentHasValidDeliveryMode(t *testing.T) { } } +func TestTaskSerializesWorkspaceFolders(t *testing.T) { + payload, err := json.Marshal(Task{WorkspaceFolders: []TaskWorkspaceFolder{{ + ID: "folder-1", TaskID: "task-1", LocalPath: "/canonical/docs", DisplayName: "docs", Position: 1, + }}}) + if err != nil { + t.Fatalf("marshal task: %v", err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(payload, &fields); err != nil { + t.Fatalf("decode task: %v", err) + } + if _, ok := fields["workspace_folders"]; !ok { + t.Fatalf("workspace_folders missing from task JSON: %s", payload) + } +} + func TestAggregateForegroundActivity(t *testing.T) { gen := ForegroundActivityGenerating bg := ForegroundActivityBackground diff --git a/apps/backend/pkg/websocket/actions.go b/apps/backend/pkg/websocket/actions.go index 8e7f8b4e33..fe672732b8 100644 --- a/apps/backend/pkg/websocket/actions.go +++ b/apps/backend/pkg/websocket/actions.go @@ -152,79 +152,80 @@ const ( ActionMessageSearch = "message.search" // Notification actions (server -> client) - ActionACPProgress = "acp.progress" - ActionACPLog = "acp.log" - ActionACPResult = "acp.result" - ActionACPError = "acp.error" - ActionACPStatus = "acp.status" - ActionACPHeartbeat = "acp.heartbeat" - ActionTaskCreated = "task.created" - ActionTaskUpdated = "task.updated" - ActionTaskDeleted = "task.deleted" - ActionTaskStateChanged = "task.state_changed" - ActionTaskPlanCreated = "task.plan.created" - ActionTaskPlanUpdated = "task.plan.updated" - ActionTaskPlanDeleted = "task.plan.deleted" - ActionTaskPlanRevisionCreated = "task.plan.revision.created" - ActionTaskPlanReverted = "task.plan.reverted" - ActionTaskWalkthroughGet = "task.walkthrough.get" - ActionTaskWalkthroughDelete = "task.walkthrough.delete" - ActionTaskWalkthroughCreated = "task.walkthrough.created" - ActionTaskWalkthroughUpdated = "task.walkthrough.updated" - ActionTaskWalkthroughDeleted = "task.walkthrough.deleted" - ActionAgentUpdated = "agent.updated" - ActionAgentAvailableUpdated = "agent.available.updated" - ActionAgentInstallStarted = "agent.install.started" - ActionAgentInstallOutput = "agent.install.output" - ActionAgentInstallFinished = "agent.install.finished" - ActionWorkspaceCreated = "workspace.created" - ActionWorkspaceUpdated = "workspace.updated" - ActionWorkspaceDeleted = "workspace.deleted" - ActionWorkflowCreated = "workflow.created" - ActionWorkflowUpdated = "workflow.updated" - ActionWorkflowDeleted = "workflow.deleted" - ActionWorkflowStepCreated = "workflow.step.created" - ActionWorkflowStepUpdated = "workflow.step.updated" - ActionWorkflowStepDeleted = "workflow.step.deleted" - ActionSessionMessageAdded = "session.message.added" - ActionSessionMessageUpdated = "session.message.updated" - ActionSessionMessageDeleted = "session.message.deleted" - ActionSessionStateChanged = "session.state_changed" - ActionSessionActivityChanged = "session.activity_changed" - ActionSessionAgentctlStarting = "session.agentctl_starting" - ActionSessionAgentctlReady = "session.agentctl_ready" - ActionSessionAgentctlError = "session.agentctl_error" - ActionSessionTurnStarted = "session.turn.started" - ActionSessionTurnCompleted = "session.turn.completed" - ActionSessionAvailableCommands = "session.available_commands" - ActionSessionModeChanged = "session.mode_changed" - ActionSessionAgentCapabilities = "session.agent_capabilities" - ActionSessionModelsUpdated = "session.models_updated" - ActionSessionInfoUpdated = "session.info_updated" - ActionSessionSetMode = "session.set_mode" - ActionSessionTodosUpdated = "session.todos_updated" - ActionSessionPromptUsage = "session.prompt_usage" - ActionSessionPollModeChanged = "session.poll_mode_changed" - ActionInputRequested = "input.requested" - ActionRepositoryCreated = "repository.created" - ActionRepositoryUpdated = "repository.updated" - ActionRepositoryDeleted = "repository.deleted" - ActionRepositoryScriptCreated = "repository.script.created" - ActionRepositoryScriptUpdated = "repository.script.updated" - ActionRepositoryScriptDeleted = "repository.script.deleted" - ActionExecutorCreated = "executor.created" - ActionExecutorUpdated = "executor.updated" - ActionExecutorDeleted = "executor.deleted" - ActionEnvironmentCreated = "environment.created" - ActionEnvironmentUpdated = "environment.updated" - ActionEnvironmentDeleted = "environment.deleted" - ActionExecutorProfileCreated = "executor.profile.created" - ActionExecutorProfileUpdated = "executor.profile.updated" - ActionExecutorProfileDeleted = "executor.profile.deleted" - ActionExecutorPrepareProgress = "executor.prepare.progress" - ActionExecutorPrepareCompleted = "executor.prepare.completed" - ActionSystemMetricsUpdated = "system.metrics.updated" - ActionUpdateAvailable = "system.update_available" + ActionACPProgress = "acp.progress" + ActionACPLog = "acp.log" + ActionACPResult = "acp.result" + ActionACPError = "acp.error" + ActionACPStatus = "acp.status" + ActionACPHeartbeat = "acp.heartbeat" + ActionTaskCreated = "task.created" + ActionTaskUpdated = "task.updated" + ActionTaskDeleted = "task.deleted" + ActionTaskStateChanged = "task.state_changed" + ActionSessionWorkspaceSourcesUpdated = "session.workspace_sources.updated" + ActionTaskPlanCreated = "task.plan.created" + ActionTaskPlanUpdated = "task.plan.updated" + ActionTaskPlanDeleted = "task.plan.deleted" + ActionTaskPlanRevisionCreated = "task.plan.revision.created" + ActionTaskPlanReverted = "task.plan.reverted" + ActionTaskWalkthroughGet = "task.walkthrough.get" + ActionTaskWalkthroughDelete = "task.walkthrough.delete" + ActionTaskWalkthroughCreated = "task.walkthrough.created" + ActionTaskWalkthroughUpdated = "task.walkthrough.updated" + ActionTaskWalkthroughDeleted = "task.walkthrough.deleted" + ActionAgentUpdated = "agent.updated" + ActionAgentAvailableUpdated = "agent.available.updated" + ActionAgentInstallStarted = "agent.install.started" + ActionAgentInstallOutput = "agent.install.output" + ActionAgentInstallFinished = "agent.install.finished" + ActionWorkspaceCreated = "workspace.created" + ActionWorkspaceUpdated = "workspace.updated" + ActionWorkspaceDeleted = "workspace.deleted" + ActionWorkflowCreated = "workflow.created" + ActionWorkflowUpdated = "workflow.updated" + ActionWorkflowDeleted = "workflow.deleted" + ActionWorkflowStepCreated = "workflow.step.created" + ActionWorkflowStepUpdated = "workflow.step.updated" + ActionWorkflowStepDeleted = "workflow.step.deleted" + ActionSessionMessageAdded = "session.message.added" + ActionSessionMessageUpdated = "session.message.updated" + ActionSessionMessageDeleted = "session.message.deleted" + ActionSessionStateChanged = "session.state_changed" + ActionSessionActivityChanged = "session.activity_changed" + ActionSessionAgentctlStarting = "session.agentctl_starting" + ActionSessionAgentctlReady = "session.agentctl_ready" + ActionSessionAgentctlError = "session.agentctl_error" + ActionSessionTurnStarted = "session.turn.started" + ActionSessionTurnCompleted = "session.turn.completed" + ActionSessionAvailableCommands = "session.available_commands" + ActionSessionModeChanged = "session.mode_changed" + ActionSessionAgentCapabilities = "session.agent_capabilities" + ActionSessionModelsUpdated = "session.models_updated" + ActionSessionInfoUpdated = "session.info_updated" + ActionSessionSetMode = "session.set_mode" + ActionSessionTodosUpdated = "session.todos_updated" + ActionSessionPromptUsage = "session.prompt_usage" + ActionSessionPollModeChanged = "session.poll_mode_changed" + ActionInputRequested = "input.requested" + ActionRepositoryCreated = "repository.created" + ActionRepositoryUpdated = "repository.updated" + ActionRepositoryDeleted = "repository.deleted" + ActionRepositoryScriptCreated = "repository.script.created" + ActionRepositoryScriptUpdated = "repository.script.updated" + ActionRepositoryScriptDeleted = "repository.script.deleted" + ActionExecutorCreated = "executor.created" + ActionExecutorUpdated = "executor.updated" + ActionExecutorDeleted = "executor.deleted" + ActionEnvironmentCreated = "environment.created" + ActionEnvironmentUpdated = "environment.updated" + ActionEnvironmentDeleted = "environment.deleted" + ActionExecutorProfileCreated = "executor.profile.created" + ActionExecutorProfileUpdated = "executor.profile.updated" + ActionExecutorProfileDeleted = "executor.profile.deleted" + ActionExecutorPrepareProgress = "executor.prepare.progress" + ActionExecutorPrepareCompleted = "executor.prepare.completed" + ActionSystemMetricsUpdated = "system.metrics.updated" + ActionUpdateAvailable = "system.update_available" ActionAgentProfileDeleted = "agent.profile.deleted" ActionAgentProfileCreated = "agent.profile.created" @@ -339,6 +340,7 @@ const ( ActionMCPCreateTask = "mcp.create_task" ActionMCPUpdateTask = "mcp.update_task" ActionMCPAddBranchToTask = "mcp.add_branch_to_task" + ActionMCPAddWorkspaceSources = "mcp.add_workspace_sources" ActionMCPUpdateRepositoryBaseBranch = "mcp.update_repository_base_branch" ActionMCPStepComplete = "mcp.step_complete" // ADR 0015: agent-emitted explicit completion signal ActionMCPAskUserQuestion = "mcp.ask_user_question" diff --git a/apps/web/components/create-local-repository-surface.test.tsx b/apps/web/components/create-local-repository-surface.test.tsx index 257b34e757..b503a0e199 100644 --- a/apps/web/components/create-local-repository-surface.test.tsx +++ b/apps/web/components/create-local-repository-surface.test.tsx @@ -255,6 +255,19 @@ describe("CreateLocalRepositorySurface submission", () => { expect(mocks.initialize).not.toHaveBeenCalled(); }); + it("allows workspace repository creation without changing an executor", async () => { + mocks.initialize.mockResolvedValue(createdRepository); + const props = renderSurface({ context: "workspace", executorSelection: null }); + + fireEvent.change(await screen.findByLabelText(REPOSITORY_NAME_LABEL), { + target: { value: REPOSITORY_NAME }, + }); + expect(screen.getByText(/registers it in this workspace/i)).toBeTruthy(); + fireEvent.click(screen.getByRole("button", { name: CREATE_BUTTON_NAME })); + + await waitFor(() => expect(props.onCreated).toHaveBeenCalledWith(createdRepository)); + }); + it("uses a mobile drawer instead of the desktop dialog on phones", async () => { mocks.isMobile = true; renderSurface(); diff --git a/apps/web/components/create-local-repository-surface.tsx b/apps/web/components/create-local-repository-surface.tsx index 30b23b2c40..6f928cda74 100644 --- a/apps/web/components/create-local-repository-surface.tsx +++ b/apps/web/components/create-local-repository-surface.tsx @@ -53,10 +53,17 @@ export type CreateLocalRepositorySurfaceProps = { onOpenChange: (open: boolean) => void; workspaceId: string | null; executorSelection: DirectLocalExecutorSelection | null; + context?: "task-create" | "workspace"; onCreated: (repository: Repository) => void; }; -function executorNotice(selection: DirectLocalExecutorSelection | null): string { +function executorNotice( + selection: DirectLocalExecutorSelection | null, + context: NonNullable, +): string { + if (context === "workspace") { + return "Creates an empty Git repository and registers it in this workspace."; + } if (!selection) { return "A direct local executor profile is required to create and use an empty repository."; } @@ -139,11 +146,13 @@ function RepositoryLocationFields({ type RepositoryFormDetailsProps = RepositoryLocationFieldsProps & { executorSelection: DirectLocalExecutorSelection | null; + context: NonNullable; submitError: string | null; }; function RepositoryFormDetails({ executorSelection, + context, submitError, ...locationFields }: RepositoryFormDetailsProps) { @@ -154,13 +163,13 @@ function RepositoryFormDetails({
-

{executorNotice(executorSelection)}

+

{executorNotice(executorSelection, context)}

{submitError ? (
void }) { @@ -222,14 +232,13 @@ function CreateRepositoryForm({ const nameError = validateLocalRepositoryName(name); const targetPath = parentPath && !nameError ? buildLocalRepositoryTargetPath(parentPath, name) : ""; - const canSubmit = Boolean( - workspaceId && executorSelection && parentPath && !nameError && !submitting, - ); + const executorReady = Boolean(executorSelection || context === "workspace"); + const canSubmit = !!workspaceId && executorReady && !!parentPath && !nameError && !submitting; const handleSubmit = async (event: FormEvent) => { event.preventDefault(); event.stopPropagation(); - if (!canSubmit || !workspaceId || !executorSelection) return; + if (!canSubmit || !workspaceId) return; setSubmitting(true); setSubmitError(null); try { @@ -264,11 +273,6 @@ function CreateRepositoryForm({ setEditingParentPath(true); }; - const handleNameChange = (nextName: string) => { - setSubmitError(null); - setName(nextName); - }; - const handleCreateDirectory = async (folderName: string) => { if (!listing) return; setSubmitError(null); @@ -285,8 +289,12 @@ function CreateRepositoryForm({ parentPath={parentPath} targetPath={targetPath} executorSelection={executorSelection} + context={context} submitError={submitError} - onNameChange={handleNameChange} + onNameChange={(nextName) => { + setSubmitError(null); + setName(nextName); + }} onParentPathChange={handleParentPathChange} onLoadTypedDirectory={loadTypedDirectory} /> diff --git a/apps/web/components/task-create-dialog-computed.ts b/apps/web/components/task-create-dialog-computed.ts index 5e55453884..7fa8a1958a 100644 --- a/apps/web/components/task-create-dialog-computed.ts +++ b/apps/web/components/task-create-dialog-computed.ts @@ -22,18 +22,7 @@ import { } from "@/components/task-create-dialog-defaults"; import { useRemoteAuthSpecs } from "@/hooks/domains/settings/use-remote-auth-specs"; import { isAgentConfiguredOnExecutor } from "@/lib/agent-executor-compat"; - -/** - * Multi-repo tasks currently only run on the git-worktree executor — - * Docker/Sprites/etc. don't yet know how to provision N sibling repos under - * one task root. Returning a non-null reason marks the option as disabled in - * the executor selector and surfaces this string as a tooltip. The dialog - * only applies this when 2+ repos are selected (see isMultiRepoSelection). - */ -function nonWorktreeDisabledReason(profile: ExecutorProfile): string | null { - if ((profile.executor_type ?? "") === "worktree") return null; - return "Multi-repo tasks only support the git-worktree executor."; -} +import { getMultiRepoExecutorDisabledReason } from "@/components/task-create-dialog-multi-repo-guard"; /** * Worktree executor needs a repository to create the worktree from. Disable @@ -48,7 +37,7 @@ function worktreeDisabledReason(profile: ExecutorProfile): string | null { /** * Combines the two executor-disable rules into a single resolver: * - no-repository mode → disable worktree (it needs a repo) - * - multi-repo selection → disable everything except worktree + * - multi-repo selection → disable runtimes without sibling-repository launch support * - otherwise → no disabling * The two never co-occur (no-repository implies zero repos, so multi-repo * cannot be true at the same time), so a simple priority order is enough. @@ -58,7 +47,9 @@ function pickExecutorDisabledReason( isMultiRepoSelection: boolean, ): ((profile: ExecutorProfile) => string | null) | undefined { if (noRepository) return worktreeDisabledReason; - if (isMultiRepoSelection) return nonWorktreeDisabledReason; + if (isMultiRepoSelection) { + return (profile) => getMultiRepoExecutorDisabledReason(profile.executor_type); + } return undefined; } @@ -198,12 +189,10 @@ export function useDialogComputed({ })), ); }, [executors]); - // Multi-repo tasks only run on the git-worktree executor today — Docker / - // Sprites / standalone don't yet know how to provision N sibling repos. Gate - // non-worktree options only when 2+ repos are selected; single-repo tasks - // keep the full executor catalogue. Count BOTH workspace/local rows AND - // remote-URL rows (each non-empty row is a distinct repo the task will - // operate on) so a 2-row Remote selection trips the same restriction. + // Gate only runtimes whose launch path cannot project sibling repositories. + // Count BOTH workspace/local rows AND remote-URL rows (each non-empty row is + // a distinct repo the task will operate on) so a 2-row Remote selection + // applies the same capability check. const selectedRepoCount = computeSelectedRepoCount(fs); const isMultiRepoSelection = selectedRepoCount > 1; // Use the effective agent ID (form value OR the workflow-locked override) diff --git a/apps/web/components/task-create-dialog-effects-executor.test.ts b/apps/web/components/task-create-dialog-effects-executor.test.ts index faf468e581..b80e1be9ac 100644 --- a/apps/web/components/task-create-dialog-effects-executor.test.ts +++ b/apps/web/components/task-create-dialog-effects-executor.test.ts @@ -247,30 +247,8 @@ describe("useDefaultSelectionsEffect - executor profile settings restoration", ( }); }); -describe("useDefaultSelectionsEffect - multi-repo guard counts Remote rows", () => { - it("prefers the saved worktree profile when multi-repo mode needs a switch", async () => { - const fs = makeDefaultSelFs({ - executorId: EXECUTOR_DOCKER, - executorProfileId: PROFILE_DOCKER, - useRemote: true, - remoteRepos: [ - { key: "remote-0", url: REMOTE_URL_A, branch: "", source: "paste" }, - { key: "remote-1", url: REMOTE_URL_B, branch: "", source: "paste" }, - ] as DialogFormState["remoteRepos"], - }); - const sel = makeSel({ - executors: [dockerExecutor(), worktreeExecutor()], - lastUsedExecutorProfileId: PROFILE_WORKTREE_B, - }); - - renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); - - await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE_B)); - }); - - it("swaps a non-worktree profile to worktree when 2+ Remote URL rows are set", async () => { - // Regression: the guard used to count only workspace/local rows, so 2 - // Remote rows slipped past it with an already-selected non-worktree profile. +describe("useDefaultSelectionsEffect - multi-repo executor selection", () => { + it("keeps Local Docker selected when 2+ Remote URL rows are set", async () => { const fs = makeDefaultSelFs({ executorId: EXECUTOR_DOCKER, executorProfileId: PROFILE_DOCKER, @@ -284,7 +262,8 @@ describe("useDefaultSelectionsEffect - multi-repo guard counts Remote rows", () renderHook(() => useDefaultSelectionsEffect(fs, true, sel, [])); - await waitFor(() => expect(fs.setExecutorProfileId).toHaveBeenCalledWith(PROFILE_WORKTREE)); + await new Promise((resolve) => setTimeout(resolve, 10)); + expect(fs.setExecutorProfileId).not.toHaveBeenCalled(); }); it("leaves a worktree profile alone when 2+ Remote rows are set", async () => { diff --git a/apps/web/components/task-create-dialog-effects.ts b/apps/web/components/task-create-dialog-effects.ts index 7e8dc85eed..9f0de81e03 100644 --- a/apps/web/components/task-create-dialog-effects.ts +++ b/apps/web/components/task-create-dialog-effects.ts @@ -19,9 +19,7 @@ import { useAgentProfileAutopickEffect, useWorkflowAgentProfileEffect, } from "@/components/task-create-dialog-autopick"; -import { useMultiRepoGuardEffect } from "@/components/task-create-dialog-multi-repo-guard"; import { useRepositoryAutoSelectEffect } from "@/components/task-create-dialog-repository-autopick"; -import { computeSelectedRepoCount } from "@/components/task-create-dialog-computed"; import { createDebugLogger, isDebug } from "@/lib/debug/log"; // Re-export autopick hooks for callers that imported them from this module. @@ -447,7 +445,6 @@ export function useDefaultSelectionsEffect( noRepository, useRemote, repositories, - remoteRepos, } = fs; const preferLocalExecutor = !noRepository && !useRemote && repositories.some((row) => Boolean(row.localPath)); @@ -500,25 +497,6 @@ export function useDefaultSelectionsEffect( } } }, [executorProfileId, executors, setExecutorId]); - - const selectedRepoCount = useMemo( - () => - computeSelectedRepoCount({ - noRepository, - useRemote, - remoteRepos, - repositories, - } as DialogFormState), - [noRepository, useRemote, remoteRepos, repositories], - ); - useMultiRepoGuardEffect({ - open, - executorProfileId, - setExecutorProfileId, - executors, - selectedRepoCount, - lastUsedExecutorProfileId: sel.lastUsedExecutorProfileId ?? null, - }); } /** diff --git a/apps/web/components/task-create-dialog-multi-repo-guard.test.ts b/apps/web/components/task-create-dialog-multi-repo-guard.test.ts new file mode 100644 index 0000000000..9074ce326f --- /dev/null +++ b/apps/web/components/task-create-dialog-multi-repo-guard.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { getMultiRepoExecutorDisabledReason } from "./task-create-dialog-multi-repo-guard"; + +describe("getMultiRepoExecutorDisabledReason", () => { + it.each(["worktree", "local_docker", "ssh", "sprites"])( + "allows multi-repository tasks on %s", + (executorType) => { + expect(getMultiRepoExecutorDisabledReason(executorType)).toBeNull(); + }, + ); + + it.each(["local", "local_pc"])( + "keeps %s disabled until initial launch supports siblings", + (executorType) => { + expect(getMultiRepoExecutorDisabledReason(executorType)).toBe( + "Multi-repo tasks are unavailable on Local until its initial launch path can project sibling repositories.", + ); + }, + ); + + it("keeps Remote Docker disabled until it can create task instances", () => { + expect(getMultiRepoExecutorDisabledReason("remote_docker")).toBe( + "Multi-repo tasks are unavailable on Remote Docker until it supports creating task instances.", + ); + }); + + it("explains that unknown executor types are unsupported", () => { + expect(getMultiRepoExecutorDisabledReason("remote_vps")).toBe( + "Multi-repo tasks are not supported by this executor.", + ); + }); +}); diff --git a/apps/web/components/task-create-dialog-multi-repo-guard.ts b/apps/web/components/task-create-dialog-multi-repo-guard.ts index 775d2a514b..64bd8b9611 100644 --- a/apps/web/components/task-create-dialog-multi-repo-guard.ts +++ b/apps/web/components/task-create-dialog-multi-repo-guard.ts @@ -1,50 +1,17 @@ -"use client"; +const MULTI_REPO_SUPPORTED_EXECUTOR_TYPES = new Set(["worktree", "local_docker", "ssh", "sprites"]); -import { useEffect } from "react"; -import type { Executor } from "@/lib/types/http"; - -type MultiRepoGuardOptions = { - open: boolean; - executorProfileId: string; - setExecutorProfileId: (id: string) => void; - executors: Executor[]; - selectedRepoCount: number; - lastUsedExecutorProfileId: string | null; -}; - -export function useMultiRepoGuardEffect({ - open, - executorProfileId, - setExecutorProfileId, - executors, - selectedRepoCount, - lastUsedExecutorProfileId, -}: MultiRepoGuardOptions) { - useEffect(() => { - if (!open || !executorProfileId || executors.length === 0) return; - if (selectedRepoCount <= 1) return; - const profileToType = new Map(); - const worktreeProfileIds: string[] = []; - for (const e of executors) { - for (const p of e.profiles ?? []) { - const type = p.executor_type ?? e.type; - profileToType.set(p.id, type); - if (type === "worktree") worktreeProfileIds.push(p.id); - } - } - if (worktreeProfileIds.length === 0) return; - if (profileToType.get(executorProfileId) === "worktree") return; - const preferredProfileId = - lastUsedExecutorProfileId && worktreeProfileIds.includes(lastUsedExecutorProfileId) - ? lastUsedExecutorProfileId - : worktreeProfileIds[0]; - void Promise.resolve().then(() => setExecutorProfileId(preferredProfileId)); - }, [ - open, - executorProfileId, - executors, - selectedRepoCount, - setExecutorProfileId, - lastUsedExecutorProfileId, - ]); +/** + * Returns the selector explanation for runtimes that cannot launch a task + * with sibling repositories. This is deliberately a pure capability check: + * changing repository rows must never replace a user's supported executor. + */ +export function getMultiRepoExecutorDisabledReason(executorType: string | null | undefined) { + if (MULTI_REPO_SUPPORTED_EXECUTOR_TYPES.has(executorType ?? "")) return null; + if (executorType === "local" || executorType === "local_pc") { + return "Multi-repo tasks are unavailable on Local until its initial launch path can project sibling repositories."; + } + if (executorType === "remote_docker") { + return "Multi-repo tasks are unavailable on Remote Docker until it supports creating task instances."; + } + return "Multi-repo tasks are not supported by this executor."; } diff --git a/apps/web/components/task-create-dialog-options.tsx b/apps/web/components/task-create-dialog-options.tsx index 179a988915..3f19671c8f 100644 --- a/apps/web/components/task-create-dialog-options.tsx +++ b/apps/web/components/task-create-dialog-options.tsx @@ -221,8 +221,8 @@ export type ExecutorProfileOptionItem = OptionItem & { export type ExecutorProfileOptionsConfig = { /** * Returns a tooltip string when the given profile should render disabled. - * Used by the kanban task-create dialog to gate non-worktree executors - * (only worktree-based execution is currently supported there). + * Used by the kanban task-create dialog for repository-mode executor + * capability gates. */ disabledReasonFor?: (profile: ExecutorProfile) => string | null; }; diff --git a/apps/web/components/task-create-dialog-remote-repo-chip.tsx b/apps/web/components/task-create-dialog-remote-repo-chip.tsx index 5cfc0d6cd3..9a04229240 100644 --- a/apps/web/components/task-create-dialog-remote-repo-chip.tsx +++ b/apps/web/components/task-create-dialog-remote-repo-chip.tsx @@ -40,6 +40,10 @@ import { import { remoteRepositoryMatchesSelection } from "./task-create-dialog-remote-repo-identity"; export { selectedRemoteRepositoryIdentity } from "./task-create-dialog-remote-repo-identity"; +import { + looksLikeSupportedRemoteURL, + looksLikeURL, +} from "@/components/workspace-source-picker/remote-url"; const TRUNCATE_THRESHOLD = 30; @@ -426,34 +430,9 @@ function visibleProviderRepositories( : accessible.repos; return { showProviderTabs, selectedProvider, visibleRepos }; } - -function looksLikeURL(value: string): boolean { - if (!value) return false; - const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`; - try { - const parsed = new URL(withScheme); - return parsed.hostname.includes(".") && value.includes("/"); - } catch { - return false; - } -} - -function looksLikeSupportedRemoteURL(value: string): boolean { - if (/^git@(github\.com|gitlab\.com|ssh\.dev\.azure\.com):\S+$/i.test(value)) return true; - if (!looksLikeURL(value)) return false; - const candidate = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`; - try { - const host = new URL(candidate).hostname.toLowerCase(); - return host === "github.com" || host === "gitlab.com" || host === "dev.azure.com"; - } catch { - return false; - } -} - function isSupportedRemoteURL(value: string): boolean { return !!parseGitHubAnyUrl(value) || looksLikeSupportedRemoteURL(value); } - function PickerList({ accessible, selectedRepositoryIdentities, diff --git a/apps/web/components/task-create-dialog-repo-chips.test.tsx b/apps/web/components/task-create-dialog-repo-chips.test.tsx index 91e23183e4..a754050fe2 100644 --- a/apps/web/components/task-create-dialog-repo-chips.test.tsx +++ b/apps/web/components/task-create-dialog-repo-chips.test.tsx @@ -1,5 +1,5 @@ import { describe, it, expect, vi, afterEach } from "vitest"; -import { render, screen, fireEvent, cleanup, within } from "@testing-library/react"; +import { render, screen, fireEvent, cleanup } from "@testing-library/react"; import type { Branch, Repository } from "@/lib/types/http"; import type { DialogFormState, TaskRepoRow } from "./task-create-dialog-types"; import { TooltipProvider } from "@kandev/ui/tooltip"; @@ -28,14 +28,12 @@ vi.mock("./task-create-dialog-remote-repo-chip", () => ({ })); import { RepoChipsRow } from "./task-create-dialog-repo-chips"; -import { WorkspaceRepoChips } from "./task-create-dialog-workspace-repo-chips"; afterEach(cleanup); const REPO_FRONT_ID = "repo-front"; const REPO_BACK_ID = "repo-back"; const REPO_CHIP_TRIGGER = "repo-chip-trigger"; -const ALREADY_ADDED_MARKER = "already-added-repository-marker"; const DISCOVERED_REPO_PATH = "/home/me/projects/local-project"; function makeRepo(id: string, name: string): Repository { @@ -92,6 +90,30 @@ const renderInProvider = (ui: Parameters[0]) => render({ui}); // eslint-disable-next-line max-lines-per-function -- test describe block, splitting hurts readability describe("RepoChipsRow", () => { + it("keeps the compact Repo, Remote, and None source-mode controls and test IDs", () => { + const onToggleRemote = vi.fn(); + const onToggleNoRepository = vi.fn(); + renderInProvider( + , + ); + + expect(screen.getByTestId("source-mode-workspace").textContent).toBe("Repo"); + expect(screen.getByTestId("source-mode-remote").textContent).toBe("Remote"); + expect(screen.getByTestId("source-mode-scratch").textContent).toBe("None"); + expect(screen.getByTestId("source-mode-workspace").className).not.toContain("min-h-11"); + fireEvent.click(screen.getByTestId("source-mode-remote")); + expect(onToggleRemote).toHaveBeenCalledOnce(); + }); + it("renders one chip per row plus an Add button", () => { renderInProvider( { mockBranches.value = { branches: [], isLoading: false }; }); }); - -const workspaceRepositories = [ - makeRepo(REPO_FRONT_ID, "frontend"), - makeRepo(REPO_BACK_ID, "backend"), -]; -const workspaceRows = [ - row({ key: "r0", repositoryId: REPO_FRONT_ID, branch: "main" }), - row({ key: "r1", branch: "develop" }), -]; - -function renderWorkspaceChips( - allowDuplicateRepositories: boolean, - chipRows: TaskRepoRow[] = workspaceRows, - discoveredRepositories: DialogFormState["discoveredRepositories"] = [], - onRowRepositoryChange = NOOP, -) { - return renderInProvider( - , - ); -} - -describe("WorkspaceRepoChips duplicate policy", () => { - it("excludes repositories already selected by another quick-chat row", () => { - renderWorkspaceChips(false); - - fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]); - - expect(screen.queryByRole("option", { name: /^frontend/ })).toBeNull(); - expect(screen.getByRole("option", { name: /^backend/ })).toBeTruthy(); - }); - - it("keeps task creation's same-repository different-branch option", () => { - renderWorkspaceChips(true); - - fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]); - - expect(screen.getByRole("option", { name: /^frontend/ })).toBeTruthy(); - expect(screen.getByRole("option", { name: /^backend/ })).toBeTruthy(); - }); - - it("does not expose repository creation when the caller does not opt in", () => { - renderWorkspaceChips(false); - - fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]); - - expect(screen.queryByText("Create new repository")).toBeNull(); - }); - - it("routes repository creation to the only row", () => { - const onCreateRepository = vi.fn(); - renderInProvider( - , - ); - - fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER)); - fireEvent.click(screen.getByTestId("create-local-repository-button")); - - expect(onCreateRepository).toHaveBeenCalledWith("r0"); - }); - - it("refreshes repositories from the selector toolbar", () => { - const onRefreshRepositories = vi.fn(); - renderInProvider( - , - ); - - fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER)); - fireEvent.click(screen.getByTestId("repo-refresh-button")); - - expect(onRefreshRepositories).toHaveBeenCalledOnce(); - }); - - it("does not expose repository creation for multi-repository tasks", () => { - renderInProvider( - , - ); - - fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]); - - expect(screen.queryByText("Create new repository")).toBeNull(); - }); -}); - -describe("WorkspaceRepoChips workspace markers", () => { - it("marks another task row's workspace repository while keeping it selectable", () => { - const onRowRepositoryChange = vi.fn(); - renderWorkspaceChips(true, workspaceRows, [], onRowRepositoryChange); - - fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]); - - const selectedElsewhere = screen.getByRole("option", { name: /^frontend/ }); - expect(selectedElsewhere).toBeTruthy(); - const marker = within(selectedElsewhere).getByTestId(ALREADY_ADDED_MARKER); - expect(marker.getAttribute("aria-label")).toBe("Already added"); - expect(marker.classList).toContain("text-primary"); - fireEvent.click(selectedElsewhere); - expect(onRowRepositoryChange).toHaveBeenCalledWith("r1", REPO_FRONT_ID); - }); - - it("does not mark the only selected workspace repository when its row is reopened", () => { - renderWorkspaceChips(true, [row({ key: "r0", repositoryId: REPO_FRONT_ID })]); - - fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER)); - fireEvent.keyDown(document, { key: "Escape" }); - expect(screen.queryByRole("option", { name: /^frontend/ })).toBeNull(); - fireEvent.click(screen.getByTestId(REPO_CHIP_TRIGGER)); - - expect( - within(screen.getByRole("option", { name: /^frontend/ })).queryByTestId(ALREADY_ADDED_MARKER), - ).toBeNull(); - }); - - it("clears a workspace marker when the selecting sibling changes or is removed", () => { - const { rerender } = renderWorkspaceChips(true, [ - row({ key: "r0", repositoryId: REPO_FRONT_ID }), - row({ key: "r1" }), - ]); - - fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]); - expect( - within(screen.getByRole("option", { name: /^frontend/ })).getByTestId(ALREADY_ADDED_MARKER), - ).toBeTruthy(); - - rerender( - - - , - ); - expect( - within(screen.getByRole("option", { name: /^frontend/ })).queryByTestId(ALREADY_ADDED_MARKER), - ).toBeNull(); - expect( - within(screen.getByRole("option", { name: /^backend/ })).getByTestId(ALREADY_ADDED_MARKER), - ).toBeTruthy(); - - rerender( - - - , - ); - expect( - within(screen.getByRole("option", { name: /^backend/ })).queryByTestId(ALREADY_ADDED_MARKER), - ).toBeNull(); - }); -}); - -describe("WorkspaceRepoChips discovered markers", () => { - it("marks normalized discovered paths selected by another task row and clears on rerender", () => { - const discoveredRepositories = [ - { path: DISCOVERED_REPO_PATH, name: "local-project" }, - ] as unknown as DialogFormState["discoveredRepositories"]; - const { rerender } = renderWorkspaceChips( - true, - [row({ key: "r0", localPath: `${DISCOVERED_REPO_PATH}/` }), row({ key: "r1" })], - discoveredRepositories, - ); - - fireEvent.click(screen.getAllByTestId(REPO_CHIP_TRIGGER)[1]); - expect( - within(screen.getByRole("option", { name: /^local-project/ })).getByTestId( - ALREADY_ADDED_MARKER, - ), - ).toBeTruthy(); - - rerender( - - - , - ); - expect( - within(screen.getByRole("option", { name: /^local-project/ })).queryByTestId( - ALREADY_ADDED_MARKER, - ), - ).toBeNull(); - - rerender( - - - , - ); - expect( - within(screen.getByRole("option", { name: /^local-project/ })).queryByTestId( - ALREADY_ADDED_MARKER, - ), - ).toBeNull(); - }); -}); diff --git a/apps/web/components/task-create-dialog-source-mode.tsx b/apps/web/components/task-create-dialog-source-mode.tsx index 887b4f1d73..35f708b592 100644 --- a/apps/web/components/task-create-dialog-source-mode.tsx +++ b/apps/web/components/task-create-dialog-source-mode.tsx @@ -17,6 +17,59 @@ type SourceModeSwitchProps = { onToggleNoRepository?: () => void; }; +type ControlledSourceModeOption = { + value: T; + label: string; + testId: string; + legacyTestId?: string; +}; + +type ControlledSourceModeSwitchProps = { + mode: T; + options: readonly ControlledSourceModeOption[]; + onModeChange: (mode: T) => void; + touchSized?: boolean; +}; + +export function ControlledSourceModeSwitch({ + mode, + options, + onModeChange, + touchSized = false, +}: ControlledSourceModeSwitchProps) { + return ( +
+ {options.map((option) => { + const isActive = mode === option.value; + return ( + + ); + })} +
+ ); +} + /** * Three-mode segmented control rendered at the right edge of the chip row: * - Repo : pick a workspace repository (default) @@ -43,18 +96,27 @@ export function SourceModeSwitch({ onToggleNoRepository, }); return ( -
- - {onToggleRemote && ( - - )} - {onToggleNoRepository && ( - - )} +
+
); } @@ -85,39 +147,3 @@ function switchMode({ if (from === "remote") onToggleRemote?.(); else if (from === "scratch") onToggleNoRepository?.(); } - -function ModeButton({ - label, - mode, - active, - onSelect, -}: { - label: string; - mode: SourceMode; - active: SourceMode; - onSelect: (m: SourceMode) => void; -}) { - const isActive = active === mode; - // The Remote button carries `data-legacy-testid="toggle-github-url"` so the older - // create-task-github-url.spec.ts and subtask.spec.ts can keep selecting it during - // the migration; the primary `data-testid` follows the source-mode- convention. - const legacyTestId = mode === "remote" ? "toggle-github-url" : undefined; - return ( - - ); -} diff --git a/apps/web/components/task-create-dialog-workspace-repo-chips.test.tsx b/apps/web/components/task-create-dialog-workspace-repo-chips.test.tsx new file mode 100644 index 0000000000..0c42775e6a --- /dev/null +++ b/apps/web/components/task-create-dialog-workspace-repo-chips.test.tsx @@ -0,0 +1,215 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, within } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import type { Repository } from "@/lib/types/http"; +import type { DialogFormState, TaskRepoRow } from "./task-create-dialog-types"; +import { WorkspaceRepoChips } from "./task-create-dialog-workspace-repo-chips"; + +vi.mock("@/hooks/domains/workspace/use-repository-branches", () => ({ + useBranches: () => ({ branches: [], isLoading: false }), +})); + +const FRONTEND_ID = "repo-front"; +const BACKEND_ID = "repo-back"; +const CHIP_TRIGGER = "repo-chip-trigger"; +const ADDED_MARKER = "already-added-repository-marker"; +const DISCOVERED_PATH = "/home/me/projects/local-project"; +const NOOP = (_key: string, _value: string) => undefined; + +function repository(id: string, name: string): Repository { + return { + id, + workspace_id: "ws-1", + name, + source_type: "local", + local_path: `/repos/${name}`, + default_branch: "main", + created_at: new Date().toISOString(), + updated_at: new Date().toISOString(), + } as Repository; +} + +function row(overrides: Partial = {}): TaskRepoRow { + return { key: `row-${Math.random()}`, branch: "", ...overrides }; +} + +const repositories = [repository(FRONTEND_ID, "frontend"), repository(BACKEND_ID, "backend")]; +const rows = [ + row({ key: "r0", repositoryId: FRONTEND_ID, branch: "main" }), + row({ key: "r1", branch: "develop" }), +]; + +type ChipsProps = Parameters[0]; + +function chips(overrides: Partial = {}) { + return ( + + ); +} + +function renderChips(overrides: Partial = {}) { + return render({chips(overrides)}); +} + +afterEach(cleanup); + +describe("WorkspaceRepoChips duplicate policy", () => { + it("excludes repositories already selected by another quick-chat row", () => { + renderChips({ allowDuplicateRepositories: false }); + fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[1]); + + expect(screen.queryByRole("option", { name: /^frontend/ })).toBeNull(); + expect(screen.getByRole("option", { name: /^backend/ })).toBeTruthy(); + }); + + it("keeps task creation's same-repository different-branch option", () => { + renderChips({ allowDuplicateRepositories: true }); + fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[1]); + + expect(screen.getByRole("option", { name: /^frontend/ })).toBeTruthy(); + expect(screen.getByRole("option", { name: /^backend/ })).toBeTruthy(); + }); + + it("does not expose repository creation when the caller does not opt in", () => { + renderChips({ allowDuplicateRepositories: false }); + fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[1]); + + expect(screen.queryByText("Create new repository")).toBeNull(); + }); + + it("routes repository creation to the only row", () => { + const onCreateRepository = vi.fn(); + renderChips({ rows: [rows[0]], onCreateRepository }); + + fireEvent.click(screen.getByTestId(CHIP_TRIGGER)); + fireEvent.click(screen.getByTestId("create-local-repository-button")); + + expect(onCreateRepository).toHaveBeenCalledWith("r0"); + }); + + it("refreshes repositories from the selector toolbar", () => { + const onRefreshRepositories = vi.fn(); + renderChips({ rows: [rows[0]], onRefreshRepositories }); + + fireEvent.click(screen.getByTestId(CHIP_TRIGGER)); + fireEvent.click(screen.getByTestId("repo-refresh-button")); + + expect(onRefreshRepositories).toHaveBeenCalledOnce(); + }); + + it("does not expose repository creation for multi-repository tasks", () => { + renderChips({ onCreateRepository: vi.fn() }); + fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[1]); + + expect(screen.queryByText("Create new repository")).toBeNull(); + }); +}); + +describe("WorkspaceRepoChips workspace markers", () => { + it("marks another task row's workspace repository while keeping it selectable", () => { + const onRowRepositoryChange = vi.fn(); + renderChips({ allowDuplicateRepositories: true, onRowRepositoryChange }); + fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[1]); + + const selectedElsewhere = screen.getByRole("option", { name: /^frontend/ }); + const marker = within(selectedElsewhere).getByTestId(ADDED_MARKER); + expect(marker.getAttribute("aria-label")).toBe("Already added"); + expect(marker.classList).toContain("text-primary"); + fireEvent.click(selectedElsewhere); + expect(onRowRepositoryChange).toHaveBeenCalledWith("r1", FRONTEND_ID); + }); + + it("does not mark the only selected workspace repository when its row is reopened", () => { + renderChips({ + allowDuplicateRepositories: true, + rows: [row({ key: "r0", repositoryId: FRONTEND_ID })], + }); + fireEvent.click(screen.getByTestId(CHIP_TRIGGER)); + fireEvent.keyDown(document, { key: "Escape" }); + expect(screen.queryByRole("option", { name: /^frontend/ })).toBeNull(); + fireEvent.click(screen.getByTestId(CHIP_TRIGGER)); + + expect( + within(screen.getByRole("option", { name: /^frontend/ })).queryByTestId(ADDED_MARKER), + ).toBeNull(); + }); + + it("clears a workspace marker when the selecting sibling changes or is removed", () => { + const { rerender } = renderChips({ + allowDuplicateRepositories: true, + rows: [row({ key: "r0", repositoryId: FRONTEND_ID }), row({ key: "r1" })], + }); + fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[1]); + expect( + within(screen.getByRole("option", { name: /^frontend/ })).getByTestId(ADDED_MARKER), + ).toBeTruthy(); + + rerender( + + {chips({ rows: [row({ key: "r0", repositoryId: BACKEND_ID }), row({ key: "r1" })] })} + , + ); + expect( + within(screen.getByRole("option", { name: /^frontend/ })).queryByTestId(ADDED_MARKER), + ).toBeNull(); + expect( + within(screen.getByRole("option", { name: /^backend/ })).getByTestId(ADDED_MARKER), + ).toBeTruthy(); + + rerender({chips({ rows: [row({ key: "r1" })] })}); + expect( + within(screen.getByRole("option", { name: /^backend/ })).queryByTestId(ADDED_MARKER), + ).toBeNull(); + }); +}); + +describe("WorkspaceRepoChips discovered markers", () => { + it("marks normalized discovered paths selected by another task row and clears on rerender", () => { + const discoveredRepositories = [ + { path: DISCOVERED_PATH, name: "local-project" }, + ] as unknown as DialogFormState["discoveredRepositories"]; + const { rerender } = renderChips({ + allowDuplicateRepositories: true, + rows: [row({ key: "r0", localPath: `${DISCOVERED_PATH}/` }), row({ key: "r1" })], + discoveredRepositories, + }); + fireEvent.click(screen.getAllByTestId(CHIP_TRIGGER)[1]); + expect( + within(screen.getByRole("option", { name: /^local-project/ })).getByTestId(ADDED_MARKER), + ).toBeTruthy(); + + rerender( + + {chips({ + rows: [ + row({ key: "r0", localPath: "/home/me/projects/another-project" }), + row({ key: "r1" }), + ], + discoveredRepositories, + })} + , + ); + expect( + within(screen.getByRole("option", { name: /^local-project/ })).queryByTestId(ADDED_MARKER), + ).toBeNull(); + + rerender( + + {chips({ rows: [row({ key: "r1" })], discoveredRepositories })} + , + ); + expect( + within(screen.getByRole("option", { name: /^local-project/ })).queryByTestId(ADDED_MARKER), + ).toBeNull(); + }); +}); diff --git a/apps/web/components/task/add-workspace-sources/add-workspace-sources-availability.test.ts b/apps/web/components/task/add-workspace-sources/add-workspace-sources-availability.test.ts new file mode 100644 index 0000000000..1851f55a58 --- /dev/null +++ b/apps/web/components/task/add-workspace-sources/add-workspace-sources-availability.test.ts @@ -0,0 +1,33 @@ +import { describe, expect, it } from "vitest"; +import { + getAddSourcesDisabledReason, + hasActiveTaskSourceWork, +} from "./add-workspace-sources-availability"; + +describe("getAddSourcesDisabledReason", () => { + it("explains loading before the Files action can safely use task sources", () => { + expect(getAddSourcesDisabledReason({ isLoading: true })).toBe( + "Wait for task sources to finish loading before adding sources.", + ); + }); + + it("explains an active turn or tool call", () => { + expect(getAddSourcesDisabledReason({ hasActiveTurn: true })).toBe( + "Wait for the active turn or tool call to finish before adding sources.", + ); + }); + + it("blocks source changes when any task session has active work", () => { + expect( + hasActiveTaskSourceWork(["primary", "subtask"], { + primary: null, + subtask: "turn-2", + unrelated: "turn-3", + }), + ).toBe(true); + }); + + it("does not treat an unrelated session as task work", () => { + expect(hasActiveTaskSourceWork(["primary"], { unrelated: "turn-3" })).toBe(false); + }); +}); diff --git a/apps/web/components/task/add-workspace-sources/add-workspace-sources-availability.ts b/apps/web/components/task/add-workspace-sources/add-workspace-sources-availability.ts new file mode 100644 index 0000000000..c1e46b0534 --- /dev/null +++ b/apps/web/components/task/add-workspace-sources/add-workspace-sources-availability.ts @@ -0,0 +1,22 @@ +type AvailabilityInput = { + isLoading?: boolean; + hasActiveTurn?: boolean; +}; + +/** True when any session attached to this task is mid-turn or tool call. */ +export function hasActiveTaskSourceWork( + taskSessionIds: readonly string[], + activeTurnBySession: Readonly>, +): boolean { + return taskSessionIds.some((sessionId) => Boolean(activeTurnBySession[sessionId])); +} + +export function getAddSourcesDisabledReason({ + isLoading, + hasActiveTurn, +}: AvailabilityInput): string | undefined { + if (isLoading) return "Wait for task sources to finish loading before adding sources."; + if (hasActiveTurn) + return "Wait for the active turn or tool call to finish before adding sources."; + return undefined; +} diff --git a/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.test.tsx b/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.test.tsx new file mode 100644 index 0000000000..033f44f5ba --- /dev/null +++ b/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.test.tsx @@ -0,0 +1,285 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { useEffect, useRef, useState } from "react"; +import { AddWorkspaceSourcesDialog } from "./add-workspace-sources-dialog"; +import { StateProvider, useAppStore, useAppStoreApi } from "@/components/state-provider"; +import { sessionId as toSessionId, taskId as toTaskId } from "@/lib/types/http"; +import { TooltipProvider } from "@kandev/ui/tooltip"; + +let isMobile = false; +const ADD_SOURCES_LABEL = "Add sources"; +const { attachTaskWorkspaceSources, discoverRepositoriesAction, refreshRepositories } = vi.hoisted( + () => ({ + attachTaskWorkspaceSources: vi.fn(), + discoverRepositoriesAction: vi.fn().mockResolvedValue({ repositories: [] }), + refreshRepositories: vi.fn().mockResolvedValue(undefined), + }), +); + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile }), +})); +vi.mock("@/hooks/domains/workspace/use-repositories", () => ({ + useRepositories: () => ({ + repositories: [], + isLoading: false, + refresh: refreshRepositories, + }), +})); +vi.mock("@/components/folder-picker", () => ({ + FolderPicker: ({ onChange }: { onChange: (path: string) => void }) => ( + + ), +})); +vi.mock("@/lib/api/domains/kanban-api", () => ({ attachTaskWorkspaceSources })); +vi.mock("@/app/actions/workspaces", () => ({ discoverRepositoriesAction })); + +async function finishClose(surface: HTMLElement, isDrawer: boolean) { + await waitFor(() => expect(surface.getAttribute("data-state")).not.toBe("open")); + if (isDrawer) fireEvent.animationEnd(surface); + await waitFor(() => expect(surface.isConnected).toBe(false)); +} + +function openRepositoryMenu() { + const trigger = screen.getByRole("button", { name: "Add repository" }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }); + fireEvent.click(trigger); + return trigger; +} + +function Harness({ + makeTurnActive = false, + executorType = "worktree", +}: { + makeTurnActive?: boolean; + executorType?: string; +}) { + return ( + + + + ); +} + +function HarnessContent({ + makeTurnActive, + executorType, +}: { + makeTurnActive: boolean; + executorType: string; +}) { + const [open, setOpen] = useState(false); + const [opener, setOpener] = useState(null); + const store = useAppStoreApi(); + const isBusy = useAppStore((state) => Boolean(state.turns.activeBySession["session-1"])); + const activated = useRef(false); + useEffect(() => { + if (!open || !makeTurnActive || activated.current) return; + activated.current = true; + store.getState().addTurn({ + id: "turn-stale", + session_id: toSessionId("session-1"), + task_id: toTaskId("task-1"), + started_at: "2026-07-23T00:00:00Z", + created_at: "2026-07-23T00:00:00Z", + updated_at: "2026-07-23T00:00:00Z", + }); + store.getState().setActiveTurn("session-1", "turn-stale"); + }, [makeTurnActive, open, store]); + return ( + <> + + + + ); +} + +afterEach(() => { + cleanup(); + isMobile = false; + attachTaskWorkspaceSources.mockReset(); + discoverRepositoriesAction.mockClear(); + refreshRepositories.mockClear(); +}); + +describe("AddWorkspaceSourcesDialog consequences", () => { + it.each([ + ["desktop", false, "add-workspace-sources-dialog"], + ["mobile", true, "add-workspace-sources-drawer"], + ])("explains the workspace and session consequences on %s", async (_, mobile, surfaceTestId) => { + isMobile = mobile; + render(); + + fireEvent.click(screen.getByRole("button", { name: ADD_SOURCES_LABEL })); + const surface = await screen.findByTestId(surfaceTestId); + const consequences = screen.getByTestId("workspace-change-consequences"); + + expect(surface.contains(consequences)).toBe(true); + expect(screen.getByText("This restarts the task workspace")).toBeTruthy(); + expect(consequences.textContent).toMatch(/task root becomes the agent's working directory/i); + expect(consequences.textContent).toMatch( + /terminals, dev servers, and other workspace processes stop/i, + ); + expect(consequences.textContent).toMatch( + /provider-private context that Kandev did not record may not carry over/i, + ); + expect(screen.getByText(/Cancel leaves the workspace unchanged/i)).toBeTruthy(); + }); + + it("explains that remote executor sources are attached without restarting the agent", async () => { + render(); + + fireEvent.click(screen.getByRole("button", { name: ADD_SOURCES_LABEL })); + const consequences = await screen.findByTestId("workspace-change-consequences"); + + expect(consequences.textContent).toContain("This updates the live task workspace"); + expect(consequences.textContent).toContain( + "The agent and running workspace processes continue", + ); + expect(consequences.textContent).not.toContain("This restarts the task workspace"); + }); +}); + +describe("AddWorkspaceSourcesDialog", () => { + it("uses a touch-sized repository menu without tabs or discarded mixed source rows", async () => { + isMobile = true; + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: ADD_SOURCES_LABEL })); + expect(screen.queryByTestId("source-mode-local")).toBeNull(); + expect(screen.queryByTestId("source-mode-remote")).toBeNull(); + const addRepository = screen.getByRole("button", { name: "Add repository" }); + const submit = screen.getByTestId("add-workspace-sources-submit"); + expect(addRepository.className).toContain("min-h-11"); + expect((submit as HTMLButtonElement).disabled).toBe(true); + + fireEvent.click(screen.getByRole("button", { name: "Add folder" })); + openRepositoryMenu(); + const remoteRepository = await screen.findByRole("menuitem", { name: "Remote repository" }); + expect(remoteRepository.className).toContain("min-h-11"); + fireEvent.click(remoteRepository); + + expect(screen.getByText("Folder")).toBeTruthy(); + expect(screen.getByText("Remote repository")).toBeTruthy(); + expect(screen.queryByText("Choose a repository and base branch.")).toBeNull(); + expect(screen.queryByRole("textbox", { name: "Checkout branch" })).toBeNull(); + expect((submit as HTMLButtonElement).disabled).toBe(false); + + fireEvent.click(submit); + + const validationError = screen.getByText("Choose a repository and base branch."); + expect(validationError.className).toContain("text-xs"); + const form = screen.getByTestId("add-workspace-sources-form"); + expect(form.querySelectorAll('[role="alert"]')).toHaveLength(2); + + openRepositoryMenu(); + fireEvent.click(await screen.findByRole("menuitem", { name: "Local Git repository" })); + expect(form.querySelectorAll('[role="alert"]')).toHaveLength(2); + }); + + it.each([ + ["desktop", false, "add-workspace-sources-dialog"], + ["mobile", true, "add-workspace-sources-drawer"], + ])("returns focus to the external %s opener after Cancel", async (_, mobile, surfaceTestId) => { + isMobile = mobile; + render(); + + const opener = screen.getByRole("button", { name: ADD_SOURCES_LABEL }); + fireEvent.click(opener); + const surface = await screen.findByTestId(surfaceTestId); + fireEvent.click(screen.getByRole("button", { name: "Cancel" })); + + await finishClose(surface, mobile); + expect(attachTaskWorkspaceSources).not.toHaveBeenCalled(); + await waitFor(() => expect(document.activeElement).toBe(opener)); + }); + + it.each([ + ["desktop", false, "add-workspace-sources-dialog"], + ["mobile", true, "add-workspace-sources-drawer"], + ])( + "reconciles adopted stale work before returning focus to the enabled %s opener", + async (_, mobile, surfaceTestId) => { + isMobile = mobile; + attachTaskWorkspaceSources.mockResolvedValueOnce({ + task_id: "task-1", + repositories: [], + workspace_folders: [], + workspace_path: "/workspace/task-1", + adopted_session_ids: ["session-1"], + session_ids: [], + }); + render(); + + const opener = screen.getByRole("button", { + name: ADD_SOURCES_LABEL, + }) as HTMLButtonElement; + fireEvent.click(opener); + const surface = await screen.findByTestId(surfaceTestId); + await waitFor(() => expect(opener.disabled).toBe(true)); + fireEvent.click(screen.getByRole("button", { name: "Add folder" })); + fireEvent.click(screen.getByRole("button", { name: "Choose local folder" })); + fireEvent.click(screen.getByTestId("add-workspace-sources-submit")); + + await waitFor(() => expect(opener.disabled).toBe(false)); + await waitFor(() => expect(surface.getAttribute("data-state")).not.toBe("open")); + await finishClose(surface, mobile); + expect(screen.queryByTestId(surfaceTestId)).toBeNull(); + await waitFor(() => expect(document.activeElement).toBe(opener)); + expect(attachTaskWorkspaceSources).toHaveBeenCalledOnce(); + }, + ); +}); + +describe("AddWorkspaceSourcesDialog saved repository picker", () => { + it("reuses discovered repository, refresh, and create controls from task creation", async () => { + discoverRepositoriesAction.mockResolvedValueOnce({ + repositories: [ + { + path: "/projects/discovered-project", + name: "discovered-project", + default_branch: "main", + }, + ], + }); + render( + + + , + ); + + fireEvent.click(screen.getByRole("button", { name: ADD_SOURCES_LABEL })); + await waitFor(() => expect(discoverRepositoriesAction).toHaveBeenCalledWith("workspace-1")); + openRepositoryMenu(); + fireEvent.click(await screen.findByRole("menuitem", { name: "Workspace repository" })); + fireEvent.click(screen.getByTestId("repo-chip-trigger")); + + expect(await screen.findByText("discovered-project")).toBeTruthy(); + expect(screen.getByText("on disk")).toBeTruthy(); + expect(screen.getByTestId("create-local-repository-button")).toBeTruthy(); + fireEvent.click(screen.getByTestId("repo-refresh-button")); + await waitFor(() => expect(refreshRepositories).toHaveBeenCalledOnce()); + }); +}); diff --git a/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.tsx b/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.tsx new file mode 100644 index 0000000000..f4a647ace3 --- /dev/null +++ b/apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.tsx @@ -0,0 +1,612 @@ +"use client"; + +import { useCallback, useEffect, useState, type RefObject, type ReactNode } from "react"; +import { + IconChevronDown, + IconCloudDownload, + IconFolderPlus, + IconGitBranch, + IconPlus, + IconStack2, + IconX, +} from "@tabler/icons-react"; +import { Button } from "@kandev/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@kandev/ui/dialog"; +import { + Drawer, + DrawerContent, + DrawerDescription, + DrawerHeader, + DrawerTitle, +} from "@kandev/ui/drawer"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; +import { Input } from "@kandev/ui/input"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; +import { useAppStore } from "@/components/state-provider"; +import { useBranchesByURL } from "@/hooks/domains/github/use-branches-by-url"; +import { usePRInfoByURL } from "@/hooks/domains/github/use-pr-info-by-url"; +import { useRemoteRepositories } from "@/hooks/domains/integrations/use-remote-repositories"; +import { FolderPicker } from "@/components/folder-picker"; +import { RemoteRepoChip } from "@/components/task-create-dialog-remote-repo-chip"; +import type { TaskRemoteRepoRow } from "@/components/task-create-dialog-types"; +import type { LocalRepository, Repository } from "@/lib/types/http"; +import { type WorkspaceSourceRow } from "@/components/workspace-source-picker/workspace-source-state"; +import { + getWorkspaceSourceCapabilities, + hasCloneableSavedRepository, +} from "@/components/workspace-source-picker/executor-capabilities"; +import { SavedRepositorySourceRow } from "./saved-repository-source-row"; +import { useDialogOpenerFocus } from "./use-dialog-opener-focus"; +import { useSubmitWorkspaceSources } from "./use-submit-workspace-sources"; +import { useWorkspaceRepositoryOptions } from "./use-workspace-repository-options"; +import { useWorkspaceSourceRows } from "./use-workspace-source-rows"; +import { WorkspaceChangeConsequences } from "./workspace-change-consequences"; +import { cn } from "@/lib/utils"; + +type Props = { + open: boolean; + onOpenChange: (open: boolean) => void; + taskId: string; + executorType?: string | null; + workspaceId: string | null; + /** The toolbar button that explicitly opened this controlled surface. */ + opener?: HTMLElement | null; + openerRef?: RefObject; +}; + +export function AddWorkspaceSourcesDialog({ + open, + onOpenChange, + taskId, + executorType, + workspaceId, + opener, + openerRef, +}: Props) { + const { isMobile } = useResponsiveBreakpoint(); + const { repositories, discoveredRepositories, repositoriesRefreshing, refreshRepositoryOptions } = + useWorkspaceRepositoryOptions(workspaceId, open); + const [submitting, setSubmitting] = useState(false); + const [submitError, setSubmitError] = useState(null); + const sourceRows = useWorkspaceSourceRows(executorType); + const reconcileWorkspaceSourcesAdopted = useAppStore( + (state) => state.reconcileWorkspaceSourcesAdopted, + ); + const { requestFocusRestoration, restoreOpenerFocus } = useDialogOpenerFocus({ + open, + opener, + openerRef, + }); + const capabilities = getWorkspaceSourceCapabilities(executorType); + const restartsWorkspace = !isRemoteWorkspaceExecutor(executorType); + const close = useCallback( + (nextOpen: boolean) => { + if (!nextOpen && !submitting) { + requestFocusRestoration(); + sourceRows.resetValidation(); + setSubmitError(null); + onOpenChange(false); + } + }, + [onOpenChange, requestFocusRestoration, sourceRows, submitting], + ); + const submit = useSubmitWorkspaceSources({ + errors: sourceRows.errors, + onOpenChange, + reconcileWorkspaceSourcesAdopted, + rows: sourceRows.rows, + submitting, + taskId, + onSuccess: () => { + sourceRows.reset(); + requestFocusRestoration(); + }, + setSubmitting, + setSubmitError, + }); + return ( + } + form={ + { + setSubmitError(null); + sourceRows.add(kind); + }} + onRemove={(key) => { + setSubmitError(null); + sourceRows.remove(key); + }} + onUpdate={(key, patch) => { + setSubmitError(null); + sourceRows.update(key, patch); + }} + isMobile={isMobile} + /> + } + submitting={submitting} + canSubmit={sourceRows.rows.length > 0} + onCancel={() => close(false)} + onSubmit={() => { + sourceRows.validate(); + void submit(); + }} + /> + ); +} + +function selectableRepositories( + repositories: Repository[], + capabilities: ReturnType, +) { + return capabilities.requiresCloneableLocalRepository + ? repositories.filter(hasCloneableSavedRepository) + : repositories; +} + +function isRemoteWorkspaceExecutor(executorType: string | null | undefined): boolean { + return ["local_docker", "remote_docker", "ssh", "sprites"].includes(executorType ?? ""); +} + +type AddWorkspaceSourcesSurfaceProps = { + isMobile: boolean; + open: boolean; + onOpenChange: (open: boolean) => void; + onCloseAutoFocus: (event?: { preventDefault(): void }) => void; + onDrawerCloseAnimationEnd: (event: { preventDefault(): void }) => void; + error: string | null; + consequences: ReactNode; + form: ReactNode; + submitting: boolean; + canSubmit: boolean; + onCancel: () => void; + onSubmit: () => void; +}; + +function AddWorkspaceSourcesSurface({ + isMobile, + open, + onOpenChange, + onCloseAutoFocus, + onDrawerCloseAnimationEnd, + error, + consequences, + form, + submitting, + canSubmit, + onCancel, + onSubmit, +}: AddWorkspaceSourcesSurfaceProps) { + const footer = ( +
+ + +
+ ); + const errorMessage = error && ( +

+ {error} +

+ ); + if (isMobile) + return ( + + { + if (event.currentTarget.dataset.state === "closed") onDrawerCloseAnimationEnd(event); + }} + className="h-dvh !max-h-dvh rounded-none flex flex-col overflow-hidden data-[vaul-drawer-direction=bottom]:!mt-0" + > + + Add to workspace + + Choose repositories or folders to make available in this task. + + +
+ {errorMessage} +
{consequences}
+ {form} +
+
+ {footer} +
+
+
+ ); + return ( + + + + Add to workspace + + Choose repositories or folders to make available in this task. + + +
+ {errorMessage} + {consequences} + {form} +
+ {footer} +
+
+ ); +} + +function SourceForm({ + rows, + repositories, + discoveredRepositories, + workspaceId, + repositoriesRefreshing, + onRefreshRepositories, + errors, + capabilities, + onAdd, + onRemove, + onUpdate, + isMobile, +}: { + rows: WorkspaceSourceRow[]; + repositories: Repository[]; + discoveredRepositories: LocalRepository[]; + workspaceId: string | null; + repositoriesRefreshing: boolean; + onRefreshRepositories: () => void; + errors: Record; + capabilities: ReturnType; + onAdd: (kind: NonNullable) => void; + onRemove: (key: string) => void; + onUpdate: (key: string, patch: Partial) => void; + isMobile: boolean; +}) { + return ( +
+
+ + {capabilities.canAddFolders && ( + + )} +
+ {capabilities.requiresCloneableLocalRepository && ( +

+ Saved and local Git repositories must have a cloneable origin for this executor. Local + folders are unavailable. +

+ )} + {rows.map((row) => ( + + ))} +
+ ); +} + +function RepositorySourceMenu({ + isMobile, + onAdd, +}: { + isMobile: boolean; + onAdd: (kind: "saved_repository" | "local_repository" | "remote_repository") => void; +}) { + const itemClass = cn("cursor-pointer items-start gap-3", isMobile ? "min-h-11" : "py-2"); + return ( + + + + + + } + className={itemClass} + onSelect={() => onAdd("saved_repository")} + /> + } + className={itemClass} + onSelect={() => onAdd("local_repository")} + /> + } + className={itemClass} + onSelect={() => onAdd("remote_repository")} + /> + + + ); +} + +function RepositorySourceMenuItem({ + label, + description, + icon, + className, + onSelect, +}: { + label: string; + description: string; + icon: ReactNode; + className: string; + onSelect: () => void; +}) { + return ( + + {icon} + + {label} + {description} + + + ); +} + +function SourceRow({ + row, + repositories, + discoveredRepositories, + workspaceId, + repositoriesRefreshing, + onRefreshRepositories, + capabilities, + error, + onRemove, + onUpdate, +}: { + row: WorkspaceSourceRow; + repositories: Repository[]; + discoveredRepositories: LocalRepository[]; + workspaceId: string | null; + repositoriesRefreshing: boolean; + onRefreshRepositories: () => void; + capabilities: ReturnType; + error?: string; + onRemove: (key: string) => void; + onUpdate: (key: string, patch: Partial) => void; +}) { + const type = row.sourceType ?? (row.kind === "folder" ? "folder" : "saved_repository"); + return ( +
+
+ {labelFor(type)} + +
+ {type === "saved_repository" && ( + + )} + {type === "local_repository" && ( + + )} + {type === "remote_repository" && ( + + )} + {type === "folder" && ( + + )} + {error && ( +

+ {error} +

+ )} +
+ ); +} + +function labelFor(type: NonNullable) { + switch (type) { + case "saved_repository": + return "Workspace repository"; + case "local_repository": + return "Local Git repository"; + case "remote_repository": + return "Remote repository"; + case "folder": + return "Folder"; + } +} + +function LocalPathRow({ + row, + label, + requiresCloneableOrigin = false, + onUpdate, +}: { + row: WorkspaceSourceRow; + label: string; + requiresCloneableOrigin?: boolean; + onUpdate: (key: string, patch: Partial) => void; +}) { + return ( + <> + + onUpdate(row.key, { localPath, repositoryId: undefined, remoteUrl: undefined }) + } + placeholder={label} + /> + {row.sourceType === "folder" && ( + onUpdate(row.key, { displayName: event.target.value })} + /> + )} + {row.sourceType === "local_repository" && ( + <> + onUpdate(row.key, { baseBranch: event.target.value })} + /> +

+ {requiresCloneableOrigin + ? "This repository must have a cloneable origin; Kandev will verify it before adding." + : "Uses the current checkout. Kandev does not switch your local repository branch."} +

+ + )} + + ); +} + +function RemoteRepositoryRow({ + row, + workspaceId, + onUpdate, +}: { + row: WorkspaceSourceRow; + workspaceId: string | null; + onUpdate: (key: string, patch: Partial) => void; +}) { + const branches = useBranchesByURL(); + const prInfo = usePRInfoByURL(); + const accessibleRepos = useRemoteRepositories(workspaceId ?? ""); + useEffect(() => { + if (row.remoteUrl) branches.ensure(row.remoteUrl, workspaceId ?? undefined); + }, [branches, row.remoteUrl, workspaceId]); + const remoteRow: TaskRemoteRepoRow = { + key: row.key, + url: row.remoteUrl ?? "", + branch: row.baseBranch ?? "", + source: "paste", + provider: row.provider, + providerRepoId: row.providerRepoId, + providerOwner: row.providerOwner, + providerName: row.providerName, + }; + return ( + <> + + onUpdate(row.key, { + remoteUrl, + repositoryId: undefined, + localPath: undefined, + provider: metadata?.provider, + providerRepoId: metadata?.providerRepoId, + providerOwner: metadata?.providerOwner, + providerName: metadata?.providerName, + baseBranch: metadata?.defaultBranch ?? "", + }) + } + onBranchChange={(baseBranch) => onUpdate(row.key, { baseBranch })} + onRemove={() => {}} + /> + + ); +} diff --git a/apps/web/components/task/add-workspace-sources/saved-repository-source-row.tsx b/apps/web/components/task/add-workspace-sources/saved-repository-source-row.tsx new file mode 100644 index 0000000000..fd7172105a --- /dev/null +++ b/apps/web/components/task/add-workspace-sources/saved-repository-source-row.tsx @@ -0,0 +1,97 @@ +"use client"; + +import { useState } from "react"; +import { CreateLocalRepositorySurface } from "@/components/create-local-repository-surface"; +import { useAppStore } from "@/components/state-provider"; +import type { TaskRepoRow } from "@/components/task-create-dialog-types"; +import { WorkspaceRepoChips } from "@/components/task-create-dialog-workspace-repo-chips"; +import type { WorkspaceSourceRow } from "@/components/workspace-source-picker/workspace-source-state"; +import type { LocalRepository, Repository } from "@/lib/types/http"; + +type Props = { + row: WorkspaceSourceRow; + repositories: Repository[]; + discoveredRepositories: LocalRepository[]; + workspaceId: string | null; + canCreateRepository: boolean; + repositoriesRefreshing: boolean; + onRefreshRepositories: () => void; + onUpdate: (key: string, patch: Partial) => void; +}; + +export function SavedRepositorySourceRow({ + row, + repositories, + discoveredRepositories, + workspaceId, + canCreateRepository, + repositoriesRefreshing, + onRefreshRepositories, + onUpdate, +}: Props) { + const [creatingRepository, setCreatingRepository] = useState(false); + const upsertRepository = useAppStore((state) => state.upsertRepository); + const chip: TaskRepoRow = { + key: row.key, + repositoryId: row.repositoryId, + localPath: row.localPath, + branch: row.baseBranch ?? "", + }; + + const selectRepository = (value: string) => { + const discovered = discoveredRepositories.find((repository) => repository.path === value); + onUpdate( + row.key, + discovered + ? { + repositoryId: undefined, + localPath: discovered.path, + remoteUrl: undefined, + baseBranch: discovered.default_branch ?? "", + } + : { + repositoryId: value, + localPath: undefined, + remoteUrl: undefined, + baseBranch: "", + }, + ); + }; + + const selectCreatedRepository = (repository: Repository) => { + if (workspaceId) upsertRepository(workspaceId, repository); + onUpdate(row.key, { + repositoryId: repository.id, + localPath: undefined, + remoteUrl: undefined, + baseBranch: repository.default_branch || "main", + }); + }; + + return ( + <> + {}} + onRemove={() => {}} + onRowRepositoryChange={(_, value) => selectRepository(value)} + onRowBranchChange={(_, baseBranch) => onUpdate(row.key, { baseBranch })} + onCreateRepository={canCreateRepository ? () => setCreatingRepository(true) : undefined} + onRefreshRepositories={onRefreshRepositories} + repositoriesRefreshing={repositoriesRefreshing} + /> + + + ); +} diff --git a/apps/web/components/task/add-workspace-sources/use-dialog-opener-focus.ts b/apps/web/components/task/add-workspace-sources/use-dialog-opener-focus.ts new file mode 100644 index 0000000000..435a926270 --- /dev/null +++ b/apps/web/components/task/add-workspace-sources/use-dialog-opener-focus.ts @@ -0,0 +1,33 @@ +"use client"; + +import { useCallback, useRef, type RefObject } from "react"; + +export function useDialogOpenerFocus({ + open, + opener, + openerRef, +}: { + open: boolean; + opener?: HTMLElement | null; + openerRef?: RefObject; +}) { + const capturedOpenerRef = useRef(null); + const shouldRestoreFocusRef = useRef(false); + if (open && opener) capturedOpenerRef.current = opener; + + const requestFocusRestoration = useCallback(() => { + shouldRestoreFocusRef.current = true; + }, []); + const restoreOpenerFocus = useCallback( + (event?: { preventDefault(): void }) => { + if (!shouldRestoreFocusRef.current) return; + const focusTarget = openerRef?.current ?? capturedOpenerRef.current; + event?.preventDefault(); + shouldRestoreFocusRef.current = false; + capturedOpenerRef.current = null; + if (focusTarget?.isConnected && !focusTarget.matches(":disabled")) focusTarget.focus(); + }, + [openerRef], + ); + return { requestFocusRestoration, restoreOpenerFocus }; +} diff --git a/apps/web/components/task/add-workspace-sources/use-submit-workspace-sources.ts b/apps/web/components/task/add-workspace-sources/use-submit-workspace-sources.ts new file mode 100644 index 0000000000..05beb52036 --- /dev/null +++ b/apps/web/components/task/add-workspace-sources/use-submit-workspace-sources.ts @@ -0,0 +1,67 @@ +import { useCallback, type Dispatch, type SetStateAction } from "react"; +import { flushSync } from "react-dom"; +import { attachTaskWorkspaceSources } from "@/lib/api/domains/kanban-api"; +import { + buildWorkspaceSourcesPayload, + type WorkspaceSourceRow, +} from "@/components/workspace-source-picker/workspace-source-state"; + +type Props = { + errors: Record; + onOpenChange: (open: boolean) => void; + onSuccess: () => void; + reconcileWorkspaceSourcesAdopted: (sessionIds: string[]) => void; + rows: WorkspaceSourceRow[]; + setSubmitting: Dispatch>; + setSubmitError: Dispatch>; + submitting: boolean; + taskId: string; +}; + +export function useSubmitWorkspaceSources({ + errors, + onOpenChange, + onSuccess, + reconcileWorkspaceSourcesAdopted, + rows, + setSubmitting, + setSubmitError, + submitting, + taskId, +}: Props) { + return useCallback(async () => { + if (submitting) return; + if (rows.length === 0) return setSubmitError("Add at least one source."); + if (Object.keys(errors).length) + return setSubmitError("Fix the marked sources before adding them."); + + setSubmitting(true); + setSubmitError(null); + try { + const result = await attachTaskWorkspaceSources(taskId, buildWorkspaceSourcesPayload(rows)); + flushSync(() => { + reconcileWorkspaceSourcesAdopted(result.adopted_session_ids ?? result.session_ids); + onSuccess(); + }); + onOpenChange(false); + } catch (error) { + setSubmitError( + error instanceof Error + ? error.message + : "Could not add sources. Your entries are still here to retry.", + ); + } finally { + setSubmitting(false); + } + }, [ + errors, + onOpenChange, + onSuccess, + reconcileWorkspaceSourcesAdopted, + rows, + setSubmitting, + setSubmitError, + submitting, + taskId, + ]); +} diff --git a/apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts b/apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts new file mode 100644 index 0000000000..ff39ccb613 --- /dev/null +++ b/apps/web/components/task/add-workspace-sources/use-workspace-repository-options.ts @@ -0,0 +1,56 @@ +"use client"; + +import { useCallback, useEffect, useRef, useState } from "react"; +import { discoverRepositoriesAction } from "@/app/actions/workspaces"; +import { useRepositories } from "@/hooks/domains/workspace/use-repositories"; +import type { LocalRepository } from "@/lib/types/http"; + +export function useWorkspaceRepositoryOptions(workspaceId: string | null, open: boolean) { + const { + repositories, + isLoading: repositoriesLoading, + refresh: refreshRepositories, + } = useRepositories(workspaceId, open); + const [discoveredRepositories, setDiscoveredRepositories] = useState([]); + const [repositoriesDiscovering, setRepositoriesDiscovering] = useState(false); + const currentWorkspaceRef = useRef(workspaceId); + currentWorkspaceRef.current = workspaceId; + + const discoverRepositories = useCallback(async () => { + if (!workspaceId) { + setDiscoveredRepositories([]); + return; + } + const requestedWorkspaceId = workspaceId; + setRepositoriesDiscovering(true); + try { + const result = await discoverRepositoriesAction(requestedWorkspaceId); + if (currentWorkspaceRef.current === requestedWorkspaceId) { + setDiscoveredRepositories(result.repositories); + } + } catch { + if (currentWorkspaceRef.current === requestedWorkspaceId) { + setDiscoveredRepositories([]); + } + } finally { + if (currentWorkspaceRef.current === requestedWorkspaceId) { + setRepositoriesDiscovering(false); + } + } + }, [workspaceId]); + + useEffect(() => { + if (open) void discoverRepositories(); + }, [discoverRepositories, open]); + + const refreshRepositoryOptions = useCallback(() => { + void Promise.all([refreshRepositories(), discoverRepositories()]); + }, [discoverRepositories, refreshRepositories]); + + return { + repositories, + discoveredRepositories, + repositoriesRefreshing: repositoriesLoading || repositoriesDiscovering, + refreshRepositoryOptions, + }; +} diff --git a/apps/web/components/task/add-workspace-sources/use-workspace-source-rows.ts b/apps/web/components/task/add-workspace-sources/use-workspace-source-rows.ts new file mode 100644 index 0000000000..4d4b8656cc --- /dev/null +++ b/apps/web/components/task/add-workspace-sources/use-workspace-source-rows.ts @@ -0,0 +1,55 @@ +"use client"; + +import { useCallback, useState } from "react"; +import { + addWorkspaceSourceRow, + getWorkspaceSourceValidation, + removeWorkspaceSourceRow, + updateWorkspaceSourceRow, + type WorkspaceSourceRow, +} from "@/components/workspace-source-picker/workspace-source-state"; + +export function useWorkspaceSourceRows(executorType?: string | null) { + const [rows, setRows] = useState([]); + const [validatedRowKeys, setValidatedRowKeys] = useState>(() => new Set()); + const errors = getWorkspaceSourceValidation(rows); + const visibleErrors = Object.fromEntries( + Object.entries(errors).filter(([key]) => validatedRowKeys.has(key)), + ); + + const add = useCallback( + (kind: NonNullable) => + setRows((current) => addWorkspaceSourceRow(current, kind, executorType)), + [executorType], + ); + const update = useCallback( + (key: string, patch: Partial) => + setRows((current) => updateWorkspaceSourceRow(current, key, patch)), + [], + ); + const remove = useCallback( + (key: string) => setRows((current) => removeWorkspaceSourceRow(current, key)), + [], + ); + const reset = useCallback(() => { + setRows([]); + setValidatedRowKeys(new Set()); + }, []); + const resetValidation = useCallback(() => setValidatedRowKeys(new Set()), []); + const validate = useCallback( + () => setValidatedRowKeys(new Set(rows.map((row) => row.key))), + [rows], + ); + + return { + rows, + errors, + visibleErrors, + add, + update, + remove, + reset, + resetValidation, + validate, + }; +} diff --git a/apps/web/components/task/add-workspace-sources/workspace-change-consequences.tsx b/apps/web/components/task/add-workspace-sources/workspace-change-consequences.tsx new file mode 100644 index 0000000000..aa9297c093 --- /dev/null +++ b/apps/web/components/task/add-workspace-sources/workspace-change-consequences.tsx @@ -0,0 +1,121 @@ +import { IconAlertTriangle } from "@tabler/icons-react"; + +export function WorkspaceChangeConsequences({ restartsWorkspace }: { restartsWorkspace: boolean }) { + return ( +
+
+
+
+ ); +} + +function RestartSummary() { + return ( +
    +
  • + The idle agent restarts at the task root. Existing files, Git changes, task and session + history, messages, plan, attached sources, model, and mode remain. +
  • +
  • + Provider-private context that Kandev did not record may not carry over. Open terminals, dev + servers, and other workspace processes stop, including the task editor server. +
  • +
+ ); +} + +function LiveUpdateSummary() { + return ( +
    +
  • + Repositories are added as top-level entries in the current remote workspace. The + agent's working directory does not change. +
  • +
  • + The agent and running workspace processes continue. Existing files, Git changes, and + recorded task and session context remain. +
  • +
+ ); +} + +function RestartConsequences() { + return ( +
    +
  • + Workspace: The task root becomes + the agent's working directory. For a single-repository task, this moves the CWD up one + level and shows every source as a named top-level entry. Existing files and Git changes are + not moved or discarded. +
  • +
  • + Session context: Kandev restarts + the idle agent while preserving the task, session, task state, messages, plan, attached + sources, and selected model and mode. Providers that support cross-directory resume keep + their native session. Other providers start a new session and receive Kandev's recorded + conversation with the next prompt. Provider-private context that Kandev did not record may + not carry over. +
  • +
  • + Running processes: Open terminals, + dev servers, and other workspace processes stop. This includes the task editor server. Save + unsaved work, then reopen or restart those processes after the sources are attached. +
  • +
+ ); +} + +function LiveUpdateConsequences() { + return ( +
    +
  • + Workspace: Repositories are cloned + as named top-level entries under the current remote workspace. The agent's working + directory does not change, and existing files and Git changes are not moved or discarded. +
  • +
  • + Session and processes: The agent + and running workspace processes continue while Kandev rescans the workspace. The task, + session, task state, messages, plan, attached sources, model, and mode remain in place. +
  • +
+ ); +} diff --git a/apps/web/components/task/file-browser-reset-key.test.ts b/apps/web/components/task/file-browser-reset-key.test.ts new file mode 100644 index 0000000000..b0a3d062d5 --- /dev/null +++ b/apps/web/components/task/file-browser-reset-key.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from "vitest"; +import { getFileBrowserResetKey } from "./file-browser"; + +describe("getFileBrowserResetKey", () => { + it("changes the session-scoped key when refresh generation increments without an environment", () => { + expect( + getFileBrowserResetKey({ + sessionId: "session-1", + environmentId: undefined, + worktreeCount: 1, + workspaceFilesRefresh: 0, + }), + ).not.toBe( + getFileBrowserResetKey({ + sessionId: "session-1", + environmentId: undefined, + worktreeCount: 1, + workspaceFilesRefresh: 1, + }), + ); + }); + + it("changes the environment-scoped key when refresh generation increments", () => { + expect( + getFileBrowserResetKey({ + sessionId: "session-1", + environmentId: "environment-1", + worktreeCount: 1, + workspaceFilesRefresh: 0, + }), + ).not.toBe( + getFileBrowserResetKey({ + sessionId: "session-1", + environmentId: "environment-1", + worktreeCount: 1, + workspaceFilesRefresh: 1, + }), + ); + }); +}); diff --git a/apps/web/components/task/file-browser-toolbar.test.tsx b/apps/web/components/task/file-browser-toolbar.test.tsx new file mode 100644 index 0000000000..b92a18863c --- /dev/null +++ b/apps/web/components/task/file-browser-toolbar.test.tsx @@ -0,0 +1,121 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { cleanup, fireEvent, render, screen, waitFor } from "@testing-library/react"; +import { TooltipProvider } from "@kandev/ui/tooltip"; +import { FileBrowserToolbar } from "./file-browser-toolbar"; + +let isMobile = false; + +vi.mock("@/hooks/use-responsive-breakpoint", () => ({ + useResponsiveBreakpoint: () => ({ isMobile }), +})); + +afterEach(() => { + cleanup(); + isMobile = false; +}); + +describe("FileBrowserToolbar workspace actions", () => { + it("keeps Open workspace folder available while Add sources explains why it is unavailable", () => { + const onAddSources = vi.fn(); + const onOpenFolder = vi.fn(); + + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Workspace actions" }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }); + fireEvent.click(trigger); + + const addSources = screen.getByRole("menuitem", { + name: /Add Repositories to workspace/i, + }); + expect(addSources.hasAttribute("data-disabled")).toBe(true); + expect(addSources.className).toContain("min-h-11"); + expect(screen.getByText("Wait for the active agent turn to finish")).toBeTruthy(); + fireEvent.click(screen.getByRole("menuitem", { name: "Open workspace folder" })); + expect(onOpenFolder).toHaveBeenCalledOnce(); + expect(onAddSources).not.toHaveBeenCalled(); + }); + + it("opens Add sources only after the menu closes and uses the combined trigger as its opener", async () => { + const onAddSources = vi.fn(); + const openerRef = { current: null as HTMLButtonElement | null }; + + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Workspace actions" }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }); + fireEvent.click(trigger); + fireEvent.click(screen.getByRole("menuitem", { name: "Add Repositories to workspace" })); + + await waitFor(() => expect(onAddSources).toHaveBeenCalledWith(trigger)); + expect(openerRef.current).toBe(trigger); + }); + + it("restores the mobile trigger after the source Drawer finishes closing", async () => { + isMobile = true; + const onAddSources = vi.fn(); + render( + + + , + ); + + const trigger = screen.getByRole("button", { name: "Workspace actions" }); + fireEvent.pointerDown(trigger, { button: 0, ctrlKey: false }); + fireEvent.click(trigger); + fireEvent.click(screen.getByRole("menuitem", { name: "Add Repositories to workspace" })); + await waitFor(() => expect(onAddSources).toHaveBeenCalledOnce()); + + const drawer = document.createElement("div"); + drawer.dataset.testid = "add-workspace-sources-drawer"; + drawer.dataset.state = "closed"; + document.body.append(drawer); + fireEvent.animationEnd(drawer); + + await waitFor(() => expect(document.activeElement).toBe(trigger)); + drawer.remove(); + }); +}); diff --git a/apps/web/components/task/file-browser-toolbar.tsx b/apps/web/components/task/file-browser-toolbar.tsx index d4099ed42c..724e30f4b8 100644 --- a/apps/web/components/task/file-browser-toolbar.tsx +++ b/apps/web/components/task/file-browser-toolbar.tsx @@ -1,17 +1,24 @@ "use client"; -import type { ReactNode } from "react"; +import { useCallback, useEffect, useRef, type ReactNode, type Ref, type RefObject } from "react"; import { IconSearch, IconListTree, - IconFolderShare, IconFolderOpen, IconCopy, IconCheck, IconPlus, + IconDots, } from "@tabler/icons-react"; import { Tooltip, TooltipContent, TooltipTrigger } from "@kandev/ui/tooltip"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@kandev/ui/dropdown-menu"; import { Skeleton } from "@kandev/ui/skeleton"; +import { useResponsiveBreakpoint } from "@/hooks/use-responsive-breakpoint"; import { cn } from "@/lib/utils"; import { PanelHeaderBarSplit } from "./panel-primitives"; @@ -51,8 +58,159 @@ type FileBrowserToolbarProps = { onStartSearch: () => void; onCollapseAll: () => void; showCreateButton: boolean; + onAddSources?: (opener: HTMLButtonElement) => void; + addSourcesButtonRef?: Ref; + addSourcesDisabledReason?: string; }; +function CopyWorkspacePathButton({ + fullPath, + copied, + onCopyPath, +}: Pick) { + return ( + + + + + Copy workspace path + + ); +} + +function useMobileDrawerFocusRestoration(triggerRef: RefObject) { + const { isMobile } = useResponsiveBreakpoint(); + const restoreAfterDrawerCloseRef = useRef(false); + + useEffect(() => { + const restoreFocus = (event: AnimationEvent) => { + if (!restoreAfterDrawerCloseRef.current) return; + const drawer = event.target; + if ( + !(drawer instanceof HTMLElement) || + drawer.dataset.testid !== "add-workspace-sources-drawer" || + drawer.dataset.state !== "closed" + ) + return; + restoreAfterDrawerCloseRef.current = false; + requestAnimationFrame(() => { + if (triggerRef.current?.isConnected && !triggerRef.current.disabled) { + triggerRef.current.focus(); + } + }); + }; + document.addEventListener("animationend", restoreFocus, true); + return () => document.removeEventListener("animationend", restoreFocus, true); + }, [triggerRef]); + + return useCallback(() => { + restoreAfterDrawerCloseRef.current = isMobile; + }, [isMobile]); +} + +function WorkspaceActionsMenu({ + onAddSources, + onOpenFolder, + addSourcesButtonRef, + addSourcesDisabledReason, +}: Pick< + FileBrowserToolbarProps, + "onAddSources" | "onOpenFolder" | "addSourcesButtonRef" | "addSourcesDisabledReason" +>) { + const triggerRef = useRef(null); + const openSourcesAfterCloseRef = useRef(false); + const restoreMobileFocusAfterDrawerClose = useMobileDrawerFocusRestoration(triggerRef); + const setTriggerRef = useCallback( + (node: HTMLButtonElement | null) => { + triggerRef.current = node; + if (typeof addSourcesButtonRef === "function") { + addSourcesButtonRef(node); + } else if (addSourcesButtonRef) { + (addSourcesButtonRef as { current: HTMLButtonElement | null }).current = node; + } + }, + [addSourcesButtonRef], + ); + const disabledReason = + addSourcesDisabledReason ?? + (onAddSources ? undefined : "This task needs a repository before sources can be added."); + + return ( + + + + + + + + Workspace actions + + { + if (!openSourcesAfterCloseRef.current) return; + event.preventDefault(); + openSourcesAfterCloseRef.current = false; + if (triggerRef.current && onAddSources) onAddSources(triggerRef.current); + }} + > + { + openSourcesAfterCloseRef.current = true; + restoreMobileFocusAfterDrawerClose(); + }} + > + + + Add Repositories to workspace + {disabledReason && ( + + {disabledReason} + + )} + + + + + Open workspace folder + + + + ); +} + export function FileBrowserToolbar({ displayPath, fullPath, @@ -64,36 +222,16 @@ export function FileBrowserToolbar({ onStartSearch, onCollapseAll, showCreateButton, + onAddSources, + addSourcesButtonRef, + addSourcesDisabledReason, }: FileBrowserToolbarProps) { return ( - - - - - Copy workspace path - + {displayPath ? ( @@ -119,10 +257,11 @@ export function FileBrowserToolbar({ icon={} /> )} - } + void; onCollapseAll: () => void; showCreateButton: boolean; + onAddSources?: (opener: HTMLButtonElement) => void; + addSourcesButtonRef?: Ref; + addSourcesDisabledReason?: string; }; function FileBrowserHeader({ @@ -54,6 +65,9 @@ function FileBrowserHeader({ onOpenFolder, onCollapseAll, showCreateButton, + onAddSources, + addSourcesButtonRef, + addSourcesDisabledReason, }: FileBrowserHeaderProps) { if (!treeLoaded) return null; if (search.isSearchActive) { @@ -79,6 +93,9 @@ function FileBrowserHeader({ onStartSearch={() => search.setIsSearchActive(true)} onCollapseAll={onCollapseAll} showCreateButton={showCreateButton} + onAddSources={onAddSources} + addSourcesButtonRef={addSourcesButtonRef} + addSourcesDisabledReason={addSourcesDisabledReason} /> ); } @@ -92,6 +109,9 @@ type FileBrowserProps = { onRenameFile?: (oldPath: string, newPath: string) => Promise; onDownloadFile?: (path: string) => Promise; activeFilePath?: string | null; + onAddSources?: (opener: HTMLButtonElement) => void; + addSourcesButtonRef?: Ref; + addSourcesDisabledReason?: string; }; function useFileBrowserHandlers( @@ -384,13 +404,35 @@ function useKeyboardShortcuts( }, [containerRef, clearSelection, selectAll]); } +export function getFileBrowserResetKey({ + sessionId, + environmentId, + worktreeCount, + workspaceFilesRefresh, +}: { + sessionId: string; + environmentId?: string | null; + worktreeCount: number; + workspaceFilesRefresh: number; +}) { + return `${environmentId ?? sessionId}:${worktreeCount}:${workspaceFilesRefresh}`; +} + function useFileBrowserResetKey(sessionId: string, environmentId?: string | null) { // Worktree count participates in the tree's reset key so an add_branch_to_task // call that materializes a sibling worktree forces a fresh tree load. const worktreeCount = useAppStore( (state) => state.sessionWorktreesBySessionId.itemsBySessionId[sessionId]?.length ?? 0, ); - return environmentId ? `${environmentId}:${worktreeCount}` : undefined; + const workspaceFilesRefresh = useAppStore( + (state) => state.workspaceFilesRefresh.bySessionId[sessionId] ?? 0, + ); + return getFileBrowserResetKey({ + sessionId, + environmentId, + worktreeCount, + workspaceFilesRefresh, + }); } function useFileBrowserData(sessionId: string, environmentId: string | null | undefined) { @@ -428,6 +470,94 @@ function useFileBrowserData(sessionId: string, environmentId: string | null | un }; } +function useFileBrowserViewModel({ + sessionId, + environmentId, + onOpenFile, + onCreateFile, + onRenameFile, + activeFilePath, + scrollAreaRef, + containerRef, +}: Pick< + FileBrowserProps, + "sessionId" | "environmentId" | "onOpenFile" | "onCreateFile" | "onRenameFile" | "activeFilePath" +> & { + scrollAreaRef: React.RefObject; + containerRef: React.RefObject; +}) { + const data = useFileBrowserData(sessionId, environmentId); + useScrollPersistence(sessionId, data.isTreeLoaded, scrollAreaRef, data.treeState.tree); + const handlers = useFileBrowserHandlers(sessionId, onOpenFile, onCreateFile, data.treeState); + const selection = useSelectionInteractions( + data.treeState, + containerRef, + activeFilePath, + onRenameFile, + ); + return { data, handlers, ...selection }; +} + +function FileBrowserTreeContent({ + scrollAreaRef, + data, + handlers, + multiSelect, + dnd, + activeFilePath, + onDeleteFile, + onRenameFile, + onDownloadFile, +}: Omit & { + scrollAreaRef: React.RefObject; + data: ReturnType; + handlers: ReturnType; + multiSelect: ReturnType["multiSelect"]; + dnd: ReturnType["dnd"]; +}) { + const { search, isSessionFailed, sessionError, treeState, fileStatuses } = data; + return ( + + void treeState.loadTree({ resetRetry: true })} + setTree={treeState.setTree} + isSelectedFn={multiSelect.isSelected} + onSelect={multiSelect.handleClick} + isDragging={dnd.isDragging} + dragOverPath={dnd.dragOverPath} + onDragStart={dnd.handleDragStart} + onDragEnd={dnd.handleDragEnd} + onDragOver={dnd.handleDragOver} + onDragLeave={dnd.handleDragLeave} + onDrop={dnd.handleDrop} + selectedCount={multiSelect.selectedPaths.size} + selectedPaths={multiSelect.selectedPaths} + /> + + ); +} + export function FileBrowser({ sessionId, environmentId, @@ -437,32 +567,23 @@ export function FileBrowser({ onRenameFile, onDownloadFile, activeFilePath, + onAddSources, + addSourcesButtonRef, + addSourcesDisabledReason, }: FileBrowserProps) { const scrollAreaRef = useRef(null); const containerRef = useRef(null); - const data = useFileBrowserData(sessionId, environmentId); - const { - isSessionFailed, - sessionError, - openFolder, - copied, - copyPath, - search, - treeState, - isTreeLoaded, - fileStatuses, - fullPath, - displayPath, - } = data; - useScrollPersistence(sessionId, isTreeLoaded, scrollAreaRef, treeState.tree); - const handlers = useFileBrowserHandlers(sessionId, onOpenFile, onCreateFile, treeState); - const { multiSelect, dnd, handleClickOutside } = useSelectionInteractions( - treeState, - containerRef, - activeFilePath, + const { data, handlers, multiSelect, dnd, handleClickOutside } = useFileBrowserViewModel({ + sessionId, + environmentId, + onOpenFile, + onCreateFile, onRenameFile, - ); - + activeFilePath, + scrollAreaRef, + containerRef, + }); + const { openFolder, copied, copyPath, search, treeState, fullPath, displayPath } = data; return (
+ - - void treeState.loadTree({ resetRetry: true })} - setTree={treeState.setTree} - isSelectedFn={multiSelect.isSelected} - onSelect={multiSelect.handleClick} - isDragging={dnd.isDragging} - dragOverPath={dnd.dragOverPath} - onDragStart={dnd.handleDragStart} - onDragEnd={dnd.handleDragEnd} - onDragOver={dnd.handleDragOver} - onDragLeave={dnd.handleDragLeave} - onDrop={dnd.handleDrop} - selectedCount={multiSelect.selectedPaths.size} - selectedPaths={multiSelect.selectedPaths} - /> -
); } diff --git a/apps/web/components/task/files-panel.tsx b/apps/web/components/task/files-panel.tsx index 5664c81af0..f80e4879b7 100644 --- a/apps/web/components/task/files-panel.tsx +++ b/apps/web/components/task/files-panel.tsx @@ -1,6 +1,6 @@ "use client"; -import { memo, useCallback } from "react"; +import { memo, useCallback, useRef, useState, type RefObject } from "react"; import { PanelRoot, PanelBody } from "./panel-primitives"; import { useAppStore } from "@/components/state-provider"; import { useFileOperations } from "@/hooks/use-file-operations"; @@ -8,12 +8,96 @@ import { useDockviewStore } from "@/lib/state/dockview-store"; import { FileBrowser } from "@/components/task/file-browser"; import { useEnvironmentSessionId } from "@/hooks/use-environment-session-id"; import type { OpenFileTab } from "@/lib/types/backend"; +import type { KanbanState } from "@/lib/state/slices/kanban/types"; import { useIsTaskArchived, ArchivedPanelPlaceholder } from "./task-archived-context"; +import { AddWorkspaceSourcesDialog } from "./add-workspace-sources/add-workspace-sources-dialog"; +import { + getAddSourcesDisabledReason, + hasActiveTaskSourceWork as getHasActiveTaskSourceWork, +} from "./add-workspace-sources/add-workspace-sources-availability"; type FilesPanelProps = { onOpenFile: (file: OpenFileTab) => void; }; +type TaskSourceDialogProps = { + activeTask: KanbanState["tasks"][number] | undefined; + open: boolean; + onOpenChange: (open: boolean) => void; + workspaceId: string | null; + opener: HTMLElement | null; + openerRef: RefObject; +}; + +function TaskSourceDialog({ + activeTask, + open, + onOpenChange, + workspaceId, + opener, + openerRef, +}: TaskSourceDialogProps) { + if (!activeTask || !(activeTask.repositoryId ?? activeTask.repositories?.length)) return null; + return ( + + ); +} + +function NoTaskSelected() { + return ( +
+ No task selected +
+ ); +} + +function useFilesPanelSourceDialog(activeSessionId: string | null) { + const [addSourcesOpen, setAddSourcesOpen] = useState(false); + const [addSourcesOpener, setAddSourcesOpener] = useState(null); + const addSourcesButtonRef = useRef(null); + const activeTask = useAppStore((state) => { + const taskId = state.tasks.activeTaskId; + return state.kanban.tasks.find((task) => task.id === taskId); + }); + const workspaceId = useAppStore((state) => state.workspaces.activeId); + const hasActiveTaskSourceWork = useAppStore((state) => { + if (!activeTask) return false; + const sessionIds = + state.taskSessionsByTask.itemsByTaskId[activeTask.id]?.map((session) => session.id) ?? + [activeSessionId].filter((sessionId): sessionId is string => Boolean(sessionId)); + return getHasActiveTaskSourceWork(sessionIds, state.turns.activeBySession); + }); + const sourceStateLoading = useAppStore((state) => + Boolean( + state.kanban.isLoading || + (workspaceId && state.repositories.loadingByWorkspaceId[workspaceId]) || + (activeTask && state.taskSessionsByTask.loadingByTaskId[activeTask.id]), + ), + ); + const addSourcesDisabledReason = getAddSourcesDisabledReason({ + isLoading: sourceStateLoading, + hasActiveTurn: hasActiveTaskSourceWork, + }); + return { + activeTask, + workspaceId, + addSourcesOpen, + setAddSourcesOpen, + addSourcesOpener, + setAddSourcesOpener, + addSourcesButtonRef, + addSourcesDisabledReason, + }; +} + const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) { // Use environment-stable sessionId so the file browser doesn't re-fetch // when switching between sessions in the same environment. @@ -28,6 +112,21 @@ const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) { }); const activeFilePath = useDockviewStore((s) => s.activeFilePath); const isArchived = useIsTaskArchived(); + const sourceDialog = useFilesPanelSourceDialog(activeSessionId); + const { + activeTask, + workspaceId, + addSourcesOpen, + setAddSourcesOpen, + addSourcesOpener, + setAddSourcesOpener, + addSourcesButtonRef, + addSourcesDisabledReason, + } = sourceDialog; + const hasRepository = Boolean(activeTask?.repositoryId ?? activeTask?.repositories?.length); + const resolvedAddSourcesDisabledReason = hasRepository + ? addSourcesDisabledReason + : "This task needs a repository before sources can be added."; const { createFile, deleteFile, renameFile, downloadFile } = useFileOperations( activeSessionId ?? null, ); @@ -69,13 +168,29 @@ const FilesPanel = memo(function FilesPanel({ onOpenFile }: FilesPanelProps) { onRenameFile={renameFile} onDownloadFile={downloadFile} activeFilePath={activeFilePath} + onAddSources={ + hasRepository + ? (opener) => { + setAddSourcesOpener(opener); + setAddSourcesOpen(true); + } + : undefined + } + addSourcesDisabledReason={resolvedAddSourcesDisabledReason} + addSourcesButtonRef={addSourcesButtonRef} /> ) : ( -
- No task selected -
+ )} + ); }); diff --git a/apps/web/components/task/task-files-panel.tsx b/apps/web/components/task/task-files-panel.tsx index 2f68240d0f..bf4aeb68c8 100644 --- a/apps/web/components/task/task-files-panel.tsx +++ b/apps/web/components/task/task-files-panel.tsx @@ -1,10 +1,17 @@ "use client"; -import { memo } from "react"; +import { memo, useRef, useState, type Ref } from "react"; import { SessionPanel, SessionPanelContent } from "@kandev/ui/pannel-session"; import { FileBrowser } from "@/components/task/file-browser"; import type { OpenFileTab } from "@/lib/types/backend"; import { useFilesPanelData } from "./task-files-panel-hooks"; +import { useAppStore } from "@/components/state-provider"; +import { AddWorkspaceSourcesDialog } from "./add-workspace-sources/add-workspace-sources-dialog"; +import { ArchivedPanelPlaceholder, useIsTaskArchived } from "./task-archived-context"; +import { + getAddSourcesDisabledReason, + hasActiveTaskSourceWork as getHasActiveTaskSourceWork, +} from "./add-workspace-sources/add-workspace-sources-availability"; function FilesTabContent({ sessionId, @@ -14,6 +21,9 @@ function FilesTabContent({ hookRenameFile, hookDownloadFile, activeFilePath, + onAddSources, + addSourcesButtonRef, + addSourcesDisabledReason, }: { sessionId: string | null; onOpenFile: (file: OpenFileTab) => void; @@ -22,6 +32,9 @@ function FilesTabContent({ hookRenameFile: (oldPath: string, newPath: string) => Promise; hookDownloadFile: (path: string) => Promise; activeFilePath?: string | null; + onAddSources?: (opener: HTMLButtonElement) => void; + addSourcesButtonRef?: Ref; + addSourcesDisabledReason?: string; }) { if (!sessionId) { return ( @@ -39,6 +52,9 @@ function FilesTabContent({ onRenameFile={hookRenameFile} onDownloadFile={hookDownloadFile} activeFilePath={activeFilePath} + onAddSources={onAddSources} + addSourcesButtonRef={addSourcesButtonRef} + addSourcesDisabledReason={addSourcesDisabledReason} /> ); } @@ -52,9 +68,39 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ onOpenFile, activeFilePath, }: TaskFilesPanelProps) { + const [addSourcesOpen, setAddSourcesOpen] = useState(false); + const [addSourcesOpener, setAddSourcesOpener] = useState(null); + const addSourcesButtonRef = useRef(null); + const isArchived = useIsTaskArchived(); + const activeTask = useAppStore((state) => + state.kanban.tasks.find((task) => task.id === state.tasks.activeTaskId), + ); const { activeSessionId, hookDeleteFile, hookRenameFile, hookDownloadFile, handleCreateFile } = useFilesPanelData(onOpenFile); - + const workspaceId = useAppStore((state) => state.workspaces.activeId); + const hasActiveTaskSourceWork = useAppStore((state) => { + if (!activeTask) return false; + const sessionIds = + state.taskSessionsByTask.itemsByTaskId[activeTask.id]?.map((session) => session.id) ?? + [activeSessionId].filter((sessionId): sessionId is string => Boolean(sessionId)); + return getHasActiveTaskSourceWork(sessionIds, state.turns.activeBySession); + }); + const sourceStateLoading = useAppStore((state) => + Boolean( + state.kanban.isLoading || + (workspaceId && state.repositories.loadingByWorkspaceId[workspaceId]) || + (activeTask && state.taskSessionsByTask.loadingByTaskId[activeTask.id]), + ), + ); + const addSourcesDisabledReason = getAddSourcesDisabledReason({ + isLoading: sourceStateLoading, + hasActiveTurn: hasActiveTaskSourceWork, + }); + const hasRepository = Boolean(activeTask?.repositoryId || activeTask?.repositories?.length); + const resolvedAddSourcesDisabledReason = hasRepository + ? addSourcesDisabledReason + : "This task needs a repository before sources can be added."; + if (isArchived) return ; return ( @@ -66,8 +112,29 @@ const TaskFilesPanel = memo(function TaskFilesPanel({ hookRenameFile={hookRenameFile} hookDownloadFile={hookDownloadFile} activeFilePath={activeFilePath} + onAddSources={ + hasRepository + ? (opener) => { + setAddSourcesOpener(opener); + setAddSourcesOpen(true); + } + : undefined + } + addSourcesButtonRef={addSourcesButtonRef} + addSourcesDisabledReason={resolvedAddSourcesDisabledReason} /> + {activeTask && hasRepository ? ( + + ) : null} ); }); diff --git a/apps/web/components/workspace-source-picker/executor-capabilities.test.ts b/apps/web/components/workspace-source-picker/executor-capabilities.test.ts new file mode 100644 index 0000000000..d03a804f19 --- /dev/null +++ b/apps/web/components/workspace-source-picker/executor-capabilities.test.ts @@ -0,0 +1,65 @@ +import { describe, expect, it } from "vitest"; +import { + canChooseCheckoutBranchForSource, + getWorkspaceSourceCapabilities, + hasCloneableSavedRepository, +} from "./executor-capabilities"; + +describe("getWorkspaceSourceCapabilities", () => { + it.each(["local", "local_pc", "worktree"] as const)("offers folders for %s", (executorType) => { + expect(getWorkspaceSourceCapabilities(executorType).canAddFolders).toBe(true); + }); + + it.each(["local_docker", "remote_docker", "ssh", "sprites"] as const)( + "hides folders for %s", + (executorType) => { + expect(getWorkspaceSourceCapabilities(executorType).canAddFolders).toBe(false); + }, + ); + + it.each(["local", "local_pc"] as const)( + "uses the live checkout and does not offer a checkout branch for %s", + (executorType) => { + expect(getWorkspaceSourceCapabilities(executorType)).toMatchObject({ + canChooseCheckoutBranch: false, + requiresCloneableLocalRepository: false, + }); + }, + ); + + it.each(["worktree", "local_docker", "remote_docker", "ssh", "sprites"] as const)( + "offers a checkout branch for owned clones on %s", + (executorType) => { + expect(getWorkspaceSourceCapabilities(executorType)).toMatchObject({ + canChooseCheckoutBranch: true, + }); + }, + ); + + it.each(["local_docker", "remote_docker", "ssh", "sprites"] as const)( + "requires a cloneable origin for a selected local repository on %s", + (executorType) => { + expect(getWorkspaceSourceCapabilities(executorType)).toMatchObject({ + requiresCloneableLocalRepository: true, + }); + }, + ); + + it.each([ + ["a valid remote URL", { remote_url: "https://git.example.com/acme/api.git" }, true], + [ + "a supported provider owner and name", + { provider: "github", provider_owner: "acme", provider_name: "api" }, + true, + ], + ["a provider ID without owner and name", { provider: "github", provider_repo_id: "42" }, false], + ["no remote locator", {}, false], + ])("recognizes %s as cloneable: %s", (_, repository, expected) => { + expect(hasCloneableSavedRepository(repository)).toBe(expected); + }); + + it("offers checkout selection for local Git sources in owned runtimes", () => { + expect(canChooseCheckoutBranchForSource("local_repository", "worktree")).toBe(true); + expect(canChooseCheckoutBranchForSource("local_repository", "local_pc")).toBe(false); + }); +}); diff --git a/apps/web/components/workspace-source-picker/executor-capabilities.ts b/apps/web/components/workspace-source-picker/executor-capabilities.ts new file mode 100644 index 0000000000..bf1e1f016b --- /dev/null +++ b/apps/web/components/workspace-source-picker/executor-capabilities.ts @@ -0,0 +1,65 @@ +import type { ExecutorType } from "@/lib/types/http"; + +type SavedRepositoryIdentity = { + remote_url?: string; + provider?: string; + provider_repo_id?: string; + provider_host?: string; + provider_owner?: string; + provider_name?: string; +}; + +export function getWorkspaceSourceCapabilities( + executorType: ExecutorType | string | null | undefined, +): { + canAddFolders: boolean; + canChooseCheckoutBranch: boolean; + requiresCloneableLocalRepository: boolean; +} { + const usesLiveCheckout = executorType === "local" || executorType === "local_pc"; + const requiresClone = + executorType === "local_docker" || + executorType === "remote_docker" || + executorType === "ssh" || + executorType === "sprites"; + return { + canAddFolders: usesLiveCheckout || executorType === "worktree", + canChooseCheckoutBranch: !usesLiveCheckout, + requiresCloneableLocalRepository: requiresClone, + }; +} + +/** Mirrors the backend remoteRepositoryLocator eligibility check. */ +export function hasCloneableSavedRepository(repository: SavedRepositoryIdentity): boolean { + return hasSafeRemoteURL(repository.remote_url) || hasProviderRepositoryLocator(repository); +} + +function hasProviderRepositoryLocator(repository: SavedRepositoryIdentity): boolean { + const provider = repository.provider?.trim().toLowerCase(); + if (!provider || !repository.provider_owner?.trim() || !repository.provider_name?.trim()) { + return false; + } + if (provider === "gitlab" && !repository.provider_host?.trim()) return false; + return provider === "github" || provider === "gitlab" || provider === "bitbucket"; +} + +function hasSafeRemoteURL(remoteURL: string | undefined): boolean { + const locator = remoteURL?.trim() ?? ""; + if (!locator || locator.startsWith("/") || locator.startsWith("file:")) return false; + if (locator.startsWith("git@")) return true; + try { + const parsed = new URL(locator); + return Boolean(parsed.host) && ["https:", "http:", "ssh:", "git:"].includes(parsed.protocol); + } catch { + return false; + } +} + +export function canChooseCheckoutBranchForSource( + sourceType: "saved_repository" | "local_repository" | "remote_repository" | "folder", + executorType: ExecutorType | string | null | undefined, +): boolean { + return ( + sourceType !== "folder" && getWorkspaceSourceCapabilities(executorType).canChooseCheckoutBranch + ); +} diff --git a/apps/web/components/workspace-source-picker/remote-url.ts b/apps/web/components/workspace-source-picker/remote-url.ts new file mode 100644 index 0000000000..7d98342d64 --- /dev/null +++ b/apps/web/components/workspace-source-picker/remote-url.ts @@ -0,0 +1,27 @@ +/** Matches the task-create picker’s URL-shape check, including scheme-less URLs. */ +export function looksLikeURL(value: string): boolean { + if (!value) return false; + const withScheme = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`; + try { + const parsed = new URL(withScheme); + return parsed.hostname.includes(".") && value.includes("/"); + } catch { + return false; + } +} + +/** + * Supported provider-host restriction shared by task creation and workspace + * source attachment. SCP Git syntax is valid only for the same providers. + */ +export function looksLikeSupportedRemoteURL(value: string): boolean { + if (/^git@(github\.com|gitlab\.com|ssh\.dev\.azure\.com):\S+$/i.test(value)) return true; + if (!looksLikeURL(value)) return false; + const candidate = /^[a-z][a-z\d+.-]*:\/\//i.test(value) ? value : `https://${value}`; + try { + const host = new URL(candidate).hostname.toLowerCase(); + return host === "github.com" || host === "gitlab.com" || host === "dev.azure.com"; + } catch { + return false; + } +} diff --git a/apps/web/components/workspace-source-picker/workspace-source-state.test.ts b/apps/web/components/workspace-source-picker/workspace-source-state.test.ts new file mode 100644 index 0000000000..37b9c377a4 --- /dev/null +++ b/apps/web/components/workspace-source-picker/workspace-source-state.test.ts @@ -0,0 +1,159 @@ +import { describe, expect, it } from "vitest"; +import { + addWorkspaceSourceRow, + buildWorkspaceSourcesPayload, + getWorkspaceSourceValidation, + updateWorkspaceSourceRow, +} from "./workspace-source-state"; + +describe("workspace source state payloads and validation", () => { + it("builds a deterministic mixed repository, remote, and folder payload", () => { + const rows = [ + { + key: "saved", + kind: "repository" as const, + repositoryId: "repo-1", + baseBranch: "main", + checkoutBranch: "feature/a", + }, + { + key: "remote", + kind: "repository" as const, + remoteUrl: "https://github.com/acme/api.git", + provider: "github" as const, + providerRepoId: "42", + providerOwner: "acme", + providerName: "api", + baseBranch: "trunk", + checkoutBranch: "release/2026", + }, + { key: "folder", kind: "folder" as const, localPath: "/src/docs", displayName: "docs" }, + ]; + + expect(buildWorkspaceSourcesPayload(rows)).toEqual({ + sources: [ + { + kind: "repository", + repository_id: "repo-1", + base_branch: "main", + checkout_branch: "feature/a", + }, + { + kind: "repository", + remote_url: "https://github.com/acme/api.git", + provider: "github", + provider_repo_id: "42", + provider_owner: "acme", + provider_name: "api", + base_branch: "trunk", + checkout_branch: "release/2026", + }, + { kind: "folder", local_path: "/src/docs", display_name: "docs" }, + ], + }); + }); + + it("validates each row independently and retains its input for retry", () => { + const invalid = { key: "bad", kind: "repository" as const, remoteUrl: "not a url" }; + const valid = { key: "good", kind: "folder" as const, localPath: "/src/docs" }; + + expect(getWorkspaceSourceValidation([invalid, valid])).toEqual({ + bad: "Enter a valid remote repository URL and base branch.", + }); + expect( + updateWorkspaceSourceRow([invalid, valid], "bad", { remoteUrl: "https://x/y.git" }), + ).toEqual([{ ...invalid, remoteUrl: "https://x/y.git" }, valid]); + }); + + it("accepts task-create's supported SCP remotes and rejects unsupported hosts", () => { + const rows = [ + { + key: "github-ssh", + kind: "repository" as const, + remoteUrl: "git@github.com:acme/api.git", + baseBranch: "main", + }, + { + key: "gitlab-ssh", + kind: "repository" as const, + remoteUrl: "git@gitlab.com:acme/web.git", + baseBranch: "main", + }, + { + key: "azure-ssh", + kind: "repository" as const, + remoteUrl: "git@ssh.dev.azure.com:v3/acme/platform/api", + baseBranch: "main", + }, + { + key: "unsupported", + kind: "repository" as const, + remoteUrl: "https://bitbucket.org/acme/api.git", + baseBranch: "main", + }, + ]; + + expect(getWorkspaceSourceValidation(rows)).toEqual({ + unsupported: "Enter a valid remote repository URL and base branch.", + }); + }); + + it("rejects duplicate repository/branch pairs and canonical folder paths", () => { + const rows = [ + { key: "one", kind: "repository" as const, repositoryId: "repo-1", baseBranch: "main" }, + { key: "two", kind: "repository" as const, repositoryId: "repo-1", baseBranch: "main" }, + { key: "three", kind: "folder" as const, localPath: "/src/docs/" }, + { key: "four", kind: "folder" as const, localPath: "/src/docs" }, + ]; + + expect(getWorkspaceSourceValidation(rows)).toEqual({ + two: "This repository and branch are already selected.", + four: "This folder is already selected.", + }); + }); +}); + +describe("workspace source state row creation", () => { + it("does not add folder rows for an executor without folder support", () => { + expect(addWorkspaceSourceRow([], "folder", "docker")).toEqual([]); + expect(addWorkspaceSourceRow([], "folder", "worktree")).toEqual([ + { + key: expect.any(String), + kind: "folder", + sourceType: "folder", + localPath: "", + displayName: "", + }, + ]); + }); + + it("adds explicit saved, local, remote, and folder rows into one mixed batch", () => { + const saved = addWorkspaceSourceRow([], "saved_repository", "worktree"); + const local = addWorkspaceSourceRow(saved, "local_repository", "worktree"); + const remote = addWorkspaceSourceRow(local, "remote_repository", "worktree"); + const folder = addWorkspaceSourceRow(remote, "folder", "worktree"); + const rows = [ + { ...folder[0], repositoryId: "repo-1", baseBranch: "main" }, + { + ...folder[1], + localPath: "/repos/local", + baseBranch: "main", + checkoutBranch: "feature/local", + }, + { ...folder[2], remoteUrl: "https://github.com/acme/remote.git", baseBranch: "trunk" }, + { ...folder[3], localPath: "/repos/docs" }, + ]; + + expect(rows.map((row) => row.sourceType)).toEqual([ + "saved_repository", + "local_repository", + "remote_repository", + "folder", + ]); + expect(buildWorkspaceSourcesPayload(rows).sources[1]).toMatchObject({ + local_path: "/repos/local", + checkout_branch: "feature/local", + }); + expect(getWorkspaceSourceValidation(rows)).toEqual({}); + }); +}); diff --git a/apps/web/components/workspace-source-picker/workspace-source-state.ts b/apps/web/components/workspace-source-picker/workspace-source-state.ts new file mode 100644 index 0000000000..073b8455b2 --- /dev/null +++ b/apps/web/components/workspace-source-picker/workspace-source-state.ts @@ -0,0 +1,152 @@ +import type { + AttachTaskWorkspaceSourcesRequest, + WorkspaceRepositorySourceRequest, + WorkspaceSourceRequest, +} from "@/lib/types/http"; +import { getWorkspaceSourceCapabilities } from "./executor-capabilities"; +import { looksLikeSupportedRemoteURL } from "./remote-url"; + +export type WorkspaceSourceRow = { + key: string; + kind: "repository" | "folder"; + sourceType?: "saved_repository" | "local_repository" | "remote_repository" | "folder"; + repositoryId?: string; + localPath?: string; + remoteUrl?: string; + provider?: "github" | "gitlab" | "azure_devops"; + providerRepoId?: string; + providerOwner?: string; + providerName?: string; + baseBranch?: string; + checkoutBranch?: string; + displayName?: string; +}; + +type RowKind = NonNullable | WorkspaceSourceRow["kind"]; + +let nextKey = 0; + +export function addWorkspaceSourceRow( + rows: WorkspaceSourceRow[], + kind: RowKind, + executorType?: string | null, +): WorkspaceSourceRow[] { + if (kind === "folder" && !getWorkspaceSourceCapabilities(executorType).canAddFolders) return rows; + nextKey += 1; + const key = `workspace-source-${nextKey}`; + const sourceType = kind === "repository" ? "saved_repository" : kind; + return [ + ...rows, + sourceType === "folder" + ? { key, kind: "folder", sourceType, localPath: "", displayName: "" } + : { key, kind: "repository", sourceType, baseBranch: "" }, + ]; +} + +export function removeWorkspaceSourceRow( + rows: WorkspaceSourceRow[], + key: string, +): WorkspaceSourceRow[] { + return rows.filter((row) => row.key !== key); +} + +export function updateWorkspaceSourceRow( + rows: WorkspaceSourceRow[], + key: string, + patch: Partial, +): WorkspaceSourceRow[] { + return rows.map((row) => + row.key === key ? { ...row, ...patch, key: row.key, kind: row.kind } : row, + ); +} + +export function getWorkspaceSourceValidation(rows: WorkspaceSourceRow[]): Record { + const errors: Record = {}; + const repositoryKeys = new Set(); + const folderPaths = new Set(); + for (const row of rows) { + const error = validateRow(row, repositoryKeys, folderPaths); + if (error) errors[row.key] = error; + } + return errors; +} + +export function buildWorkspaceSourcesPayload( + rows: WorkspaceSourceRow[], +): AttachTaskWorkspaceSourcesRequest { + return { sources: rows.map(toRequestSource) }; +} + +function validateRow( + row: WorkspaceSourceRow, + repositoryKeys: Set, + folderPaths: Set, +): string | null { + if (row.kind === "folder") { + const path = canonicalPath(row.localPath); + if (!path) return "Choose a folder."; + if (folderPaths.has(path)) return "This folder is already selected."; + folderPaths.add(path); + return null; + } + const locator = repositoryLocator(row); + if (row.remoteUrl && !isValidRemoteUrl(row.remoteUrl)) { + return "Enter a valid remote repository URL and base branch."; + } + if (!locator || !row.baseBranch?.trim()) return "Choose a repository and base branch."; + const duplicateKey = `${locator}\u0000${row.baseBranch.trim()}\u0000${row.checkoutBranch?.trim() ?? ""}`; + if (repositoryKeys.has(duplicateKey)) return "This repository and branch are already selected."; + repositoryKeys.add(duplicateKey); + return null; +} + +function repositoryLocator(row: WorkspaceSourceRow): string { + return row.repositoryId || canonicalPath(row.localPath) || row.remoteUrl?.trim() || ""; +} + +function canonicalPath(path: string | undefined): string { + return path?.trim().replace(/\/+$/, "") ?? ""; +} + +function isValidRemoteUrl(value: string): boolean { + return looksLikeSupportedRemoteURL(value.trim()); +} + +function toRequestSource(row: WorkspaceSourceRow): WorkspaceSourceRequest { + if (row.kind === "folder") { + return { + kind: "folder", + local_path: canonicalPath(row.localPath), + ...(row.displayName?.trim() ? { display_name: row.displayName.trim() } : {}), + }; + } + return { + kind: "repository", + ...repositoryLocation(row), + ...repositoryProvider(row), + base_branch: row.baseBranch?.trim() ?? "", + ...(row.checkoutBranch?.trim() ? { checkout_branch: row.checkoutBranch.trim() } : {}), + }; +} + +function repositoryLocation( + row: WorkspaceSourceRow, +): Pick { + if (row.repositoryId) return { repository_id: row.repositoryId }; + if (row.localPath) return { local_path: canonicalPath(row.localPath) }; + return row.remoteUrl ? { remote_url: row.remoteUrl.trim() } : {}; +} + +function repositoryProvider( + row: WorkspaceSourceRow, +): Pick< + WorkspaceRepositorySourceRequest, + "provider" | "provider_repo_id" | "provider_owner" | "provider_name" +> { + return { + ...(row.provider ? { provider: row.provider } : {}), + ...(row.providerRepoId ? { provider_repo_id: row.providerRepoId } : {}), + ...(row.providerOwner ? { provider_owner: row.providerOwner } : {}), + ...(row.providerName ? { provider_name: row.providerName } : {}), + }; +} diff --git a/apps/web/e2e/fixtures/backend-env.ts b/apps/web/e2e/fixtures/backend-env.ts new file mode 100644 index 0000000000..037cf40ed0 --- /dev/null +++ b/apps/web/e2e/fixtures/backend-env.ts @@ -0,0 +1,62 @@ +export class BackendFixtureEnvOverrides { + private readonly entries = new Map>(); + + add(overrides: Record): () => void { + this.assertNoConflicts(overrides); + const token = Symbol("backend-fixture-env"); + this.entries.set(token, overrides); + return () => this.entries.delete(token); + } + + apply( + baseline: Record, + oneShotOverrides?: Record, + ): Record { + if (oneShotOverrides) this.assertNoConflicts(oneShotOverrides); + const env = { ...baseline }; + for (const overrides of this.entries.values()) Object.assign(env, overrides); + if (oneShotOverrides) Object.assign(env, oneShotOverrides); + return env; + } + + private assertNoConflicts(next: Record): void { + for (const current of this.entries.values()) { + for (const [key, value] of Object.entries(next)) { + if (key in current && current[key] !== value) { + throw new Error(`conflicting backend fixture environment override for ${key}`); + } + } + } + } +} + +export function createScopedEnvUse( + scopedEnv: BackendFixtureEnvOverrides, + restart: () => Promise, +): (overrides: Record) => Promise<() => Promise> { + return async (overrides) => { + const removeScope = scopedEnv.add(overrides); + try { + await restart(); + } catch (activationError) { + removeScope(); + try { + await restart(); + } catch (restoreError) { + throw new AggregateError( + [activationError, restoreError], + "failed to activate backend fixture environment and restore baseline", + ); + } + throw activationError; + } + + let released = false; + return async () => { + if (released) return; + removeScope(); + await restart(); + released = true; + }; + }; +} diff --git a/apps/web/e2e/fixtures/backend.ts b/apps/web/e2e/fixtures/backend.ts index 9ed73816f9..3e5977d6d6 100644 --- a/apps/web/e2e/fixtures/backend.ts +++ b/apps/web/e2e/fixtures/backend.ts @@ -3,6 +3,7 @@ import { type ChildProcess, spawn } from "node:child_process"; import fs from "node:fs"; import os from "node:os"; import path from "node:path"; +import { BackendFixtureEnvOverrides, createScopedEnvUse } from "./backend-env"; const BACKEND_DIR = path.resolve(__dirname, "../../../../apps/backend"); const WEB_DIR = path.resolve(__dirname, "../.."); @@ -48,6 +49,11 @@ export type BackendContext = { * agents, WS connections) is lost. */ restart: (envOverrides?: Record) => Promise; + /** + * Applies test-owned process environment values to the current backend and + * every later restart until the returned release callback is awaited. + */ + useEnv: (overrides: Record) => Promise<() => Promise>; }; function observeProcessExit(proc?: ChildProcess): { @@ -450,9 +456,10 @@ exec git "$@" // a clean copy each call instead of accumulating leftover keys (e.g. // KANDEV_MOCK_PROVIDERS, KANDEV_PROVIDER_FAILURES) from prior tests. const baselineEnv = { ...backendEnv } as Record; + const scopedEnv = new BackendFixtureEnvOverrides(); // --- Spawn backend --- - backendProc = spawnBackendProcess(backendEnv, debug, backendPort); + backendProc = spawnBackendProcess(scopedEnv.apply(baselineEnv), debug, backendPort); registerProcess(backendProc); await waitForHealth(`${baseUrl}/health`, HEALTH_TIMEOUT_MS, backendProc); const frontendUrl = baseUrl; @@ -468,12 +475,7 @@ exec git "$@" // set by the routing specs would otherwise stick for the rest of // the worker's lifetime and register canonical agent IDs that // sibling specs count). - const nextEnv: Record = { ...baselineEnv }; - if (envOverrides) { - for (const [k, v] of Object.entries(envOverrides)) { - nextEnv[k] = v; - } - } + const nextEnv = scopedEnv.apply(baselineEnv, envOverrides); const runningProcess = backendProc; if (!runningProcess) throw new Error("Backend process is not running"); await killProcessGroup(runningProcess); @@ -487,7 +489,17 @@ exec git "$@" await waitForHealth(`${baseUrl}/health`, HEALTH_TIMEOUT_MS, backendProc); }; - await use({ port: backendPort, baseUrl, frontendPort, frontendUrl, tmpDir, restart }); + const useEnv = createScopedEnvUse(scopedEnv, restart); + + await use({ + port: backendPort, + baseUrl, + frontendPort, + frontendUrl, + tmpDir, + restart, + useEnv, + }); }); }, { scope: "worker", timeout: 60_000 }, diff --git a/apps/web/e2e/helpers/api-client.ts b/apps/web/e2e/helpers/api-client.ts index 5ca5fe578a..6f3870e874 100644 --- a/apps/web/e2e/helpers/api-client.ts +++ b/apps/web/e2e/helpers/api-client.ts @@ -1497,6 +1497,12 @@ export class ApiClient { return this.request("GET", `/api/v1/task-sessions/${sessionId}/messages`); } + async listSessionTurns(sessionId: string): Promise<{ + turns: Array<{ id: string; completed_at?: string | null }>; + }> { + return this.request("GET", `/api/v1/task-sessions/${sessionId}/turns`); + } + async listTasks( workspaceId: string, ): Promise<{ tasks: Array<{ id: string; title: string; workflow_step_id?: string }> }> { @@ -1546,6 +1552,13 @@ export class ApiClient { checkout_branch?: string; position: number; }>; + workspace_folders?: Array<{ + id: string; + task_id: string; + local_path: string; + display_name: string; + position: number; + }>; }> { return this.request("GET", `/api/v1/tasks/${taskId}`); } diff --git a/apps/web/e2e/helpers/http-git-server.ts b/apps/web/e2e/helpers/http-git-server.ts new file mode 100644 index 0000000000..dd4c3242ea --- /dev/null +++ b/apps/web/e2e/helpers/http-git-server.ts @@ -0,0 +1,141 @@ +import { execFileSync, spawnSync } from "node:child_process"; +import { createServer, type Server } from "node:http"; +import fs from "node:fs"; +import path from "node:path"; + +type HTTPGitFixture = { + remoteURL: string; + /** + * Backend-only config that survives restarts without colliding with + * repoclone's command-scoped credential configuration. + */ + backendEnv: Record; + /** + * Test-executor-only Git configuration that rewrites the trusted public + * origin to this fixture's bridge-reachable HTTP server. + */ + gitConfigEnvVars: Array<{ key: string; value: string }>; + close: () => Promise; +}; + +export type HTTPGitFixtureOptions = { + onListening?: (port: number) => void; + writeBackendGitConfig?: (file: string, content: string) => void; + closeServer?: (server: Server) => Promise; +}; + +/** + * Serves a disposable bare repository from the Docker bridge gateway. This + * exercises the same HTTP clone path used by Docker and SSH executors without + * relying on an external provider or developer checkout. + */ +export async function startHTTPGitFixture( + root: string, + name: string, + options: HTTPGitFixtureOptions = {}, +): Promise { + const remoteDir = path.join(root, "fixture", `${name}.git`); + const checkout = path.join(root, `${name}-checkout`); + fs.mkdirSync(checkout, { recursive: true }); + execFileSync("git", ["init", "--bare", "-b", "main", remoteDir]); + execFileSync("git", ["init", "-b", "main"], { cwd: checkout }); + fs.writeFileSync(path.join(checkout, "remote-source.txt"), `${name} fixture\n`); + execFileSync("git", ["add", "."], { cwd: checkout }); + execFileSync( + "git", + ["-c", "user.name=E2E Test", "-c", "user.email=e2e@test.local", "commit", "-m", "fixture"], + { cwd: checkout }, + ); + execFileSync("git", ["remote", "add", "origin", remoteDir], { cwd: checkout }); + execFileSync("git", ["push", "origin", "main"], { cwd: checkout }); + execFileSync("git", ["--git-dir", remoteDir, "update-server-info"]); + + const server = createStaticGitServer(root); + const port = await listen(server); + try { + options.onListening?.(port); + const fixtureOrigin = `http://${dockerBridgeGateway()}:${port}/`; + const remoteURL = `https://gitlab.com/fixture/${name}.git`; + const backendGitConfigPath = path.join(root, "fixture", `${name}.gitconfig`); + const config = `[url "${fixtureOrigin}fixture/${name}.git"]\n\tinsteadOf = ${remoteURL}\n`; + (options.writeBackendGitConfig ?? fs.writeFileSync)(backendGitConfigPath, config); + return { + // The source endpoint must receive the real GitLab identity so the + // production trusted-origin validation remains exercised. Disposable test + // executor profiles and their isolated backend fixture rewrite Git's clone + // transport to this local HTTP server. + remoteURL, + backendEnv: { GIT_CONFIG_GLOBAL: backendGitConfigPath }, + gitConfigEnvVars: [ + { key: "GIT_CONFIG_COUNT", value: "1" }, + { key: "GIT_CONFIG_KEY_0", value: `url.${fixtureOrigin}.insteadOf` }, + { key: "GIT_CONFIG_VALUE_0", value: "https://gitlab.com/" }, + ], + // Leave the config under the backend fixture root until that fixture has + // released its environment and stopped its process, then removes the root. + close: () => closeServer(server), + }; + } catch (setupError) { + try { + await (options.closeServer ?? closeServer)(server); + } catch (closeError) { + throw new AggregateError( + [setupError, closeError], + "HTTP Git fixture setup failed and its server did not close", + ); + } + throw setupError; + } +} + +function createStaticGitServer(root: string): Server { + return createServer((request, response) => { + const pathname = decodeURIComponent(new URL(request.url ?? "/", "http://fixture").pathname); + const relative = pathname.replace(/^\/+/, ""); + const file = path.resolve(root, relative); + if (file !== root && !file.startsWith(`${root}${path.sep}`)) { + response.writeHead(400).end(); + return; + } + try { + const stat = fs.statSync(file); + if (!stat.isFile()) throw new Error("not a file"); + response.writeHead(200, { "content-length": stat.size }); + fs.createReadStream(file).pipe(response); + } catch { + response.writeHead(404).end(); + } + }); +} + +function dockerBridgeGateway(): string { + const result = spawnSync( + "docker", + ["network", "inspect", "bridge", "-f", "{{(index .IPAM.Config 0).Gateway}}"], + { encoding: "utf8" }, + ); + const gateway = result.status === 0 ? result.stdout.trim() : ""; + if (!gateway) throw new Error(`Could not determine Docker bridge gateway: ${result.stderr}`); + return gateway; +} + +function listen(server: Server): Promise { + return new Promise((resolve, reject) => { + server.once("error", reject); + server.listen(0, "0.0.0.0", () => { + server.off("error", reject); + const address = server.address(); + if (!address || typeof address === "string") { + reject(new Error("HTTP Git fixture did not receive a TCP port")); + return; + } + resolve(address.port); + }); + }); +} + +function closeServer(server: Server): Promise { + return new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); +} diff --git a/apps/web/e2e/tests/docker/add-workspace-sources.spec.ts b/apps/web/e2e/tests/docker/add-workspace-sources.spec.ts new file mode 100644 index 0000000000..b850b00485 --- /dev/null +++ b/apps/web/e2e/tests/docker/add-workspace-sources.spec.ts @@ -0,0 +1,238 @@ +import { expect, test } from "../../fixtures/docker-test-base"; +import { E2E_IMAGE_TAG } from "../../fixtures/docker-probe"; +import { dockerInspectExists } from "../../helpers/docker"; +import { startHTTPGitFixture } from "../../helpers/http-git-server"; +import { waitForLatestSessionDone, waitForSessionDone } from "../../helpers/session"; +import { SessionPage } from "../../pages/session-page"; +import { execFileSync } from "node:child_process"; + +function sourcePayload(remoteURL: string) { + return { + sources: [ + { kind: "repository", remote_url: remoteURL, provider: "gitlab", base_branch: "main" }, + ], + }; +} + +function containerFile(containerID: string, file: string): string { + return execFileSync("docker", ["exec", containerID, "cat", file], { encoding: "utf8" }); +} + +async function cleanupFixture( + fixture: { close: () => Promise }, + releaseBackendEnv?: () => Promise, +): Promise { + let releaseError: unknown; + try { + await releaseBackendEnv?.(); + } catch (error) { + releaseError = error; + } finally { + try { + await fixture.close(); + } catch (closeError) { + if (releaseError) { + throw new AggregateError( + [releaseError, closeError], + "failed to release the backend environment and close the HTTP Git fixture", + ); + } + throw closeError; + } + } + if (releaseError) throw releaseError; +} + +test.describe("Docker executor — attach workspace sources", () => { + test("materializes an HTTP repository, rejects folders, and reconstructs the sibling", async ({ + apiClient, + backend, + seedData, + testPage, + }) => { + test.setTimeout(240_000); + const fixture = await startHTTPGitFixture(backend.tmpDir, "docker-second-source"); + let releaseBackendEnv: (() => Promise) | undefined; + try { + releaseBackendEnv = await backend.useEnv(fixture.backendEnv); + const { executors } = await apiClient.listExecutors(); + const dockerExecutor = executors.find((executor) => executor.type === "local_docker"); + expect(dockerExecutor).toBeTruthy(); + const fixtureProfile = await apiClient.createExecutorProfile(dockerExecutor!.id, { + name: "E2E Docker HTTP Git fixture", + config: { image_tag: E2E_IMAGE_TAG }, + prepare_script: "", + cleanup_script: "", + env_vars: fixture.gitConfigEnvVars, + }); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Docker remote workspace sources", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + executor_profile_id: fixtureProfile.id, + }, + ); + await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for Docker task"); + const before = await apiClient.getTaskEnvironment(task.id); + expect(before?.container_id).toBeTruthy(); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.clickTab("Files"); + await testPage.getByTestId("files-workspace-actions").click(); + await testPage.getByRole("menuitem", { name: "Add Repositories to workspace" }).click(); + const dialog = testPage.getByTestId("add-workspace-sources-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByTestId("source-mode-local")).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "Add folder" })).toHaveCount(0); + await dialog.getByRole("button", { name: "Add repository" }).click(); + await expect(testPage.getByRole("menuitem", { name: "Workspace repository" })).toBeVisible(); + await expect(testPage.getByRole("menuitem", { name: "Local Git repository" })).toBeVisible(); + await expect(testPage.getByRole("menuitem", { name: "Remote repository" })).toBeVisible(); + await testPage.keyboard.press("Escape"); + await dialog.getByRole("button", { name: "Cancel" }).click(); + + const attached = await apiClient.rawRequest( + "POST", + `/api/v1/tasks/${task.id}/workspace-sources`, + sourcePayload(fixture.remoteURL), + ); + expect(attached.status).toBe(200); + expect( + containerFile( + before!.container_id!, + "/workspace/fixture-docker-second-source-main/remote-source.txt", + ), + ).toBe("docker-second-source fixture\n"); + await expect( + session.files + .getByTestId("file-tree-node") + .filter({ hasText: "fixture-docker-second-source-main" }), + ).toBeVisible({ timeout: 30_000 }); + + const forgedFolder = await apiClient.rawRequest( + "POST", + `/api/v1/tasks/${task.id}/workspace-sources`, + { + sources: [{ kind: "folder", local_path: backend.tmpDir, display_name: "forged-folder" }], + }, + ); + expect(forgedFolder.status).toBe(422); + const persisted = await apiClient.getTask(task.id); + expect(persisted.repositories).toHaveLength(2); + expect(persisted.workspace_folders ?? []).toHaveLength(0); + expect(dockerInspectExists(before!.container_id!)).toBe(true); + expect(() => + containerFile(before!.container_id!, "/workspace/forged-folder/remote-source.txt"), + ).toThrow(); + + const reset = await apiClient.rawRequest( + "POST", + `/api/v1/tasks/${task.id}/environment/reset`, + {}, + ); + expect(reset.status).toBe(200); + const relaunched = await apiClient.launchSession({ + task_id: task.id, + agent_profile_id: seedData.agentProfileId, + executor_profile_id: fixtureProfile.id, + workflow_step_id: seedData.startStepId, + prompt: "/e2e:simple-message", + }); + await waitForSessionDone( + apiClient, + task.id, + relaunched.session_id, + "Waiting for Docker relaunch", + ); + const after = await apiClient.getTaskEnvironment(task.id); + expect(after?.container_id).toBeTruthy(); + expect(after?.container_id).not.toBe(before?.container_id); + expect( + containerFile( + after!.container_id!, + "/workspace/fixture-docker-second-source-main/remote-source.txt", + ), + ).toBe("docker-second-source fixture\n"); + await testPage.reload(); + await session.waitForLoad(); + await session.clickTab("Files"); + await expect( + session.files + .getByTestId("file-tree-node") + .filter({ hasText: "fixture-docker-second-source-main" }), + ).toBeVisible({ timeout: 30_000 }); + } finally { + await cleanupFixture(fixture, releaseBackendEnv); + } + }); + + test("rolls back a partially materialized remote batch", async ({ + apiClient, + backend, + seedData, + }) => { + test.setTimeout(180_000); + const fixture = await startHTTPGitFixture(backend.tmpDir, "docker-rollback-source"); + let releaseBackendEnv: (() => Promise) | undefined; + try { + releaseBackendEnv = await backend.useEnv(fixture.backendEnv); + const { executors } = await apiClient.listExecutors(); + const dockerExecutor = executors.find((executor) => executor.type === "local_docker"); + expect(dockerExecutor).toBeTruthy(); + const fixtureProfile = await apiClient.createExecutorProfile(dockerExecutor!.id, { + name: "E2E Docker rollback HTTP Git fixture", + config: { image_tag: E2E_IMAGE_TAG }, + prepare_script: "", + cleanup_script: "", + env_vars: fixture.gitConfigEnvVars, + }); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Docker source rollback", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + executor_profile_id: fixtureProfile.id, + }, + ); + await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for Docker rollback task"); + const environment = await apiClient.getTaskEnvironment(task.id); + expect(environment?.container_id).toBeTruthy(); + const failed = await apiClient.rawRequest( + "POST", + `/api/v1/tasks/${task.id}/workspace-sources`, + { + sources: [ + ...sourcePayload(fixture.remoteURL).sources, + { + kind: "repository", + remote_url: `${fixture.remoteURL}-missing`, + provider: "gitlab", + base_branch: "main", + }, + ], + }, + ); + expect(failed.status).toBe(422); + expect((await apiClient.getTask(task.id)).repositories).toHaveLength(1); + expect(() => + containerFile( + environment!.container_id!, + "/workspace/docker-rollback-source-main/remote-source.txt", + ), + ).toThrow(); + } finally { + await cleanupFixture(fixture, releaseBackendEnv); + } + }); +}); diff --git a/apps/web/e2e/tests/ssh/add-workspace-sources.spec.ts b/apps/web/e2e/tests/ssh/add-workspace-sources.spec.ts new file mode 100644 index 0000000000..8cf486e402 --- /dev/null +++ b/apps/web/e2e/tests/ssh/add-workspace-sources.spec.ts @@ -0,0 +1,146 @@ +import { expect, test } from "../../fixtures/ssh-test-base"; +import { startHTTPGitFixture } from "../../helpers/http-git-server"; +import { readRemoteFile, remotePathExists } from "../../helpers/ssh"; +import { waitForLatestSessionDone } from "../../helpers/session"; +import { SessionPage } from "../../pages/session-page"; +import fs from "node:fs"; + +async function cleanupFixture( + fixture: { close: () => Promise }, + releaseBackendEnv?: () => Promise, +): Promise { + let releaseError: unknown; + try { + await releaseBackendEnv?.(); + } catch (error) { + releaseError = error; + } finally { + try { + await fixture.close(); + } catch (closeError) { + if (releaseError) { + throw new AggregateError( + [releaseError, closeError], + "failed to release the backend environment and close the HTTP Git fixture", + ); + } + throw closeError; + } + } + if (releaseError) throw releaseError; +} + +test.describe("SSH executor — attach workspace sources", () => { + test("materializes a cloneable repository across backend reconnect without leaking credentials", async ({ + apiClient, + backend, + seedData, + testPage, + }) => { + test.setTimeout(240_000); + const fixture = await startHTTPGitFixture(backend.tmpDir, "ssh-second-source"); + let releaseBackendEnv: (() => Promise) | undefined; + try { + releaseBackendEnv = await backend.useEnv(fixture.backendEnv); + const fixtureProfile = await apiClient.createExecutorProfile(seedData.sshExecutorId, { + name: "E2E SSH HTTP Git fixture", + config: {}, + prepare_script: "", + cleanup_script: "", + env_vars: fixture.gitConfigEnvVars, + }); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "SSH remote workspace source", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + executor_profile_id: fixtureProfile.id, + }, + ); + await waitForLatestSessionDone(apiClient, task.id, 1, "Waiting for SSH task"); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.clickTab("Files"); + await testPage.getByTestId("files-workspace-actions").click(); + await testPage.getByRole("menuitem", { name: "Add Repositories to workspace" }).click(); + const dialog = testPage.getByTestId("add-workspace-sources-dialog"); + await expect(dialog).toBeVisible(); + await expect(dialog.getByTestId("source-mode-local")).toHaveCount(0); + await expect(dialog.getByRole("button", { name: "Add folder" })).toHaveCount(0); + await dialog.getByRole("button", { name: "Add repository" }).click(); + await expect(testPage.getByRole("menuitem", { name: "Workspace repository" })).toBeVisible(); + await expect(testPage.getByRole("menuitem", { name: "Local Git repository" })).toBeVisible(); + await expect(testPage.getByRole("menuitem", { name: "Remote repository" })).toBeVisible(); + await testPage.keyboard.press("Escape"); + await dialog.getByRole("button", { name: "Cancel" }).click(); + const response = await apiClient.rawRequest( + "POST", + `/api/v1/tasks/${task.id}/workspace-sources`, + { + sources: [ + { + kind: "repository", + remote_url: fixture.remoteURL, + provider: "gitlab", + base_branch: "main", + }, + ], + }, + ); + expect(response.status).toBe(200); + const responseText = await response.text(); + expect(responseText).not.toContain(seedData.sshTarget.identityFile); + expect(responseText).not.toContain("BEGIN OPENSSH PRIVATE KEY"); + + const rows = await apiClient.listSSHSessions(seedData.sshExecutorId); + const row = rows.find((candidate) => candidate.task_id === task.id); + expect(row?.remote_task_dir).toBeTruthy(); + const sibling = `${row!.remote_task_dir}/fixture-ssh-second-source-main/remote-source.txt`; + expect(remotePathExists(seedData.sshTarget, sibling)).toBe(true); + expect(readRemoteFile(seedData.sshTarget, sibling)).toBe("ssh-second-source fixture\n"); + const agentctlLog = readRemoteFile( + seedData.sshTarget, + `${row!.remote_task_dir}/.kandev/sessions/${row!.session_id}/agentctl.log`, + ); + expect(agentctlLog).not.toContain(fs.readFileSync(seedData.sshTarget.identityFile, "utf8")); + + await session.clickTab("Files"); + await expect( + session.files + .getByTestId("file-tree-node") + .filter({ hasText: "fixture-ssh-second-source-main" }), + ).toBeVisible({ timeout: 30_000 }); + + await backend.restart(); + await expect + .poll( + async () => + (await apiClient.listSSHSessions(seedData.sshExecutorId)).find( + (item) => item.task_id === task.id, + )?.local_forward_port ?? 0, + { + timeout: 60_000, + message: "Waiting for SSH backend reconnect", + }, + ) + .toBeGreaterThan(0); + expect(remotePathExists(seedData.sshTarget, sibling)).toBe(true); + expect(readRemoteFile(seedData.sshTarget, sibling)).toBe("ssh-second-source fixture\n"); + await testPage.reload(); + await session.waitForLoad(); + await session.clickTab("Files"); + await expect( + session.files + .getByTestId("file-tree-node") + .filter({ hasText: "fixture-ssh-second-source-main" }), + ).toBeVisible({ timeout: 30_000 }); + } finally { + await cleanupFixture(fixture, releaseBackendEnv); + } + }); +}); diff --git a/apps/web/e2e/tests/task/add-workspace-sources.spec.ts b/apps/web/e2e/tests/task/add-workspace-sources.spec.ts new file mode 100644 index 0000000000..36da7f7f2a --- /dev/null +++ b/apps/web/e2e/tests/task/add-workspace-sources.spec.ts @@ -0,0 +1,259 @@ +import { expect, test } from "../../fixtures/test-base"; +import type { Locator, Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { GitHelper, makeGitEnv } from "../../helpers/git-helper"; +import { SessionPage } from "../../pages/session-page"; + +async function chooseDirectory( + page: Page, + trigger: Locator, + pickerRoot: string, + directory: string, +) { + const relativeDirectory = path.relative(pickerRoot, directory); + if (relativeDirectory === ".." || relativeDirectory.startsWith(`..${path.sep}`)) { + throw new Error(`Directory ${directory} is outside picker root ${pickerRoot}`); + } + await trigger.click(); + const picker = page.locator('[data-testid="folder-picker-popover"][data-state="open"]'); + await expect(picker).toBeVisible(); + for (const segment of relativeDirectory.split(path.sep).filter(Boolean)) { + await picker.getByTestId("folder-picker-entry").filter({ hasText: segment }).click(); + } + await picker.getByTestId("folder-picker-choose").click(); +} + +function createSourceDirectories(root: string) { + const gitEnv = makeGitEnv(root); + const repositoryPath = path.join(root, "sources", "second-local-repository"); + const folderPath = path.join(root, "sources", "plain-local-folder"); + fs.mkdirSync(repositoryPath, { recursive: true }); + fs.mkdirSync(folderPath, { recursive: true }); + fs.writeFileSync(path.join(repositoryPath, "second-source.txt"), "repository source\n"); + fs.writeFileSync(path.join(folderPath, "folder-source.txt"), "folder source\n"); + execFileSync("git", ["init", "-b", "main"], { cwd: repositoryPath, env: gitEnv }); + execFileSync("git", ["add", "."], { cwd: repositoryPath, env: gitEnv }); + execFileSync("git", ["commit", "-m", "initial source"], { cwd: repositoryPath, env: gitEnv }); + return { repositoryPath, folderPath }; +} + +test.describe("Attach local workspace sources", () => { + test("adds a local repository and folder successively, scopes Changes to Git, and persists", async ({ + testPage, + apiClient, + seedData, + backend, + prCapture, + }) => { + test.setTimeout(120_000); + const { repositoryPath, folderPath } = createSourceDirectories(backend.tmpDir); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Attach mixed local sources", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + executor_profile_id: seedData.worktreeExecutorProfileId, + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await session.clickTab("Files"); + + const workspaceActions = testPage.getByTestId("files-workspace-actions"); + await expect(workspaceActions).toBeEnabled(); + await expect(workspaceActions).toHaveAccessibleName("Workspace actions"); + const workspaceActionsBox = await workspaceActions.boundingBox(); + expect(workspaceActionsBox).not.toBeNull(); + expect(workspaceActionsBox!.width).toBeLessThanOrEqual(44); + await workspaceActions.click(); + const addSources = testPage.getByRole("menuitem", { + name: "Add Repositories to workspace", + }); + const openFolder = testPage.getByRole("menuitem", { name: "Open workspace folder" }); + await expect(addSources).toBeEnabled(); + await expect(openFolder).toBeEnabled(); + if (!task.session_id) throw new Error("task creation did not return a session id"); + await Promise.all([ + testPage.waitForRequest( + (request) => + request.method() === "POST" && + request.url().endsWith(`/api/v1/task-sessions/${task.session_id}/open-folder`), + ), + openFolder.click(), + ]); + await expect(openFolder).not.toBeVisible(); + await workspaceActions.click(); + await testPage.getByRole("menuitem", { name: "Add Repositories to workspace" }).click(); + const dialog = testPage.getByTestId("add-workspace-sources-dialog"); + await expect(dialog).toBeVisible(); + const [dialogBox, viewport] = await Promise.all([ + dialog.boundingBox(), + testPage.evaluate(() => ({ width: innerWidth, height: innerHeight })), + ]); + expect(dialogBox).not.toBeNull(); + expect(dialogBox!.y).toBeGreaterThanOrEqual(0); + expect(dialogBox!.y + dialogBox!.height).toBeLessThanOrEqual(viewport.height); + await expect(dialog.getByTestId("add-workspace-sources-dialog-scroll")).toHaveCSS( + "overflow-y", + "auto", + ); + const consequences = dialog.getByTestId("workspace-change-consequences"); + await expect(consequences).toBeVisible(); + await expect(consequences).toContainText("This restarts the task workspace"); + await expect(consequences).toContainText("Cancel leaves the workspace unchanged"); + await expect(consequences).toContainText( + "terminals, dev servers, and other workspace processes stop", + ); + const fullImpactDetails = consequences.getByText("Full impact details"); + await fullImpactDetails.click(); + await expect( + consequences.getByText(/The task root becomes the agent's working directory/), + ).toBeVisible(); + await fullImpactDetails.click(); + await expect(dialog.getByTestId("source-mode-local")).toHaveCount(0); + const addRepository = dialog.getByRole("button", { name: "Add repository" }); + const submit = dialog.getByTestId("add-workspace-sources-submit"); + await expect(submit).toBeDisabled(); + await addRepository.click(); + await expect(testPage.getByRole("menuitem", { name: "Local Git repository" })).toBeVisible(); + await expect(testPage.getByRole("menuitem", { name: "Remote repository" })).toBeVisible(); + await testPage.getByRole("menuitem", { name: "Workspace repository" }).click(); + const savedRepositoryRow = dialog.getByTestId("workspace-source-row").first(); + await expect(savedRepositoryRow.getByRole("alert")).toHaveCount(0); + await expect(savedRepositoryRow.getByRole("textbox", { name: "Checkout branch" })).toHaveCount( + 0, + ); + await savedRepositoryRow.getByTestId("repo-chip-trigger").click(); + await expect(testPage.getByTestId("repo-refresh-button")).toBeVisible(); + await expect(testPage.getByTestId("create-local-repository-button")).toBeVisible(); + await testPage.keyboard.press("Escape"); + await submit.click(); + const savedRepositoryError = savedRepositoryRow.getByRole("alert"); + await expect(savedRepositoryError).toHaveText("Choose a repository and base branch."); + await expect(savedRepositoryError).toHaveCSS("font-size", "12px"); + await savedRepositoryRow.getByRole("button", { name: "Remove source" }).click(); + await addRepository.click(); + await testPage.getByRole("menuitem", { name: "Local Git repository" }).click(); + const repositoryRow = dialog.getByTestId("workspace-source-row"); + await chooseDirectory( + testPage, + repositoryRow.getByTestId("folder-picker-trigger"), + backend.tmpDir, + repositoryPath, + ); + await repositoryRow.getByRole("textbox", { name: "Base branch" }).fill("main"); + await submit.click(); + await expect(dialog).not.toBeVisible(); + await expect( + session.files + .getByTestId("file-tree-node") + .filter({ hasText: "second-local-repository-main" }), + ).toBeVisible({ timeout: 30_000 }); + if (!task.session_id) throw new Error("task creation did not return a session id"); + const turnsAfterFirstAttachment = await apiClient.listSessionTurns(task.session_id); + expect(turnsAfterFirstAttachment.turns.filter((turn) => !turn.completed_at)).toEqual([]); + + await workspaceActions.click(); + await testPage.getByRole("menuitem", { name: "Add Repositories to workspace" }).click(); + await expect(dialog).toBeVisible(); + await dialog.getByRole("button", { name: "Add folder" }).click(); + const folderRow = dialog.getByTestId("workspace-source-row"); + await chooseDirectory( + testPage, + folderRow.getByTestId("folder-picker-trigger"), + backend.tmpDir, + folderPath, + ); + await prCapture.screenshot("workspace-actions-mixed-sources", { + caption: "Desktop Add to workspace dialog with a local folder configured", + }); + await submit.click(); + await expect(dialog).not.toBeVisible(); + + await expect( + session.files.getByTestId("file-tree-node").filter({ hasText: "plain-local-folder" }), + ).toBeVisible(); + + const sessionData = (await apiClient.listTaskSessions(task.id)) as { + sessions: Array<{ worktrees?: Array<{ worktree_path?: string }> }>; + }; + const repoPaths = (sessionData.sessions[0]?.worktrees ?? []).flatMap((worktree) => + worktree.worktree_path ? [worktree.worktree_path] : [], + ); + expect(repoPaths).toHaveLength(2); + for (const [index, repoPath] of repoPaths.entries()) { + new GitHelper(repoPath, makeGitEnv(backend.tmpDir)).createFile( + `changes/repository-${index}.txt`, + `repository ${index}\n`, + ); + } + + await session.clickTab("Changes"); + const changes = session.changes; + await expect(changes.getByTestId("changes-repo-group")).toHaveCount(2, { timeout: 30_000 }); + await expect( + changes.getByTestId("changes-repo-header").filter({ hasText: "second-local-repository" }), + ).toBeVisible(); + await expect(changes.getByText("plain-local-folder", { exact: true })).not.toBeVisible(); + + await testPage.reload(); + await session.waitForLoad(); + await session.clickTab("Files"); + await expect( + session.files + .getByTestId("file-tree-node") + .filter({ hasText: "second-local-repository-main" }), + ).toBeVisible({ timeout: 30_000 }); + await expect( + session.files.getByTestId("file-tree-node").filter({ hasText: "plain-local-folder" }), + ).toBeVisible(); + }); + + test("explains the disabled action while an active turn is running", async ({ + testPage, + apiClient, + seedData, + }) => { + test.setTimeout(90_000); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Busy source attachment gate", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + executor_profile_id: seedData.worktreeExecutorProfileId, + }, + ); + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await session.sendMessage("/slow 8s"); + await expect(session.agentStatus()).toBeVisible({ timeout: 15_000 }); + await session.clickTab("Files"); + + const workspaceActions = testPage.getByTestId("files-workspace-actions"); + await expect(workspaceActions).toBeEnabled(); + await workspaceActions.click(); + const action = testPage.getByRole("menuitem", { + name: "Add Repositories to workspace", + }); + await expect(action).toBeDisabled(); + await expect(action).toContainText( + "Wait for the active turn or tool call to finish before adding sources.", + ); + await expect(testPage.getByRole("menuitem", { name: "Open workspace folder" })).toBeEnabled(); + }); +}); diff --git a/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts b/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts new file mode 100644 index 0000000000..fd4a6e2caf --- /dev/null +++ b/apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts @@ -0,0 +1,247 @@ +import { expect, test } from "../../fixtures/test-base"; +import type { Locator, Page } from "@playwright/test"; +import { execFileSync } from "node:child_process"; +import fs from "node:fs"; +import path from "node:path"; +import { makeGitEnv } from "../../helpers/git-helper"; +import { SessionPage } from "../../pages/session-page"; + +async function chooseDirectory( + page: Page, + trigger: Locator, + pickerRoot: string, + directory: string, +) { + const relativeDirectory = path.relative(pickerRoot, directory); + if (relativeDirectory === ".." || relativeDirectory.startsWith(`..${path.sep}`)) { + throw new Error(`Directory ${directory} is outside picker root ${pickerRoot}`); + } + await trigger.tap(); + const picker = page.locator('[data-testid="folder-picker-popover"][data-state="open"]'); + await expect(picker).toBeVisible(); + for (const segment of relativeDirectory.split(path.sep).filter(Boolean)) { + await picker.getByTestId("folder-picker-entry").filter({ hasText: segment }).tap(); + } + await picker.getByTestId("folder-picker-choose").tap(); +} + +test("mobile Files drawer attaches sources with fixed controls and persisted workspace", async ({ + testPage, + apiClient, + seedData, + backend, + prCapture, +}) => { + test.setTimeout(120_000); + const gitEnv = makeGitEnv(backend.tmpDir); + const repositoryPath = path.join(backend.tmpDir, "mobile-sources", "mobile-local-repository"); + const folderPath = path.join(backend.tmpDir, "mobile-sources", "mobile-local-folder"); + fs.mkdirSync(repositoryPath, { recursive: true }); + fs.mkdirSync(folderPath, { recursive: true }); + fs.writeFileSync(path.join(repositoryPath, "mobile-repository.txt"), "repository source\n"); + fs.writeFileSync(path.join(folderPath, "mobile-folder.txt"), "folder source\n"); + execFileSync("git", ["init", "-b", "main"], { cwd: repositoryPath, env: gitEnv }); + execFileSync("git", ["add", "."], { cwd: repositoryPath, env: gitEnv }); + execFileSync("git", ["commit", "-m", "initial source"], { cwd: repositoryPath, env: gitEnv }); + const task = await apiClient.createTaskWithAgent( + seedData.workspaceId, + "Mobile attach local sources", + seedData.agentProfileId, + { + description: "/e2e:simple-message", + workflow_id: seedData.workflowId, + workflow_step_id: seedData.startStepId, + repository_ids: [seedData.repositoryId], + executor_profile_id: seedData.worktreeExecutorProfileId, + }, + ); + + await testPage.goto(`/t/${task.id}`); + const session = new SessionPage(testPage); + await session.waitForLoad(); + await session.waitForChatIdle({ timeout: 30_000 }); + await testPage.getByRole("button", { name: "Files" }).tap(); + const entryPoint = testPage.getByTestId("files-workspace-actions"); + await expect(entryPoint).toBeVisible(); + await expect(entryPoint).toBeEnabled(); + const entryBox = await entryPoint.boundingBox(); + expect(entryBox).not.toBeNull(); + expect(entryBox!.height).toBeGreaterThanOrEqual(44); + expect(entryBox!.width).toBeGreaterThanOrEqual(44); + expect(entryBox!.width).toBeLessThanOrEqual(48); + await entryPoint.tap(); + const addSources = testPage.getByRole("menuitem", { + name: "Add Repositories to workspace", + }); + const openFolder = testPage.getByRole("menuitem", { name: "Open workspace folder" }); + const actionMenu = addSources.locator("xpath=ancestor::*[@role='menu'][1]"); + await actionMenu.evaluate((element) => + Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); + const [menuBox, addSourcesBox, openFolderBox] = await Promise.all([ + actionMenu.boundingBox(), + addSources.boundingBox(), + openFolder.boundingBox(), + ]); + const menuViewport = testPage.viewportSize(); + if (!menuBox || !addSourcesBox || !openFolderBox || !menuViewport) { + throw new Error("mobile workspace actions menu has no layout box"); + } + expect(menuBox.x).toBeGreaterThanOrEqual(8); + expect(menuBox.x + menuBox.width).toBeLessThanOrEqual(menuViewport.width - 8); + expect(menuViewport.height - (menuBox.y + menuBox.height)).toBeGreaterThanOrEqual(7); + expect(addSourcesBox.height).toBeGreaterThanOrEqual(44); + expect(openFolderBox.height).toBeGreaterThanOrEqual(44); + if (!task.session_id) throw new Error("task creation did not return a session id"); + await Promise.all([ + testPage.waitForRequest( + (request) => + request.method() === "POST" && + request.url().endsWith(`/api/v1/task-sessions/${task.session_id}/open-folder`), + ), + openFolder.tap(), + ]); + await expect(openFolder).not.toBeVisible(); + await entryPoint.tap(); + await testPage.getByRole("menuitem", { name: "Add Repositories to workspace" }).tap(); + + const drawer = testPage.getByTestId("add-workspace-sources-drawer"); + await expect(drawer).toBeVisible(); + const consequences = drawer.getByTestId("workspace-change-consequences"); + await expect(consequences).toBeVisible(); + await expect(consequences).toContainText("This restarts the task workspace"); + await expect(consequences).toContainText("Cancel leaves the workspace unchanged"); + await expect(consequences).toContainText( + "terminals, dev servers, and other workspace processes stop", + ); + const fullImpactDetails = consequences.getByText("Full impact details"); + const fullImpactDetailsBox = await fullImpactDetails.boundingBox(); + expect(fullImpactDetailsBox?.height).toBeGreaterThanOrEqual(44); + await fullImpactDetails.tap(); + await expect( + consequences.getByText(/The task root becomes the agent's working directory/), + ).toBeVisible(); + await fullImpactDetails.tap(); + const [drawerBox, viewport] = await Promise.all([ + drawer.boundingBox(), + testPage.evaluate(() => ({ width: innerWidth, height: innerHeight })), + ]); + expect(drawerBox).not.toBeNull(); + expect(drawerBox!.height).toBeGreaterThanOrEqual(viewport.height - 1); + await expect(testPage.locator("html")).toHaveJSProperty("scrollWidth", viewport.width); + const scrollOwners = await drawer.locator("*").evaluateAll( + (elements) => + elements.filter((element) => { + const overflowY = getComputedStyle(element).overflowY; + return overflowY === "auto" || overflowY === "scroll"; + }).length, + ); + expect(scrollOwners).toBe(1); + + await expect(drawer.getByTestId("source-mode-local")).toHaveCount(0); + await expect(drawer.getByTestId("source-mode-remote")).toHaveCount(0); + const addRepository = drawer.getByRole("button", { name: "Add repository" }); + const addFolder = drawer.getByRole("button", { name: "Add folder" }); + const submit = drawer.getByTestId("add-workspace-sources-submit"); + const [addRepositoryBox, addFolderBox] = await Promise.all([ + addRepository.boundingBox(), + addFolder.boundingBox(), + ]); + expect(addRepositoryBox?.height).toBeGreaterThanOrEqual(44); + expect(addFolderBox?.height).toBeGreaterThanOrEqual(44); + await expect(submit).toBeDisabled(); + await addRepository.tap(); + const workspaceRepository = testPage.getByRole("menuitem", { name: "Workspace repository" }); + const localRepository = testPage.getByRole("menuitem", { name: "Local Git repository" }); + const remoteRepository = testPage.getByRole("menuitem", { name: "Remote repository" }); + const repositoryMenu = workspaceRepository.locator("xpath=ancestor::*[@role='menu'][1]"); + await repositoryMenu.evaluate((element) => + Promise.all( + element + .getAnimations({ subtree: true }) + .map((animation) => animation.finished.catch(() => undefined)), + ), + ); + const [workspaceRepositoryBox, localRepositoryBox, remoteRepositoryBox] = await Promise.all([ + workspaceRepository.boundingBox(), + localRepository.boundingBox(), + remoteRepository.boundingBox(), + ]); + expect(workspaceRepositoryBox?.height).toBeGreaterThanOrEqual(44); + expect(localRepositoryBox?.height).toBeGreaterThanOrEqual(44); + expect(remoteRepositoryBox?.height).toBeGreaterThanOrEqual(44); + await workspaceRepository.tap(); + const savedRepositoryRow = drawer.getByTestId("workspace-source-row").first(); + await expect(savedRepositoryRow.getByRole("alert")).toHaveCount(0); + await expect(savedRepositoryRow.getByRole("textbox", { name: "Checkout branch" })).toHaveCount(0); + await savedRepositoryRow.getByTestId("repo-chip-trigger").tap(); + const refreshRepositories = testPage.getByTestId("repo-refresh-button"); + const createRepository = testPage.getByTestId("create-local-repository-button"); + await expect(refreshRepositories).toBeVisible(); + await expect(createRepository).toBeVisible(); + const [refreshBox, createBox] = await Promise.all([ + refreshRepositories.boundingBox(), + createRepository.boundingBox(), + ]); + expect(refreshBox?.height).toBeGreaterThanOrEqual(44); + expect(createBox?.height).toBeGreaterThanOrEqual(44); + await testPage.keyboard.press("Escape"); + await savedRepositoryRow.getByRole("button", { name: "Remove source" }).tap(); + await addRepository.tap(); + await testPage.getByRole("menuitem", { name: "Local Git repository" }).tap(); + await addFolder.tap(); + const rows = drawer.getByTestId("workspace-source-row"); + await expect(rows).toHaveCount(2); + await chooseDirectory( + testPage, + rows.nth(0).getByTestId("folder-picker-trigger"), + backend.tmpDir, + repositoryPath, + ); + await rows.nth(0).getByRole("textbox", { name: "Base branch" }).fill("main"); + await chooseDirectory( + testPage, + rows.nth(1).getByTestId("folder-picker-trigger"), + backend.tmpDir, + folderPath, + ); + await expect(rows).toHaveCount(2); + await prCapture.screenshot("workspace-actions-mixed-sources", { + caption: "Pixel 5 Add to workspace drawer with a local repository and folder configured", + }); + + const footer = drawer.locator("div.border-t").last(); + const [footerBox, submitBox] = await Promise.all([footer.boundingBox(), submit.boundingBox()]); + expect(footerBox).not.toBeNull(); + expect(submitBox).not.toBeNull(); + expect(submitBox!.y + submitBox!.height).toBeLessThanOrEqual(drawerBox!.y + drawerBox!.height); + await submit.tap(); + await expect(drawer).not.toBeVisible(); + await expect(entryPoint).toBeEnabled(); + await expect(entryPoint).toBeFocused(); + + const files = testPage; + await expect( + files.getByTestId("file-tree-node").filter({ hasText: "mobile-local-repository-main" }), + ).toBeVisible({ timeout: 30_000 }); + await expect( + files.getByTestId("file-tree-node").filter({ hasText: "mobile-local-folder" }), + ).toBeVisible(); + await testPage.reload(); + await session.waitForLoad(); + await testPage.getByRole("button", { name: "Files" }).tap(); + await expect( + files.getByTestId("file-tree-node").filter({ hasText: "mobile-local-repository-main" }), + ).toBeVisible({ timeout: 30_000 }); + await expect( + files.getByTestId("file-tree-node").filter({ hasText: "mobile-local-folder" }), + ).toBeVisible(); + await expect(testPage.locator("html")).toHaveJSProperty( + "scrollWidth", + await testPage.evaluate(() => innerWidth), + ); +}); diff --git a/apps/web/lib/api/domains/kanban-api.test.ts b/apps/web/lib/api/domains/kanban-api.test.ts index 4a6cff0df8..dd6acd43fb 100644 --- a/apps/web/lib/api/domains/kanban-api.test.ts +++ b/apps/web/lib/api/domains/kanban-api.test.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; -import { detachTask } from "./kanban-api"; +import { attachTaskWorkspaceSources, detachTask } from "./kanban-api"; const fetchSpy = vi.fn(); @@ -28,3 +28,54 @@ describe("detachTask", () => { expect(init?.body).toBeUndefined(); }); }); + +describe("attachTaskWorkspaceSources", () => { + it("posts the exact mixed-source payload and returns the persisted projection", async () => { + fetchSpy.mockResolvedValueOnce( + new Response( + JSON.stringify({ + task_id: "task-1", + repositories: [], + workspace_folders: [ + { id: "folder-1", local_path: "/docs", display_name: "docs", position: 0 }, + ], + workspace_path: "/workspace/task-1", + session_ids: ["session-1"], + }), + { status: 200, headers: { "Content-Type": "application/json" } }, + ), + ); + + await expect( + attachTaskWorkspaceSources( + "task-1", + { sources: [{ kind: "folder", local_path: "/docs", display_name: "docs" }] }, + { baseUrl: "http://api.test" }, + ), + ).resolves.toMatchObject({ task_id: "task-1", workspace_path: "/workspace/task-1" }); + + expect(fetchSpy).toHaveBeenCalledOnce(); + const [url, init] = fetchSpy.mock.calls[0]; + expect(url).toBe("http://api.test/api/v1/tasks/task-1/workspace-sources"); + expect(init).toMatchObject({ + method: "POST", + body: JSON.stringify({ + sources: [{ kind: "folder", local_path: "/docs", display_name: "docs" }], + }), + }); + expect(new Headers(init?.headers).get("Content-Type")).toBe("application/json"); + }); + + it("preserves normalized API errors for retry UI", async () => { + fetchSpy.mockResolvedValueOnce( + new Response(JSON.stringify({ error: "task has an active turn" }), { + status: 409, + headers: { "Content-Type": "application/json" }, + }), + ); + + await expect( + attachTaskWorkspaceSources("task-1", { sources: [] }, { baseUrl: "http://api.test" }), + ).rejects.toMatchObject({ name: "ApiError", status: 409, message: "task has an active turn" }); + }); +}); diff --git a/apps/web/lib/api/domains/kanban-api.ts b/apps/web/lib/api/domains/kanban-api.ts index c8a700d67a..222ff6489c 100644 --- a/apps/web/lib/api/domains/kanban-api.ts +++ b/apps/web/lib/api/domains/kanban-api.ts @@ -5,6 +5,8 @@ import type { ListWorkflowsResponse, ListTasksResponse, CreateTaskResponse, + AttachTaskWorkspaceSourcesRequest, + AttachTaskWorkspaceSourcesResponse, Task, MoveTaskResponse, } from "@/lib/types/http"; @@ -132,6 +134,21 @@ export async function detachTask(taskId: string, options?: ApiRequestOptions) { }); } +/** Attach a validated batch of repository and folder sources to an idle task. */ +export async function attachTaskWorkspaceSources( + taskId: string, + payload: AttachTaskWorkspaceSourcesRequest, + options?: ApiRequestOptions, +) { + return fetchJson( + `/api/v1/tasks/${taskId}/workspace-sources`, + { + ...options, + init: { method: "POST", body: JSON.stringify(payload), ...(options?.init ?? {}) }, + }, + ); +} + export async function updateTaskRepositoryBaseBranch( taskId: string, taskRepositoryId: string, diff --git a/apps/web/lib/backend-fixture-env.test.ts b/apps/web/lib/backend-fixture-env.test.ts new file mode 100644 index 0000000000..062e1cfc77 --- /dev/null +++ b/apps/web/lib/backend-fixture-env.test.ts @@ -0,0 +1,90 @@ +import { expect, test } from "vitest"; +import { BackendFixtureEnvOverrides, createScopedEnvUse } from "../e2e/fixtures/backend-env"; + +test("keeps a scoped backend environment across restarts and restores baseline on release", () => { + const overrides = new BackendFixtureEnvOverrides(); + const release = overrides.add({ + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "url.http://172.17.0.1:40123/.insteadOf", + GIT_CONFIG_VALUE_0: "https://gitlab.com/", + }); + + const expectedEnv = { + PATH: "/bin", + GIT_CONFIG_COUNT: "1", + GIT_CONFIG_KEY_0: "url.http://172.17.0.1:40123/.insteadOf", + GIT_CONFIG_VALUE_0: "https://gitlab.com/", + }; + expect(overrides.apply({ PATH: "/bin" })).toEqual(expectedEnv); + expect(overrides.apply({ PATH: "/bin" })).toEqual(expectedEnv); + + expect(() => + overrides.add({ GIT_CONFIG_KEY_0: "url.http://172.17.0.1:40124/.insteadOf" }), + ).toThrow("conflicting backend fixture environment override"); + + release(); + expect(overrides.apply({ PATH: "/bin" })).toEqual({ PATH: "/bin" }); +}); + +test("merges one-shot restart values without overriding an active scoped value", () => { + const overrides = new BackendFixtureEnvOverrides(); + const release = overrides.add({ GIT_CONFIG_COUNT: "1" }); + + expect(overrides.apply({ PATH: "/bin" }, { KANDEV_MOCK_AGENT: "only" })).toEqual({ + PATH: "/bin", + GIT_CONFIG_COUNT: "1", + KANDEV_MOCK_AGENT: "only", + }); + expect(overrides.apply({ PATH: "/bin" }, { GIT_CONFIG_COUNT: "1" })).toEqual({ + PATH: "/bin", + GIT_CONFIG_COUNT: "1", + }); + expect(() => overrides.apply({ PATH: "/bin" }, { GIT_CONFIG_COUNT: "2" })).toThrow( + "conflicting backend fixture environment override", + ); + + release(); +}); + +test("restores the baseline environment after activation and restoration both fail", async () => { + const overrides = new BackendFixtureEnvOverrides(); + const activationError = new Error("activation failed"); + const restoreError = new Error("baseline restoration failed"); + const outcomes = [activationError, restoreError]; + const observedEnvironments: Record[] = []; + const useEnv = createScopedEnvUse(overrides, async () => { + observedEnvironments.push(overrides.apply({ PATH: "/bin" })); + throw outcomes.shift(); + }); + + await expect(useEnv({ GIT_CONFIG_COUNT: "1" })).rejects.toMatchObject({ + errors: [activationError, restoreError], + message: "failed to activate backend fixture environment and restore baseline", + }); + expect(observedEnvironments).toEqual([{ PATH: "/bin", GIT_CONFIG_COUNT: "1" }, { PATH: "/bin" }]); + expect(overrides.apply({ PATH: "/bin" })).toEqual({ PATH: "/bin" }); +}); + +test("retries release until the baseline restart is healthy", async () => { + const overrides = new BackendFixtureEnvOverrides(); + const releaseError = new Error("baseline restart failed"); + const outcomes = [undefined, releaseError, undefined]; + const observedEnvironments: Record[] = []; + const useEnv = createScopedEnvUse(overrides, async () => { + observedEnvironments.push(overrides.apply({ PATH: "/bin" })); + const outcome = outcomes.shift(); + if (outcome) throw outcome; + }); + + const release = await useEnv({ GIT_CONFIG_COUNT: "1" }); + await expect(release()).rejects.toThrow("baseline restart failed"); + expect(overrides.apply({ PATH: "/bin" })).toEqual({ PATH: "/bin" }); + + await release(); + await release(); + expect(observedEnvironments).toEqual([ + { PATH: "/bin", GIT_CONFIG_COUNT: "1" }, + { PATH: "/bin" }, + { PATH: "/bin" }, + ]); +}); diff --git a/apps/web/lib/http-git-server.test.ts b/apps/web/lib/http-git-server.test.ts new file mode 100644 index 0000000000..26d881c6ae --- /dev/null +++ b/apps/web/lib/http-git-server.test.ts @@ -0,0 +1,105 @@ +import { expect, test } from "vitest"; +import { startHTTPGitFixture } from "../e2e/helpers/http-git-server"; +import { execFileSync } from "node:child_process"; +import { type Server } from "node:http"; +import fs from "node:fs"; +import net from "node:net"; +import os from "node:os"; +import path from "node:path"; + +test("uses a trusted GitLab URL with isolated executor and backend Git rewrites", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-http-git-test-")); + try { + const fixture = await startHTTPGitFixture(root, "rewrite-source"); + try { + expect(fixture.remoteURL).toBe("https://gitlab.com/fixture/rewrite-source.git"); + expect(fixture.gitConfigEnvVars).toEqual([ + { key: "GIT_CONFIG_COUNT", value: "1" }, + { + key: "GIT_CONFIG_KEY_0", + value: expect.stringMatching(/^url\.http:\/\/[^/]+\/.insteadOf$/), + }, + { key: "GIT_CONFIG_VALUE_0", value: "https://gitlab.com/" }, + ]); + expect(fixture.backendEnv).toEqual({ + GIT_CONFIG_GLOBAL: expect.stringMatching(/rewrite-source\.gitconfig$/), + }); + expect(fixture.backendEnv.GIT_CONFIG_GLOBAL.startsWith(root)).toBe(true); + const configuredRewrite = execFileSync( + "git", + ["config", "--global", "--get-regexp", "^url\\..*\\.insteadOf$"], + { env: { ...process.env, ...fixture.backendEnv }, encoding: "utf8" }, + ).trim(); + expect(configuredRewrite).toMatch( + /^url\.http:\/\/[^/]+\/fixture\/rewrite-source\.git\.insteadof https:\/\/gitlab\.com\/fixture\/rewrite-source\.git$/, + ); + } finally { + await fixture.close(); + } + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("closes its listener when backend config setup fails after listening", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-http-git-test-")); + let port: number | undefined; + try { + await expect( + startHTTPGitFixture(root, "config-write-failure", { + onListening: (listeningPort) => { + port = listeningPort; + }, + writeBackendGitConfig: () => { + throw new Error("config write failed"); + }, + }), + ).rejects.toThrow("config write failed"); + + expect(port).toBeDefined(); + expect(await canConnect(port!)).toBe(false); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +test("aggregates setup and listener-close failures", async () => { + const root = fs.mkdtempSync(path.join(os.tmpdir(), "kandev-http-git-test-")); + const setupError = new Error("config write failed"); + const closeError = new Error("listener close failed"); + try { + await expect( + startHTTPGitFixture(root, "close-failure", { + writeBackendGitConfig: () => { + throw setupError; + }, + closeServer: async (server) => { + await closeTestServer(server); + throw closeError; + }, + }), + ).rejects.toMatchObject({ + errors: [setupError, closeError], + message: "HTTP Git fixture setup failed and its server did not close", + }); + } finally { + fs.rmSync(root, { recursive: true, force: true }); + } +}); + +function canConnect(port: number): Promise { + return new Promise((resolve) => { + const socket = net.connect(port, "127.0.0.1"); + socket.once("connect", () => { + socket.destroy(); + resolve(true); + }); + socket.once("error", () => resolve(false)); + }); +} + +function closeTestServer(server: Server): Promise { + return new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ); +} diff --git a/apps/web/lib/kanban/map-task.ts b/apps/web/lib/kanban/map-task.ts index 79b57c064c..c9b8baf473 100644 --- a/apps/web/lib/kanban/map-task.ts +++ b/apps/web/lib/kanban/map-task.ts @@ -37,6 +37,12 @@ export type TaskLike = { checkout_branch?: string; position?: number; }>; + workspace_folders?: Array<{ + id: string; + local_path: string; + display_name: string; + position: number; + }>; repository_id?: string; primary_session_id?: string | null; primary_session_state?: TaskSessionState | string | null; @@ -104,6 +110,10 @@ function pickRepositories(source: TaskLike): KanbanTaskRepository[] | undefined })); } +function pickWorkspaceFolders(source: TaskLike): KanbanTask["workspaceFolders"] | undefined { + return source.workspace_folders?.map((folder) => ({ ...folder })); +} + /** * Build a canonical {@link KanbanTask} from either an HTTP DTO or a WebSocket * payload. Both paths share this helper so a single publisher change can never @@ -120,6 +130,7 @@ export function toKanbanTask(source: TaskLike): KanbanTask { state: source.state, repositoryId: pickRepositoryId(source), repositories: pickRepositories(source), + workspaceFolders: pickWorkspaceFolders(source), primarySessionId: source.primary_session_id ?? undefined, primarySessionState: source.primary_session_state ?? undefined, primarySessionPendingAction: pickPendingAction(source.primary_session_pending_action), diff --git a/apps/web/lib/state/slices/kanban/types.ts b/apps/web/lib/state/slices/kanban/types.ts index c944e8f94a..6a33ea8801 100644 --- a/apps/web/lib/state/slices/kanban/types.ts +++ b/apps/web/lib/state/slices/kanban/types.ts @@ -63,6 +63,12 @@ export type KanbanState = { checkout_branch?: string; position: number; }>; + workspaceFolders?: Array<{ + id: string; + local_path: string; + display_name: string; + position: number; + }>; primarySessionId?: string | null; primarySessionState?: string | null; primarySessionPendingAction?: TaskPendingAction | null; diff --git a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts index 255bbfdc0a..fe0a0fc118 100644 --- a/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts +++ b/apps/web/lib/state/slices/session-runtime/session-runtime-slice.ts @@ -280,6 +280,7 @@ export const defaultSessionRuntimeState: SessionRuntimeSliceState = { userShells: { byEnvironmentId: {}, loading: {}, loaded: {} }, prepareProgress: { bySessionId: {} }, sessionPollMode: { bySessionId: {} }, + workspaceFilesRefresh: { bySessionId: {} }, }; type ImmerSet = Parameters[0]; @@ -534,6 +535,11 @@ export const createSessionRuntimeSlice: StateCreator< delete draft.gitStatus.byEnvironmentId[envKey]; delete draft.gitStatus.byEnvironmentRepo[envKey]; }), + bumpWorkspaceFilesRefresh: (sessionId) => + set((draft) => { + draft.workspaceFilesRefresh.bySessionId[sessionId] = + (draft.workspaceFilesRefresh.bySessionId[sessionId] ?? 0) + 1; + }), clearLegacyGitStatusEntry: (sessionId) => set((draft) => { // Drops the single-repo (empty-repo-name) entries so a session that just diff --git a/apps/web/lib/state/slices/session-runtime/types.ts b/apps/web/lib/state/slices/session-runtime/types.ts index 242cef75b6..44f9b7ebac 100644 --- a/apps/web/lib/state/slices/session-runtime/types.ts +++ b/apps/web/lib/state/slices/session-runtime/types.ts @@ -357,6 +357,7 @@ export type SessionRuntimeSliceState = { userShells: UserShellsState; prepareProgress: PrepareProgressState; sessionPollMode: SessionPollModeState; + workspaceFilesRefresh: { bySessionId: Record }; }; export type SessionRuntimeSliceActions = { @@ -375,6 +376,7 @@ export type SessionRuntimeSliceActions = { * can invalidate derived caches without repeating the deep comparison). */ setGitStatus: (sessionId: string, gitStatus: GitStatusEntry) => boolean; clearGitStatus: (sessionId: string) => void; + bumpWorkspaceFilesRefresh: (sessionId: string) => void; /** Drops the pre-multi-repo (empty-repo-name) git-status entries so a * freshly-multi-branch session doesn't surface a stale snapshot from the * workspace tracker that was replaced on the backend during rescan. */ diff --git a/apps/web/lib/state/slices/session/session-slice.ts b/apps/web/lib/state/slices/session/session-slice.ts index b326125357..6bf9d3791b 100644 --- a/apps/web/lib/state/slices/session/session-slice.ts +++ b/apps/web/lib/state/slices/session/session-slice.ts @@ -2,6 +2,7 @@ import type { StateCreator } from "zustand"; import { original } from "immer"; import type { Message, TaskSession } from "@/lib/types/http"; import type { SessionSlice, SessionSliceState } from "./types"; +import { buildTurnActions } from "./turn-actions"; import { reconcileMessages } from "./message-signature"; import { migrateEnvKeyedData, @@ -115,6 +116,43 @@ function mergeTaskSession(existing: TaskSession, incoming: TaskSession): TaskSes }; } +const IDLE_SESSION_STATES = new Set([ + "IDLE", + "WAITING_FOR_INPUT", + "COMPLETED", + "FAILED", + "CANCELLED", +]); + +/** + * An API session snapshot is authoritative about whether a session is idle, + * while turn history can be incomplete after boot or a WS reconnect. Drop an + * orphaned active-turn marker, but only retire a known turn when the idle + * session snapshot is at least as new as that turn's start time. + */ +function reconcileActiveTurnForIdleSession(draft: SessionSliceState, session: TaskSession): void { + if (!IDLE_SESSION_STATES.has(session.state)) return; + + const activeTurnId = draft.turns.activeBySession[session.id]; + if (!activeTurnId) return; + + const activeTurn = draft.turns.bySession[session.id]?.find((turn) => turn.id === activeTurnId); + if (!activeTurn || activeTurn.completed_at) { + draft.turns.activeBySession[session.id] = null; + return; + } + + const sessionUpdatedAt = Date.parse(session.updated_at); + const turnStartedAt = Date.parse(activeTurn.started_at); + if ( + !Number.isNaN(sessionUpdatedAt) && + !Number.isNaN(turnStartedAt) && + turnStartedAt <= sessionUpdatedAt + ) { + draft.turns.activeBySession[session.id] = null; + } +} + export const defaultSessionState: SessionSliceState = { messages: { bySession: {}, metaBySession: {} }, turns: { @@ -443,6 +481,7 @@ function buildTaskSessionActions(set: ImmerSet) { if (sessionIndex >= 0) sessionsByTask[sessionIndex] = mergedSession; } syncEnvironmentMapping(draft, session.id, mergedSession.task_environment_id); + reconcileActiveTurnForIdleSession(draft, mergedSession); }), removeTaskSession: (taskId: string, sessionId: string) => set((draft) => { @@ -478,11 +517,9 @@ function buildTaskSessionActions(set: ImmerSet) { draft.taskSessions.items[session.id] = session; syncEnvironmentMapping(draft, session.id, session.task_environment_id); syncPrepareProgress(draft, session); + reconcileActiveTurnForIdleSession(draft, session); } }), - // Upsert a session from a WS event without flipping the per-task `loadedByTaskId` - // flag — partial event-driven records must not gate the API hydration that - // fills in fields like agent_profile_id / repository_id / worktree_path. upsertTaskSessionFromEvent: ( taskId: string, session: Parameters[1], @@ -500,6 +537,7 @@ function buildTaskSessionActions(set: ImmerSet) { draft.taskSessionsByTask.itemsByTaskId[taskId] = [merged]; } syncEnvironmentMapping(draft, session.id, merged.task_environment_id); + reconcileActiveTurnForIdleSession(draft, merged); }), setTaskSessionsLoading: (taskId: string, loading: boolean) => set((draft) => { @@ -516,26 +554,7 @@ export const createSessionSlice: StateCreator< > = (set, get) => ({ ...defaultSessionState, ...buildMessageActions(set), - addTurn: (turn) => - set((draft) => { - const sessionId = turn.session_id; - if (!draft.turns.bySession[sessionId]) draft.turns.bySession[sessionId] = []; - if (!draft.turns.bySession[sessionId].find((t) => t.id === turn.id)) { - draft.turns.bySession[sessionId].push(turn); - } - }), - completeTurn: (sessionId, turnId, completedAt, metadata) => - set((draft) => { - const turn = draft.turns.bySession[sessionId]?.find((t) => t.id === turnId); - if (turn) { - turn.completed_at = completedAt; - if (metadata) turn.metadata = metadata; - } - }), - setActiveTurn: (sessionId, turnId) => - set((draft) => { - draft.turns.activeBySession[sessionId] = turnId; - }), + ...buildTurnActions(set), ...buildTaskSessionActions(set), setSessionAgentctlStatus: (sessionId, status) => set((draft) => { diff --git a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts index 23078e83fa..d3caa0b04d 100644 --- a/apps/web/lib/state/slices/session/session-slice.upsert.test.ts +++ b/apps/web/lib/state/slices/session/session-slice.upsert.test.ts @@ -138,6 +138,96 @@ describe("setTaskSessionsForTask preserves WS-seeded fields", () => { expect(store.getState().taskSessionsByTask.loadedByTaskId[TASK_ID]).toBe(true); }); + + it("clears an orphaned active turn when an authoritative refresh reports WAITING_FOR_INPUT", () => { + const store = makeStore(); + store.setState((draft) => { + draft.turns.activeBySession[SESSION_ID] = "turn-from-missed-history"; + }); + + store + .getState() + .setTaskSessionsForTask(TASK_ID, [ + makeSession({ state: "WAITING_FOR_INPUT", updated_at: "2026-04-20T00:01:00Z" }), + ]); + + expect(store.getState().turns.activeBySession[SESSION_ID]).toBeNull(); + }); + + it("keeps a newer active turn when an older WAITING_FOR_INPUT state arrives", () => { + const store = makeStore(); + const turnStartedAt = "2026-04-20T00:02:00Z"; + store.getState().addTurn({ + id: "turn-new", + session_id: SESSION_ID, + task_id: TASK_ID, + started_at: turnStartedAt, + created_at: turnStartedAt, + updated_at: turnStartedAt, + }); + store.getState().setActiveTurn(SESSION_ID, "turn-new"); + + store + .getState() + .upsertTaskSessionFromEvent( + TASK_ID, + makeSession({ state: "WAITING_FOR_INPUT", updated_at: "2026-04-20T00:01:00Z" }), + ); + + expect(store.getState().turns.activeBySession[SESSION_ID]).toBe("turn-new"); + }); + + it("keeps an active turn while the refreshed session remains RUNNING", () => { + const store = makeStore(); + const turnStartedAt = "2026-04-20T00:02:00Z"; + store.getState().addTurn({ + id: "turn-running", + session_id: SESSION_ID, + task_id: TASK_ID, + started_at: turnStartedAt, + created_at: turnStartedAt, + updated_at: turnStartedAt, + }); + store.getState().setActiveTurn(SESSION_ID, "turn-running"); + + store + .getState() + .setTaskSessionsForTask(TASK_ID, [ + makeSession({ state: "RUNNING", updated_at: "2026-04-20T00:03:00Z" }), + ]); + + expect(store.getState().turns.activeBySession[SESSION_ID]).toBe("turn-running"); + }); + + it("clears only the active-turn markers authoritatively retired by source adoption", () => { + const store = makeStore(); + const adoptedSession = SESSION_ID; + const stillActiveSession = toSessionId("session-still-active"); + const startedAt = "2026-04-20T00:02:00Z"; + store.getState().addTurn({ + id: "turn-adopted", + session_id: adoptedSession, + task_id: TASK_ID, + started_at: startedAt, + created_at: startedAt, + updated_at: startedAt, + }); + store.getState().addTurn({ + id: "turn-still-active", + session_id: stillActiveSession, + task_id: TASK_ID, + started_at: startedAt, + created_at: startedAt, + updated_at: startedAt, + }); + store.getState().setActiveTurn(adoptedSession, "turn-adopted"); + store.getState().setActiveTurn(stillActiveSession, "turn-still-active"); + + store.getState().reconcileWorkspaceSourcesAdopted([adoptedSession]); + + expect(store.getState().turns.activeBySession[adoptedSession]).toBeNull(); + expect(store.getState().turns.activeBySession[stillActiveSession]).toBe("turn-still-active"); + }); }); // ADR-0049 — a fresh page-load / second tab receives the diff --git a/apps/web/lib/state/slices/session/turn-actions.ts b/apps/web/lib/state/slices/session/turn-actions.ts new file mode 100644 index 0000000000..a72da5fbf9 --- /dev/null +++ b/apps/web/lib/state/slices/session/turn-actions.ts @@ -0,0 +1,77 @@ +import type { StateCreator } from "zustand"; +import type { SessionSlice } from "./types"; + +type ImmerSet = Parameters< + StateCreator +>[0]; + +export function buildTurnActions(set: ImmerSet) { + return { + addTurn: (turn: Parameters[0]) => + set((draft) => { + const sessionId = turn.session_id; + if (!draft.turns.bySession[sessionId]) draft.turns.bySession[sessionId] = []; + const existing = draft.turns.bySession[sessionId].find((item) => item.id === turn.id); + if (!existing) { + draft.turns.bySession[sessionId].push(turn); + return; + } + Object.assign(existing, turn, { completed_at: existing.completed_at ?? turn.completed_at }); + }), + completeTurn: ( + sessionId: Parameters[0], + turnId: Parameters[1], + completedAt: Parameters[2], + metadata: Parameters[3], + ) => + set((draft) => { + const turn = draft.turns.bySession[sessionId]?.find((item) => item.id === turnId); + if (turn) { + turn.completed_at = completedAt; + if (metadata) turn.metadata = metadata; + } + if (draft.turns.activeBySession[sessionId] === turnId) { + draft.turns.activeBySession[sessionId] = null; + } + }), + setActiveTurn: ( + sessionId: Parameters[0], + turnId: Parameters[1], + ) => + set((draft) => { + if (!turnId) { + draft.turns.activeBySession[sessionId] = null; + return; + } + const turns = draft.turns.bySession[sessionId] ?? []; + const next = turns.find((turn) => turn.id === turnId); + if (!next || next.completed_at) return; + + const currentId = draft.turns.activeBySession[sessionId]; + const current = turns.find((turn) => turn.id === currentId); + if (!current || current.completed_at) { + draft.turns.activeBySession[sessionId] = turnId; + return; + } + if (current.id === turnId) return; + + const currentStartedAt = Date.parse(current.started_at); + const nextStartedAt = Date.parse(next.started_at); + if ( + !Number.isNaN(currentStartedAt) && + !Number.isNaN(nextStartedAt) && + nextStartedAt > currentStartedAt + ) { + draft.turns.activeBySession[sessionId] = turnId; + } + }), + reconcileWorkspaceSourcesAdopted: ( + sessionIds: Parameters[0], + ) => + set((draft) => { + for (const sessionId of new Set(sessionIds)) { + draft.turns.activeBySession[sessionId] = null; + } + }), + }; +} diff --git a/apps/web/lib/state/slices/session/types.ts b/apps/web/lib/state/slices/session/types.ts index 22d261445b..94a393a3f9 100644 --- a/apps/web/lib/state/slices/session/types.ts +++ b/apps/web/lib/state/slices/session/types.ts @@ -206,6 +206,8 @@ export type SessionSliceActions = { metadata?: Record, ) => void; setActiveTurn: (sessionId: string, turnId: string | null) => void; + /** Source adoption is an authoritative idle boundary for the listed sessions. */ + reconcileWorkspaceSourcesAdopted: (sessionIds: string[]) => void; setTaskSession: (session: TaskSession) => void; removeTaskSession: (taskId: string, sessionId: string) => void; setTaskSessionsForTask: (taskId: string, sessions: TaskSession[]) => void; diff --git a/apps/web/lib/state/store-reexports.ts b/apps/web/lib/state/store-reexports.ts index e55494efa6..f0f01aeef7 100644 --- a/apps/web/lib/state/store-reexports.ts +++ b/apps/web/lib/state/store-reexports.ts @@ -1,5 +1,13 @@ // Re-export all types from slices for backwards compatibility. // Extracted from store.ts to keep that file under the max-lines limit. +import type { SessionRuntimeSlice, SessionSliceActions } from "./slices"; + +export type WorkspaceSourceStoreState = Pick< + SessionRuntimeSlice, + "workspaceFilesRefresh" | "bumpWorkspaceFilesRefresh" +> & + Pick; + export type { KanbanState, KanbanMultiState, diff --git a/apps/web/lib/state/store.ts b/apps/web/lib/state/store.ts index 1e48818d47..1f26343804 100644 --- a/apps/web/lib/state/store.ts +++ b/apps/web/lib/state/store.ts @@ -615,6 +615,7 @@ export type AppState = { appendRunAttempt: (runId: string, attempt: RouteAttempt) => void; setAgentRouting: (agentId: string, data: AgentRouteData | undefined) => void; } & GitHubSliceActions & + import("./store-reexports").WorkspaceSourceStoreState & AzureDevOpsSliceActions & SystemSliceActions & FeaturesSliceActions & diff --git a/apps/web/lib/types/backend.ts b/apps/web/lib/types/backend.ts index ae91a30f43..ef4a996af8 100644 --- a/apps/web/lib/types/backend.ts +++ b/apps/web/lib/types/backend.ts @@ -548,6 +548,15 @@ export type BackendMessageMap = OfficeBackendMessageMap & >; "session.agentctl_ready": BackendMessage<"session.agentctl_ready", TaskSessionAgentctlPayload>; "session.agentctl_error": BackendMessage<"session.agentctl_error", TaskSessionAgentctlPayload>; + "session.workspace_sources.updated": BackendMessage< + "session.workspace_sources.updated", + { + task_id: string; + session_id: string; + workspace_path: string; + adopted_session_ids?: string[]; + } + >; "session.turn.started": BackendMessage<"session.turn.started", TurnEventPayload>; "session.turn.completed": BackendMessage<"session.turn.completed", TurnEventPayload>; "session.available_commands": BackendMessage< diff --git a/apps/web/lib/types/http-workspace-sources.ts b/apps/web/lib/types/http-workspace-sources.ts new file mode 100644 index 0000000000..bafd905350 --- /dev/null +++ b/apps/web/lib/types/http-workspace-sources.ts @@ -0,0 +1,62 @@ +import type { RepositoryId, SessionId, TaskId } from "./ids"; + +export type TaskRepository = { + id: string; + task_id: TaskId; + repository_id: RepositoryId; + base_branch: string; + /** + * Optional branch to fetch and check out after worktree creation + * (e.g. a PR head branch). Empty when no specific branch is requested. + */ + checkout_branch?: string; + position: number; + metadata?: Record; + created_at: string; + updated_at: string; +}; + +export type WorkspaceFolder = { + id: string; + task_id: TaskId; + local_path: string; + display_name: string; + position: number; + created_at?: string; + updated_at?: string; +}; + +export type WorkspaceRepositorySourceRequest = { + kind: "repository"; + repository_id?: string; + local_path?: string; + remote_url?: string; + provider?: "github" | "gitlab" | "azure_devops"; + provider_repo_id?: string; + provider_owner?: string; + provider_name?: string; + base_branch: string; + checkout_branch?: string; +}; + +export type WorkspaceFolderSourceRequest = { + kind: "folder"; + local_path: string; + display_name?: string; +}; + +export type WorkspaceSourceRequest = + | WorkspaceRepositorySourceRequest + | WorkspaceFolderSourceRequest; + +export type AttachTaskWorkspaceSourcesRequest = { sources: WorkspaceSourceRequest[] }; + +export type AttachTaskWorkspaceSourcesResponse = { + task_id: TaskId; + repositories: TaskRepository[]; + workspace_folders: WorkspaceFolder[]; + workspace_path: string; + /** Authoritative idle sessions whose runtime adopted the new sources. */ + adopted_session_ids?: SessionId[]; + session_ids: SessionId[]; +}; diff --git a/apps/web/lib/types/http.ts b/apps/web/lib/types/http.ts index 0edfc8847e..f42122e265 100644 --- a/apps/web/lib/types/http.ts +++ b/apps/web/lib/types/http.ts @@ -1,5 +1,6 @@ import type { ExecutorType } from "./executor"; import type { UserSettings } from "./http-user-settings"; +import type { TaskRepository, WorkspaceFolder } from "./http-workspace-sources"; import type { AgentProfileId, RepositoryId, @@ -24,6 +25,15 @@ export type { UserSettingsResponse, UserSettingsUpdatePayload, } from "./http-user-settings"; +export type { + AttachTaskWorkspaceSourcesRequest, + AttachTaskWorkspaceSourcesResponse, + TaskRepository, + WorkspaceFolder, + WorkspaceFolderSourceRequest, + WorkspaceRepositorySourceRequest, + WorkspaceSourceRequest, +} from "./http-workspace-sources"; export * from "./ids"; export type { MoveToStepConfig, @@ -270,22 +280,6 @@ export type ProcessInfo = { output?: ProcessOutputChunk[]; }; -export type TaskRepository = { - id: string; - task_id: TaskId; - repository_id: RepositoryId; - base_branch: string; - /** - * Optional branch to fetch and check out after worktree creation - * (e.g. a PR head branch). Empty when no specific branch is requested. - */ - checkout_branch?: string; - position: number; - metadata?: Record; - created_at: string; - updated_at: string; -}; - /** * Returns the primary task repository (lowest Position, first by created_at on * tie). Returns undefined for repo-less tasks. Consumers that historically @@ -314,6 +308,7 @@ export type Task = { state: TaskState; priority: number; repositories?: TaskRepository[]; + workspace_folders?: WorkspaceFolder[]; primary_session_id?: SessionId | null; primary_session_state?: TaskSessionState | null; primary_session_pending_action?: TaskPendingAction | null; diff --git a/apps/web/lib/ws/handlers/agent-session.test.ts b/apps/web/lib/ws/handlers/agent-session.test.ts index b5b7554952..b0a1cd2af9 100644 --- a/apps/web/lib/ws/handlers/agent-session.test.ts +++ b/apps/web/lib/ws/handlers/agent-session.test.ts @@ -29,6 +29,10 @@ function makeStore(overrides: Record = {}) { setSessionAgentctlStatus: vi.fn(), setSessionFailureNotification: vi.fn(), setContextWindow: vi.fn(), + clearLegacyGitStatusEntry: vi.fn(), + bumpSessionCommitsRefetch: vi.fn(), + bumpWorkspaceFilesRefresh: vi.fn(), + reconcileWorkspaceSourcesAdopted: vi.fn(), ...overrides, }; return { @@ -204,6 +208,34 @@ describe("session.state_changed handler", () => { }); }); +describe("session.workspace_sources.updated handler", () => { + it("adopts the workspace root and bumps the Files refresh key", () => { + const setTaskSession = vi.fn(); + const bumpWorkspaceFilesRefresh = vi.fn(); + const store = makeStore({ + taskSessions: { + items: { "s-1": { id: "s-1", task_id: "t-1", state: "IDLE", worktree_path: "/old" } }, + }, + setTaskSession, + bumpWorkspaceFilesRefresh, + }); + + const handler = registerTaskSessionHandlers(store)["session.workspace_sources.updated"]!; + handler({ + id: "msg-workspace-sources", + type: "notification", + action: "session.workspace_sources.updated", + payload: { task_id: "t-1", session_id: "s-1", workspace_path: "/new" }, + } as never); + + expect(setTaskSession).toHaveBeenCalledWith( + expect.objectContaining({ id: "s-1", worktree_path: "/new" }), + ); + expect(bumpWorkspaceFilesRefresh).toHaveBeenCalledWith("s-1"); + expect(store.getState().reconcileWorkspaceSourcesAdopted).toHaveBeenCalledWith(["s-1"]); + }); +}); + describe("session.state_changed name propagation", () => { let store: ReturnType; // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/apps/web/lib/ws/handlers/agent-session.ts b/apps/web/lib/ws/handlers/agent-session.ts index abb6ab47f4..68741a28d8 100644 --- a/apps/web/lib/ws/handlers/agent-session.ts +++ b/apps/web/lib/ws/handlers/agent-session.ts @@ -515,6 +515,24 @@ function applyForegroundActivity( }); } +function handleWorkspaceSourcesUpdated( + store: StoreApi, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + payload: any, +): void { + const { + session_id: sessionId, + workspace_path: workspacePath, + adopted_session_ids: adoptedSessionIds, + } = payload; + const existing = store.getState().taskSessions.items[sessionId]; + if (existing) store.getState().setTaskSession({ ...existing, worktree_path: workspacePath }); + store.getState().reconcileWorkspaceSourcesAdopted(adoptedSessionIds ?? [sessionId]); + store.getState().bumpWorkspaceFilesRefresh(sessionId); + store.getState().clearLegacyGitStatusEntry(sessionId); + store.getState().bumpSessionCommitsRefetch(sessionId); +} + export function registerTaskSessionHandlers(store: StoreApi): WsHandlers { return { "message.queue.status_changed": (message) => { @@ -622,5 +640,7 @@ export function registerTaskSessionHandlers(store: StoreApi): WsHandle updatedAt: message.timestamp, }); }, + "session.workspace_sources.updated": (message) => + handleWorkspaceSourcesUpdated(store, message.payload), }; } diff --git a/apps/web/lib/ws/handlers/tasks.deleted.test.ts b/apps/web/lib/ws/handlers/tasks.deleted.test.ts new file mode 100644 index 0000000000..3dede13e34 --- /dev/null +++ b/apps/web/lib/ws/handlers/tasks.deleted.test.ts @@ -0,0 +1,156 @@ +import { beforeEach, describe, expect, it, vi } from "vitest"; +import type { AppState } from "@/lib/state/store"; +import { removeRecentTask } from "@/lib/recent-tasks"; +import { registerTasksHandlers } from "./tasks"; +import { + makeActiveStore, + makeDeletedMessage, + makeInactiveStore, + makeStore, + REVIEW_TITLE, + SESS_OTHER, + SESS_PINNED, +} from "./tasks.test-helpers"; + +vi.mock("@/lib/recent-tasks", () => ({ removeRecentTask: vi.fn() })); + +describe("task.deleted cleanup", () => { + beforeEach(() => { + vi.mocked(removeRecentTask).mockClear(); + }); + + it("removes the deleted task from recent task history", () => { + const store = makeStore({ + kanban: { + workflowId: "wf1", + steps: [], + tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }], + } as unknown as AppState["kanban"], + environmentIdBySessionId: {}, + }); + + registerTasksHandlers(store)["task.deleted"]!( + makeDeletedMessage({ task_id: "t1", workflow_id: "wf1" }), + ); + + expect(removeRecentTask).toHaveBeenCalledOnce(); + expect(removeRecentTask).toHaveBeenCalledWith("t1"); + }); + + it("clears deleted task session state", () => { + const store = makeStore({ + kanban: { + workflowId: "wf1", + steps: [], + tasks: [{ id: "t1", primarySessionId: SESS_PINNED, workflowId: "wf1" }], + } as unknown as AppState["kanban"], + tasks: { + activeTaskId: "t1", + activeSessionId: SESS_PINNED, + pinnedSessionId: SESS_PINNED, + lastSessionByTaskId: { t1: SESS_PINNED, t2: SESS_OTHER }, + }, + environmentIdBySessionId: {}, + }); + + registerTasksHandlers(store)["task.deleted"]!( + makeDeletedMessage({ task_id: "t1", workflow_id: "wf1" }), + ); + + const state = store.getState(); + expect(state.tasks.pinnedSessionId).toBeNull(); + expect(state.tasks.lastSessionByTaskId).not.toHaveProperty("t1"); + expect(state.tasks.lastSessionByTaskId).toHaveProperty("t2", SESS_OTHER); + }); +}); + +describe("task.deleted live notification + redirect", () => { + it("sets a task-deleted notification (with title + reason) when the focused task is deleted", () => { + const store = makeActiveStore(); + registerTasksHandlers(store)["task.deleted"]!( + makeDeletedMessage({ + task_id: "t1", + workflow_id: "wf1", + title: REVIEW_TITLE, + reason: "pr_approved_by_user", + }), + ); + + expect(store.getState().setTaskDeletedNotification).toHaveBeenCalledWith({ + taskId: "t1", + title: REVIEW_TITLE, + reason: "pr_approved_by_user", + }); + }); + + it("does not notify when a non-focused task is deleted", () => { + const store = makeStore({ + kanban: { workflowId: "wf1", steps: [], tasks: [{ id: "t1", workflowId: "wf1" }] }, + tasks: { + activeTaskId: "t2", + activeSessionId: null, + pinnedSessionId: null, + lastSessionByTaskId: {}, + }, + environmentIdBySessionId: {}, + } as unknown as Partial); + + registerTasksHandlers(store)["task.deleted"]!( + makeDeletedMessage({ task_id: "t1", workflow_id: "wf1" }), + ); + + expect(store.getState().setTaskDeletedNotification).not.toHaveBeenCalled(); + }); + + it("does not redirect or notify for a user-initiated delete on the task route", () => { + window.history.replaceState({}, "", "/t/t1"); + const store = makeActiveStore(); + + registerTasksHandlers(store)["task.deleted"]!( + makeDeletedMessage({ task_id: "t1", workflow_id: "wf1", title: REVIEW_TITLE }), + ); + + expect(window.location.pathname).toBe("/t/t1"); + expect(store.getState().setTaskDeletedNotification).not.toHaveBeenCalled(); + }); + + it.each(["/t/t1", "/tasks/t1"])( + "redirects home and notifies when parked on %s before activeTaskId hydrates", + (path) => { + window.history.replaceState({}, "", path); + const store = makeInactiveStore(); + + registerTasksHandlers(store)["task.deleted"]!( + makeDeletedMessage({ + task_id: "t1", + workflow_id: "wf1", + title: REVIEW_TITLE, + reason: "pr_approved_by_user", + }), + ); + + expect(window.location.pathname).toBe("/"); + expect(store.getState().setTaskDeletedNotification).toHaveBeenCalledWith({ + taskId: "t1", + title: REVIEW_TITLE, + reason: "pr_approved_by_user", + }); + }, + ); + + it("does not redirect an auto-deletion when viewing a different route", () => { + window.history.replaceState({}, "", "/t/other"); + const store = makeActiveStore(); + + registerTasksHandlers(store)["task.deleted"]!( + makeDeletedMessage({ + task_id: "t1", + workflow_id: "wf1", + title: REVIEW_TITLE, + reason: "pr_approved_by_user", + }), + ); + + expect(window.location.pathname).toBe("/t/other"); + }); +}); diff --git a/apps/web/lib/ws/handlers/tasks.test-helpers.ts b/apps/web/lib/ws/handlers/tasks.test-helpers.ts new file mode 100644 index 0000000000..dd95fe5f0c --- /dev/null +++ b/apps/web/lib/ws/handlers/tasks.test-helpers.ts @@ -0,0 +1,128 @@ +import { vi } from "vitest"; +import type { StoreApi } from "zustand"; +import type { AppState } from "@/lib/state/store"; +import { registerTasksHandlers } from "./tasks"; + +export const SESS_OTHER = "sess-other"; +export const SESS_DRIFTED = "sess-drifted"; +export const SESS_PINNED = "sess-pinned"; +export const REVIEW_TITLE = "Review PR #11259"; + +type Listener = (state: AppState) => void; + +export function makeStore(initial: Partial = {}) { + let state = { + kanban: { workflowId: "wf1", steps: [], tasks: [] }, + kanbanMulti: { snapshots: {}, isLoading: false }, + tasks: { + activeTaskId: null, + activeSessionId: null, + pinnedSessionId: null, + lastSessionByTaskId: {}, + }, + taskSessionsByTask: { itemsByTaskId: {}, loadedByTaskId: {}, loadingByTaskId: {} }, + environmentIdBySessionId: {}, + setActiveSession: vi.fn((taskId: string, sessionId: string | null) => { + state = { + ...state, + tasks: { + activeTaskId: taskId, + activeSessionId: sessionId, + pinnedSessionId: sessionId, + lastSessionByTaskId: sessionId + ? { ...state.tasks.lastSessionByTaskId, [taskId]: sessionId } + : state.tasks.lastSessionByTaskId, + }, + }; + }), + setActiveSessionAuto: vi.fn((taskId: string, sessionId: string | null) => { + state = { + ...state, + tasks: { + ...state.tasks, + activeTaskId: taskId, + activeSessionId: sessionId, + }, + }; + }), + removeTaskFromSidebarPrefs: vi.fn(), + setTaskDeletedNotification: vi.fn(), + ...initial, + } as unknown as AppState; + + const listeners = new Set(); + return { + getState: () => state, + setState: (updater: AppState | ((current: AppState) => AppState)) => { + const next = + typeof updater === "function" + ? (updater as (current: AppState) => AppState)(state) + : updater; + state = { ...state, ...next }; + for (const listener of listeners) listener(state); + }, + subscribe: (listener: Listener) => { + listeners.add(listener); + return () => listeners.delete(listener); + }, + destroy: vi.fn(), + getInitialState: vi.fn(), + } as unknown as StoreApi & { getState: () => AppState }; +} + +export function makeTask(id: string, primarySessionId: string | null, workflowId = "wf1") { + return { + task_id: id, + workflow_id: workflowId, + workflow_step_id: "step1", + title: "Test", + description: "", + state: "IN_PROGRESS", + primary_session_id: primarySessionId, + is_ephemeral: false, + } as Record; +} + +export function makeMessage(payload: Record) { + return { + id: "msg-1", + type: "notification" as const, + action: "task.updated" as const, + payload, + } as Parameters["task.updated"]>>[0]; +} + +export function makeDeletedMessage(payload: Record) { + return { + id: "msg-1", + type: "notification" as const, + action: "task.deleted" as const, + payload, + } as Parameters["task.deleted"]>>[0]; +} + +export function makeActiveStore() { + return makeStore({ + kanban: { workflowId: "wf1", steps: [], tasks: [{ id: "t1", workflowId: "wf1" }] }, + tasks: { + activeTaskId: "t1", + activeSessionId: null, + pinnedSessionId: null, + lastSessionByTaskId: {}, + }, + environmentIdBySessionId: {}, + } as unknown as Partial); +} + +export function makeInactiveStore() { + return makeStore({ + kanban: { workflowId: "wf1", steps: [], tasks: [{ id: "t1", workflowId: "wf1" }] }, + tasks: { + activeTaskId: null, + activeSessionId: null, + pinnedSessionId: null, + lastSessionByTaskId: {}, + }, + environmentIdBySessionId: {}, + } as unknown as Partial); +} diff --git a/apps/web/lib/ws/handlers/tasks.test.ts b/apps/web/lib/ws/handlers/tasks.test.ts index e2477c234d..2263e49fb1 100644 --- a/apps/web/lib/ws/handlers/tasks.test.ts +++ b/apps/web/lib/ws/handlers/tasks.test.ts @@ -1,13 +1,8 @@ import { beforeEach, describe, expect, it, vi } from "vitest"; import type { StoreApi } from "zustand"; -import { removeRecentTask } from "@/lib/recent-tasks"; import type { AppState } from "@/lib/state/store"; import { registerTasksHandlers } from "./tasks"; -vi.mock("@/lib/recent-tasks", () => ({ - removeRecentTask: vi.fn(), -})); - const SESS_OTHER = "sess-other"; const SESS_DRIFTED = "sess-drifted"; const SESS_PINNED = "sess-pinned"; @@ -99,17 +94,6 @@ function makeMessage(payload: Record) { } as Parameters["task.updated"]>>[0]; } -function makeDeletedMessage(payload: Record) { - return { - id: "msg-1", - type: "notification" as const, - action: "task.deleted" as const, - payload, - } as Parameters["task.deleted"]>>[0]; -} - -const REVIEW_TITLE = "Review PR #11259"; - // Shared setup for the primary-session focus-follow tests: a single task t1 // whose kanban primary, plus the active/pinned session ids, are the only knobs // that vary between cases. @@ -135,39 +119,12 @@ function makeFollowStore(opts: { }); } -function makeActiveStore() { - return makeStore({ - kanban: { workflowId: "wf1", steps: [], tasks: [{ id: "t1", workflowId: "wf1" }] }, - tasks: { - activeTaskId: "t1", - activeSessionId: null, - pinnedSessionId: null, - lastSessionByTaskId: {}, - }, - environmentIdBySessionId: {}, - } as unknown as Partial); -} - -function makeInactiveStore() { - return makeStore({ - kanban: { workflowId: "wf1", steps: [], tasks: [{ id: "t1", workflowId: "wf1" }] }, - tasks: { - activeTaskId: null, - activeSessionId: null, - pinnedSessionId: null, - lastSessionByTaskId: {}, - }, - environmentIdBySessionId: {}, - } as unknown as Partial); -} - describe("task.updated primary-session focus follow", () => { let store: ReturnType; let setActiveSessionAuto: ReturnType void>>; beforeEach(() => { setActiveSessionAuto = vi.fn(); - vi.mocked(removeRecentTask).mockClear(); }); it("follows focus to the new primary when the user is on the previous primary", () => { @@ -237,6 +194,28 @@ describe("task.updated primary-session focus follow", () => { }); }); +describe("task.updated workspace sources", () => { + it("merges authoritative workspace folders into the kanban task", () => { + const store = makeStore({ + kanban: { workflowId: "wf1", steps: [], tasks: [{ id: "t1", workflowId: "wf1" }] }, + } as unknown as Partial); + + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ + ...makeTask("t1", "sess-1"), + repositories: [{ id: "tr-1", repository_id: "repo-1", base_branch: "main", position: 0 }], + workspace_folders: [ + { id: "folder-1", local_path: "/work/docs", display_name: "docs", position: 0 }, + ], + }), + ); + + expect(store.getState().kanban.tasks[0].workspaceFolders).toEqual([ + { id: "folder-1", local_path: "/work/docs", display_name: "docs", position: 0 }, + ]); + }); +}); + // Regression: even when the user happens to be sitting on the previous // primary, an explicit pin on it must override primary-follow-focus here. // A pinned user whose session is genuinely retired is followed via the session @@ -249,7 +228,6 @@ describe("task.updated primary-session focus follow (pinning)", () => { beforeEach(() => { setActiveSessionAuto = vi.fn(); - vi.mocked(removeRecentTask).mockClear(); }); it("does NOT follow focus when the user has pinned the previous primary", () => { @@ -317,7 +295,6 @@ describe("task.updated primary-session focus follow (stale pin cleanup)", () => beforeEach(() => { setActiveSessionAuto = vi.fn(); - vi.mocked(removeRecentTask).mockClear(); }); it("clears a terminal orphaned pin when following focus to the new primary", () => { @@ -546,154 +523,76 @@ describe("task.updated repository clearing", () => { }); }); -describe("task.deleted cleanup", () => { - beforeEach(() => { - vi.mocked(removeRecentTask).mockClear(); - }); +describe("task.updated executor preservation", () => { + const executorMetadata = { + primaryExecutorId: "executor-1", + primaryExecutorType: "worktree", + primaryExecutorName: "Worktree", + isRemoteExecutor: false, + }; - it("removes the deleted task from recent task history", () => { + it("preserves executor metadata when a partial update omits executor fields", () => { + const existingTask = { + id: "t1", + workflowStepId: "step1", + title: "Old title", + position: 0, + primarySessionId: "session-1", + ...executorMetadata, + }; const store = makeStore({ kanban: { workflowId: "wf1", steps: [], - tasks: [{ id: "t1", primarySessionId: "sess-old", workflowId: "wf1" }], + tasks: [existingTask], } as unknown as AppState["kanban"], - environmentIdBySessionId: {}, + kanbanMulti: { + isLoading: false, + snapshots: { + wf1: { workflowId: "wf1", workflowName: "WF1", steps: [], tasks: [existingTask] }, + }, + } as unknown as AppState["kanbanMulti"], }); - const handlers = registerTasksHandlers(store); - handlers["task.deleted"]!( - makeDeletedMessage({ - task_id: "t1", - workflow_id: "wf1", + registerTasksHandlers(store)["task.updated"]!( + makeMessage({ + ...makeTask("t1", "session-1"), + title: "Renamed task", }), ); - expect(removeRecentTask).toHaveBeenCalledTimes(1); - expect(removeRecentTask).toHaveBeenCalledWith("t1"); - }); - - it("clears deleted task session state", () => { - const store = makeStore({ - kanban: { - workflowId: "wf1", - steps: [], - tasks: [{ id: "t1", primarySessionId: SESS_PINNED, workflowId: "wf1" }], - } as unknown as AppState["kanban"], - tasks: { - activeTaskId: "t1", - activeSessionId: SESS_PINNED, - pinnedSessionId: SESS_PINNED, - lastSessionByTaskId: { t1: SESS_PINNED, t2: SESS_OTHER }, - }, - environmentIdBySessionId: {}, - }); - - const handlers = registerTasksHandlers(store); - handlers["task.deleted"]!(makeDeletedMessage({ task_id: "t1", workflow_id: "wf1" })); - const state = store.getState(); - expect(state.tasks.pinnedSessionId).toBeNull(); - expect(state.tasks.lastSessionByTaskId).not.toHaveProperty("t1"); - expect(state.tasks.lastSessionByTaskId).toHaveProperty("t2", SESS_OTHER); + const kanbanTask = state.kanban.tasks.find((task) => task.id === "t1"); + const snapshotTask = state.kanbanMulti.snapshots.wf1.tasks.find((task) => task.id === "t1"); + expect(kanbanTask).toMatchObject(executorMetadata); + expect(snapshotTask).toMatchObject(executorMetadata); }); -}); -describe("task.deleted live notification + redirect", () => { - it("sets a task-deleted notification (with title + reason) when the focused task is deleted", () => { - const store = makeActiveStore(); - const handlers = registerTasksHandlers(store); + it("clears executor metadata when the primary session is explicitly cleared", () => { + const existingTask = { + id: "t1", + workflowStepId: "step1", + title: "Task", + position: 0, + primarySessionId: "session-1", + ...executorMetadata, + }; + const store = makeStore({ + kanban: { + workflowId: "wf1", + steps: [], + tasks: [existingTask], + } as unknown as AppState["kanban"], + }); - handlers["task.deleted"]!( - makeDeletedMessage({ - task_id: "t1", - workflow_id: "wf1", - title: REVIEW_TITLE, - reason: "pr_approved_by_user", - }), - ); + registerTasksHandlers(store)["task.updated"]!(makeMessage(makeTask("t1", null))); - expect(store.getState().setTaskDeletedNotification).toHaveBeenCalledWith({ - taskId: "t1", - title: REVIEW_TITLE, - reason: "pr_approved_by_user", + expect(store.getState().kanban.tasks[0]).toMatchObject({ + primarySessionId: undefined, + primaryExecutorId: undefined, + primaryExecutorType: undefined, + primaryExecutorName: undefined, + isRemoteExecutor: false, }); }); - - it("does not notify when a non-focused task is deleted", () => { - const store = makeStore({ - kanban: { workflowId: "wf1", steps: [], tasks: [{ id: "t1", workflowId: "wf1" }] }, - tasks: { - activeTaskId: "t2", - activeSessionId: null, - pinnedSessionId: null, - lastSessionByTaskId: {}, - }, - environmentIdBySessionId: {}, - } as unknown as Partial); - const handlers = registerTasksHandlers(store); - - handlers["task.deleted"]!(makeDeletedMessage({ task_id: "t1", workflow_id: "wf1" })); - - expect(store.getState().setTaskDeletedNotification).not.toHaveBeenCalled(); - }); - - it("does not redirect or notify for a user-initiated delete (no reason) even on the task route", () => { - window.history.replaceState({}, "", "/t/t1"); - const store = makeActiveStore(); - const handlers = registerTasksHandlers(store); - - handlers["task.deleted"]!( - makeDeletedMessage({ task_id: "t1", workflow_id: "wf1", title: REVIEW_TITLE }), - ); - - // The local delete flow owns navigation for user-initiated deletes; the WS - // handler must not preempt it by redirecting. - expect(window.location.pathname).toBe("/t/t1"); - expect(store.getState().setTaskDeletedNotification).not.toHaveBeenCalled(); - }); - - // Covers both the canonical `/t/:id` and compatibility `/tasks/:id` routes, - // and the not-yet-hydrated case (activeTaskId still null) via makeInactiveStore. - it.each(["/t/t1", "/tasks/t1"])( - "redirects home and notifies when parked on %s before activeTaskId hydrates", - (path) => { - window.history.replaceState({}, "", path); - const store = makeInactiveStore(); - const handlers = registerTasksHandlers(store); - - handlers["task.deleted"]!( - makeDeletedMessage({ - task_id: "t1", - workflow_id: "wf1", - title: REVIEW_TITLE, - reason: "pr_approved_by_user", - }), - ); - - expect(window.location.pathname).toBe("/"); - expect(store.getState().setTaskDeletedNotification).toHaveBeenCalledWith({ - taskId: "t1", - title: REVIEW_TITLE, - reason: "pr_approved_by_user", - }); - }, - ); - - it("does not redirect an auto-deletion when viewing a different route", () => { - window.history.replaceState({}, "", "/t/other"); - const store = makeActiveStore(); - const handlers = registerTasksHandlers(store); - - handlers["task.deleted"]!( - makeDeletedMessage({ - task_id: "t1", - workflow_id: "wf1", - title: REVIEW_TITLE, - reason: "pr_approved_by_user", - }), - ); - - expect(window.location.pathname).toBe("/t/other"); - }); }); diff --git a/apps/web/lib/ws/handlers/tasks.ts b/apps/web/lib/ws/handlers/tasks.ts index 6f62ebc693..4801b32db2 100644 --- a/apps/web/lib/ws/handlers/tasks.ts +++ b/apps/web/lib/ws/handlers/tasks.ts @@ -23,6 +23,28 @@ function hasPayloadField(payload: TaskEventPayload, field: keyof TaskEventPayloa return Object.prototype.hasOwnProperty.call(payload, field); } +function preservePrimaryExecutorFields( + existing: KanbanTask, + merged: KanbanTask, + payload: TaskEventPayload, +): void { + const primarySessionCleared = + hasPayloadField(payload, "primary_session_id") && payload.primary_session_id === null; + if (primarySessionCleared) return; + if (!hasPayloadField(payload, "primary_executor_id")) { + merged.primaryExecutorId = existing.primaryExecutorId; + } + if (!hasPayloadField(payload, "primary_executor_type")) { + merged.primaryExecutorType = existing.primaryExecutorType; + } + if (!hasPayloadField(payload, "primary_executor_name")) { + merged.primaryExecutorName = existing.primaryExecutorName; + } + if (!hasPayloadField(payload, "is_remote_executor")) { + merged.isRemoteExecutor = existing.isRemoteExecutor; + } +} + function mergeTaskUpdate( existing: KanbanTask | undefined, nextTask: KanbanTask, @@ -48,6 +70,7 @@ function mergeTaskUpdate( ) { merged.primarySessionPendingAction = existing.primarySessionPendingAction; } + preservePrimaryExecutorFields(existing, merged, payload); if ( !hasPayloadField(payload, "task_pending_action") && nextTask.taskPendingAction === undefined diff --git a/apps/web/lib/ws/handlers/turns.test.ts b/apps/web/lib/ws/handlers/turns.test.ts new file mode 100644 index 0000000000..4c0393cada --- /dev/null +++ b/apps/web/lib/ws/handlers/turns.test.ts @@ -0,0 +1,83 @@ +import { describe, expect, it } from "vitest"; +import { create } from "zustand"; +import { immer } from "zustand/middleware/immer"; +import { createSessionSlice } from "@/lib/state/slices/session/session-slice"; +import type { SessionSlice } from "@/lib/state/slices/session/types"; +import type { TurnEventPayload } from "@/lib/types/backend"; +import { registerTurnsHandlers } from "./turns"; + +const SESSION_ID = "session-1"; +const TASK_ID = "task-1"; +const TURN_STARTED = "session.turn.started"; +const TURN_COMPLETED = "session.turn.completed"; +const NOTIFICATION = "notification"; +const TURN_STARTED_AT = "2026-07-23T10:00:00.000Z"; + +function makeStore() { + return create()( + immer((set) => ({ + // eslint-disable-next-line @typescript-eslint/no-explicit-any + ...(createSessionSlice as any)(set), + quickChat: { sessions: [] }, + availableCommands: { bySessionId: {} }, + })), + ); +} + +function turn(id: string, startedAt: string, completedAt?: string): TurnEventPayload { + return { + id, + session_id: SESSION_ID, + task_id: TASK_ID, + started_at: startedAt, + completed_at: completedAt, + created_at: startedAt, + updated_at: completedAt ?? startedAt, + }; +} + +function send( + store: ReturnType, + action: typeof TURN_STARTED | typeof TURN_COMPLETED, + payload: TurnEventPayload, +) { + const handler = registerTurnsHandlers(store as never)[action]!; + handler({ type: NOTIFICATION, action, payload } as never); +} + +describe("session turn WebSocket handlers", () => { + it("keeps a completed turn inactive when its delayed started event arrives", () => { + const store = makeStore(); + const completed = "2026-07-23T10:01:00.000Z"; + + send(store, TURN_COMPLETED, turn("turn-1", TURN_STARTED_AT, completed)); + send(store, TURN_STARTED, turn("turn-1", TURN_STARTED_AT)); + + expect(store.getState().turns.activeBySession[SESSION_ID]).toBeFalsy(); + expect(store.getState().turns.bySession[SESSION_ID]).toEqual([ + expect.objectContaining({ id: "turn-1", completed_at: completed }), + ]); + }); + + it("does not clear a newer active turn when an older turn completes", () => { + const store = makeStore(); + + send(store, TURN_STARTED, turn("turn-a", TURN_STARTED_AT)); + send(store, TURN_STARTED, turn("turn-b", "2026-07-23T10:01:00.000Z")); + send(store, TURN_COMPLETED, turn("turn-a", TURN_STARTED_AT, "2026-07-23T10:02:00.000Z")); + + expect(store.getState().turns.activeBySession[SESSION_ID]).toBe("turn-b"); + }); + + it("tracks a normal turn from start through completion", () => { + const store = makeStore(); + const completed = "2026-07-23T10:01:00.000Z"; + + send(store, TURN_STARTED, turn("turn-1", TURN_STARTED_AT)); + expect(store.getState().turns.activeBySession[SESSION_ID]).toBe("turn-1"); + + send(store, TURN_COMPLETED, turn("turn-1", TURN_STARTED_AT, completed)); + expect(store.getState().turns.activeBySession[SESSION_ID]).toBeNull(); + expect(store.getState().turns.bySession[SESSION_ID][0].completed_at).toBe(completed); + }); +}); diff --git a/apps/web/lib/ws/handlers/turns.ts b/apps/web/lib/ws/handlers/turns.ts index 1c5bc5b254..eb19be3a38 100644 --- a/apps/web/lib/ws/handlers/turns.ts +++ b/apps/web/lib/ws/handlers/turns.ts @@ -7,6 +7,22 @@ import { maybeEmitEmptyTurnNotice } from "@/lib/ws/handlers/empty-turn-notice"; const debug = createDebugLogger("session:turns"); +function completePendingToolCalls(store: StoreApi, sessionId: string): void { + const messages = store.getState().messages.bySession[sessionId]; + if (!messages) return; + + for (const message of messages) { + if (message.type === "permission_request") continue; + const metadata = message.metadata as Record | undefined; + if (metadata?.tool_call_id && metadata.status !== "complete" && metadata.status !== "error") { + store.getState().updateMessage({ + ...message, + metadata: { ...metadata, status: "complete" }, + }); + } + } +} + export function registerTurnsHandlers(store: StoreApi): WsHandlers { return { "session.turn.started": (message) => { @@ -43,6 +59,16 @@ export function registerTurnsHandlers(store: StoreApi): WsHandlers { turnId: payload.id, completedAt: payload.completed_at ?? "-", }); + store.getState().addTurn({ + id: payload.id, + session_id: sessionId(payload.session_id), + task_id: taskId(payload.task_id), + started_at: payload.started_at, + completed_at: payload.completed_at || new Date().toISOString(), + metadata: payload.metadata, + created_at: payload.created_at, + updated_at: payload.updated_at, + }); store .getState() .completeTurn( @@ -53,8 +79,6 @@ export function registerTurnsHandlers(store: StoreApi): WsHandlers { ); // Surface a notice when the turn finished with no agent output. maybeEmitEmptyTurnNotice(store, payload); - // Clear the active turn when it completes - store.getState().setActiveTurn(payload.session_id, null); // Safety net: mark any tool calls still in a non-terminal state as "complete". // This handles edge cases where tool_update events were dropped or not processed. @@ -62,19 +86,7 @@ export function registerTurnsHandlers(store: StoreApi): WsHandlers { // `status` represents the user's approve/reject decision — not the tool call // state — so they must be excluded from the sweep. Forcing them to "complete" // wipes "approved"/"rejected" and re-shows the prompt buttons in the UI. - const messages = store.getState().messages.bySession[payload.session_id]; - if (messages) { - for (const msg of messages) { - if (msg.type === "permission_request") continue; - const meta = msg.metadata as Record | undefined; - if (meta?.tool_call_id && meta?.status !== "complete" && meta?.status !== "error") { - store.getState().updateMessage({ - ...msg, - metadata: { ...meta, status: "complete" }, - }); - } - } - } + completePendingToolCalls(store, payload.session_id); }, }; } diff --git a/docs/decisions/2026-07-22-runtime-mutable-task-workspace-sources.md b/docs/decisions/2026-07-22-runtime-mutable-task-workspace-sources.md new file mode 100644 index 0000000000..92c88155c2 --- /dev/null +++ b/docs/decisions/2026-07-22-runtime-mutable-task-workspace-sources.md @@ -0,0 +1,74 @@ +# ADR-2026-07-22-runtime-mutable-task-workspace-sources: Runtime-Mutable Task Workspace Sources + +**Status:** accepted +**Date:** 2026-07-22 +**Area:** backend, frontend, protocol + +## Context + +Task creation can select multiple repositories, but the UI restricts that layout to the worktree +executor. After launch, only the task-mode `add_branch_to_task_kandev` MCP path can append a source, +and it is both Git-only and worktree-only. Repository-less tasks may start in one arbitrary host +folder, but that single `workspace_path` is not a durable mixed-source model. Exposing the existing +MCP call in the Files panel would therefore report success only for a subset of tasks and could not +represent arbitrary folders. + +## Decision + +Task workspace sources are durable task-scoped attachments, while executor filesystems remain +materialized views of those attachments. + +Existing repository identity and Git behavior stay in `task_repositories`. Arbitrary folders use a +separate `task_workspace_folders` relation rather than migrating every repository consumer to a new +polymorphic table. Services expose a combined source projection when callers need the complete +workspace. + +A batch `AttachWorkspaceSources` service becomes the single mutation boundary for HTTP, UI, and +MCP callers. It validates the entire batch, persists attachments transactionally, materializes them +through an executor-capability interface, asks agentctl to adopt and rescan the effective workspace +root, and publishes events only after success. Failure rolls back newly created durable records and +owned filesystem entries without touching pre-existing sources. The legacy +`add_branch_to_task_kandev` tool becomes a compatibility wrapper over a one-item batch. + +Executor adapters own placement and transport: + +- local and worktree environments use live references to explicitly trusted host folders and may + re-root an idle execution into a Kandev-owned task root; +- Docker, SSH, Sprites, and future remote runtimes materialize repositories in their own workspace + but do not accept arbitrary host-folder attachments; +- agentctl owns workspace-root adoption, repository tracker reconciliation, and the file-tree + refresh boundary common to every runtime. + +Source attachment is rejected while a turn or tool call is active. An idle environment may be +restarted when its process working directory cannot be changed safely in place. Provider +credentials stay behind the existing backend/executor clone boundary and never enter persisted +URLs or agent-visible source metadata. + +## Consequences + +- The Files panel, MCP tools, and future automation use one source-attachment contract instead of + reimplementing worktree-specific behavior. +- Existing repository, diff, PR, and branch consumers keep their current relational model. +- Arbitrary folders can coexist with Git sources without pretending to be repositories. +- Runtime backends must implement explicit source materialization and rollback capabilities; + previously implicit single-repository assumptions become testable interfaces. +- Folder attachment remains a host-executor capability. Remote/container callers receive an + explicit unsupported-source error instead of a partial copy with different persistence semantics. +- Idle-only mutation avoids changing a process working directory during an active command, at the + cost of making the UI wait for the current turn to finish. + +## Alternatives Considered + +1. **Expose `add_branch_to_task_kandev` directly in the Files panel.** Rejected because it is + worktree-only, Git-only, single-item, and cannot support the requested executor or folder scope. +2. **Replace `task_repositories` with one polymorphic source table.** Rejected because it forces a + high-risk migration across mature Git, PR, diff, and session-worktree code for no user-visible + benefit. +3. **Treat arbitrary folders as repositories.** Rejected because non-Git folders have no branch, + diff, PR, or repository identity and would leak invalid assumptions into existing consumers. +4. **Copy host folders into remote runtimes as snapshots.** Rejected because a detached copy is not + equivalent to adding the selected live folder and introduces size, retention, and stale-data + expectations. +5. **Continuously synchronize host folders with remote runtimes.** Rejected because conflict + resolution, deletion propagation, watching, and credential boundaries form a separate product + and reliability problem. diff --git a/docs/decisions/2026-07-23-workspace-source-root-move-boundary.md b/docs/decisions/2026-07-23-workspace-source-root-move-boundary.md new file mode 100644 index 0000000000..ee25f42990 --- /dev/null +++ b/docs/decisions/2026-07-23-workspace-source-root-move-boundary.md @@ -0,0 +1,32 @@ +# ADR-2026-07-23-workspace-source-root-move-boundary: Workspace Source Root Move Boundary + +**Status:** accepted +**Date:** 2026-07-23 +**Area:** backend + +## Context + +Workspace-source materialization performs filesystem mutations for sources rooted at canonical +workspace paths. Go's `os.Root` can safely anchor operations within one such root, but it does not +provide a portable atomic, no-replace publication operation across two roots. Treating a cross-root +move as a copy followed by deletion could race with concurrent changes, overwrite a destination, or +lose data when either phase fails. + +## Decision + +File mutations are descriptor-rooted: each operation uses one canonical workspace/source root as +its filesystem authority. Rename is allowed only when both paths resolve under that same root. +Cross-root moves and renames are rejected explicitly before mutation. + +## Consequences + +Source materializers have one clear authority boundary and do not emulate a move with an unsafe +copy/delete sequence. A future cross-root transfer requires an explicit copy workflow or a +cross-platform no-replace primitive; it must supersede this ADR before being introduced. + +## Alternatives Considered + +1. **Copy then delete.** Rejected because it is not atomic and can race, overwrite an existing + destination, or lose data on partial failure. +2. **Platform-specific rename or publication syscalls.** Rejected for now because their semantics + and availability do not supply a portable cross-root, atomic no-replace contract. diff --git a/docs/decisions/INDEX.md b/docs/decisions/INDEX.md index 71bdc80652..3b145b83bf 100644 --- a/docs/decisions/INDEX.md +++ b/docs/decisions/INDEX.md @@ -60,6 +60,7 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-07-23-opencode-review-evidence-trust | [Trusted OpenCode Review Evidence](2026-07-23-opencode-review-evidence-trust.md) | accepted | workflow, infra | 2026-07-23 | | 2026-07-23-planner-direct-small-work | [Planner Direct Small Work](2026-07-23-planner-direct-small-work.md) | accepted | workflow | 2026-07-23 | | 2026-07-23-post-commit-hook-aware-verification | [Post-Commit Hook-Aware Verification](2026-07-23-post-commit-hook-aware-verification.md) | accepted | workflow | 2026-07-23 | +| 2026-07-23-workspace-source-root-move-boundary | [Workspace Source Root Move Boundary](2026-07-23-workspace-source-root-move-boundary.md) | accepted | backend | 2026-07-23 | | 2026-07-24-operator-owned-agent-launcher-settings | [Operator-Owned Agent Launcher Settings](2026-07-24-operator-owned-agent-launcher-settings.md) | accepted | backend, frontend, protocol | 2026-07-24 | | 2026-07-18-turn-configuration-snapshots | [Attribute runtime configuration to turns](2026-07-18-turn-configuration-snapshots.md) | accepted | backend, frontend | 2026-07-18 | | 2026-07-22-gpg-signed-release-tags | [GPG-signed release tags](2026-07-22-gpg-signed-release-tags.md) | accepted | infra, workflow | 2026-07-22 | @@ -70,5 +71,6 @@ Read individual ADRs for full context. Create new ones via `/record decision` or | 2026-07-20-repository-provider-origin-identity | [Persist Provider Origin In Repository Identity](2026-07-20-repository-provider-origin-identity.md) | accepted | backend, frontend | 2026-07-20 | | 2026-07-21-portable-status-bar-order | [Portable Status Bar Order](2026-07-21-portable-status-bar-order.md) | accepted | backend, frontend, protocol | 2026-07-21 | | 2026-07-21-work-item-reference-search | [Backend-normalized work-item references](2026-07-21-work-item-reference-search.md) | accepted | backend, frontend, protocol | 2026-07-21 | +| 2026-07-22-runtime-mutable-task-workspace-sources | [Runtime-Mutable Task Workspace Sources](2026-07-22-runtime-mutable-task-workspace-sources.md) | accepted | backend, frontend, protocol | 2026-07-22 | | 2026-07-24-opt-in-authentication | [Opt-in Authentication and Per-User Workspace Scoping](2026-07-24-opt-in-authentication.md) | accepted | backend, frontend, protocol, security | 2026-07-24 | | 2026-07-24-semantic-notification-events | [Semantic notification events come from domain occurrences](2026-07-24-semantic-notification-events.md) | accepted | backend, frontend, desktop | 2026-07-24 | diff --git a/docs/plans/attach-workspace-sources/plan.md b/docs/plans/attach-workspace-sources/plan.md new file mode 100644 index 0000000000..1944c83297 --- /dev/null +++ b/docs/plans/attach-workspace-sources/plan.md @@ -0,0 +1,295 @@ +--- +spec: docs/specs/tasks/attach-workspace-sources.md +created: 2026-07-22 +status: completed +--- + +# Implementation Plan: Attach Workspace Sources + +## Overview + +Add a durable task-level source attachment contract above the current worktree-only branch flow, +then teach host and remote runtimes to materialize the same source set. Reuse the task-create +repository controls inside a dedicated responsive Files-panel picker, publish live source/root +updates through the existing task/session data flow, and finish with desktop, mobile, Docker, and +SSH coverage. The plan preserves `task_repositories` for Git behavior and introduces a separate +folder attachment relation, as required by +[ADR-2026-07-22-runtime-mutable-task-workspace-sources](../../decisions/2026-07-22-runtime-mutable-task-workspace-sources.md). +The active UX delta extracts the task-create source-mode presentation into a controlled shared +selector, presents **Local** and **Remote** modes inside Add sources, and combines **Add sources** +with **Open workspace folder** in one Files-toolbar action menu without changing the durable source +union or materialization contract. + +--- + +## Backend + +### Persistence and source projection + +- Add `TaskWorkspaceFolder` to `apps/backend/internal/task/models/models.go` with task ownership, + canonical host path, collision-safe display name, global source position, and timestamps. +- Add `task_workspace_folders` to both the base schema and replayable migrations in + `apps/backend/internal/task/repository/sqlite/base_schema.go` and `base_migrations.go`; enforce + task/path and task/display-name uniqueness, cascade deletion, and task/position lookup indexes. +- Extend `apps/backend/internal/task/repository/interface.go` and add a focused SQLite repository + file for folder CRUD plus a transactional batch that creates repository/folder attachments and a + compensating batch that removes only rows created by the failed operation. +- Extend `Task`, `TaskDTO`, `pkg/api/v1.Task`, boot/snapshot queries, and `task.updated` serialization + with `workspace_folders`. Build a combined ordered source projection without changing existing + repository DTOs or Git consumers. +- Allocate positions under the existing per-task mutation lock using the maximum across repository + and folder rows. Reject runtime-name collisions across both tables after applying the same + directory-name sanitizer used for multi-repository worktrees. + +### Attachment service + +- Add `apps/backend/internal/task/service/service_workspace_sources.go` with + `AttachWorkspaceSources(ctx, request)` and typed repository/folder input/result shapes. +- Validate the whole batch before writes: task exists and is repository-backed; workspace ownership; + exactly one repository locator; provider-neutral URL and identity consistency; canonical explicit + local Git paths; canonical arbitrary directories; branches; duplicates; display-name collisions; + and idle session/turn state. +- Resolve or create repository entities through the existing `ResolveRepositoryRef` path so local + trust and remote credential rules stay authoritative. Track newly created repository entities for + rollback without deleting pre-existing workspace repositories. +- Persist the batch, call the runtime materializer synchronously, and compensate database plus owned + filesystem/runtime entries on failure with a detached bounded context. Publish `task.updated` only + after both persistence and materialization succeed. +- Refactor `AddBranchToTask` into a compatibility adapter over a one-item repository-source request; + retain its existing auto-branch naming, duplicate, and slug-collision behavior. + +### HTTP, WebSocket, and MCP contracts + +- Register `POST /api/v1/tasks/:id/workspace-sources` in + `apps/backend/internal/task/handlers/task_handlers.go`; define the discriminated source request in + `task_http_handlers.go`, map typed service errors to `400`/`404`/`409`/`422`, and return the durable + source projection, effective workspace path, and affected sessions. +- Add `workspace.sources.updated` / `session.workspace_sources_updated` event constants and gateway + routing in `internal/events/types.go`, `pkg/websocket/actions.go`, and + `internal/gateway/websocket/task_notifications.go`. Emit only after agentctl has adopted the root. +- Register `add_workspace_sources_kandev` in `internal/mcp/server/server.go` and its backend handler + in `internal/mcp/handlers/handlers.go`; default `task_id` to the current task and accept the same + batch union as HTTP. Keep `add_branch_to_task_kandev` externally compatible while routing it + through the generalized service. + +### Host materialization + +- Replace the narrow `BranchMaterializer` hook in `internal/task/service/service.go` with a + `WorkspaceSourceMaterializer` batch interface, retaining a thin compatibility adapter for tests + and existing call sites. +- Refactor `internal/backendapp/branch_materializer.go` into host source materialization that reuses + `worktree.Manager` for repository branches and creates a Kandev-owned task root for mixed local + sources. Link live local sources into that root with platform-native directory links (Unix + symlinks, Windows directory junctions), while validating the canonical target before and after + link creation. +- Never copy, move, delete, or write marker files into user-owned local sources. Rollback and task + cleanup remove only Kandev-owned worktrees, links, and task roots. +- Extend lifecycle workspace rebinding so an idle Local execution can restart/rebind at the mixed + source root without losing its task conversation; Worktree executions keep their existing live + sibling materialization path. Persist the promoted `task_environments.workspace_path` for future + sessions and resets. +- Generalize agentctl rescan from repository-only reconciliation to workspace-source reconciliation: + update the workdir, rebuild repository trackers for Git entries, refresh file-tree scope for plain + folders, and fail the attachment if the running agentctl cannot adopt the new root. + +### Container and remote materialization + +- Add authenticated agentctl endpoints and client methods for validated repository clone/checkout + materialization under the current workspace root. +- Route local Docker, SSH, and Sprites attachments through their live `AgentExecution` agentctl + client. Reuse existing executor credential resolution for repository clones; do not persist or log + tokens or credentialed URLs. +- Reject folder inputs for Docker, SSH, Sprites, and remote Docker during service capability + validation, before persistence or executor filesystem access. The frontend uses the same executor + capability to omit the folder source kind. +- Make subsequent launch/reset preparation derive every repository from the durable source + projection. Remove the task-create frontend guard that forces multi-repository work to Worktree + only after the corresponding runtime launch paths support repository projection. +- Add the same materializer capability to the remote-Docker interface and tests; the executor remains + unavailable until its existing `CreateInstance` implementation is completed. + +--- + +## Frontend + +### Shared source picker + +- Extract reusable repository selection leaves from + `task-create-dialog-workspace-repo-chips.tsx`, `task-create-dialog-remote-repo-chip.tsx`, + `task-create-dialog-remote-repo-chips.tsx`, and `folder-picker.tsx` into + `components/workspace-source-picker/` without changing the task-create dialog behavior. +- Keep `task-create-dialog-source-mode.tsx` scoped to task creation. Add sources does not reuse its + segmented mode switch: it presents one **Add repository** menu whose choices append workspace, + local-Git, or remote rows without changing the visibility of configured rows. +- Add typed mixed rows (`repository`, `remote_repository`, `folder`) and a + `useWorkspaceSourcePickerState` hook. **Workspace repository** reuses the combined + saved/discovered repository selector; **Local Git repository** exposes the local path affordance; + **Remote repository** reuses provider-backed and pasted-URL controls. **Add folder** remains + adjacent when supported. Appending rows never removes configured rows, so one submission can mix + source kinds on Local/Worktree. Executor capability removes the folder affordance on + container/remote tasks. +- Decouple reusable remote-row presentation from create-task-only `DialogFormState`; keep branch, + provider metadata, discovery, validation, and payload conversion owned by the consuming dialog. +- Add `attachTaskWorkspaceSources` to `lib/api/domains/kanban-api.ts`, source types to + `lib/types/http.ts` and the kanban slice, and unit-tested payload/error normalization. +- Once backend runtime support lands, delete the non-Worktree multi-repository disable resolver and + auto-switch effect in `task-create-dialog-computed.ts` and + `task-create-dialog-multi-repo-guard.ts`; keep the no-repository Worktree restriction. + +### Files panel surface and live state + +- Replace the separate **Add sources** and **Open workspace folder** toolbar controls with one + labeled/icon **Workspace actions** trigger in `file-browser-toolbar.tsx`, threaded through + `file-browser.tsx`, `files-panel.tsx`, `task-files-panel.tsx`, and their hooks. Its dropdown menu + contains both actions. Keep the trigger enabled whenever opening the workspace folder is valid; + disable only the **Add sources** row for archived/non-repository tasks, active turns/tool calls, + or loading source state, and render the reason as visible menu copy. +- Build `components/task/add-workspace-sources/` around the shared picker. Desktop uses a Dialog; + phone uses a full-height Drawer with fixed header/footer, `min-h-0` internal scrolling, + `100dvh` sizing, safe-area bottom padding, and a single submit action. Preserve keyboard focus, + Enter behavior, dismissal, pending state, per-source errors, retryable batch contents, and rows + configured from every repository or folder kind. +- Coordinate Radix menu close autofocus before opening the controlled Dialog/Drawer so dismissal + returns focus to the workspace-actions trigger. On phones, use the existing responsive + `DropdownMenu` bottom-sheet treatment rather than introducing a second action-menu drawer. +- Subscribe to `task.updated` for repository/folder state and + `session.workspace_sources_updated` for the adopted workspace root. Update the kanban/session + slices, reset the file-tree cache for affected environment/session IDs, and let existing + repository/worktree handlers refresh Changes and editor pickers. +- Keep the new phone entry point visible and touch-usable (at least 44px active dimension) without + hiding any source kind supported by the task's executor. The closest shipped mobile exemplar is + `components/task/mobile/mobile-picker-sheet.tsx`; reuse its focused picker hierarchy while using a + full-height surface because mixed rows, branch search, validation, and batch errors can be deep. + +### Mobile design contract + +- **Desktop outcome:** one compact labeled/icon Files-toolbar trigger opens a dropdown with **Add + sources** and **Open workspace folder**. Selecting Add sources opens the multi-row dialog and + refreshes the workspace tree after one atomic submission. +- **Mobile entry point:** the same workspace-actions trigger is visible in the Files toolbar with an + actual 44px touch target. The shared responsive DropdownMenu becomes a short inset bottom-sheet + action menu; it does not depend on hover, context click, or desktop Dockview chrome. +- **Hierarchy and primary action:** the action menu chooses between workspace actions. The Add + sources surface then exposes one **Add repository** control and, when supported, **Add folder**. + Repository location is a temporary menu choice; configured rows and validation own the single + scroll body. Cancel and **Add to workspace** stay fixed in the footer, with submit as the sole + primary action and disabled until a row exists. +- **Presentation rationale:** the two workspace actions are a short temporary choice, so they fit + the shared inset menu. Source attachment can contain several searchable, branch-aware rows, so it + continues into a full-height Drawer rather than stacking the full form inside that menu. +- **Shared versus responsive:** source state, validation, payload construction, picker leaves, and + API calls are shared. Add sources owns its tab-free source-kind menu. Only Dialog/Drawer + composition, responsive menu treatment, and toolbar hitbox treatment differ. +- **Geometry:** one internal vertical scroll owner, dynamic viewport height, safe-area clearance, + 44px phone triggers/menu rows, contained repository popovers/drawers, and no document horizontal + overflow. + +--- + +## Tests + +- **What:** folder persistence, canonical uniqueness, cross-table display-name/position allocation, + cascade deletion, and SQLite/Postgres replay safety. + **File:** `internal/task/repository/*workspace_source*_test.go` and + `internal/task/repository/sqlite/*workspace_folder*_test.go`. + **How:** real database tests, including migration replay. +- **What:** valid mixed batches, repository resolution, idle gate, duplicate/cross-workspace/path + failures, partial materialization rollback, client cancellation, and legacy add-branch parity. + **File:** `internal/task/service/service_workspace_sources_test.go` and existing branch tests. + **How:** table-driven service tests with real SQLite plus fake materializer failure injection. +- **What:** HTTP status mapping, MCP current-task defaulting and batch forwarding, event truthfulness, + and compatibility tool behavior. + **File:** task handler, MCP handler/server, gateway, and service event tests beside the source. + **How:** HTTP/WS integration tests and MCP captured-payload tests. +- **What:** host root promotion, link safety, Windows/Unix path behavior, idle restart/rebind, + agentctl source rescan, rollback, and relaunch reconstruction. + **File:** `internal/backendapp/workspace_source_materializer_test.go`, lifecycle tests, and agentctl + process/API tests. + **How:** temporary Git repositories/folders plus fake lifecycle clients; platform-specific tests + where link semantics differ. +- **What:** Docker/SSH/Sprites repository clone transport, unsupported-folder rejection, credential + redaction, cancellation, cleanup, and reset reconstruction. + **File:** lifecycle executor tests and agentctl workspace-source API tests. + **How:** table-driven unit/integration tests with fake agentctl plus existing container harnesses. +- **What:** mixed picker state, payload construction, duplicate errors, disabled busy state, + task/session event merges, and file-tree cache reset. + **File:** focused `*.test.ts(x)` files beside the new picker, API helper, toolbar, and WS handlers. + **How:** Vitest and Testing Library. +- **What:** task creation keeps its existing mode presentation while Add sources renders no + Local/Remote tabs, appends mixed rows from one repository menu, and provides 44px mobile choices. + **File:** `task-create-dialog-repo-chips.test.tsx`, + `add-workspace-sources-dialog.test.tsx`, and a focused shared-mode component test. + **How:** Testing Library controlled-state and responsive-geometry assertions. +- **What:** one workspace-actions trigger exposes both actions, keeps Open workspace folder enabled + when Add sources is disabled, shows the disabled reason, and hands focus to/from the source + surface. + **File:** a focused test beside `file-browser-toolbar.tsx`. + **How:** Testing Library menu interaction and callback assertions. + +## E2E Tests + +- **Scenario:** GIVEN an idle one-repository worktree task, WHEN a desktop user adds a second local + repository and a plain folder through **Workspace actions → Add sources**, THEN both + top-level entries appear and only the repository appears in Changes. + **File:** `apps/web/e2e/tests/task/add-workspace-sources.spec.ts`. + **What to verify:** both menu actions, open-folder request routing, the tab-free repository-kind + menu, mixed-row preservation, dialog flow, atomic success, Files refresh, + repository-aware UI, and persistence after reload. +- **Scenario:** GIVEN the Files tab on a Pixel 5 viewport, WHEN the user opens the full-height source + picker through the workspace-actions bottom-sheet menu and attaches two source kinds through the + responsive repository menu, THEN the same task value is delivered without horizontal overflow. + **File:** `apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts`. + **What to verify:** 44px trigger/menu rows, both actions, open-folder request routing, + internal scroll, fixed safe-area footer, submit outcome, dismissal/focus return, and document + containment. +- **Scenario:** GIVEN a Docker task, WHEN a remote repository is attached, THEN it is readable inside + the running container; the folder source kind is absent and a forged folder request is rejected. + **File:** `apps/web/e2e/tests/docker/add-workspace-sources.spec.ts`. + **What to verify:** live agentctl repository materialization, capability gating, rollback, and + reset/relaunch. +- **Scenario:** GIVEN an SSH task, WHEN a repository source is attached, THEN it appears in the + remote task directory and survives backend reconnect. + **File:** `apps/web/e2e/tests/ssh/add-workspace-sources.spec.ts`. + **What to verify:** remote placement, rescan, persistence, and cleanup ownership. + +--- + +## Implementation Waves + +Wave 1: + +- [x] [Task 01: Durable source contracts](task-01-durable-source-contracts.md) + +Wave 2: + +- [x] [Task 02: Attachment service](task-02-attachment-service.md) + +Wave 3 (parallel): + +- [x] [Task 03: Protocol surfaces](task-03-protocol-surfaces.md) +- [x] [Task 04: Host materialization](task-04-host-materialization.md) +- [x] [Task 06: Shared source picker](task-06-shared-source-picker.md) + +Wave 4 (parallel after dependencies): + +- [x] [Task 05: Remote materialization](task-05-remote-materialization.md) +- [x] [Task 07: Files panel surface](task-07-files-panel-surface.md) + +Wave 5 (parallel): + +- [x] [Task 08: End-to-end coverage](task-08-end-to-end-coverage.md) +- [x] [Task 09: Public documentation](task-09-public-documentation.md) + +Wave 6: + +- [x] [Task 10: Final verification](task-10-final-verification.md) + +Wave 7 (parallel UX delta): + +- [x] [Task 11: Shared Local and Remote selector](task-11-shared-local-remote-selector.md) +- [x] [Task 12: Unified Files workspace actions](task-12-unified-files-workspace-actions.md) + +Wave 8 (parallel after Tasks 11 and 12): + +- [x] [Task 13: UX delta end-to-end coverage](task-13-ux-delta-e2e.md) +- [x] [Task 14: UX terminology documentation](task-14-ux-terminology-documentation.md) diff --git a/docs/plans/attach-workspace-sources/task-01-durable-source-contracts.md b/docs/plans/attach-workspace-sources/task-01-durable-source-contracts.md new file mode 100644 index 0000000000..45741cb304 --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-01-durable-source-contracts.md @@ -0,0 +1,53 @@ +--- +id: "01-durable-source-contracts" +title: "Durable source contracts" +status: done +wave: 1 +depends_on: [] +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 01: Durable Source Contracts + +## Acceptance + +- `task_workspace_folders` persists canonical task-owned folder attachments with replay-safe schema, + uniqueness, cascade deletion, and indexed ordering in SQLite and Postgres-compatible schema paths. +- Task/API/event models expose `workspace_folders` while existing repository payloads remain backward + compatible. +- Repository methods can create and compensate one attachment batch transactionally, and tests pin + path/name uniqueness plus mixed source position allocation. + +## Verification + +```bash +cd apps/backend +rtk go test ./internal/task/repository/... ./internal/task/dto/... ./pkg/api/v1/... +``` + +## Files likely touched + +- `apps/backend/internal/task/models/models.go` +- `apps/backend/internal/task/repository/interface.go` +- `apps/backend/internal/task/repository/sqlite/base_schema.go` +- `apps/backend/internal/task/repository/sqlite/base_migrations.go` +- `apps/backend/internal/task/repository/sqlite/workspace_folder.go` (new) +- `apps/backend/internal/task/repository/**/*workspace_folder*_test.go` +- `apps/backend/internal/task/dto/dto.go` +- `apps/backend/pkg/api/v1/task.go` +- `apps/backend/internal/task/service/service_events.go` + +## Dependencies + +None. + +## Inputs + +- Spec: Data model, Persistence guarantees. +- ADR: preserve `task_repositories`; folders use a separate relation. +- Patterns: `sqlite/task_repository.go`, replayable schema tests, task event repository projection. + +## Output contract + +Summary, files changed, tests run, blockers, risks, divergence, and task/plan status updates. diff --git a/docs/plans/attach-workspace-sources/task-02-attachment-service.md b/docs/plans/attach-workspace-sources/task-02-attachment-service.md new file mode 100644 index 0000000000..ffef2e8307 --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-02-attachment-service.md @@ -0,0 +1,50 @@ +--- +id: "02-attachment-service" +title: "Attachment service" +status: done +wave: 2 +depends_on: ["01-durable-source-contracts"] +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 02: Attachment Service + +## Acceptance + +- `AttachWorkspaceSources` validates and resolves complete mixed batches under a per-task lock before + persistence, including task/workspace ownership, idle state, locator/path/branch validity, + duplicates, and runtime-name collisions. +- Materialization success publishes one truthful task update; failure or caller cancellation removes + only records/repository entities created by the operation and returns a typed error. +- Existing `AddBranchToTask` behavior and tests pass through a one-item compatibility adapter. + +## Verification + +```bash +cd apps/backend +rtk go test ./internal/task/service/... -run 'Test(AttachWorkspaceSources|AddBranchToTask)' +``` + +## Files likely touched + +- `apps/backend/internal/task/service/service.go` +- `apps/backend/internal/task/service/service_workspace_sources.go` (new) +- `apps/backend/internal/task/service/service_workspace_sources_test.go` (new) +- `apps/backend/internal/task/service/service_branches.go` +- `apps/backend/internal/task/service/service_branches_test.go` +- `apps/backend/internal/task/service/service_events.go` + +## Dependencies + +Task 01. + +## Inputs + +- Spec: What, Failure modes, Permissions. +- Existing repository resolution in `service_tasks.go` and `repository_discovery.go`. +- Existing add-branch materialization/rollback behavior in `service_branches.go`. + +## Output contract + +Summary, files changed, tests run, blockers, risks, divergence, and task/plan status updates. diff --git a/docs/plans/attach-workspace-sources/task-03-protocol-surfaces.md b/docs/plans/attach-workspace-sources/task-03-protocol-surfaces.md new file mode 100644 index 0000000000..2d3da971b6 --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-03-protocol-surfaces.md @@ -0,0 +1,53 @@ +--- +id: "03-protocol-surfaces" +title: "Protocol surfaces" +status: done +wave: 3 +depends_on: ["02-attachment-service"] +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 03: Protocol Surfaces + +## Acceptance + +- HTTP accepts the documented mixed batch, maps typed failures to the documented status codes, and + returns the durable source/session projection. +- `add_workspace_sources_kandev` defaults to the current task and reaches the same service; + `add_branch_to_task_kandev` remains wire-compatible. +- Task and session source-update events are routed through the gateway only after successful + materialization, with handler/MCP/event tests covering payloads and failure truthfulness. + +## Verification + +```bash +cd apps/backend +rtk go test ./internal/task/handlers/... ./internal/mcp/... ./internal/gateway/websocket/... ./pkg/websocket/... +``` + +## Files likely touched + +- `apps/backend/internal/task/handlers/task_handlers.go` +- `apps/backend/internal/task/handlers/task_http_handlers.go` +- `apps/backend/internal/task/handlers/*workspace_sources*_test.go` +- `apps/backend/internal/mcp/server/server.go` +- `apps/backend/internal/mcp/server/handlers_test.go` +- `apps/backend/internal/mcp/handlers/handlers.go` +- `apps/backend/internal/mcp/handlers/handlers_test.go` +- `apps/backend/internal/events/types.go` +- `apps/backend/pkg/websocket/actions.go` +- `apps/backend/internal/gateway/websocket/task_notifications.go` + +## Dependencies + +Task 02. + +## Inputs + +- Spec: API surface and error codes. +- Existing create-task repository union and add-branch MCP forwarding tests. + +## Output contract + +Summary, files changed, tests run, blockers, risks, divergence, and task/plan status updates. diff --git a/docs/plans/attach-workspace-sources/task-04-host-materialization.md b/docs/plans/attach-workspace-sources/task-04-host-materialization.md new file mode 100644 index 0000000000..bd718e2612 --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-04-host-materialization.md @@ -0,0 +1,53 @@ +--- +id: "04-host-materialization" +title: "Host materialization" +status: done +wave: 3 +depends_on: ["02-attachment-service"] +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 04: Host Materialization + +## Acceptance + +- Worktree repository additions and mixed Local/Worktree folder sources materialize under a safe + Kandev-owned task root using live, canonical, platform-native directory links. +- An idle Local execution can re-root/rebind without losing task conversation state; Worktree keeps + synchronous sibling materialization. Active work is never interrupted. +- Agentctl adopts the new workdir, refreshes file scope and Git trackers, and materialization rolls + back safely when linking, restart, rescan, or event publication fails. + +## Verification + +```bash +cd apps/backend +rtk go test ./internal/backendapp/... ./internal/agent/runtime/lifecycle/... ./internal/agentctl/server/api/... ./internal/agentctl/server/process/... +``` + +## Files likely touched + +- `apps/backend/internal/backendapp/branch_materializer.go` +- `apps/backend/internal/backendapp/workspace_source_materializer.go` (new) +- `apps/backend/internal/backendapp/*workspace_source_materializer*_test.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_workspace_rescan.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_execution.go` +- `apps/backend/internal/agent/runtime/lifecycle/env_preparer_local.go` +- `apps/backend/internal/agentctl/server/api/workspace_rescan.go` +- `apps/backend/internal/agentctl/server/process/manager.go` +- platform-specific link helper/test files under `apps/backend/internal/worktree/` + +## Dependencies + +Task 02. + +## Inputs + +- Spec: host live-reference semantics and idle gate. +- ADR-2026-07-19-workspace-symlink-entries. +- Existing `branch_materializer.go` root promotion and agentctl rescan path. + +## Output contract + +Summary, files changed, tests run, blockers, risks, divergence, and task/plan status updates. diff --git a/docs/plans/attach-workspace-sources/task-05-remote-materialization.md b/docs/plans/attach-workspace-sources/task-05-remote-materialization.md new file mode 100644 index 0000000000..a7ecedd969 --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-05-remote-materialization.md @@ -0,0 +1,57 @@ +--- +id: "05-remote-materialization" +title: "Remote materialization" +status: done +wave: 4 +depends_on: ["03-protocol-surfaces", "04-host-materialization"] +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 05: Remote Materialization + +## Acceptance + +- Agentctl can atomically clone/checkout a validated repository into a collision-checked workspace + subdirectory with cancellation and cleanup defenses. +- Docker, SSH, and Sprites use their live agentctl clients and existing credential resolution to + materialize and rescan repository sources; secrets are absent from logs and metadata. +- Container and remote folder requests are rejected before persistence or filesystem access, and the + same executor capability is available to the frontend picker. +- New/reset environments rebuild all durable sources, and adapter tests cover failure rollback plus + the future remote-Docker capability boundary. + +## Verification + +```bash +cd apps/backend +rtk go test ./internal/agentctl/... ./internal/agent/runtime/lifecycle/... ./internal/backendapp/... +``` + +## Files likely touched + +- `apps/backend/internal/agentctl/client_workspace_sources.go` (new) +- `apps/backend/internal/agentctl/server/api/workspace_sources.go` (new) +- `apps/backend/internal/agentctl/server/api/workspace_sources_test.go` (new) +- `apps/backend/internal/agentctl/server/api/server.go` +- `apps/backend/internal/agentctl/server/process/manager.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_docker.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_ssh.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_sprites.go` +- `apps/backend/internal/agent/runtime/lifecycle/executor_remote_docker.go` +- `apps/backend/internal/agent/runtime/lifecycle/manager_launch.go` +- executor/materialization tests beside those files + +## Dependencies + +Tasks 03 and 04. + +## Inputs + +- Spec: executor source capabilities, credential boundary, persistence, failure modes. +- Existing `workspace/copy-files`, remote copy-files, Docker prepare, SSH upload, and Sprites file + uploader patterns. + +## Output contract + +Summary, files changed, tests run, blockers, risks, divergence, and task/plan status updates. diff --git a/docs/plans/attach-workspace-sources/task-06-shared-source-picker.md b/docs/plans/attach-workspace-sources/task-06-shared-source-picker.md new file mode 100644 index 0000000000..85e4a0fd69 --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-06-shared-source-picker.md @@ -0,0 +1,76 @@ +--- +id: "06-shared-source-picker" +title: "Shared source picker" +status: done +wave: 3 +depends_on: ["02-attachment-service"] +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 06: Shared Source Picker + +## Acceptance + +- Reusable workspace-repository, remote-repository, branch, and folder selection leaves live under + `components/workspace-source-picker/` with task-create behavior unchanged. +- Mixed source form state supports independent stable rows, validation, retry, and exact HTTP payload + construction for repository, remote repository, and folder kinds. +- Executor capability hides the folder row kind for Docker, SSH, Sprites, and remote Docker while + leaving local and remote repository choices available. +- Frontend API/types/store contracts model workspace folders and attachment responses, and the old + Worktree-only multi-repository guard is removed only after runtime capability is present. + +## Verification + +```bash +cd apps/web +rtk pnpm test -- components/workspace-source-picker components/task-create-dialog lib/api/domains/kanban-api +rtk pnpm run typecheck +``` + +## Files likely touched + +- `apps/web/components/workspace-source-picker/**` (new) +- `apps/web/components/task-create-dialog-workspace-repo-chips.tsx` +- `apps/web/components/task-create-dialog-remote-repo-chip.tsx` +- `apps/web/components/task-create-dialog-remote-repo-chips.tsx` +- `apps/web/components/folder-picker.tsx` +- `apps/web/components/task-create-dialog-computed.ts` +- `apps/web/components/task-create-dialog-multi-repo-guard.ts` +- `apps/web/lib/api/domains/kanban-api.ts` +- `apps/web/lib/types/http.ts` +- `apps/web/lib/state/slices/kanban/types.ts` + +## Dependencies + +Task 02 for the final request contract. This task may begin extraction in parallel with Tasks 03/04 +but must not delete the executor guard until Task 05 is integrated. + +## Inputs + +- Existing task-create picker files named above. +- Spec: mixed batch inputs and executor parity. + +## Output contract + +Summary, files changed, tests run, blockers, risks, divergence, and task/plan status updates. + +## Completion notes + +- Added the shared typed source-row state, validation, deterministic request + builder, and executor capability helper under + `apps/web/components/workspace-source-picker/`. +- Re-exported the existing task-create repository, remote repository, branch, + and folder picker leaves from the shared namespace without changing their + behavior. Task 07 owns the Add sources desktop/dialog/drawer compositions. +- Mobile parity handoff: Task 07 must consume this same state/capability layer + in a phone-native, viewport-contained `MobilePickerSheet` or Drawer with a + single internal scroll owner and 44px touch controls; this task introduces + no rendered outer surface, so no mobile E2E is appropriate yet. +- Kept the existing Worktree-only multi-repository guard in + `task-create-dialog-computed.ts` and `task-create-dialog-multi-repo-guard.ts`. + **Deferred handoff to Task 05:** remove or relax that create-task guard only + after Task 05 wires the remote runtime multi-repository capability and its + executor profile contract; this attachment-picker capability does not make + create-task provisioning safe by itself. diff --git a/docs/plans/attach-workspace-sources/task-07-files-panel-surface.md b/docs/plans/attach-workspace-sources/task-07-files-panel-surface.md new file mode 100644 index 0000000000..cf27013849 --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-07-files-panel-surface.md @@ -0,0 +1,56 @@ +--- +id: "07-files-panel-surface" +title: "Files panel surface" +status: done +wave: 4 +depends_on: ["03-protocol-surfaces", "06-shared-source-picker"] +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 07: Files Panel Surface + +## Acceptance + +- Eligible desktop and mobile Files panels expose an accessible Add sources action, with archived, + repository-less, loading, and active-turn states handled explicitly. +- Desktop Dialog and phone full-height Drawer share logic, support mixed rows, retain retryable input + on failure, and meet the documented scroll, safe-area, focus, touch-target, and overflow contract. +- Successful task/session events merge repository/folder state, adopt the new workspace path, reset + affected file-tree caches, and refresh Files/Changes/editor surfaces without a page reload. + +## Verification + +```bash +cd apps/web +rtk pnpm test -- components/task/add-workspace-sources components/task/file-browser lib/ws/handlers +rtk pnpm run typecheck +``` + +## Files likely touched + +- `apps/web/components/task/add-workspace-sources/**` (new) +- `apps/web/components/task/file-browser-toolbar.tsx` +- `apps/web/components/task/file-browser.tsx` +- `apps/web/components/task/files-panel.tsx` +- `apps/web/components/task/task-files-panel.tsx` +- `apps/web/components/task/task-files-panel-hooks.ts` +- `apps/web/lib/ws/handlers/tasks.ts` +- `apps/web/lib/ws/handlers/agent-session.ts` +- `apps/web/lib/state/slices/kanban/**` +- `apps/web/lib/state/slices/session-runtime/**` + +## Dependencies + +Tasks 03 and 06. + +## Inputs + +- Plan: Frontend and Mobile design contract. +- Mobile exemplar: `components/task/mobile/mobile-picker-sheet.tsx`. +- Existing Files toolbar and mobile/tablet task layouts. + +## Output contract + +Summary, files changed, tests run, rendered desktop/mobile checks, blockers, risks, divergence, and +task/plan status updates. diff --git a/docs/plans/attach-workspace-sources/task-08-end-to-end-coverage.md b/docs/plans/attach-workspace-sources/task-08-end-to-end-coverage.md new file mode 100644 index 0000000000..e3ab2caebf --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-08-end-to-end-coverage.md @@ -0,0 +1,61 @@ +--- +id: "08-end-to-end-coverage" +title: "End-to-end coverage" +status: completed +wave: 5 +depends_on: ["05-remote-materialization", "07-files-panel-surface"] +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 08: End-to-End Coverage + +## Acceptance + +- Desktop and `mobile-chrome` tests prove mixed local repository/folder attachment, live Files + refresh, Git-only scoping, reload persistence, mobile geometry, and active-turn gating. +- Container-gated Docker and SSH tests prove repository materialization, folder capability gating, + rollback, relaunch/reconnect, and owned cleanup. +- Tests use isolated disposable repositories/folders and leave developer data untouched. + +## Verification + +```bash +rtk make build-web +rtk make build-backend +cd apps/web +rtk pnpm e2e -- e2e/tests/task/add-workspace-sources.spec.ts --project=chromium +rtk pnpm e2e -- e2e/tests/task/mobile-add-workspace-sources.spec.ts --project=mobile-chrome +rtk env KANDEV_E2E_CONTAINERS=1 pnpm e2e -- e2e/tests/docker/add-workspace-sources.spec.ts e2e/tests/ssh/add-workspace-sources.spec.ts --project=containers +``` + +## Files likely touched + +- `apps/web/e2e/tests/task/add-workspace-sources.spec.ts` (new) +- `apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts` (new) +- `apps/web/e2e/tests/docker/add-workspace-sources.spec.ts` (new) +- `apps/web/e2e/tests/ssh/add-workspace-sources.spec.ts` (new) +- focused E2E fixtures/helpers only when necessary + +## Dependencies + +Tasks 05 and 07. + +## Inputs + +- Spec Scenarios. +- `apps/web/e2e/README.md` and existing task-create, mobile file viewer, Docker launch, and SSH launch + specs. + +## Output contract + +Summary, files changed, exact projects/tests run, artifacts/screenshots, blockers, risks, divergence, +and task/plan status updates. + +## Result + +- Managed desktop Chromium coverage passed: 2/2 tests. +- Managed Pixel 5/mobile Chrome coverage passed: 1/1 test, including enabled-state and focus return. +- Docker and SSH specs were added, collected, typechecked, and linted. Live execution was + environment-blocked before source attachment: the managed runner did not expose its Docker + socket, while the direct host Docker task did not reach an idle state within 120 seconds. diff --git a/docs/plans/attach-workspace-sources/task-09-public-documentation.md b/docs/plans/attach-workspace-sources/task-09-public-documentation.md new file mode 100644 index 0000000000..9778a1107f --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-09-public-documentation.md @@ -0,0 +1,50 @@ +--- +id: "09-public-documentation" +title: "Public documentation" +status: done +wave: 5 +depends_on: ["05-remote-materialization", "07-files-panel-surface"] +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 09: Public Documentation + +## Acceptance + +- Public task, coordination, executor, developer-tools, MCP, and feature-status docs explain the Files + action, supported source kinds, idle-only mutation, executor behavior, Local/Worktree-only folder + support, and legacy/new MCP tools. +- Statements that multi-repository tasks and post-create additions are Worktree-only are removed or + replaced only where implementation now proves broader support. +- Security guidance explains that arbitrary folders remain live host grants and are unavailable to + container/remote tasks. + +## Verification + +```bash +rtk rg -n "Worktree-only|worktree executor|add_branch_to_task_kandev|multi-repository" docs/public +``` + +## Files likely touched + +- `docs/public/tasks-and-workflows.md` +- `docs/public/coordination.md` +- `docs/public/executors.md` +- `docs/public/developer-tools.md` +- `docs/public/automation-and-mcp.md` +- `docs/public/security.md` +- `docs/public/feature-status.md` + +## Dependencies + +Tasks 05 and 07. + +## Inputs + +- Final implemented UI/API/runtime behavior. +- Spec and ADR. + +## Output contract + +Summary, files changed, searches/checks run, blockers, risks, divergence, and task/plan status updates. diff --git a/docs/plans/attach-workspace-sources/task-10-final-verification.md b/docs/plans/attach-workspace-sources/task-10-final-verification.md new file mode 100644 index 0000000000..bef9e1237a --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-10-final-verification.md @@ -0,0 +1,65 @@ +--- +id: "10-final-verification" +title: "Final verification" +status: completed +wave: 6 +depends_on: ["08-end-to-end-coverage", "09-public-documentation"] +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 10: Final Verification + +## Acceptance + +- Repository-wide formatting runs before typecheck, tests, and lint, and every command result is + recorded without source/test fixes by the verification worker. +- Focused desktop/mobile rendered checks and executor coverage from Task 08 are linked in the report. +- The spec, ADR, plan, task statuses, scoped `AGENTS.md` files, and public docs match the final + behavior; any failure or deferred executor is reported explicitly. + +## Verification + +```bash +rtk make fmt +rtk make typecheck +rtk make test +rtk make lint +``` + +## Files likely touched + +- `docs/plans/attach-workspace-sources/plan.md` (status only) +- `docs/plans/attach-workspace-sources/task-10-final-verification.md` (status/report only) +- scoped `AGENTS.md` only if implementation changed a documented architectural pattern + +## Dependencies + +Tasks 08 and 09; all earlier tasks must already be done. + +## Inputs + +- Completed implementation tasks and their verification reports. +- Root and scoped `AGENTS.md` verification order. + +## Output contract + +Exact commands/results, failures without fixes, coverage gaps, blockers, risks, divergence, final +task/plan status, and readiness recommendation. + +## Result + +Final verification used `TMPDIR=/tmp/kvt` to avoid the runner's Unix-socket path-length limit. + +- `make fmt`: passed. +- `make typecheck`: passed. +- `make test`: passed. +- `make lint`: passed. +- Backend compile-only and focused race suites: passed. +- `git diff --check`: passed. +- Desktop Chromium: 2/2 passed. +- Mobile Chrome: 1/1 passed. +- Independent final security review: approved. + +Live Docker/SSH attachment execution remains an environment coverage limitation, not a source +failure; see Task 08. diff --git a/docs/plans/attach-workspace-sources/task-11-shared-local-remote-selector.md b/docs/plans/attach-workspace-sources/task-11-shared-local-remote-selector.md new file mode 100644 index 0000000000..434bd8a51e --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-11-shared-local-remote-selector.md @@ -0,0 +1,66 @@ +--- +id: "11-shared-local-remote-selector" +title: "Tab-free repository source chooser" +status: done +wave: 7 +depends_on: + - "06-shared-source-picker" +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 11: Tab-free repository source chooser + +## Acceptance + +- Task creation retains **Repo / Remote / None**, its state semantics, and existing test IDs. Add + sources does not render a second segmented mode component. +- Add sources presents one **Add repository** menu with **Workspace repository**, **Local Git + repository**, and **Remote repository**. It reuses the combined saved/discovered picker and the + provider-backed/pasted-URL picker; **Add folder** remains available only when supported. +- Adding another kind never removes configured rows, validation state, or mixed-batch payload data. +- Phone triggers and source-kind menu rows provide an actual 44px touch target. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run \ + components/task-create-dialog-repo-chips.test.tsx \ + components/task/add-workspace-sources/add-workspace-sources-dialog.test.tsx \ + components/workspace-source-picker/workspace-source-state.test.ts +cd apps/web && pnpm run typecheck +cd apps && pnpm --filter @kandev/web exec eslint \ + components/task-create-dialog-source-mode.tsx \ + components/task-create-dialog-repo-chips.tsx \ + components/task/add-workspace-sources/add-workspace-sources-dialog.tsx \ + --max-warnings=0 +``` + +## Files likely touched + +- `apps/web/components/task-create-dialog-source-mode.tsx` +- `apps/web/components/task-create-dialog-repo-chips.tsx` +- `apps/web/components/task-create-dialog-remote-repo-chips.tsx` +- `apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.tsx` +- `apps/web/components/task/add-workspace-sources/add-workspace-sources-dialog.test.tsx` +- `apps/web/components/workspace-source-picker/` +- `apps/web/components/task-create-dialog-*.test.tsx` + +## Dependencies + +Task 06. + +## Inputs + +- Spec: **What** repository-kind requirements and mixed-source scenarios. +- Plan: **Frontend → Shared source picker** and **Mobile design contract**. +- Patterns: `WorkspaceRepoChips`, `RemoteRepoChip`, `useDiscoverReposEffect`, and the existing + create-task `SourceModeSwitch`. + +## Output contract + +- Update only this task file to `in_progress` when starting and `done` after acceptance and + verification pass. +- Return a compact handoff capsule: accepted behaviors, base/head SHA, changed files and entry + points, targeted command results, risk tags, and any uncertainty. +- Do not update `plan.md`; the planner serializes shared-plan status. diff --git a/docs/plans/attach-workspace-sources/task-12-unified-files-workspace-actions.md b/docs/plans/attach-workspace-sources/task-12-unified-files-workspace-actions.md new file mode 100644 index 0000000000..cfbdfbbf4f --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-12-unified-files-workspace-actions.md @@ -0,0 +1,67 @@ +--- +id: "12-unified-files-workspace-actions" +title: "Unified Files workspace actions" +status: done +wave: 7 +depends_on: + - "07-files-panel-surface" +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 12: Unified Files workspace actions + +## Acceptance + +- Files shows one labeled/icon workspace-actions trigger instead of separate **Add sources** and + **Open workspace folder** controls. Its menu exposes both actions on desktop, tablet, and phone. +- The trigger and **Open workspace folder** remain enabled when source attachment is blocked. + **Add sources** alone is disabled and its existing busy/loading/ineligible reason is visible in + the menu. +- Selecting Add sources closes the menu and opens the existing Dialog/Drawer without focus + contention; dismissal returns focus to the combined trigger. The phone trigger and menu rows have + at least a 44px active dimension. + +## Verification + +```bash +cd apps && pnpm --filter @kandev/web test -- --run \ + components/task/file-browser-toolbar.test.tsx \ + components/task/add-workspace-sources/add-workspace-sources-availability.test.ts +cd apps/web && pnpm run typecheck +cd apps && pnpm --filter @kandev/web exec eslint \ + components/task/file-browser-toolbar.tsx \ + components/task/file-browser.tsx \ + components/task/files-panel.tsx \ + components/task/task-files-panel.tsx \ + --max-warnings=0 +``` + +## Files likely touched + +- `apps/web/components/task/file-browser-toolbar.tsx` +- `apps/web/components/task/file-browser-toolbar.test.tsx` +- `apps/web/components/task/file-browser.tsx` +- `apps/web/components/task/files-panel.tsx` +- `apps/web/components/task/task-files-panel.tsx` +- `apps/web/components/task/add-workspace-sources/add-workspace-sources-availability.ts` + +## Dependencies + +Task 07. + +## Inputs + +- Spec: combined action-menu requirements and active-turn scenario. +- Plan: **Frontend → Files panel surface and live state** and **Mobile design contract**. +- Patterns: `components/editors/file-actions-dropdown.tsx`, + `components/app-sidebar/sections/tasks-view-picker.tsx`, and the responsive Radix menu rules in + `app/globals.css`. + +## Output contract + +- Update only this task file to `in_progress` when starting and `done` after acceptance and + verification pass. +- Return a compact handoff capsule: accepted behaviors, base/head SHA, changed files and entry + points, targeted command results, risk tags, and any uncertainty. +- Do not update `plan.md`; the planner serializes shared-plan status. diff --git a/docs/plans/attach-workspace-sources/task-13-ux-delta-e2e.md b/docs/plans/attach-workspace-sources/task-13-ux-delta-e2e.md new file mode 100644 index 0000000000..318582d471 --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-13-ux-delta-e2e.md @@ -0,0 +1,62 @@ +--- +id: "13-ux-delta-e2e" +title: "UX delta end-to-end coverage" +status: done +wave: 8 +depends_on: + - "11-shared-local-remote-selector" + - "12-unified-files-workspace-actions" +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 13: UX delta end-to-end coverage + +## Acceptance + +- Desktop E2E opens the combined menu, exercises both actions, adds sources through the tab-free + repository-kind menu, and proves mixed rows remain visible. +- Mobile E2E proves the 44px trigger/menu geometry, inset action menu, full-height Add sources + drawer, safe-area/footer containment, no document horizontal overflow, submit outcome, and focus + return. +- Executor E2E enters Add sources through the combined menu and continues proving capability + filtering without weakening backend source validation. The disposable HTTP Git fixture uses + test-appropriate provider semantics instead of mislabeling a bridge URL as GitLab. + +## Verification + +```bash +cd apps/web && pnpm e2e:run e2e/tests/task/add-workspace-sources.spec.ts +cd apps/web && pnpm e2e:run e2e/tests/task/mobile-add-workspace-sources.spec.ts +cd apps/web && KANDEV_E2E_CONTAINERS=1 pnpm e2e -- \ + e2e/tests/docker/add-workspace-sources.spec.ts \ + e2e/tests/ssh/add-workspace-sources.spec.ts \ + --project=containers +``` + +## Files likely touched + +- `apps/web/e2e/tests/task/add-workspace-sources.spec.ts` +- `apps/web/e2e/tests/task/mobile-add-workspace-sources.spec.ts` +- `apps/web/e2e/tests/docker/add-workspace-sources.spec.ts` +- `apps/web/e2e/tests/ssh/add-workspace-sources.spec.ts` +- `apps/web/e2e/helpers/http-git-server.ts` +- Existing E2E page objects/helpers only when the same interaction is repeated. + +## Dependencies + +Tasks 11 and 12. + +## Inputs + +- Spec: combined-menu, selector-parity, mixed-row, active-turn, and phone scenarios. +- Plan: **Tests**, **E2E Tests**, and **Mobile design contract**. +- Mobile project: configured `mobile-chrome` Pixel 5; do not add per-test device overrides. + +## Output contract + +- Update only this task file to `in_progress` when starting and `done` after acceptance and + verification pass. +- Return a compact handoff capsule with exact test names, commands/results, screenshot or rendered + evidence, environment blockers, risk tags, and any uncertainty. +- Do not update `plan.md`; the planner serializes shared-plan status. diff --git a/docs/plans/attach-workspace-sources/task-14-ux-terminology-documentation.md b/docs/plans/attach-workspace-sources/task-14-ux-terminology-documentation.md new file mode 100644 index 0000000000..a6a5cfdcc3 --- /dev/null +++ b/docs/plans/attach-workspace-sources/task-14-ux-terminology-documentation.md @@ -0,0 +1,55 @@ +--- +id: "14-ux-terminology-documentation" +title: "UX terminology documentation" +status: done +wave: 8 +depends_on: + - "11-shared-local-remote-selector" + - "12-unified-files-workspace-actions" +plan: "plan.md" +spec: "../../specs/tasks/attach-workspace-sources.md" +--- + +# Task 14: UX terminology documentation + +## Acceptance + +- Public docs direct users through **Files → Workspace actions → Add sources** and describe the + tab-free **Add repository** menu rather than the removed Local/Remote modes. +- Documentation preserves executor capability, branch, atomicity, and folder limitations without + implying a backend contract change. +- Feature-status and cross-linked coordination/developer-tool descriptions use the same concise + terminology. + +## Verification + +```bash +rtk rg -n "Files → Add sources|saved workspace repository, a local Git repository, a remote Git repository" \ + docs/public +make docs-check +rtk git diff --check +``` + +## Files likely touched + +- `docs/public/feature-status.md` +- `docs/public/tasks-and-workflows.md` +- `docs/public/coordination.md` +- `docs/public/developer-tools.md` + +## Dependencies + +Tasks 11 and 12. + +## Inputs + +- Spec: **What**, **Scenarios**, and unchanged executor/failure contracts. +- Plan: UX delta overview and Files/source-selector sections. + +## Output contract + +- Update only this task file to `in_progress` when starting and `done` after acceptance and + verification pass. +- Return a compact handoff capsule with files changed, terminology decisions, command results, + risks, and any uncertainty. +- Do not update `plan.md`; the planner serializes shared-plan status. diff --git a/docs/public/automation-and-mcp.md b/docs/public/automation-and-mcp.md index 8f30112b1a..8df33ce17b 100644 --- a/docs/public/automation-and-mcp.md +++ b/docs/public/automation-and-mcp.md @@ -145,7 +145,7 @@ Task mode currently registers these tool groups: | User interaction | Ask a structured question when the current agent/session supports it. | | Plans | Create, get, update, and delete the current task plan. | | Walkthroughs | Show, get, and delete the task's code walkthrough. | -| Relationships and branches | List related tasks, add a branch/worktree to a task, and change a repository's diff base. | +| Relationships and workspace sources | List related tasks, add a mixed repository/folder source batch to an idle task, use the legacy one-branch tool, and change a repository's diff base. | | Workflow signal | Signal step completion when an auto-advance step explicitly requires that signal. | Task identity is injected for operations that require it. Workspace, parent/subtask, executor, and task-state rules still apply. @@ -158,7 +158,11 @@ Use `stop_task_kandev` only when the direct child should halt without a replacem After an accepted stop, Kandev attempts to move an unarchived, non-Office task from `IN_PROGRESS` or `SCHEDULING` to `REVIEW`; other task states are preserved. Worktrees, task environments, commits, task records, descendants, and queued messages remain available, and the task can be started again later. -`add_branch_to_task_kandev` works only with the Worktree executor. It can add another branch of the same repository or a second repository entirely. Select at most one of `repository_id`, `repository_url`, or `local_path`; `repository_url` accepts a GitHub repository URL, while the URL/path forms find or create that repository in the task's workspace. A locator is optional for a single-repository task and required to disambiguate a multi-repository task. The new repository/branch receives its own worktree. `update_repository_base_branch_kandev` changes the base used for Kandev's diff, not a pull request's target branch. +`add_workspace_sources_kandev` adds one or more sources to an idle task and defaults `task_id` to the current task. Its `sources` input accepts the same atomic mixed batch as the Files panel: `repository` sources use exactly one saved repository ID, local Git path, or remote repository locator plus branch fields; `folder` sources use a local path and optional display name. Repository sources work on Worktree, Local/Local PC, Local Docker, SSH, and Sprites; folders work only on Worktree and Local/Local PC. The task must be repository-backed and have no active turn or tool call. Invalid, duplicate, unsupported, or failed sources roll back the entire batch. + +`add_branch_to_task_kandev` is preserved for compatibility with its one-repository/branch input. Use `add_workspace_sources_kandev` for all new automation. `update_repository_base_branch_kandev` changes the base used for Kandev's diff, not a pull request's target branch. + +The HTTP equivalent is `POST /api/v1/tasks/:id/workspace-sources`, with `{ "sources": [...] }`. It returns `400` for invalid input, `404` for a missing task/source outside the workspace, `409` for duplicates or an active task, and `422` when materialization or executor capability fails. Successful adoption publishes `task.updated` and `session.workspace_sources.updated`; clients should refresh their Files and repository state from those updates. `step_complete_kandev` is registered and discoverable in every task-mode session. Kandev includes its completion instruction, and acts on its signal, only on Kanban steps whose auto-advance action explicitly requires that signal. A user message arriving before transition can cancel that automatic move. diff --git a/docs/public/coordination.md b/docs/public/coordination.md index c073e5d0de..bde4d3f062 100644 --- a/docs/public/coordination.md +++ b/docs/public/coordination.md @@ -9,12 +9,12 @@ Kandev can coordinate several agents without turning every unit of work into a s ## Choose a coordination boundary -| Need | Use | Filesystem relationship | Independent workflow state | -|---|---|---|---| -| Another agent on the same files and goal | Additional named session | Shares the task environment | No | -| A child deliverable that must see current uncommitted files | Subtask with **Inherit parent workspace** | Shares the parent's materialized workspace | Yes | -| A child deliverable needing isolated files or a different repository | Subtask with **Create new workspace** | Gets its own materialized environment | Yes | -| One deliverable spanning repositories | Multi-repository task | Separate worktree per attachment | No; one task workflow | +| Need | Use | Filesystem relationship | Independent workflow state | +| -------------------------------------------------------------------- | ----------------------------------------- | ------------------------------------------ | -------------------------- | +| Another agent on the same files and goal | Additional named session | Shares the task environment | No | +| A child deliverable that must see current uncommitted files | Subtask with **Inherit parent workspace** | Shares the parent's materialized workspace | Yes | +| A child deliverable needing isolated files or a different repository | Subtask with **Create new workspace** | Gets its own materialized environment | Yes | +| One deliverable spanning repositories | Multi-repository task | Separate worktree per attachment | No; one task workflow | Shared files make handoff cheap but allow concurrent edits to collide. Separate environments isolate file state, but only the executor determines whether host processes and credentials are also isolated. @@ -34,7 +34,6 @@ Use an additional session when agents need the same task, repository attachments 2. Choose **Add panel (+) → New Agent**, or run **New Agent** from the command panel. 3. Select a compatible agent profile. Missing credentials link to the relevant **Settings → Executors** or **Settings → Agents** configuration. 4. Choose context: - - **Blank** starts without copied conversation context. - **Copy initial prompt** copies the task's initial prompt. - **Summarize session** asks the configured utility agent to summarize the current session. It is unavailable without a utility agent. @@ -70,14 +69,14 @@ The dialog proposes `_Parent title_ / Subtask _N_`. Title and prompt are require Choose a workspace mode: -| Mode | Default and inheritance | Use it when | -|---|---|---| -| **Inherit parent workspace** | Default when the parent has an active worktree. Reuses the parent's executor, repositories, worktrees, branch, and current uncommitted files. Repository and executor controls are hidden. | The child is a focused collaborator on the same file state. | -| **Create new workspace** | Default when the parent has no active worktree branch. Lets you choose another configured/discovered repository, folder, remote URL, branch, and executor. | The child needs isolation, a different branch, or a different repository. | +| Mode | Default and inheritance | Use it when | +| ---------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------------------- | +| **Inherit parent workspace** | Default when the parent has an active worktree. Reuses the parent's executor, repositories, worktrees, branch, and current uncommitted files. Repository and executor controls are hidden. | The child is a focused collaborator on the same file state. | +| **Create new workspace** | Default when the parent has no active worktree branch. Lets you choose another configured/discovered repository, folder, remote URL, branch, and executor. | The child needs isolation, a different branch, or a different repository. | The context choices are **Blank**, **Copy initial prompt**, and, when a utility agent is configured, **Summarize session**. Context supplies background; it does not create a shared conversation. Attachments and prompt enhancement are also available. -The subtask dialog currently does not enforce agent/executor credential compatibility or disable non-worktree executors after two or more repository rows are selected. For an isolated multi-repository subtask, choose **git-worktree**. For every subtask, choose an agent profile configured on the inherited or selected executor; otherwise creation can succeed while agent launch or repository materialization fails. +The subtask dialog currently does not enforce agent/executor credential compatibility. For an isolated multi-repository subtask, choose **Worktree**, **Local Docker**, **SSH**, or **Sprites**; Local/Local PC creation remains gated and Remote Docker is not implemented. For every subtask, choose an agent profile configured on the inherited or selected executor; otherwise creation can succeed while agent launch or repository materialization fails. Regular Kanban allows one subtask level: a root task can have children, but a child cannot have another child. Split further work into sibling subtasks, additional sessions, or a separate top-level task. Arbitrary-depth trees belong to the in-progress Office surface. @@ -107,22 +106,22 @@ For predictable top-level creation, pass `repository_url`, `repository_id`, or ` `message_task_kandev` sends a message to another task's primary session by default. Supply a session ID to target a particular sibling session on the current task. -| Target state | Result | -|---|---| -| Running or starting | Queues the message FIFO for a later turn. | -| Waiting, idle, or completed | Starts a new turn immediately. | -| Created but not started | Starts the session with the message. | -| Failed or cancelled | Returns an error. | +| Target state | Result | +| --------------------------- | ----------------------------------------- | +| Running or starting | Queues the message FIFO for a later turn. | +| Waiting, idle, or completed | Starts a new turn immediately. | +| Created but not started | Starts the session with the message. | +| Failed or cancelled | Returns an error. | The default delivery mode is queued. Each session accepts 10 queued messages by default; operators can change it with `KANDEV_QUEUE_MAX_PER_SESSION`, and a value of `0` or less disables the limit. Only one queued message drains per agent turn. When the cap is reached, the sender receives a structured `queue_full` error and should retry after a target turn completes. Choose the control by intent: -| Intent | Operation | Result | -|---|---|---| -| Send information that can wait | `message_task_kandev` with queued delivery or no `delivery_mode` | The current turn continues and the message waits FIFO. | -| Stop the current approach and give replacement work now | `message_task_kandev` with `delivery_mode: "interrupt"` | The direct parent requests immediate cancellation and redispatch. If that cannot proceed safely, the response reports the message as queued. | -| Halt all current work with no replacement prompt | `stop_task_kandev` | The direct parent requests logical cancellation and graceful teardown without creating or dispatching a message. | +| Intent | Operation | Result | +| ------------------------------------------------------- | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------- | +| Send information that can wait | `message_task_kandev` with queued delivery or no `delivery_mode` | The current turn continues and the message waits FIFO. | +| Stop the current approach and give replacement work now | `message_task_kandev` with `delivery_mode: "interrupt"` | The direct parent requests immediate cancellation and redispatch. If that cannot proceed safely, the response reports the message as queued. | +| Halt all current work with no replacement prompt | `stop_task_kandev` | The direct parent requests logical cancellation and graceful teardown without creating or dispatching a message. | Only a direct parent may interrupt its child. Halt-only stop is stricter: it accepts only a same-workspace direct child, while self, siblings, ancestors other than the parent, deeper descendants, unrelated tasks, and cross-workspace callers are rejected. Use interrupt for stop-and-steer work. Reserve stop for halt-only intent. @@ -159,7 +158,7 @@ For a human gate, use **On Turn Complete → Do nothing (wait for user)** and re ### Create a multi-repository task -In **New Task**, add more **Repo** or **Remote** rows and select a base branch for each. Multi-repository creation currently supports only the **git-worktree** executor. Kandev materializes a worktree per repository and scopes Changes, review, and pull-request surfaces by repository. +In **New Task**, add more **Repo** or **Remote** rows and select a base branch for each. Multi-repository creation supports **Worktree**, **Local Docker**, **SSH**, and **Sprites**. Local/Local PC creation remains gated while its initial-launch path cannot project sibling repositories, and Remote Docker is not implemented. Kandev scopes Changes, review, and pull-request surfaces by repository. Before starting, document: @@ -169,19 +168,34 @@ Before starting, document: - the merge order for dependent changes; and - the test command required in each repository. -### Add another branch after creation +### Add sources after creation -The regular task UI does not currently expose an after-creation add-branch form. A task agent using the **git-worktree** executor can call `add_branch_to_task_kandev`. +For an idle, non-archived repository-backed task, use **Files → Workspace actions → Add Repositories to workspace**. **Add repository** offers a workspace repository, an existing local Git checkout, or a provider-backed/pasted remote URL. The workspace option shares task creation's saved/discovered selector, including refresh and create-repository actions. **Add folder** appears when the executor supports it. Add multiple mixed sources together; validation, persistence, and materialization are atomic. Desktop uses a dialog and phones use a full-height drawer with a touch-sized repository menu. -- Identify exactly one repository by task repository ID, GitHub repository URL, or local path. Omit the locator only when the task has one unique repository. -- For a multi-repository task, the repository locator is required. -- An empty checkout branch creates a fresh feature branch from the selected base. -- The base defaults to the repository's default branch when omitted. -- A repository/base/checkout tuple must be unique on the task. -- The tool can add a second branch from an attached repository or attach a branch from another repository. -- A launched task must use the **git-worktree** executor. A task with no fixed environment yet is allowed through the prelaunch path, but it must ultimately use git-worktree for the added branch to be materialized as a sibling worktree. +Repository sources work on **Worktree**, **Local/Local PC**, **Local Docker**, **SSH**, and **Sprites**. Folder sources are live host grants and work only on **Worktree** and **Local/Local PC**; they are unavailable to Docker and remote runtimes. Remote Docker remains unimplemented. Local Git sources need a cloneable origin on Docker, SSH, and Sprites; Worktree and Local/Local PC can use host repositories directly. -On a live task, branch materialization is synchronous; if it fails, Kandev removes the new task-repository row and returns an error. On a prelaunch task, a materialization failure leaves the row in place and defers worktree creation to the next session launch. Branch names that differ but sanitize to the same on-disk slug are rejected because they would collide at the worktree path. +Use a base branch for every repository attachment. Local/Local PC never changes the user-owned repository checkout. + +Read the consequence summary in the dialog or drawer before submitting. It describes whether the +current executor will restart the agent or update the live remote workspace. **Cancel** or closing +the surface sends no attachment request and leaves the workspace unchanged. + +Kandev rejects the entire request if a source is invalid, duplicated, inaccessible, or cannot be materialized. A failure removes new source records and Kandev-owned materialization; existing task contents remain intact. Attachments persist across reload, relaunch, and reset. A missing persisted folder is surfaced during a new or reset environment rather than silently omitted. + +When a second source promotes a single-repository workspace to the task root, Kandev restarts the +idle Worktree or Local/Local PC agent there. Existing files, Git changes, task state, messages, +plan, attached sources, model, and mode remain. Providers that support loading a conversation from +a different working directory keep their native session; other providers receive a fresh session +with recorded conversation context on the next prompt. Provider-private context that Kandev did not +record may not carry over. This intentional restart is not reported as an agent failure. + +That host rebind stops open task terminals, dev servers, the task editor server, and other +agentctl-managed workspace processes. Save unsaved work first, then reopen or restart those +processes after attachment. Local Docker, SSH, and Sprites instead clone the new repository under +the current remote workspace and rescan it without changing the agent CWD or restarting the agent +and workspace processes. + +Task agents can call `add_workspace_sources_kandev` with the same mixed batch; `task_id` defaults to the current task. `add_branch_to_task_kandev` remains available for its legacy one-repository/branch input. Neither operation can run during an active turn or tool call. Use `update_repository_base_branch_kandev` with a task-repository ID to change the comparison base. The database update is authoritative. Resetting cached session bases, refreshing Changes, base commit, ahead/behind counts, and cumulative diff in a live tracker are best-effort side effects; a failure is logged without rolling back the new base, and the persisted value is rebuilt on the next session launch. The tool does not rewrite commits, switch the checkout, or change an existing pull request's target branch. @@ -233,7 +247,7 @@ When Office is enabled, it prototypes **Blocked by** and **Blocking** properties - **Summarize session is missing:** configure a utility agent; otherwise use **Blank** or **Copy initial prompt**. - **Parallel agents overwrite work:** stop one writer, assign disjoint files, or create an isolated subtask workspace. - **Subtask cannot be created:** confirm title, prompt, compatible profile, workflow, and the one-level Kanban depth limit. -- **Subtask creates but its agent or repositories fail to start:** the dialog does not enforce agent/executor compatibility. Confirm the agent is configured on that executor, and use **git-worktree** for two or more repository rows. +- **Subtask creates but its agent or repositories fail to start:** the dialog does not enforce agent/executor compatibility. Confirm the agent is configured on that executor; multi-repository creation supports Worktree, Local Docker, SSH, and Sprites. - **Inherited subtask sees unexpected changes:** it intentionally shares the parent's materialized files and branch. - **Message remains queued:** the target is busy and only one queued message drains per turn. Check for `queue_full` before retrying. - **Interrupt is rejected:** only the target's direct parent may use interrupt delivery. @@ -241,8 +255,8 @@ When Office is enabled, it prototypes **Blocked by** and **Blocking** properties - **Stop reports `stopped` but a process is still visible:** the response confirms logical cancellation and scheduled graceful teardown, not process exit. - **Agent cannot spawn on another task:** `spawn_session_kandev` is same-workspace only; create a task or use a normal targeted message instead. - **Parent does not advance after children finish:** the parent needs an active session in `CREATED`, `STARTING`, `RUNNING`, or `WAITING_FOR_INPUT` in addition to terminal direct children. -- **A multi-repository executor is disabled:** multi-repository tasks require **git-worktree**. -- **Add branch is rejected:** launched tasks require a worktree executor; multi-repository tasks require an unambiguous locator, repository URLs must be GitHub URLs, and sanitized branch slugs must be unique. +- **A multi-repository executor is disabled:** Local/Local PC creation is still gated, or Remote Docker is not implemented. Choose Worktree, Local Docker, SSH, or Sprites. +- **Adding repositories is rejected:** wait until the task has no active turn or tool call, check every source's locator and branch, and remove duplicate repository/branch pairs or folder paths. A Docker or remote task cannot attach a folder. - **Changed base did not refresh immediately:** the database value is saved even when live tracker refresh fails; the next session launch rebuilds the comparison state. - **Changed base did not retarget the PR:** the base-update tool changes Kandev's comparison context, not Git history or provider PR metadata. diff --git a/docs/public/coverage.json b/docs/public/coverage.json index b727714d3d..c65fcae4a7 100644 --- a/docs/public/coverage.json +++ b/docs/public/coverage.json @@ -74,6 +74,7 @@ ], "mcpTools": [ "add_branch_to_task_kandev", + "add_workspace_sources_kandev", "archive_task_kandev", "create_task_kandev", "delete_task_kandev", diff --git a/docs/public/developer-tools.md b/docs/public/developer-tools.md index b6ed3f24ce..f669060834 100644 --- a/docs/public/developer-tools.md +++ b/docs/public/developer-tools.md @@ -99,26 +99,26 @@ Open **Settings > Voice Mode** (`/settings/voice-mode`). Voice Mode inserts a tr Defaults are: -| Setting | Default | -|---|---| -| Enabled | On | -| Engine | Automatic | -| Language | Auto-detect | -| Activation | Click to toggle | -| Auto-send | Off | +| Setting | Default | +| ----------------- | ------------------------- | +| Enabled | On | +| Engine | Automatic | +| Language | Auto-detect | +| Activation | Click to toggle | +| Auto-send | Off | | Whisper Web model | Base, approximately 75 MB | -| Shortcut | `Cmd/Ctrl+Shift+M` | +| Shortcut | `Cmd/Ctrl+Shift+M` | The shortcut is also configurable under **Settings > General > Keyboard Shortcuts**. Hold-to-talk applies on a fine-pointer device. On touch/coarse-pointer devices, Kandev uses toggle behavior while preserving the stored preference. With auto-send enabled, a successful transcript is sent as soon as it is inserted. ### Choose an engine -| Engine | Where recognition happens | Requirements and data flow | -|---|---|---| -| **Automatic** | First available engine | Selects the first currently available capability in this order: Web Speech, Whisper Web, then Whisper Server. A pinned engine that is unavailable is resolved through the same capability order. | -| **Web Speech** | Browser-provided implementation | No audio is sent to the Kandev backend. Browser/vendor behavior and privacy policy still apply, and some implementations require a network service. | -| **Whisper Web** | In the browser | Downloads and caches an ONNX model from Hugging Face, then runs local inference in a worker. Tiny is about 40 MB, Base about 75 MB, and Small about 240 MB. | -| **Whisper Server** | Kandev backend and OpenAI | The browser uploads audio to Kandev; Kandev sends it to OpenAI's `whisper-1` transcription API. Configure `KANDEV_VOICE_OPENAI_API_KEY` on the backend. | +| Engine | Where recognition happens | Requirements and data flow | +| ------------------ | ------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Automatic** | First available engine | Selects the first currently available capability in this order: Web Speech, Whisper Web, then Whisper Server. A pinned engine that is unavailable is resolved through the same capability order. | +| **Web Speech** | Browser-provided implementation | No audio is sent to the Kandev backend. Browser/vendor behavior and privacy policy still apply, and some implementations require a network service. | +| **Whisper Web** | In the browser | Downloads and caches an ONNX model from Hugging Face, then runs local inference in a worker. Tiny is about 40 MB, Base about 75 MB, and Small about 240 MB. | +| **Whisper Server** | Kandev backend and OpenAI | The browser uploads audio to Kandev; Kandev sends it to OpenAI's `whisper-1` transcription API. Configure `KANDEV_VOICE_OPENAI_API_KEY` on the backend. | Whisper Server accepts at most 10 MiB per request and has a 60-second backend timeout. It returns an unavailable error when the key is not configured, a payload-too-large error above the limit, and an upstream error when transcription fails. Automatic selection can choose this unconfigured server when both browser capabilities are unavailable. @@ -128,6 +128,8 @@ Microphone capture requires browser permission and normally HTTPS or localhost. ## Files and editor integrations +For an idle, non-archived repository-backed task, **Files → Workspace actions → Add Repositories to workspace** opens a tab-free source picker. **Add repository** offers a workspace repository, a local Git checkout, or a provider-backed/pasted remote URL. The workspace option shares task creation's saved/discovered selector, refresh, and create-repository actions. **Add folder** is available on Local/Local PC or Worktree. Every repository chooses one base branch, and Local/Local PC uses the current checkout without switching it. Desktop uses a dialog; phones use a full-height drawer with a touch-sized repository menu. A mixed submission is atomic, and repository additions refresh repository-aware tools while folders remain Files-only. See [Tasks and workflows](tasks-and-workflows.md#add-sources-to-an-existing-task). + The task **Files** panel browses, searches, opens, and edits task-worktree files. Kandev rejects file paths that escape the resolved worktree. A session with one worktree opens that worktree directly in a host editor. When a session has several worktrees, the editor button asks which repository or worktree to open, and each configured editor in the adjacent menu expands to the same repository-and-branch picker. Check that selection before launching an editor from a multi-repository task. Older API clients that omit `worktree_id` retain the first-worktree fallback. **VS Code (Embedded)** runs code-server inside the task environment and displays it in a workbench panel. Opening it starts code-server independently of the agent process. It launches with `--auth none` and binds `0.0.0.0` on a random port inside that runtime; network/firewall isolation is therefore important, especially for Local and SSH environments. If the binary is absent, agentctl attempts to download it into `~/.kandev/tools/code-server`; first use needs a supported host platform and access to the GitHub release. Use the panel error and task-environment logs when installation, startup, or proxying fails. @@ -194,15 +196,15 @@ If no terminal can be created, wait for the task environment to become ready and ## Choose the right surface -| Need | Use | -|---|---| -| Disposable question or experiment | Quick Chat | -| Board history, branch, review, dependency, or PR | A normal task and workflow | -| Small generated title, summary, or description | Utility agent | -| Reusable task-chat instructions | Saved prompt | -| Dictate into chat | Voice Mode | -| Browse or edit task files | Files and editor integration | -| Diagnostics for supported languages | Language server | -| Run a command in the task runtime | Integrated terminal | +| Need | Use | +| ------------------------------------------------ | ---------------------------- | +| Disposable question or experiment | Quick Chat | +| Board history, branch, review, dependency, or PR | A normal task and workflow | +| Small generated title, summary, or description | Utility agent | +| Reusable task-chat instructions | Saved prompt | +| Dictate into chat | Voice Mode | +| Browse or edit task files | Files and editor integration | +| Diagnostics for supported languages | Language server | +| Run a command in the task runtime | Integrated terminal | Related: [Use Kandev](use-kandev.md), [Sessions and review](sessions-and-review.md), [Agents and profiles](agents-and-profiles.md), and [Integrations](integrations.md). diff --git a/docs/public/executors.md b/docs/public/executors.md index d5180090ba..117ddc8803 100644 --- a/docs/public/executors.md +++ b/docs/public/executors.md @@ -9,14 +9,14 @@ An executor determines where Kandev creates a task environment and runs `agentct ## Current support -| Executor | Current status | Workspace | Use it when | -|---|---|---|---| -| Worktree | Supported; normal default | Dedicated Git worktree on the Kandev host | Parallel coding on a trusted machine | -| Local | Supported | The selected checkout, or an explicit folder for a repository-free task | One controlled task must work in that exact folder | -| Local Docker | Supported when the global Docker runtime is enabled and its daemon is reachable | `/workspace` in a new Docker container | You need a repeatable container boundary | -| Sprites.dev | Supported, provider-dependent | `/workspace` in a provider sandbox | You need remote compute and accept provider lifecycle/billing | -| SSH | Available with important repository-setup limitations | A task folder on a trusted SSH host | The task can start in an empty remote folder, or you manage repository materialization separately | -| Remote Docker | **Not implemented** | None | Do not select or create this type | +| Executor | Current status | Workspace | Use it when | +| ------------- | ------------------------------------------------------------------------------- | ----------------------------------------------------------------------- | ------------------------------------------------------------------------ | +| Worktree | Supported; normal default | Dedicated Git worktree on the Kandev host | Parallel coding on a trusted machine | +| Local | Supported | The selected checkout, or an explicit folder for a repository-free task | One controlled task must work in that exact folder | +| Local Docker | Supported when the global Docker runtime is enabled and its daemon is reachable | `/workspace` in a new Docker container | You need a repeatable container boundary | +| Sprites.dev | Supported, provider-dependent | `/workspace` in a provider sandbox | You need remote compute and accept provider lifecycle/billing | +| SSH | Supported for repository sources on a trusted host | A task folder on a trusted SSH host | You need a remote host with SSH, SFTP, forwarding, and clone credentials | +| Remote Docker | **Not implemented** | None | Do not select or create this type | `mock_remote` also exists in backend models for tests. It is not a product executor. @@ -52,12 +52,12 @@ Profile edits apply when Kandev provisions a launch, but a Docker container or S Do not treat the two script fields as universal hooks: -| Runtime | Prepare script | Profile cleanup script | -|---|---|---| -| Local / Worktree | Runs on the host during preparation. A failure is shown but is non-fatal, so the agent can still start for diagnosis. | Not executed by the executor runtime. Repository-level worktree cleanup is a separate repository setting. | -| Local Docker | Runs inside the container before `agentctl`. Failure is logged but `agentctl` still starts. | Not executed. | -| Sprites | Runs inside a newly created sandbox. Failure aborts the launch and destroys that new sandbox. | Runs, with a 60-second limit, only when a live execution is stopped with a task/session archived or deleted reason; failure does not prevent the subsequent destroy attempt. Plain Stop, **Reset Environment**, and profile-page direct destroy do not run this script. | -| SSH | **Currently not executed.** | **Currently not executed.** | +| Runtime | Prepare script | Profile cleanup script | +| ---------------- | --------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Local / Worktree | Runs on the host during preparation. A failure is shown but is non-fatal, so the agent can still start for diagnosis. | Not executed by the executor runtime. Repository-level worktree cleanup is a separate repository setting. | +| Local Docker | Runs inside the container before `agentctl`. Failure is logged but `agentctl` still starts. | Not executed. | +| Sprites | Runs inside a newly created sandbox. Failure aborts the launch and destroys that new sandbox. | Runs, with a 60-second limit, only when a live execution is stopped with a task/session archived or deleted reason; failure does not prevent the subsequent destroy attempt. Plain Stop, **Reset Environment**, and profile-page direct destroy do not run this script. | +| SSH | **Currently not executed.** | **Currently not executed.** | Keep working prepare scripts noninteractive and idempotent. Kandev resolves supported placeholders and appends its managed branch checkout for Docker and Sprites after the user script. A profile cleanup script must never remove paths outside the environment it owns. @@ -86,10 +86,20 @@ Use `git worktree list --porcelain` in the source repository when diagnosing sta ## Local -Local runs directly in the selected checkout. It provides no file isolation: concurrent tasks, the user, and other tools can edit the same files and switch the same branch. A requested branch checkout can fail when the checkout is dirty or has an unfinished merge. +Local runs directly in the selected checkout. It provides no file isolation: concurrent tasks, the user, and other tools can edit the same files. When sources are attached, Local uses each user-owned repository's current checkout; Kandev does not switch its branch. Use Local for an intentionally shared checkout, a controlled single task, or a repository-free task with an explicit workspace folder. Prefer Worktree for parallel coding. Stop ends the agent process but does not clean the checkout or undo its changes. +## Workspace sources + +An idle, non-archived repository-backed task can add sources from its **Files** panel. Repository sources (saved workspace repository, local Git repository, or remote Git repository) are supported on **Worktree**, **Local/Local PC**, **Local Docker**, **SSH**, and **Sprites**. Worktree materializes Remote Git from Kandev's owned host cache. Docker, SSH, and Sprites clone local Git sources and therefore require a cloneable origin; Worktree and Local/Local PC can use the host repository directly. + +Every repository row records a base branch. Worktree, Docker, SSH, and Sprites may also materialize an existing checkout branch for repository rows. Local/Local PC always uses the repository's current checkout and does not offer or perform a branch switch. + +Arbitrary folders are supported only on **Worktree** and **Local/Local PC**. They remain live host paths; Kandev links them into its task workspace and never copies, moves, or deletes their contents. Docker and remote executors do not offer folders and reject a forged folder request. Remote Docker remains unavailable because its runtime is not implemented. + +Source batches are atomic: if validation, cloning, or runtime adoption fails, Kandev removes the new records and Kandev-owned entries while preserving existing task contents. Persisted attachments are reapplied after reload, relaunch, or **Reset Environment**; a previously attached folder that later disappears is reported instead of silently skipped. See [Tasks and workflows](tasks-and-workflows.md#add-sources-to-an-existing-task). + ## Local Docker ### Prerequisites and profile creation @@ -183,9 +193,9 @@ The profile editor exposes remote shell and agent-readiness checks. Backend/API The remote-auth card is built from the currently enabled agents. Depending on an agent's declared methods, it can copy selected local credential files, resolve a stored secret into that agent's authentication environment variable, or run an agent-specific setup script on the remote host. GitHub can instead use a stored `GITHUB_TOKEN` or the local `gh auth token`. These transfers write sensitive material under the remote user's home and are best-effort—verify authentication on the remote after saving. Although the profile editor also stores Git name/email controls for SSH, the current SSH runtime does not apply them; configure Git identity on the remote host yourself. -### Current repository limitation +### Repository sources and cleanup -The SSH runtime currently creates `/tasks/` and a per-session `.kandev/sessions/` directory, but it does **not** clone or otherwise materialize attached repositories. It also ignores the profile prepare and cleanup scripts. Therefore repository-backed coding through SSH is not yet a complete supported path. Use it only when an empty remote task directory is sufficient or when another trusted mechanism places the required content there and you have verified the exact path. +SSH materializes attached repository sources in the remote task directory. It still ignores profile prepare and cleanup scripts. Ensure the remote host has the required Git credentials and can reach every selected remote; folders cannot be attached to SSH tasks. The runtime preflights the selected agent command and reports an installation hint when missing; it does not install the agent or its toolchain. Only these resolved credential environment names are forwarded to the remote agent: `CLAUDE_CODE_OAUTH_TOKEN`, `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `GEMINI_API_KEY`, `GOOGLE_API_KEY`, `GITHUB_TOKEN`, and `GH_TOKEN`. Arbitrary profile variables and control-plane process variables are not forwarded to that agent process. Agent-specific auth setup scripts can still consume a selected stored secret and materialize their own remote login state. diff --git a/docs/public/feature-status.md b/docs/public/feature-status.md index f9265e036d..ecc240a7bc 100644 --- a/docs/public/feature-status.md +++ b/docs/public/feature-status.md @@ -11,99 +11,99 @@ 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 -| 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 rows on Worktree, Local Docker, SSH, and Sprites; Local/Local PC creation remains gated and Remote Docker is not implemented. Changes/Review/PR surfaces group by repository. | +| Add workspace sources | Supported | An idle, non-archived repository-backed task can use **Files → Workspace actions → Add Repositories to workspace** for an atomic batch. **Add repository** offers workspace, local Git, and remote repository choices; workspace repositories share task creation's saved/discovered selector, refresh, and create-repository actions. **Add folder** is available only on Local/Local PC or Worktree. Repository sources also work on Local Docker, SSH, and Sprites. | +| 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. | ## 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, and multi-repository views; persisted reviewed hashes, stale detection, and anchored comments are implemented. Discard is destructive and permanent. | -| 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 CI-fix automation | Dependency-bound | Requires GitHub credentials and repository permissions. CI automation is opt-in. Azure has a create-PR path only; other Git hosts do not get the full task PR panel. | -| 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`. | -| Task and work-item references in chat | Dependency-bound | Structured Task Chat and Quick Chat search workspace tasks plus connected Jira, Linear, GitHub, GitLab, Azure DevOps, and Sentry sources with `#`. Passthrough chat remains literal, and provider scope, credentials, availability, and rate limits determine external results. | -| 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 | Dependency-bound | Browser Web Speech, a browser-downloaded Whisper model, and server Whisper paths exist. Selection does not retry another engine after a recognition error. Server Whisper requires `KANDEV_VOICE_OPENAI_API_KEY` (empty by default); without it the transcription route returns 503. | -| 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. | -| 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 | Current built-in mapping covers TypeScript/JavaScript, Go, Rust, and Python. Auto-start/installation default off, and subprocesses run on the backend host rather than inside Docker, SSH, or Sprites runtimes. | -| 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. | +| 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, and multi-repository views; persisted reviewed hashes, stale detection, and anchored comments are implemented. Discard is destructive and permanent. | +| 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 CI-fix automation | Dependency-bound | Requires GitHub credentials and repository permissions. CI automation is opt-in. Azure has a create-PR path only; other Git hosts do not get the full task PR panel. | +| 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`. | +| Task and work-item references in chat | Dependency-bound | Structured Task Chat and Quick Chat search workspace tasks plus connected Jira, Linear, GitHub, GitLab, Azure DevOps, and Sentry sources with `#`. Passthrough chat remains literal, and provider scope, credentials, availability, and rate limits determine external results. | +| 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 | Dependency-bound | Browser Web Speech, a browser-downloaded Whisper model, and server Whisper paths exist. Selection does not retry another engine after a recognition error. Server Whisper requires `KANDEV_VOICE_OPENAI_API_KEY` (empty by default); without it the transcription route returns 503. | +| 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. | +| 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 | Current built-in mapping covers TypeScript/JavaScript, Go, Rust, and Python. Auto-start/installation default off, and subprocesses run on the backend host rather than inside Docker, SSH, or Sprites runtimes. | +| 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. | ## 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. | -| 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. Kandev does not currently clone/materialize attached repositories there, ignores profile prepare/cleanup scripts, and retains the remote task directory for manual cleanup. | -| 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, read-only work-item and pull-request browsing, feedback, task launch, and durable task PR associations. Azure DevOps Server/TFS, Entra OAuth, writes, watches, 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. | -| Slack integration | Experimental | Uses a nonstandard browser-session token/cookie and polling rather than OAuth or a bot installation. A first scan can process up to 30 existing matches, and reactions/replies are best-effort. | -| Integration **Enabled** switches | Limited | Jira/Linear/Sentry/Slack switches are browser-local presentation state; they do not stop backend pollers. Pause/delete watches or remove the Slack/provider configuration when polling must stop. | -| Manual, preset/`@every` schedule, authenticated webhook, and GitHub PR-open automations | Limited | Task and Run modes are shipped, but Run cannot answer permission prompts. PR triggers need explicit repositories and ignore the saved Opened checkbox. 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 Run mode | Limited | Creates a hidden ephemeral task and starts immediately. It cannot require user input. Automatic worktree/branch reaping applies only when a Worktree runtime ID is present; other executors follow their own lifecycle. | +| 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. | +| 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, TCP forwarding, and repository-source materialization. It ignores profile prepare/cleanup scripts and retains the remote task directory for manual cleanup. | +| 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, read-only work-item and pull-request browsing, feedback, task launch, and durable task PR associations. Azure DevOps Server/TFS, Entra OAuth, writes, watches, 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. | +| Slack integration | Experimental | Uses a nonstandard browser-session token/cookie and polling rather than OAuth or a bot installation. A first scan can process up to 30 existing matches, and reactions/replies are best-effort. | +| Integration **Enabled** switches | Limited | Jira/Linear/Sentry/Slack switches are browser-local presentation state; they do not stop backend pollers. Pause/delete watches or remove the Slack/provider configuration when polling must stop. | +| Manual, preset/`@every` schedule, authenticated webhook, and GitHub PR-open automations | Limited | Task and Run modes are shipped, but Run cannot answer permission prompts. PR triggers need explicit repositories and ignore the saved Opened checkbox. 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 Run mode | Limited | Creates a hidden ephemeral task and starts immediately. It cannot require user input. Automatic worktree/branch reaping applies only when a Worktree runtime ID is present; other executors follow their own lifecycle. | ## 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. Execution-environment metrics depend on executor/runtime support. | -| SQLite backups, database maintenance, status, and logs | Supported | Backups use SQLite `VACUUM INTO` snapshots under the data root. 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, Plugins, 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 | In progress | Production defaults `KANDEV_FEATURES_PLUGINS=false`; development and E2E enable it for implementation/testing. Loaded plugin code runs with backend privileges; manifest schema, capabilities, and host API may 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, workspace-source, workflow-step, and coordination tools. `add_workspace_sources_kandev` adds an atomic source batch to an idle task; the legacy `add_branch_to_task_kandev` remains available. | +| 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. Execution-environment metrics depend on executor/runtime support. | +| SQLite backups, database maintenance, status, and logs | Supported | Backups use SQLite `VACUUM INTO` snapshots under the data root. 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, Plugins, 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 | In progress | Production defaults `KANDEV_FEATURES_PLUGINS=false`; development and E2E enable it for implementation/testing. Loaded plugin code runs with backend privileges; manifest schema, capabilities, and host API may 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. | ## Publication boundary diff --git a/docs/public/k8s.md b/docs/public/k8s.md index b597d6a737..a3444d38f9 100644 --- a/docs/public/k8s.md +++ b/docs/public/k8s.md @@ -168,7 +168,7 @@ Local and Worktree profiles run agents inside the Kandev pod. Install agent CLIs The checked-in ConfigMap disables Local Docker. Do not add only a Docker socket mount: the current runtime also needs helper, credential-session, and local-clone bind sources to exist at identical paths on the Docker daemon host, and it currently selects a Linux/amd64 helper. See [containerized control plane limitation](docker.md#containerized-control-plane-limitation). A privileged Docker-in-Docker sidecar has a separate security and persistence model and no supplied Kandev manifest. -SSH and Sprites profiles can run from Kubernetes if the pod can reach their endpoints and has the required secrets/helper bundle. SSH currently does not materialize attached repositories; review [SSH limitations](executors.md#current-repository-limitation). Remote Docker is unimplemented. +SSH and Sprites profiles can run from Kubernetes if the pod can reach their endpoints and has the required secrets/helper bundle. SSH can materialize repository sources; review [SSH repository-source limits](executors.md#repository-sources-and-cleanup). Remote Docker is unimplemented. Interactive commands should run as the service user. With the base image, a Kubernetes exec starts as root, so use: diff --git a/docs/public/security.md b/docs/public/security.md index f95077dd35..e6f71d6286 100644 --- a/docs/public/security.md +++ b/docs/public/security.md @@ -74,6 +74,12 @@ Agent CLIs can also discover authentication from their normal home-directory fil See [Agents and profiles](agents-and-profiles.md) for exact profile fields and [Automation and MCP](automation-and-mcp.md) for unattended and external-client boundaries. +## Treat workspace sources as access grants + +Adding a repository or folder gives the task access to that source. A local folder is a live host-path grant, not an upload: Kandev does not copy, move, delete, or add marker files to it. Folder sources are therefore limited to Local/Local PC and Worktree tasks and are never sent to Docker, SSH, Sprites, or Remote Docker. + +Remote repository locators and clone credentials can reveal authority. Kandev does not persist credential-bearing URLs or include credentials in source metadata or logs. Use provider credentials or a safe cloneable locator, and never paste tokens into a repository URL, task prompt, or source display name. + ## Protect stored secrets Secrets created through Kandev are encrypted in the database with the AES-256 master key at `/data/master.key`. Protect both files: diff --git a/docs/public/tasks-and-workflows.md b/docs/public/tasks-and-workflows.md index 7f7a490f38..875e108041 100644 --- a/docs/public/tasks-and-workflows.md +++ b/docs/public/tasks-and-workflows.md @@ -9,15 +9,15 @@ A task is the unit of work. A workflow is the ordered process that task follows. ## Understand the model -| Concept | What it controls | -|---|---| -| Workspace | The scope containing repositories, workflows, tasks, integrations, and workspace defaults. | -| Workflow | An ordered set of steps plus the rules that run when a task or agent turn reaches an event. | -| Workflow step | The task's current process position, such as Backlog, Work, Review, or Done. | -| Task | The title, prompt, workflow position, repository attachments, sessions, and one shared plan. | -| Task repository | A repository, base branch, and optional checkout branch attached to a task. A task can have more than one. | -| Session | One agent conversation attached to a task. Several sessions can share the same task environment. | -| Plan | The task's single editable Markdown plan, with version history. Consecutive writes can be coalesced into one revision. | +| Concept | What it controls | +| --------------- | ---------------------------------------------------------------------------------------------------------------------- | +| Workspace | The scope containing repositories, workflows, tasks, integrations, and workspace defaults. | +| Workflow | An ordered set of steps plus the rules that run when a task or agent turn reaches an event. | +| Workflow step | The task's current process position, such as Backlog, Work, Review, or Done. | +| Task | The title, prompt, workflow position, repository attachments, sessions, and one shared plan. | +| Task repository | A repository, base branch, and optional checkout branch attached to a task. A task can have more than one. | +| Session | One agent conversation attached to a task. Several sessions can share the same task environment. | +| Plan | The task's single editable Markdown plan, with version history. Consecutive writes can be coalesced into one revision. | Workflow position and runtime state are different. Moving a card changes its workflow step; it does not prove that an agent ran, code was committed, review passed, or a pull request merged. @@ -52,16 +52,15 @@ Use **New Task** in the sidebar. In an open task, the **Task** split button also 2. Select the workspace and workflow when Kandev cannot infer them. A regular non-ephemeral task must belong to a workflow. 3. Select a source: - | Source | Use it for | Important behavior | - |---|---|---| - | **Repo** | A configured, discovered, or new local repository | Select a base branch for each repository row. For a single-row new task, **Create new repository** initializes an empty `main` repository in a parent folder you choose. Add more rows for a multi-repository task. | - | **Remote** | A remote repository | Search configured GitHub, GitLab, or Azure DevOps repositories, or paste a supported URL. A pasted URL stays editable until you press Enter; then select the branch. Anonymous, credential-free reads include public GitHub repository branches, pull requests, and issues, plus public `gitlab.com` branch discovery. Private resources and authenticated browse/write features require valid provider credentials. | - | **None** | Planning, research, or work outside Git | Use a scratch workspace or an optional folder on the Kandev host. Git worktree execution and repository-aware Changes, branch, and pull-request features are unavailable. | + | Source | Use it for | Important behavior | + | ---------- | ------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | + | **Repo** | A configured, discovered, or new local repository | Select a base branch for each repository row. For a single-row new task, **Create new repository** initializes an empty `main` repository in a parent folder you choose. Add more rows for a multi-repository task. | + | **Remote** | A remote repository | Search configured GitHub, GitLab, or Azure DevOps repositories, or paste a supported URL. A pasted URL stays editable until you press Enter; then select the branch. Anonymous, credential-free reads include public GitHub repository branches, pull requests, and issues, plus public `gitlab.com` branch discovery. Private resources and authenticated browse/write features require valid provider credentials. | + | **None** | Planning, research, or work outside Git | Use a scratch workspace or an optional folder on the Kandev host. Git worktree execution and repository-aware Changes, branch, and pull-request features are unavailable. | 4. Select a compatible executor profile and agent profile. A workflow default agent profile locks the task-level agent selector. Executor and agent compatibility is validated before launch. 5. Enter the initial description. In the **New Task** dialog, an empty description changes the primary action to **Start Plan Mode**; the other dialog actions require a description. Agent-facing task MCP has different empty-description rules. A nonempty description exposes the standard split actions. 6. Choose the applicable action: - - **Start Plan Mode** is the primary empty-description action and creates the task through the plan-mode path. - **Start task** requires a nonempty description, creates the task, and starts its agent. - **Start task in plan mode** requires a nonempty description and starts the agent with plan mode enabled. This path starts in the first positional workflow step, even if another step is marked **Start step**. @@ -84,12 +83,36 @@ Select an option, then choose **Save changes**. The only affected Kandev MCP too ### Multiple repositories -A task can include several local or remote repository rows. Multi-repository task creation currently requires the **git-worktree** executor; the dialog leaves incompatible executors visible but disables them with `Multi-repo tasks only support the git-worktree executor.` Public GitHub and GitLab repositories can be cloned and fetched anonymously. Private repositories and authenticated browse/write features need credentials that can access the selected base branch. +A task can include several local or remote repository rows. Multi-repository creation supports **Worktree**, **Local Docker**, **SSH**, and **Sprites**. Local/Local PC creation remains unavailable until its initial-launch path can materialize sibling repositories, and Remote Docker is not implemented. Public GitHub and GitLab repositories can be cloned and fetched anonymously. Private repositories and authenticated browse/write features need credentials that can access the selected base branch. If Kandev cannot resolve a pasted remote URL or its branch, the repository row keeps the URL and shows the provider error. Use **Retry** after correcting the URL or when a transient provider failure has cleared. Changes and review are scoped by repository. State the expected deliverable, base branch, and pull-request target for every attachment. See [Coordinate work](coordination.md) for adding branches after creation and splitting multi-repository work. +### Add sources to an existing task + +For a non-archived, repository-backed task, open the **Files** panel and choose **Workspace actions → Add Repositories to workspace**. Use **Add repository** to choose a workspace repository, an existing local Git checkout, or a provider-backed/pasted remote URL. The workspace option shares task creation's saved/discovered selector, refresh, and create-repository actions. Use **Add folder** for an arbitrary local folder when the executor supports it. Add one or more rows in a single submission. Repository rows choose a base branch once; the flow does not ask for a second checkout branch. Local/Local PC uses the user-owned repository's current checkout and never switches it. The whole mixed batch succeeds or fails together. + +The task must be idle: Kandev disables the action while a turn or tool call is active, and rejects a race without changing the task. Desktop opens a dialog; phones open the same flow in a full-height drawer. On success, repositories appear in Files and repository-aware Changes, branch, editor, and pull-request surfaces; folders are Files-only. + +Before submission, the dialog or drawer summarizes the effect on the workspace, session context, +and running processes. **Cancel** or closing the surface sends no request and changes nothing. A +submitted batch remains all-or-nothing. + +If adding a source promotes a Worktree or Local/Local PC workspace from one repository directory to +the task root, Kandev restarts the idle agent in the new root. Existing files, Git changes, task +state, messages, plan, attached sources, model, and mode remain. Native cross-directory resume is +retained where supported; otherwise Kandev starts a fresh provider session and supplies recorded +conversation context with the next prompt. Provider-private context not recorded by Kandev may not +carry over. The intentional restart is not shown as a previous agent error. + +The host rebind stops open task terminals, dev servers, the task editor server, and other +agentctl-managed workspace processes, so save unsaved work and restart those processes afterward. +Local Docker, SSH, and Sprites attach repository siblings to the current remote workspace and rescan +without restarting the agent or changing its CWD. + +Folders are live host paths and are available only to **Local/Local PC** and **Worktree** tasks. Repository sources are supported for **Worktree**, **Local/Local PC**, **Local Docker**, **SSH**, and **Sprites**. Local Git rows need a cloneable origin on Docker, SSH, and Sprites; Worktree and Local/Local PC can use the host repository directly. See [Executors](executors.md#workspace-sources) and [Coordinate work](coordination.md#add-sources-after-creation) for runtime limits and recovery behavior. + ### Attachments and local-change consent The task prompt supports image, audio, and resource attachments. The backend accepts at most 10 attachments and rejects an encoded item or encoded batch larger than 10 MB. The picker also applies a 10 MB raw-file and 20 MB raw-total guard, so encoding overhead can make the backend limit stricter than the picker limit. @@ -138,18 +161,18 @@ You can add, reorder, edit, and delete steps. Deleting a step that still contain New steps allow manual moves by default. **Show in command panel** also defaults on. WIP is unlimited and auto-archive is off until configured. -| Setting | Effect | -|---|---| -| **Start step** | Makes this the normal starting step. Only one step per workflow should be selected. If none is selected, Kandev falls back to the first positional step. | -| Agent profile | Overrides the workflow/task profile when entering this step. A different profile creates a new session with fresh conversation context. | -| **Auto-start agent** | Starts an agent whenever a task enters the step. | -| **Plan mode** | Enables plan mode when the task enters the step. | -| **Reset agent context** | Starts with fresh conversation context on entry. It is disabled when the step has a profile override because the profile switch already creates a fresh session. | -| **Allow manual move** | Allows dragging a task into this step. Treat it as workflow UX, not as a security or approval boundary. | -| **Show in command panel** | Includes tasks in this step in the default, empty-search **Cmd+K** task list. Typed task search currently searches every step and can also return archived tasks, regardless of this setting. | -| **Auto-archive** | Archives inactive tasks after the configured number of hours. Enabling it starts at 24 hours; the minimum is 1. | -| **WIP limit** | Maximum active, non-archived, non-ephemeral tasks accepted by the step. `0` means unlimited. A move into a full step is rejected; reordering within the same step does not consume another slot. | -| **Pull from** | Optional feeder step. When capacity opens, Kandev pulls eligible work from that step. It requires a nonzero WIP limit. | +| Setting | Effect | +| ------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **Start step** | Makes this the normal starting step. Only one step per workflow should be selected. If none is selected, Kandev falls back to the first positional step. | +| Agent profile | Overrides the workflow/task profile when entering this step. A different profile creates a new session with fresh conversation context. | +| **Auto-start agent** | Starts an agent whenever a task enters the step. | +| **Plan mode** | Enables plan mode when the task enters the step. | +| **Reset agent context** | Starts with fresh conversation context on entry. It is disabled when the step has a profile override because the profile switch already creates a fresh session. | +| **Allow manual move** | Allows dragging a task into this step. Treat it as workflow UX, not as a security or approval boundary. | +| **Show in command panel** | Includes tasks in this step in the default, empty-search **Cmd+K** task list. Typed task search currently searches every step and can also return archived tasks, regardless of this setting. | +| **Auto-archive** | Archives inactive tasks after the configured number of hours. Enabling it starts at 24 hours; the minimum is 1. | +| **WIP limit** | Maximum active, non-archived, non-ephemeral tasks accepted by the step. `0` means unlimited. A move into a full step is rejected; reordering within the same step does not consume another slot. | +| **Pull from** | Optional feeder step. When capacity opens, Kandev pulls eligible work from that step. It requires a nonzero WIP limit. | Auto-archive is checked on a five-minute background interval and uses the task's last update time. Any task update postpones eligibility, so the archive is not guaranteed at the exact configured minute. Auto-archive affects the task itself, not its children. @@ -157,10 +180,10 @@ Pull configuration rejects self-references, cycles, and cross-workflow feeders. ### Configure events and transitions -| Event | Available transition | -|---|---| -| **On Turn Start** | Do nothing, move next, move previous, or move to a selected step when the user sends a message. | -| **On Turn Complete** | **Do nothing (wait for user)**, move next, move previous, or move to a selected step after the agent turn. | +| Event | Available transition | +| ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| **On Turn Start** | Do nothing, move next, move previous, or move to a selected step when the user sends a message. | +| **On Turn Complete** | **Do nothing (wait for user)**, move next, move previous, or move to a selected step after the agent turn. | | **When Child Tasks Complete** | Do nothing, move next, move previous, or move to a selected step after every active direct child reaches `COMPLETED`, `FAILED`, or `CANCELLED`, provided the parent has an active session. | The child-completion event ignores archived and ephemeral children, does not inspect grandchildren, and does nothing when the parent has no children. It also requires a parent session in `CREATED`, `STARTING`, `RUNNING`, or `WAITING_FOR_INPUT`; a parent with no session, or only an `IDLE`, `COMPLETED`, `FAILED`, or `CANCELLED` session, does not transition. @@ -216,12 +239,12 @@ Revision history is not an immutable record of every autosave. Consecutive write > [!EXPERIMENTAL] > Office is feature-flagged, disabled in the production profile by default, and still in progress. Its named documents, labels, and blocker controls are not stable regular-Kanban features. -| Capability | Regular Kanban | Office | -|---|---|---| -| One versioned task plan | Available | Available in Office-specific surfaces where enabled | -| Multiple named task documents | Not exposed | In-progress Office capability | -| Task label editor and label filters | Not exposed | In-progress Office capability | -| Blocked-by / blocking property editor | Not exposed | In-progress Office capability | +| Capability | Regular Kanban | Office | +| ------------------------------------- | -------------- | --------------------------------------------------- | +| One versioned task plan | Available | Available in Office-specific surfaces where enabled | +| Multiple named task documents | Not exposed | In-progress Office capability | +| Task label editor and label filters | Not exposed | In-progress Office capability | +| Blocked-by / blocking property editor | Not exposed | In-progress Office capability | Stored related-task data can include blocker relationships, but regular Kanban has no blocker editor or blocker filter. Use workflow gates, direct-child completion, and explicit messages for supported Kanban coordination. Do not treat Office's named documents, labels, or blocker UI as a stable public contract yet. @@ -229,14 +252,14 @@ Stored related-task data can include blocker relationships, but regular Kanban h Archive records the task as archived and removes it from active views immediately. Runtime stopping and physical cleanup then run in the background with a 60-second timeout. Cleanup is best-effort: a stop or deletion failure is logged and does not undo the archive, and Kandev preserves a runtime or environment when a nonterminal session cannot be stopped. Shared inherited environments and borrowed worktrees are also preserved while another active task still uses them. -| Executor | Archive cleanup | -|---|---| -| Local | Attempts to stop the agent runtime; leaves the local folder, files, and branch untouched. | -| Git worktree | Attempts to remove the Kandev-owned worktree and its local task branch. It does not delete the remote branch, and shared or borrowed worktrees can remain until their last active user is gone. | -| Local Docker | Attempts to stop and remove the container; the host repository remains. | -| Remote Docker | Runtime create and stop are not implemented. This executor is in progress and cannot currently start a task, so it has no supported archive-cleanup flow. | -| Sprites | Attempts to destroy the sandbox; if cleanup succeeds, uncommitted sandbox work is lost. | -| SSH | Attempts to stop the remote session runtime, but the remote task directory remains. Audit and remove retained task directories manually after confirming that no session needs them. | +| Executor | Archive cleanup | +| ------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Local | Attempts to stop the agent runtime; leaves the local folder, files, and branch untouched. | +| Git worktree | Attempts to remove the Kandev-owned worktree and its local task branch. It does not delete the remote branch, and shared or borrowed worktrees can remain until their last active user is gone. | +| Local Docker | Attempts to stop and remove the container; the host repository remains. | +| Remote Docker | Runtime create and stop are not implemented. This executor is in progress and cannot currently start a task, so it has no supported archive-cleanup flow. | +| Sprites | Attempts to destroy the sandbox; if cleanup succeeds, uncommitted sandbox work is lost. | +| SSH | Attempts to stop the remote session runtime, but the remote task directory remains. Audit and remove retained task directories manually after confirming that no session needs them. | The archive confirmation is enabled by default at **Settings → General → Task Actions → Archive Confirmation** under **Confirm before archiving tasks**. If a parent has children, **Also archive _N_ subtasks** is unchecked by default; without it, the children remain active. Task MCP archive/delete operations affect only the selected task and do not offer the cascade checkbox. MCP delete also does not reparent direct children the way the UI's non-cascade delete does; use the UI rather than task MCP to delete a parent that still has children. diff --git a/docs/specs/INDEX.md b/docs/specs/INDEX.md index deeac10a2f..713335e0d3 100644 --- a/docs/specs/INDEX.md +++ b/docs/specs/INDEX.md @@ -53,6 +53,7 @@ Kandev's task model: documents, execution stages, labels, blocker escalation, su | [labels](tasks/labels.md) | shipped | | [model-unification](tasks/model-unification.md) | draft | | [without-repositories](tasks/without-repositories.md) | draft | +| [attach-workspace-sources](tasks/attach-workspace-sources.md) | building | | [subtask-checklist](tasks/subtask-checklist.md) | shipped | | [subtask-detachment](tasks/subtask-detachment.md) | shipped | | [subtask-completion-trigger](tasks/subtask-completion-trigger.md) | draft | diff --git a/docs/specs/tasks/attach-workspace-sources.md b/docs/specs/tasks/attach-workspace-sources.md new file mode 100644 index 0000000000..c357870706 --- /dev/null +++ b/docs/specs/tasks/attach-workspace-sources.md @@ -0,0 +1,230 @@ +--- +status: building +created: 2026-07-22 +owner: kandev +--- + +# Attach Workspace Sources + +## Why + +Tasks often grow beyond the repositories selected at creation time. Users need to add another +repository or supporting folder without recreating the task, losing conversation context, or +manually moving files into the task workspace. + +## What + +- A repository-backed task exposes one **Workspace actions** menu in the Files panel on desktop and + mobile. The menu contains **Add Repositories to workspace** and **Open workspace folder** rather + than separate toolbar controls. +- **Add sources** uses the same repository-selection language as task creation without adding a + second mode switch. One **Add repository** menu offers **Workspace repository**, **Local Git + repository**, and **Remote repository**; **Add folder** remains a separate action when supported. +- Subject to the task executor's capabilities, one submission can add one or more mixed sources. + **Workspace repository** combines saved workspace repositories and discovered local Git + repositories in the shared repository selector, including its refresh and create-repository + actions. **Local Git repository** accepts an existing checkout, **Remote repository** reuses the + provider-backed and pasted-URL selector from task creation, and **Add folder** offers an arbitrary + local folder when supported. +- Adding another repository or folder appends a row without hiding or discarding configured rows, + so one submission can mix workspace, local, remote, and folder sources. +- Repository rows choose a base branch with the shared task-creation controls. The add-sources UI + does not expose a second checkout-branch field. +- A successful submission makes every added source visible as a named top-level entry in the Files + panel. Repository sources also appear in repository-aware Changes, branch, editor, and pull + request surfaces; folder sources remain file-only. +- Duplicate repository/branch pairs, duplicate canonical folder paths, cross-workspace repository + IDs, invalid remote URLs, and inaccessible local paths are rejected before the task changes. +- A source file rename may stay within its canonical workspace/source root; a cross-root move or + rename is rejected before either source is mutated. +- A multi-source submission is atomic from the user's perspective: either every source is attached + and materialized in the current task environment, or none of the new attachments remain. +- Repository attachment works for every executor that can run the task. Arbitrary folders are + available only to Local and Worktree tasks, where the selected host paths remain live. Container + and remote pickers do not offer the folder source kind, and the backend rejects a forged folder + request without changing the task. +- Kandev may re-root or restart an idle task environment when its executor cannot safely change the + agent working directory in place. The action is unavailable while a turn or tool call is active, + and the backend independently rejects that race with a conflict response. +- Before submission, the desktop dialog and phone drawer explain the executor-specific effect on + the agent working directory, provider session context, terminal and workspace processes, existing + files and Git changes, and atomic rollback. They state that **Cancel** leaves the task unchanged. +- Providers that honor a changed `session/load` working directory keep their native ACP session + after the re-root. Providers that do not are started in a fresh ACP session at the promoted task + root, and Kandev rehydrates the recorded conversation context with the next prompt. +- Worktree and Local/Local PC rebinds stop terminal shells, the task editor server, dev servers, and + other agentctl-managed workspace processes; users must reopen or restart them. Docker, SSH, and + Sprites attach repository siblings through the live workspace and rescan without restarting the + agent or those processes. +- When **Add Repositories to workspace** is unavailable, the combined Files action remains + reachable so **Open workspace folder** still works. The repository action is disabled and shows + the reason in touch-visible text rather than relying on a tooltip. +- Existing conversations, task state, plan, sessions, and repository attachments remain intact. +- Agents receive a batch `add_workspace_sources_kandev` MCP tool that uses the same validation and + materialization path. The existing `add_branch_to_task_kandev` tool remains as a compatibility + wrapper for one repository/branch source. + +Decisions: [ADR-2026-07-22-runtime-mutable-task-workspace-sources](../../decisions/2026-07-22-runtime-mutable-task-workspace-sources.md) +and [ADR-2026-07-23-workspace-source-root-move-boundary](../../decisions/2026-07-23-workspace-source-root-move-boundary.md). + +## Data model + +Repository attachments continue to use `task_repositories`; their current uniqueness contract on +`(task_id, repository_id, base_branch, checkout_branch)` is unchanged. + +Arbitrary folder attachments use `task_workspace_folders`: + +| Field | Contract | +| -------------------------- | ---------------------------------------------------- | +| `id` | Stable attachment identity. | +| `task_id` | Owning task; cascade-deleted with the task. | +| `local_path` | Canonical absolute path selected on the Kandev host. | +| `display_name` | Sanitized, non-empty top-level workspace entry name. | +| `position` | Stable order among folder attachments. | +| `created_at`, `updated_at` | Audit timestamps. | + +`(task_id, local_path)` and `(task_id, display_name)` are unique. The effective source projection +combines ordered `task_repositories` and `task_workspace_folders`; it does not replace repository +identity or make folders participate in Git operations. + +## API surface + +`POST /api/v1/tasks/:id/workspace-sources` + +```json +{ + "sources": [ + { + "kind": "repository", + "repository_id": "optional-workspace-repository-id", + "local_path": "optional-local-git-path", + "remote_url": "optional-provider-or-pasted-url", + "provider": "optional-provider", + "provider_repo_id": "optional-provider-id", + "provider_owner": "optional-provider-owner", + "provider_name": "optional-provider-name", + "base_branch": "main", + "checkout_branch": "optional-existing-branch" + }, + { + "kind": "folder", + "local_path": "/absolute/path/to/folder", + "display_name": "optional-name" + } + ] +} +``` + +The response returns the persisted source projection, the effective task workspace path, and the +affected session IDs. Validation errors return `400`, ownership/not-found errors return `404`, +duplicates or an active turn return `409`, and materialization failures return `422` after rollback. + +The backend publishes `task.updated` with both `repositories` and `workspace_folders`, then emits a +session-scoped workspace-sources update after agentctl has adopted the new workspace root. Clients +refresh the Files tree and repository trackers from those events rather than assuming the POST +response is the only writer. + +`add_workspace_sources_kandev` accepts the same source union and defaults `task_id` to the current +task. `add_branch_to_task_kandev` translates its existing arguments to a one-item repository batch. + +## Permissions + +The action follows Kandev's trusted-local-user model and is scoped to the task's workspace. Saved +repository IDs must belong to that workspace. Explicit local repository and folder selections grant +access only to their canonical paths, not to parent directories, sibling paths, or filesystem +volumes. Remote credentials follow the existing provider-neutral repository contract and are never +persisted in source URLs or copied into agent-visible metadata. + +## Failure modes + +| Condition | Observable behavior | +| -------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- | +| A turn or tool call is active | The UI disables the action when known; a racing request returns `409` without mutation. | +| Any source is invalid or duplicated | The full batch is rejected before persistence or materialization. | +| A host materializer fails | New filesystem entries and source records are rolled back; existing task contents remain. | +| A container/remote repository clone fails | Newly created remote entries are removed best-effort, durable attachments are rolled back, and the response identifies the failed source. | +| A container/remote task submits a folder source | The request returns `422` without persistence or filesystem changes. | +| Agentctl cannot rescan the new root | The attachment fails rather than reporting success with a stale Files tree. | +| An idle agent must restart to adopt the promoted root | The intentional stop is not shown as a prior agent failure; the replacement agent uses the promoted task root. | +| A requested file move or rename crosses canonical source roots | The request is rejected before either source is mutated. | +| A persisted local folder later disappears | The current live environment keeps its existing materialization; a new/reset environment surfaces the missing source and does not silently omit it. | +| The client disconnects during materialization | Rollback runs on a detached bounded context and the eventual task event reflects durable state. | + +## Persistence guarantees + +Repository and folder attachments survive backend restarts. Local/worktree environments continue to +resolve the exact canonical host path. New container or remote environments recreate repository +checkouts from durable repository attachments; they never persist folder attachments. Existing task +conversations and source records survive an environment restart even when runtime materialization +must be retried. + +## Scenarios + +- **GIVEN** a repository-backed task with the Files tree loaded, **WHEN** the user opens the + workspace-actions control, **THEN** one menu exposes **Add Repositories to workspace** and **Open + workspace folder**. +- **GIVEN** a running worktree task with one repository and no active turn, **WHEN** the user opens + **Add sources**, chooses **Workspace repository**, and adds a saved or discovered repository and + branch with the shared task-create selector, **THEN** the new worktree appears as a top-level + Files entry and in repository-aware Changes surfaces without recreating the task. +- **GIVEN** an idle task whose runtime resumes its agent or emits a late session-resumed status while + adopting an updated workspace root, **WHEN** the user attaches another source, **THEN** lifecycle + boot/status messages do not create a phantom turn and the subsequent attachment succeeds. +- **GIVEN** an idle single-repository task whose agent started in the repository directory, **WHEN** + another source promotes the workspace to the task root, **THEN** the next agent prompt runs from + the task root without a previous-agent-error banner; compatible providers retain their native + session and incompatible providers receive a fresh session with recorded conversation context. +- **GIVEN** an idle Worktree or Local task, **WHEN** the user opens **Add sources**, **THEN** a + visible consequence summary explains that the CWD moves to the task root, the agent and + agentctl-managed workspace processes restart or stop, recorded task context remains, and + provider-private context that Kandev did not record may not carry over. +- **GIVEN** an idle Docker, SSH, or Sprites task, **WHEN** the user opens **Add sources**, **THEN** + the consequence summary says repositories are attached and rescanned under the current remote + workspace without restarting the agent or changing its CWD. +- **GIVEN** configured source rows in either add-sources surface, **WHEN** the user chooses + **Cancel** or closes the surface before submission, **THEN** no attachment request is sent and + the task workspace remains unchanged. +- **GIVEN** an Add sources batch with a local row already configured, **WHEN** the user chooses + **Remote repository** from **Add repository**, **THEN** both rows remain visible in the batch and + submit atomically. +- **GIVEN** a repository-backed local task, **WHEN** the user adds a local Git repository and an + arbitrary folder in one submission, **THEN** both live sources appear under one task workspace and + the folder does not appear in Git-only controls. +- **GIVEN** a Docker, SSH, or Sprites task, **WHEN** the user opens **Add sources**, **THEN** the + workspace, local-Git, and remote repository choices are available from **Add repository**, and + the local-folder affordance is not offered. +- **GIVEN** a Docker, SSH, or Sprites task, **WHEN** a client submits a forged folder source, + **THEN** the backend returns `422` and leaves the task and executor filesystem unchanged. +- **GIVEN** a mixed three-source submission whose second source cannot be cloned, **WHEN** + materialization fails, **THEN** none of the three new attachments remain in the database, Files + tree, or executor workspace. +- **GIVEN** an active agent turn, **WHEN** the user attempts to add sources, **THEN** no source is + attached, the **Add Repositories to workspace** menu item explains that the task must be idle + first, and **Open workspace folder** remains available. +- **GIVEN** the same repository/branch or canonical folder is already attached, **WHEN** it is + submitted again, **THEN** the request returns a conflict naming the duplicate and leaves the task + unchanged. +- **GIVEN** a phone viewport on the Files tab, **WHEN** the user opens the 44px workspace-actions + control, **THEN** an inset bottom-sheet menu exposes both actions with touch-sized rows. +- **GIVEN** that phone action menu, **WHEN** the user selects **Add Repositories to workspace**, + chooses repository kinds from the touch-sized **Add repository** menu, adds two sources, and + submits, **THEN** a touch-usable full-height picker completes the same operation without + horizontal document overflow and returns focus to the workspace-actions control. +- **GIVEN** an agent calls `add_workspace_sources_kandev` for its current idle task, **WHEN** all + inputs materialize, **THEN** the UI receives the same task and session updates as the human flow. + +## Out of scope + +- Removing or detaching sources after they have been attached. +- Promoting a repository-less task into a repository-backed task. +- Copying, mounting, or synchronizing arbitrary host folders into container or remote executors. +- Attaching sources while an agent turn or tool call is running. +- Reordering sources after attachment. +- Sharing task-creation state, its mode switch, or its **None**/scratch semantics with Add sources; + only the repository picker leaves are shared. +- Making the unimplemented remote Docker executor runnable; its source-materializer capability is + required when that executor becomes available. + +## Implementation plan + +See [Attach Workspace Sources plan](../../plans/attach-workspace-sources/plan.md).