From 6ade6e8fa9491dd37413ab9a920db752249af4bf Mon Sep 17 00:00:00 2001 From: Jerry Yan <792602257@qq.com> Date: Mon, 22 Jun 2026 08:55:57 +0800 Subject: [PATCH] bugfix --- Dockerfile | 11 +- config.example.yaml | 180 +++- docker-compose.yml | 32 + go.mod | 10 +- go.sum | 24 +- internal/acp/agent_home.go | 15 +- internal/acp/agent_home_internal_test.go | 43 +- internal/acp/agentkind_service.go | 83 +- internal/acp/agentkind_service_test.go | 69 ++ internal/acp/dashboard_handler_test.go | 137 +++ internal/acp/domain.go | 242 +++++ internal/acp/dto.go | 174 +++- internal/acp/handler.go | 354 ++++++- internal/acp/handler_e2e_test.go | 140 ++- internal/acp/handlers/handlers.go | 9 +- internal/acp/handlers/server_fs_list.go | 88 ++ internal/acp/handlers/server_fs_list_test.go | 82 ++ internal/acp/handlers/server_grep.go | 209 ++++ internal/acp/handlers/server_grep_test.go | 125 +++ internal/acp/mcp_bridge.go | 119 ++- internal/acp/permission_hitl_test.go | 153 +++ internal/acp/permission_service.go | 117 ++- internal/acp/permission_service_test.go | 90 ++ .../acp/queries/agent_kind_config_files.sql | 4 +- internal/acp/queries/agent_kinds.sql | 47 +- internal/acp/queries/dashboard.sql | 59 ++ internal/acp/queries/session_usage.sql | 9 + internal/acp/queries/sessions.sql | 176 +++- internal/acp/queries/turns.sql | 39 + internal/acp/relay.go | 48 +- internal/acp/relay_discovery_test.go | 107 ++ internal/acp/relay_test.go | 267 +++++ internal/acp/repository.go | 258 ++++- internal/acp/repository_test.go | 218 ++++ internal/acp/sandbox/sandbox.go | 115 +++ .../acp/sandbox/sandbox_container_linux.go | 118 +++ internal/acp/sandbox/sandbox_linux.go | 15 + internal/acp/sandbox/sandbox_other.go | 9 + internal/acp/sandbox/sandbox_test.go | 59 ++ internal/acp/sandbox/sandbox_uid_linux.go | 92 ++ internal/acp/session_brief_internal_test.go | 148 +++ internal/acp/session_service.go | 322 +++++- internal/acp/session_service_test.go | 309 +++++- .../acp/sqlc/agent_kind_config_files.sql.go | 6 +- internal/acp/sqlc/agent_kinds.sql.go | 137 ++- internal/acp/sqlc/dashboard.sql.go | 201 ++++ internal/acp/sqlc/models.go | 257 ++++- internal/acp/sqlc/session_usage.sql.go | 54 + internal/acp/sqlc/sessions.sql.go | 628 +++++++++++- internal/acp/sqlc/turns.sql.go | 180 ++++ internal/acp/supervisor.go | 316 +++++- internal/acp/turn.go | 242 +++++ internal/acp/turn_test.go | 248 +++++ internal/acp/turnbus.go | 131 +++ internal/acp/turnbus_test.go | 75 ++ internal/acp/usage.go | 396 ++++++++ internal/acp/usage_repo.go | 317 ++++++ internal/acp/usage_test.go | 260 +++++ internal/app/acl_test.go | 48 + internal/app/acp_integration_test.go | 2 +- internal/app/app.go | 955 +++++++++++++++++- internal/app/integration_test.go | 4 +- internal/app/mcp_integration_test.go | 10 +- internal/app/orchestrator_adapters.go | 285 ++++++ internal/audit/handler.go | 158 +++ internal/audit/handler_test.go | 136 +++ internal/audit/queries/audit.sql | 29 + internal/audit/reader.go | 164 +++ internal/audit/sqlc/audit.sql.go | 116 +++ internal/audit/sqlc/models.go | 257 ++++- internal/brief/composer.go | 432 ++++++++ internal/brief/composer_test.go | 282 ++++++ internal/brief/orient.go | 69 ++ internal/brief/orient_test.go | 107 ++ internal/brief/prompts.go | 53 + internal/changerequest/domain.go | 119 +++ internal/changerequest/dto.go | 70 ++ internal/changerequest/handler.go | 218 ++++ internal/changerequest/handler_test.go | 140 +++ .../changerequest/queries/change_requests.sql | 63 ++ internal/changerequest/repository.go | 264 +++++ internal/changerequest/service.go | 338 +++++++ internal/changerequest/service_test.go | 312 ++++++ .../changerequest/sqlc/change_requests.sql.go | 429 ++++++++ internal/changerequest/sqlc/db.go | 32 + internal/changerequest/sqlc/models.go | 593 +++++++++++ internal/chat/domain.go | 15 + internal/chat/pricing.go | 14 +- internal/chat/pricing_test.go | 13 + internal/chat/service_message.go | 78 +- internal/chat/service_message_test.go | 129 ++- internal/chat/sqlc/endpoints.sql.go | 9 +- internal/chat/sqlc/models.go | 257 ++++- internal/codeindex/build_runner.go | 191 ++++ internal/codeindex/build_runner_test.go | 213 ++++ internal/codeindex/chunker.go | 216 ++++ internal/codeindex/chunker_test.go | 91 ++ internal/codeindex/domain.go | 75 ++ internal/codeindex/queries/chunks.sql | 9 + internal/codeindex/queries/runs.sql | 58 ++ internal/codeindex/repository.go | 294 ++++++ internal/codeindex/service.go | 195 ++++ internal/codeindex/service_test.go | 68 ++ internal/codeindex/sqlc/chunks.sql.go | 36 + internal/codeindex/sqlc/db.go | 32 + internal/codeindex/sqlc/models.go | 593 +++++++++++ internal/codeindex/sqlc/runs.sql.go | 251 +++++ internal/config/config.go | 342 ++++++- internal/config/config_test.go | 26 + internal/docs/domain.go | 53 + internal/docs/queries/summaries.sql | 29 + internal/docs/repository.go | 127 +++ internal/docs/service.go | 54 + internal/docs/service_test.go | 44 + internal/docs/sqlc/db.go | 32 + internal/docs/sqlc/models.go | 593 +++++++++++ internal/docs/sqlc/summaries.sql.go | 153 +++ internal/docs/summary_runner.go | 123 +++ internal/docs/summary_runner_test.go | 110 ++ internal/infra/crypto/keyed.go | 163 +++ internal/infra/crypto/keyed_test.go | 141 +++ internal/infra/crypto/keystore_postgres.go | 106 ++ internal/infra/crypto/provider.go | 103 ++ internal/infra/crypto/secret.go | 79 +- internal/infra/errs/errs.go | 34 +- internal/infra/errs/errs_test.go | 1 + internal/infra/git/diff.go | 106 ++ internal/infra/git/diff_test.go | 125 +++ internal/infra/git/diffstat.go | 17 + internal/infra/git/lsfiles.go | 34 + internal/infra/git/lsfiles_test.go | 56 + internal/infra/git/runner.go | 7 + internal/infra/llm/anthropic.go | 17 +- internal/infra/llm/embedder.go | 25 + internal/infra/llm/embedder_test.go | 129 +++ internal/infra/llm/fake.go | 59 ++ internal/infra/llm/gemini.go | 41 +- internal/infra/llm/openai.go | 46 +- internal/infra/llm/registry.go | 15 + internal/infra/metrics/metrics.go | 108 ++ internal/infra/metrics/metrics_test.go | 90 ++ internal/infra/notify/dispatcher.go | 11 + internal/infra/notify/handler.go | 91 ++ internal/infra/notify/notifier_email.go | 141 +++ internal/infra/notify/notifier_email_test.go | 195 ++++ internal/infra/notify/notifier_im.go | 131 +++ internal/infra/notify/notifier_im_test.go | 177 ++++ internal/infra/notify/prefs.go | 107 ++ internal/infra/notify/prefs_handler.go | 133 +++ internal/infra/notify/prefs_handler_test.go | 168 +++ internal/infra/notify/queries/prefs.sql | 14 + internal/infra/notify/sqlc/models.go | 257 ++++- internal/infra/notify/sqlc/prefs.sql.go | 62 ++ internal/infra/notify/stream.go | 84 ++ internal/infra/notify/stream_test.go | 120 +++ internal/infra/proc/proc_run.go | 168 +++ internal/infra/proc/proc_run_test.go | 143 +++ internal/infra/vcs/gitea.go | 259 +++++ internal/infra/vcs/gitea_test.go | 152 +++ internal/infra/vcs/parse.go | 89 ++ internal/infra/vcs/parse_test.go | 60 ++ internal/infra/vcs/provider.go | 75 ++ internal/jobs/domain.go | 17 + internal/jobs/handlers/secret_reencrypt.go | 100 ++ internal/jobs/handlers/secret_reencrypt_pg.go | 195 ++++ .../jobs/handlers/secret_reencrypt_test.go | 173 ++++ internal/jobs/module.go | 9 + internal/jobs/runners/acp_session_reaper.go | 148 +++ .../jobs/runners/acp_session_reaper_test.go | 150 +++ internal/jobs/runners/acp_turns_purge.go | 41 + internal/jobs/runners/audit_retention.go | 45 + internal/jobs/sqlc/models.go | 257 ++++- internal/mcp/auth_middleware.go | 14 +- internal/mcp/server.go | 16 +- internal/mcp/sqlc/models.go | 257 ++++- internal/mcp/token_service.go | 13 + internal/mcp/tools_acp.go | 262 ++++- internal/mcp/tools_acp_test.go | 167 +++ internal/mcp/tools_changerequest.go | 234 +++++ internal/mcp/tools_changerequest_test.go | 200 ++++ internal/mcp/tools_codeindex.go | 109 ++ internal/mcp/tools_codeindex_memory_test.go | 219 ++++ internal/mcp/tools_memory.go | 211 ++++ internal/mcp/tools_orchestrator.go | 85 ++ internal/mcp/tools_orchestrator_test.go | 108 ++ internal/mcp/tools_pm.go | 378 ++++++- internal/mcp/tools_pm_decomposition_test.go | 153 +++ internal/mcp/tools_pm_test.go | 166 +++ internal/mcp/tools_run.go | 102 ++ internal/mcp/tools_workspace.go | 54 + internal/mcp/tools_workspace_test.go | 31 + internal/orchestrator/config.go | 17 + internal/orchestrator/decomposition.go | 163 +++ internal/orchestrator/decomposition_test.go | 130 +++ internal/orchestrator/domain.go | 159 +++ internal/orchestrator/fakes_test.go | 412 ++++++++ internal/orchestrator/gate.go | 104 ++ internal/orchestrator/gate_test.go | 111 ++ internal/orchestrator/handler.go | 239 +++++ internal/orchestrator/helpers.go | 56 + internal/orchestrator/mcp_bridge.go | 37 + internal/orchestrator/planner.go | 135 +++ internal/orchestrator/planner_test.go | 110 ++ internal/orchestrator/pm_access.go | 26 + internal/orchestrator/queries/runs.sql | 56 + internal/orchestrator/queries/steps.sql | 77 ++ internal/orchestrator/repository.go | 381 +++++++ internal/orchestrator/runner.go | 107 ++ internal/orchestrator/scheduler.go | 184 ++++ internal/orchestrator/scheduler_test.go | 209 ++++ internal/orchestrator/service.go | 500 +++++++++ internal/orchestrator/service_test.go | 263 +++++ internal/orchestrator/sqlc/db.go | 32 + internal/orchestrator/sqlc/models.go | 593 +++++++++++ internal/orchestrator/sqlc/runs.sql.go | 253 +++++ internal/orchestrator/sqlc/steps.sql.go | 356 +++++++ internal/orchestrator/step_handler.go | 341 +++++++ internal/orchestrator/step_handler_test.go | 307 ++++++ internal/project/artifact_service.go | 48 +- internal/project/artifact_service_test.go | 125 ++- internal/project/dependency_service_test.go | 220 ++++ internal/project/domain.go | 179 +++- internal/project/handler.go | 134 +++ internal/project/handler_test.go | 34 +- internal/project/issue_service.go | 168 +++ internal/project/issue_service_test.go | 2 +- internal/project/members_test.go | 254 +++++ internal/project/phase_gate.go | 116 +++ internal/project/phase_gate_test.go | 142 +++ internal/project/project_service.go | 164 ++- internal/project/project_service_test.go | 292 +++++- internal/project/queries/artifacts.sql | 15 +- internal/project/queries/dependencies.sql | 92 ++ internal/project/queries/issues.sql | 29 +- internal/project/queries/members.sql | 21 + internal/project/queries/phase_pointers.sql | 20 + internal/project/repository.go | 342 +++++++ internal/project/requirement_service.go | 51 +- internal/project/requirement_service_test.go | 129 ++- internal/project/sqlc/artifacts.sql.go | 61 +- internal/project/sqlc/dependencies.sql.go | 352 +++++++ internal/project/sqlc/issues.sql.go | 98 +- internal/project/sqlc/members.sql.go | 120 +++ internal/project/sqlc/models.go | 257 ++++- internal/project/sqlc/phase_pointers.sql.go | 108 ++ internal/projectmemory/agents_md.go | 88 ++ internal/projectmemory/agents_md_test.go | 63 ++ internal/projectmemory/domain.go | 77 ++ internal/projectmemory/queries/memory.sql | 38 + internal/projectmemory/repository.go | 230 +++++ internal/projectmemory/service.go | 129 +++ internal/projectmemory/service_test.go | 133 +++ internal/projectmemory/sqlc/db.go | 32 + internal/projectmemory/sqlc/memory.sql.go | 268 +++++ internal/projectmemory/sqlc/models.go | 593 +++++++++++ internal/run/exec_dto.go | 62 ++ internal/run/exec_parsers.go | 308 ++++++ internal/run/exec_parsers_test.go | 129 +++ internal/run/exec_service.go | 246 +++++ internal/run/exec_service_test.go | 356 +++++++ internal/run/run_profile_service.go | 9 +- internal/run/sqlc/models.go | 257 ++++- internal/run/sqlc/run_profiles.sql.go | 15 +- internal/run/testdata/gotest.jsonl | 9 + internal/run/testdata/junit.xml | 12 + internal/run/testdata/vitest.json | 17 + internal/security/handler.go | 189 ++++ internal/security/handler_test.go | 122 +++ internal/transport/http/middleware/cors.go | 91 ++ .../transport/http/middleware/cors_test.go | 63 ++ .../http/middleware/security_headers.go | 78 ++ .../http/middleware/security_headers_test.go | 49 + internal/transport/http/router.go | 42 + .../transport/http/router_metrics_test.go | 71 ++ internal/user/login_throttle.go | 120 +++ internal/user/login_throttle_test.go | 105 ++ internal/user/service.go | 44 +- internal/user/sqlc/models.go | 257 ++++- internal/workspace/domain.go | 2 + internal/workspace/dto.go | 7 + internal/workspace/gitops_service.go | 60 +- internal/workspace/handler.go | 63 ++ internal/workspace/hooks_test.go | 62 ++ internal/workspace/sqlc/credentials.sql.go | 6 +- internal/workspace/sqlc/models.go | 257 ++++- internal/workspace/workspace_service.go | 12 + internal/workspace/workspace_service_test.go | 5 + internal/workspace/worktree_service.go | 28 +- migrations/0016_acp_turns.down.sql | 2 + migrations/0016_acp_turns.up.sql | 19 + migrations/0017_change_requests.down.sql | 3 + migrations/0017_change_requests.up.sql | 37 + migrations/0018_phase_gates.down.sql | 10 + migrations/0018_phase_gates.up.sql | 24 + migrations/0019_orchestrator.down.sql | 5 + migrations/0019_orchestrator.up.sql | 72 ++ migrations/0020_acp_cost_budgets.down.sql | 20 + migrations/0020_acp_cost_budgets.up.sql | 49 + .../0021_secret_key_versioning.down.sql | 16 + migrations/0021_secret_key_versioning.up.sql | 44 + migrations/0022_pgvector_code_index.down.sql | 5 + migrations/0022_pgvector_code_index.up.sql | 49 + migrations/0023_project_memory.down.sql | 1 + migrations/0023_project_memory.up.sql | 21 + migrations/0024_doc_summaries.down.sql | 1 + migrations/0024_doc_summaries.up.sql | 18 + migrations/0025_issue_graph.down.sql | 7 + migrations/0025_issue_graph.up.sql | 34 + migrations/0026_observability_hitl.down.sql | 12 + migrations/0026_observability_hitl.up.sql | 45 + sqlc.yaml | 50 + web/src/api/changeRequests.ts | 32 + web/src/api/orchestrator.ts | 57 ++ web/src/api/types.ts | 57 ++ web/src/api/workspaces.ts | 31 +- web/src/components/DiffViewer.vue | 154 +++ .../constants/requirementPhasePrompts.test.ts | 57 +- web/src/constants/requirementPhasePrompts.ts | 68 +- web/src/views/admin/AdminTerminalView.vue | 29 +- .../views/projects/RequirementDetailView.vue | 12 + .../components/RequirementChatPanel.vue | 9 +- .../RequirementOrchestratorPanel.vue | 205 ++++ .../views/workspaces/WorkspaceHomeView.vue | 72 +- .../components/ChangeRequestsTab.vue | 208 ++++ .../views/workspaces/components/DiffTab.vue | 82 ++ 325 files changed, 41131 insertions(+), 855 deletions(-) create mode 100644 internal/acp/dashboard_handler_test.go create mode 100644 internal/acp/handlers/server_fs_list.go create mode 100644 internal/acp/handlers/server_fs_list_test.go create mode 100644 internal/acp/handlers/server_grep.go create mode 100644 internal/acp/handlers/server_grep_test.go create mode 100644 internal/acp/permission_hitl_test.go create mode 100644 internal/acp/queries/dashboard.sql create mode 100644 internal/acp/queries/session_usage.sql create mode 100644 internal/acp/queries/turns.sql create mode 100644 internal/acp/relay_discovery_test.go create mode 100644 internal/acp/sandbox/sandbox.go create mode 100644 internal/acp/sandbox/sandbox_container_linux.go create mode 100644 internal/acp/sandbox/sandbox_linux.go create mode 100644 internal/acp/sandbox/sandbox_other.go create mode 100644 internal/acp/sandbox/sandbox_test.go create mode 100644 internal/acp/sandbox/sandbox_uid_linux.go create mode 100644 internal/acp/session_brief_internal_test.go create mode 100644 internal/acp/sqlc/dashboard.sql.go create mode 100644 internal/acp/sqlc/session_usage.sql.go create mode 100644 internal/acp/sqlc/turns.sql.go create mode 100644 internal/acp/turn.go create mode 100644 internal/acp/turn_test.go create mode 100644 internal/acp/turnbus.go create mode 100644 internal/acp/turnbus_test.go create mode 100644 internal/acp/usage.go create mode 100644 internal/acp/usage_repo.go create mode 100644 internal/acp/usage_test.go create mode 100644 internal/app/acl_test.go create mode 100644 internal/app/orchestrator_adapters.go create mode 100644 internal/audit/handler.go create mode 100644 internal/audit/handler_test.go create mode 100644 internal/audit/reader.go create mode 100644 internal/brief/composer.go create mode 100644 internal/brief/composer_test.go create mode 100644 internal/brief/orient.go create mode 100644 internal/brief/orient_test.go create mode 100644 internal/brief/prompts.go create mode 100644 internal/changerequest/domain.go create mode 100644 internal/changerequest/dto.go create mode 100644 internal/changerequest/handler.go create mode 100644 internal/changerequest/handler_test.go create mode 100644 internal/changerequest/queries/change_requests.sql create mode 100644 internal/changerequest/repository.go create mode 100644 internal/changerequest/service.go create mode 100644 internal/changerequest/service_test.go create mode 100644 internal/changerequest/sqlc/change_requests.sql.go create mode 100644 internal/changerequest/sqlc/db.go create mode 100644 internal/changerequest/sqlc/models.go create mode 100644 internal/codeindex/build_runner.go create mode 100644 internal/codeindex/build_runner_test.go create mode 100644 internal/codeindex/chunker.go create mode 100644 internal/codeindex/chunker_test.go create mode 100644 internal/codeindex/domain.go create mode 100644 internal/codeindex/queries/chunks.sql create mode 100644 internal/codeindex/queries/runs.sql create mode 100644 internal/codeindex/repository.go create mode 100644 internal/codeindex/service.go create mode 100644 internal/codeindex/service_test.go create mode 100644 internal/codeindex/sqlc/chunks.sql.go create mode 100644 internal/codeindex/sqlc/db.go create mode 100644 internal/codeindex/sqlc/models.go create mode 100644 internal/codeindex/sqlc/runs.sql.go create mode 100644 internal/docs/domain.go create mode 100644 internal/docs/queries/summaries.sql create mode 100644 internal/docs/repository.go create mode 100644 internal/docs/service.go create mode 100644 internal/docs/service_test.go create mode 100644 internal/docs/sqlc/db.go create mode 100644 internal/docs/sqlc/models.go create mode 100644 internal/docs/sqlc/summaries.sql.go create mode 100644 internal/docs/summary_runner.go create mode 100644 internal/docs/summary_runner_test.go create mode 100644 internal/infra/crypto/keyed.go create mode 100644 internal/infra/crypto/keyed_test.go create mode 100644 internal/infra/crypto/keystore_postgres.go create mode 100644 internal/infra/crypto/provider.go create mode 100644 internal/infra/git/diff.go create mode 100644 internal/infra/git/diff_test.go create mode 100644 internal/infra/git/diffstat.go create mode 100644 internal/infra/git/lsfiles.go create mode 100644 internal/infra/git/lsfiles_test.go create mode 100644 internal/infra/llm/embedder.go create mode 100644 internal/infra/llm/embedder_test.go create mode 100644 internal/infra/metrics/metrics.go create mode 100644 internal/infra/metrics/metrics_test.go create mode 100644 internal/infra/notify/notifier_email.go create mode 100644 internal/infra/notify/notifier_email_test.go create mode 100644 internal/infra/notify/notifier_im.go create mode 100644 internal/infra/notify/notifier_im_test.go create mode 100644 internal/infra/notify/prefs.go create mode 100644 internal/infra/notify/prefs_handler.go create mode 100644 internal/infra/notify/prefs_handler_test.go create mode 100644 internal/infra/notify/queries/prefs.sql create mode 100644 internal/infra/notify/sqlc/prefs.sql.go create mode 100644 internal/infra/notify/stream.go create mode 100644 internal/infra/notify/stream_test.go create mode 100644 internal/infra/proc/proc_run.go create mode 100644 internal/infra/proc/proc_run_test.go create mode 100644 internal/infra/vcs/gitea.go create mode 100644 internal/infra/vcs/gitea_test.go create mode 100644 internal/infra/vcs/parse.go create mode 100644 internal/infra/vcs/parse_test.go create mode 100644 internal/infra/vcs/provider.go create mode 100644 internal/jobs/handlers/secret_reencrypt.go create mode 100644 internal/jobs/handlers/secret_reencrypt_pg.go create mode 100644 internal/jobs/handlers/secret_reencrypt_test.go create mode 100644 internal/jobs/runners/acp_session_reaper.go create mode 100644 internal/jobs/runners/acp_session_reaper_test.go create mode 100644 internal/jobs/runners/acp_turns_purge.go create mode 100644 internal/jobs/runners/audit_retention.go create mode 100644 internal/mcp/tools_changerequest.go create mode 100644 internal/mcp/tools_changerequest_test.go create mode 100644 internal/mcp/tools_codeindex.go create mode 100644 internal/mcp/tools_codeindex_memory_test.go create mode 100644 internal/mcp/tools_memory.go create mode 100644 internal/mcp/tools_orchestrator.go create mode 100644 internal/mcp/tools_orchestrator_test.go create mode 100644 internal/mcp/tools_pm_decomposition_test.go create mode 100644 internal/orchestrator/config.go create mode 100644 internal/orchestrator/decomposition.go create mode 100644 internal/orchestrator/decomposition_test.go create mode 100644 internal/orchestrator/domain.go create mode 100644 internal/orchestrator/fakes_test.go create mode 100644 internal/orchestrator/gate.go create mode 100644 internal/orchestrator/gate_test.go create mode 100644 internal/orchestrator/handler.go create mode 100644 internal/orchestrator/helpers.go create mode 100644 internal/orchestrator/mcp_bridge.go create mode 100644 internal/orchestrator/planner.go create mode 100644 internal/orchestrator/planner_test.go create mode 100644 internal/orchestrator/pm_access.go create mode 100644 internal/orchestrator/queries/runs.sql create mode 100644 internal/orchestrator/queries/steps.sql create mode 100644 internal/orchestrator/repository.go create mode 100644 internal/orchestrator/runner.go create mode 100644 internal/orchestrator/scheduler.go create mode 100644 internal/orchestrator/scheduler_test.go create mode 100644 internal/orchestrator/service.go create mode 100644 internal/orchestrator/service_test.go create mode 100644 internal/orchestrator/sqlc/db.go create mode 100644 internal/orchestrator/sqlc/models.go create mode 100644 internal/orchestrator/sqlc/runs.sql.go create mode 100644 internal/orchestrator/sqlc/steps.sql.go create mode 100644 internal/orchestrator/step_handler.go create mode 100644 internal/orchestrator/step_handler_test.go create mode 100644 internal/project/dependency_service_test.go create mode 100644 internal/project/members_test.go create mode 100644 internal/project/phase_gate.go create mode 100644 internal/project/phase_gate_test.go create mode 100644 internal/project/queries/dependencies.sql create mode 100644 internal/project/queries/members.sql create mode 100644 internal/project/queries/phase_pointers.sql create mode 100644 internal/project/sqlc/dependencies.sql.go create mode 100644 internal/project/sqlc/members.sql.go create mode 100644 internal/project/sqlc/phase_pointers.sql.go create mode 100644 internal/projectmemory/agents_md.go create mode 100644 internal/projectmemory/agents_md_test.go create mode 100644 internal/projectmemory/domain.go create mode 100644 internal/projectmemory/queries/memory.sql create mode 100644 internal/projectmemory/repository.go create mode 100644 internal/projectmemory/service.go create mode 100644 internal/projectmemory/service_test.go create mode 100644 internal/projectmemory/sqlc/db.go create mode 100644 internal/projectmemory/sqlc/memory.sql.go create mode 100644 internal/projectmemory/sqlc/models.go create mode 100644 internal/run/exec_dto.go create mode 100644 internal/run/exec_parsers.go create mode 100644 internal/run/exec_parsers_test.go create mode 100644 internal/run/exec_service.go create mode 100644 internal/run/exec_service_test.go create mode 100644 internal/run/testdata/gotest.jsonl create mode 100644 internal/run/testdata/junit.xml create mode 100644 internal/run/testdata/vitest.json create mode 100644 internal/security/handler.go create mode 100644 internal/security/handler_test.go create mode 100644 internal/transport/http/middleware/cors.go create mode 100644 internal/transport/http/middleware/cors_test.go create mode 100644 internal/transport/http/middleware/security_headers.go create mode 100644 internal/transport/http/middleware/security_headers_test.go create mode 100644 internal/transport/http/router_metrics_test.go create mode 100644 internal/user/login_throttle.go create mode 100644 internal/user/login_throttle_test.go create mode 100644 internal/workspace/hooks_test.go create mode 100644 migrations/0016_acp_turns.down.sql create mode 100644 migrations/0016_acp_turns.up.sql create mode 100644 migrations/0017_change_requests.down.sql create mode 100644 migrations/0017_change_requests.up.sql create mode 100644 migrations/0018_phase_gates.down.sql create mode 100644 migrations/0018_phase_gates.up.sql create mode 100644 migrations/0019_orchestrator.down.sql create mode 100644 migrations/0019_orchestrator.up.sql create mode 100644 migrations/0020_acp_cost_budgets.down.sql create mode 100644 migrations/0020_acp_cost_budgets.up.sql create mode 100644 migrations/0021_secret_key_versioning.down.sql create mode 100644 migrations/0021_secret_key_versioning.up.sql create mode 100644 migrations/0022_pgvector_code_index.down.sql create mode 100644 migrations/0022_pgvector_code_index.up.sql create mode 100644 migrations/0023_project_memory.down.sql create mode 100644 migrations/0023_project_memory.up.sql create mode 100644 migrations/0024_doc_summaries.down.sql create mode 100644 migrations/0024_doc_summaries.up.sql create mode 100644 migrations/0025_issue_graph.down.sql create mode 100644 migrations/0025_issue_graph.up.sql create mode 100644 migrations/0026_observability_hitl.down.sql create mode 100644 migrations/0026_observability_hitl.up.sql create mode 100644 web/src/api/changeRequests.ts create mode 100644 web/src/api/orchestrator.ts create mode 100644 web/src/components/DiffViewer.vue create mode 100644 web/src/views/projects/components/RequirementOrchestratorPanel.vue create mode 100644 web/src/views/workspaces/components/ChangeRequestsTab.vue create mode 100644 web/src/views/workspaces/components/DiffTab.vue diff --git a/Dockerfile b/Dockerfile index f3cae86..292a882 100644 --- a/Dockerfile +++ b/Dockerfile @@ -27,9 +27,16 @@ FROM registry.jerryyan.top/library/alpine:3.24 # 均为 npm 包)。npm 全局 prefix 指到 /data/npm-global(持久卷),容器内 # `npm i -g xxx` 一次安装,重建镜像后无需重装;binary_path 填 # /data/npm-global/bin/ 即可。 +# bubblewrap + util-linux(prlimit/setpriv) 供 acp.sandbox mode=uid/container 使用: +# - prlimit 给 agent 子进程套 RLIMIT_AS/NPROC/CPU/FSIZE(sandbox_uid_linux.go) +# - bubblewrap 作 rootless 隔离(USER app 无 CAP_SYS_ADMIN,bwrap 走 user namespace) +# mode=none(默认)时这些工具不被调用,安装它们对默认部署无副作用。 RUN sed -i 's/dl-cdn.alpinelinux.org/mirrors.aliyun.com/g' /etc/apk/repositories \ - && apk add --no-cache ca-certificates tzdata git openssh-client nodejs npm && \ - addgroup -S app && adduser -S app -G app + && apk add --no-cache ca-certificates tzdata git openssh-client nodejs npm \ + bubblewrap util-linux && \ + addgroup -S app && adduser -S app -G app && \ + addgroup -S sandbox && \ + for i in 0 1 2 3 4 5 6 7 8 9; do adduser -S -D -H -G sandbox "sbx$i" 2>/dev/null || true; done WORKDIR /app COPY --from=go /out/server /app/server COPY migrations /app/migrations diff --git a/config.example.yaml b/config.example.yaml index 4db4c21..81b31ee 100644 --- a/config.example.yaml +++ b/config.example.yaml @@ -20,6 +20,45 @@ master_key: "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff" http: # HTTP 监听地址,格式 host:port,留空 host 表示监听所有网卡 addr: ":8080" + # 可选 TLS:enabled=true 时以 HTTPS 提供服务,读取 cert/key 文件。 + tls: + enabled: false + cert_file: "" + key_file: "" + # 跨域中间件:默认 off,避免误配破坏 SPA + WS 握手。开启时填 origin 白名单。 + cors: + enabled: false + allowed_origins: [] # 如 ["https://app.example.com"] + allowed_methods: [] # 留空 = GET,POST,PUT,PATCH,DELETE,OPTIONS + allowed_headers: [] # 留空 = Authorization,Content-Type,X-Client-Request-ID + allow_credentials: true + max_age_seconds: 600 + # 安全响应头:默认 on(透传安全)。HSTS 仅在 tls.enabled 时下发。 + security: + enabled: true + hsts_max_age_seconds: 31536000 + frame_options: "DENY" + referrer_policy: "no-referrer" + content_security_policy: "" # 默认不下发 CSP(可能破坏 SPA/WS);按需显式配置 + +# secret 加密 key provider:env(默认,APP_MASTER_KEY=version 1)| vault | kms。 +# 轮换时把新版本 key 暂存到 APP_MASTER_KEY_V,再调 POST /api/v1/admin/security/rotate-key。 +crypto: + provider: env + vault: + address: "" + token: "" + key_name: "" + kms: + key_id: "" + region: "" + +# user 模块:登录暴力破解节流(key = ip + 小写 email,固定窗口计数)。 +user: + login_throttle: + enabled: true + max_failures: 10 + window: 15m db: # PostgreSQL DSN(必填) @@ -45,6 +84,15 @@ workspace: git: binary: "git" +# VCS / PR 集成(v1 支持 Gitea)。change_requests 与 done 阶段网关会使用这里的配置。 +vcs: + require_ci_pass: false + gitea: + base_url: "https://git.example.com" + token: "" # 平台级 PAT;也可用 APP_VCS_GITEA_TOKEN 注入 + timeout: 15s + merge_method: "merge" # merge / squash / rebase,取决于 Gitea 实例支持 + # Chat 模块(多模态对话 + LLM endpoints) chat: attachment: @@ -93,7 +141,9 @@ jobs: job_reaper: enabled: true interval: 1m - lease_timeout: 5m + # 须 > orchestrator.turn_timeout:编排器步骤的 handler 超时 = lease_timeout*9/10, + # 必须容纳一次完整 agent 回合(默认 turn_timeout 10m → lease 15m,handler 13.5m)。 + lease_timeout: 15m job_purge: enabled: true interval: 24h @@ -106,6 +156,18 @@ jobs: enabled: true interval: 24h retention: 168h # 7 days + # 审计日志保留期清理(observability roadmap §11):删除早于 retention 的 audit_logs。 + audit_retention: + enabled: true + interval: 24h + retention: 2160h # 90 天 + +# Prometheus /metrics 端点(observability roadmap §11)。 +# 暴露成本/会话数:auth_token 非空时要求 Authorization: Bearer 或 ?token=; +# 为空仅适用于绑定内网 / 由反向代理鉴权的部署。 +metrics: + enabled: true + auth_token: "" # 通知通道配置 notify: @@ -121,6 +183,15 @@ notify: # - worktree.prune_pending # - webhook.dead_letter # - acp.session_crashed + email: + enabled: false + smtp_host: "" + port: 587 + from: "" + username: "" + password: "" + im: + enabled: false # ACP module: agent subprocess pool + WebSocket fanout + event retention acp: @@ -138,6 +209,46 @@ acp: event_truncate_field_bytes: 4096 event_retention_days: 7 shutdown_grace: 30s + permission_timeout: 5m + brief_token_budget: 6000 + default_project_budget_usd: 0 + # per-session 执行沙箱(mode=none 默认;uid/container 仅 Linux)。 + # none : 不隔离(Windows 开发 / CI / 任何非 Linux 机器) + # uid : 降权到低权 UID + setrlimit(+ egress 代理 env) + # container : rootless 容器(UID + 文件系统屏蔽 + 网络策略 + cgroup 限额) + sandbox: + mode: none + base_uid: 0 # 低权 UID 基址;per-session uid = base + offset + proxy_url: "" # 注入子进程的 HTTP(S)_PROXY(egress 白名单代理) + address_space_bytes: 0 # RLIMIT_AS / --memory(0 = 不限) + nproc: 0 # RLIMIT_NPROC(0 = 不限) + cpu_seconds: 0 # RLIMIT_CPU(0 = 不限) + pids: 0 # 容器 --pids-limit(0 = 取 nproc) + disk_bytes: 0 # RLIMIT_FSIZE(0 = 不限) + +# Orchestrator / Planner module configuration(per-requirement run 状态机) +orchestrator: + enabled: true + max_attempts_per_phase: 3 # 单阶段 step 在 dead-letter 前的最大尝试次数 + turn_timeout: 10m # 单回合(SendPromptAndWait)超时,须 < jobs.job_reaper.lease_timeout + max_depth: 3 # request_subtask 递归深度上限 + fan_out_limit: 3 # 单 step 并发子任务上限 + # 阶段→默认 agent-kind 名映射(run.config 未覆盖时用)。app 层按名解析为 agent_kind_id。 + # 为空时 StartRun 必须在 phase_agent_kinds 入参里显式提供每阶段 agent-kind id。 + phase_agent_kinds: + # planning: "codex" + # prototyping: "codex" + # auditing: "codex" + # implementing: "codex" + # reviewing: "codex" + # done: "codex" + # 任务分解并行调度器(autonomy roadmap §10):拓扑选取就绪子任务并扇出到并行 ACP session。 + # 注册为 jobs 周期 RunnerFn(单实例、单 tick,跨重启存活)。默认关闭,须显式开启。 + scheduler: + enabled: false + interval: 30s # 调度 tick 周期 + max_fanout_per_tick: 8 # 每 project 每 tick 最多派发的 session 数 + max_concurrent_per_project: 4 # project 内活跃 session 软上限(<=0 不预检,仅靠 acp 硬上限) # MCP (Model Context Protocol) module configuration mcp: @@ -175,3 +286,70 @@ run: - PATHEXT - ComSpec - NUMBER_OF_PROCESSORS + # 一次性 exec 原语(MCP: run_command / run_tests)。默认保持命令白名单非空; + # 生产环境不要设成 [],否则 system token 会得到任意命令执行能力。 + exec: + default_timeout: 60s + max_timeout: 600s + max_output_bytes: 1048576 + allowed_commands: + - go + - npm + - pnpm + - yarn + - node + - python + - python3 + - pytest + - uv + - cargo + - rustc + - make + - cmake + - ctest + - git + +# 管理员网页终端。仅 admin 可用;命令在 server 宿主进程/运行容器内执行。 +terminal: + enabled: true + shell: "/bin/bash" # Windows 开发环境可用 APP_TERMINAL_SHELL 覆盖为 powershell.exe + max_sessions: 5 + kill_grace: 5s + env_whitelist: + - PATH + - HOME + - LANG + - LC_ALL + - TZ + - USER + - SHELL + - TERM + - NPM_CONFIG_PREFIX + - NODE_PATH + - NODE_ENV + - SystemRoot + - TEMP + - TMP + - USERPROFILE + - APPDATA + - LOCALAPPDATA + - PATHEXT + - ComSpec + - NUMBER_OF_PROCESSORS + +# 代码索引 / 项目记忆。embedding_endpoint_id 为空时自动降级:向量检索不可用, +# memory_search 走 keyword/tag 回退,grep/list 文件类工具仍可用。 +code_index: + enabled: true + embedding_endpoint_id: "" + embedding_model: "text-embedding-3-small" + chunk_window_lines: 60 + chunk_overlap_lines: 10 + max_file_bytes: 524288 + search_top_k_cap: 50 + grep_max_matches: 200 + grep_max_file_bytes: 1048576 + +# 自动文档 / PR 摘要。默认关闭,避免每次提交都调用 LLM 产生成本。 +docs: + enabled: false diff --git a/docker-compose.yml b/docker-compose.yml index 92137cc..1e9b511 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -25,6 +25,14 @@ services: APP_MASTER_KEY: "${APP_MASTER_KEY}" APP_BOOTSTRAP_ADMIN_EMAIL: "${APP_BOOTSTRAP_ADMIN_EMAIL:-admin@local}" APP_BOOTSTRAP_ADMIN_PASSWORD: "${APP_BOOTSTRAP_ADMIN_PASSWORD}" + # secret key provider(env|vault|kms)。轮换时把新版本 key 暂存到 + # APP_MASTER_KEY_V2 再调 POST /api/v1/admin/security/rotate-key。 + APP_CRYPTO_PROVIDER: "${APP_CRYPTO_PROVIDER:-env}" + # per-session 沙箱:默认 none。Linux 生产可设 uid/container,并配 base_uid / + # egress proxy。需要 egress 白名单时取消下方 forward-proxy 注释并指向它。 + APP_ACP_SANDBOX_MODE: "${APP_ACP_SANDBOX_MODE:-none}" + APP_ACP_SANDBOX_BASE_UID: "${APP_ACP_SANDBOX_BASE_UID:-0}" + APP_ACP_SANDBOX_PROXY_URL: "${APP_ACP_SANDBOX_PROXY_URL:-}" ports: - "8080:8080" volumes: @@ -39,6 +47,30 @@ services: retries: 6 start_period: 30s + # ── Egress allowlist forward proxy (opt-in for acp.sandbox egress control) ── + # Stand up a forward proxy whose only outbound is the allowlisted hosts, put + # the app (and, in container mode, the agent containers) on the egress-net, + # and set APP_ACP_SANDBOX_PROXY_URL=http://forward-proxy:8888. Provide your own + # tinyproxy.conf with an Allow/ConnectPort allowlist. Commented out so the + # default `docker compose up` works without extra config. + # + # forward-proxy: + # image: registry.jerryyan.top/library/tinyproxy:latest + # restart: unless-stopped + # volumes: + # - ./deploy/tinyproxy.conf:/etc/tinyproxy/tinyproxy.conf:ro + # networks: + # - egress-net + # - default + volumes: acw-pg-data: acw-data: + +# networks: +# egress-net: +# # internal:true makes this network have NO direct internet route; only the +# # forward-proxy (multi-homed) can reach allowlisted hosts. Attach agent +# # sandbox containers here in mode=container so a non-cooperating binary +# # cannot bypass the proxy. +# internal: true diff --git a/go.mod b/go.mod index 68f24ce..d5615d6 100644 --- a/go.mod +++ b/go.mod @@ -17,7 +17,9 @@ require ( github.com/oklog/ulid/v2 v2.1.1 github.com/openai/openai-go v1.12.0 github.com/pelletier/go-toml/v2 v2.2.4 + github.com/pgvector/pgvector-go v0.4.0 github.com/pkoukk/tiktoken-go v0.1.8 + github.com/prometheus/client_golang v1.23.2 github.com/spf13/viper v1.21.0 github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go/modules/postgres v0.42.0 @@ -33,6 +35,7 @@ require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect github.com/Microsoft/go-winio v0.6.2 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect + github.com/beorn7/perks v1.0.1 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect @@ -76,10 +79,14 @@ require ( github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect github.com/moby/term v0.5.2 // indirect + github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_model v0.6.2 // indirect + github.com/prometheus/common v0.66.1 // indirect + github.com/prometheus/procfs v0.16.1 // indirect github.com/sagikazarmark/locafero v0.11.0 // indirect github.com/segmentio/asm v1.1.3 // indirect github.com/segmentio/encoding v0.5.4 // indirect @@ -105,6 +112,7 @@ require ( go.opentelemetry.io/otel v1.41.0 // indirect go.opentelemetry.io/otel/metric v1.41.0 // indirect go.opentelemetry.io/otel/trace v1.41.0 // indirect + go.yaml.in/yaml/v2 v2.4.2 // indirect go.yaml.in/yaml/v3 v3.0.4 // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/net v0.49.0 // indirect @@ -113,6 +121,6 @@ require ( golang.org/x/text v0.34.0 // indirect google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c // indirect google.golang.org/grpc v1.74.2 // indirect - google.golang.org/protobuf v1.36.7 // indirect + google.golang.org/protobuf v1.36.8 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index 08d8430..ea578f5 100644 --- a/go.sum +++ b/go.sum @@ -18,6 +18,8 @@ github.com/anthropics/anthropic-sdk-go v1.38.0 h1:bA4DcK+91gorIX+5VTONnynyt9LRU4 github.com/anthropics/anthropic-sdk-go v1.38.0/go.mod h1:d288C1L+m74OYuYBvc4UFtR1Q8J0gC55oYDh2t+XxdI= github.com/bahlo/generic-list-go v0.2.0 h1:5sz/EEAK+ls5wF+NeqDpk5+iNdMDXrh3z3nPnH1Wvgk= github.com/bahlo/generic-list-go v0.2.0/go.mod h1:2KvAjgMlE5NNynlg/5iLrrCCZ2+5xWbdbCW3pNTGyYg= +github.com/beorn7/perks v1.0.1 h1:VlbKKnNfV8bJzeqoa4cOKqO6bYr3WgKZxO8Z16+hsOM= +github.com/beorn7/perks v1.0.1/go.mod h1:G2ZrVWU2WbWT9wwq4/hrbKbnv/1ERSJQ0ibhJ6rlkpw= github.com/buger/jsonparser v1.1.2 h1:frqHqw7otoVbk5M8LlE/L7HTnIq2v9RX6EJ48i9AxJk= github.com/buger/jsonparser v1.1.2/go.mod h1:6RYKKt7H4d4+iWqouImQ9R2FZql3VbhNgx27UK13J/0= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -116,6 +118,8 @@ github.com/kr/pretty v0.3.1 h1:flRD4NNwYAUpkphVc1HcthR4KEIFJ65n8Mw5qdRn3LE= github.com/kr/pretty v0.3.1/go.mod h1:hoEshYVHaxMs3cyo3Yncou5ZscifuDolrwPKZanG3xk= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/kylelemons/godebug v1.1.0 h1:RPNrshWIDI6G2gRW9EHilWtl7Z6Sb1BR0xunSBf0SNc= +github.com/kylelemons/godebug v1.1.0/go.mod h1:9/0rRGxNHcop5bhtWyNeEfOS8JIWk580+fNqagV/RAw= github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= github.com/lufia/plan9stats v0.0.0-20211012122336-39d0f177ccd0 h1:6E+4a0GO5zZEnZ81pIr0yLvtUWk2if982qA3F3QD6H4= @@ -148,6 +152,8 @@ github.com/modelcontextprotocol/go-sdk v1.6.0 h1:PPLS3kn7WtOEnR+Af4X5H96SG0qSab8 github.com/modelcontextprotocol/go-sdk v1.6.0/go.mod h1:kzm3kzFL1/+AziGOE0nUs3gvPoNxMCvkxokMkuFapXQ= github.com/morikuni/aec v1.0.0 h1:nP9CBfwrvYnBRgY6qfDQkygYDmYwOilePFkwzv4dU8A= github.com/morikuni/aec v1.0.0/go.mod h1:BbKIizmSmc5MMPqRYbxO4ZU0S0+P200+tUnFx7PXmsc= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822 h1:C3w9PqII01/Oq1c1nUAm88MOHcQC9l5mIlSMApZMrHA= +github.com/munnerz/goautoneg v0.0.0-20191010083416-a7dc8b61c822/go.mod h1:+n7T8mK8HuQTcFwEeznm/DIxMOiR9yIdICNftLE1DvQ= github.com/oklog/ulid/v2 v2.1.1 h1:suPZ4ARWLOJLegGFiZZ1dFAkqzhMjL3J1TzI+5wHz8s= github.com/oklog/ulid/v2 v2.1.1/go.mod h1:rcEKHmBBKfef9DhnvX7y1HZBYxjXb0cP5ExxNsTT1QQ= github.com/openai/openai-go v1.12.0 h1:NBQCnXzqOTv5wsgNC36PrFEiskGfO5wccfCWDo9S1U0= @@ -159,6 +165,8 @@ github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgr github.com/pborman/getopt v0.0.0-20170112200414-7148bc3a4c30/go.mod h1:85jBQOZwpVEaDAr341tbn15RS4fCAsIst0qp7i8ex1o= github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4= github.com/pelletier/go-toml/v2 v2.2.4/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= +github.com/pgvector/pgvector-go v0.4.0 h1:879hQCnuix1bkfa5TQISnnK9ik4Fo+cHj2vuZSgW5v4= +github.com/pgvector/pgvector-go v0.4.0/go.mod h1:4fSXyjl1TYAIdByAql6JazKWRr2s7J0g4hcRY5cBFCk= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkoukk/tiktoken-go v0.1.8 h1:85ENo+3FpWgAACBaEUVp+lctuTcYUO7BtmfhlN/QTRo= @@ -168,6 +176,14 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 h1:o4JXh1EVt9k/+g42oCprj/FisM4qX9L3sZB3upGN2ZU= github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55/go.mod h1:OmDBASR4679mdNQnz2pUhc2G8CO2JrUAVFDRBDP/hJE= +github.com/prometheus/client_golang v1.23.2 h1:Je96obch5RDVy3FDMndoUsjAhG5Edi49h0RJWRi/o0o= +github.com/prometheus/client_golang v1.23.2/go.mod h1:Tb1a6LWHB3/SPIzCoaDXI4I8UHKeFTEQ1YCr+0Gyqmg= +github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNwqPLxwZyk= +github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= +github.com/prometheus/common v0.66.1 h1:h5E0h5/Y8niHc5DlaLlWLArTQI7tMrsfQjHV+d9ZoGs= +github.com/prometheus/common v0.66.1/go.mod h1:gcaUsgf3KfRSwHY4dIMXLPV0K/Wg1oZ8+SbZk/HH/dA= +github.com/prometheus/procfs v0.16.1 h1:hZ15bTNuirocR6u0JZ6BAHHmwS1p8B4P6MRqxtzMyRg= +github.com/prometheus/procfs v0.16.1/go.mod h1:teAbpZRB1iIAJYREa1LsoWUXykVXA1KlTmWl8x/U+Is= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/sagikazarmark/locafero v0.11.0 h1:1iurJgmM9G3PA/I+wWYIOw/5SyBtxapeHDcg+AAIFXc= @@ -238,6 +254,10 @@ go.opentelemetry.io/otel/sdk/metric v1.36.0 h1:r0ntwwGosWGaa0CrSt8cuNuTcccMXERFw go.opentelemetry.io/otel/sdk/metric v1.36.0/go.mod h1:qTNOhFDfKRwX0yXOqJYegL5WRaW376QbB7P4Pb0qva4= go.opentelemetry.io/otel/trace v1.41.0 h1:Vbk2co6bhj8L59ZJ6/xFTskY+tGAbOnCtQGVVa9TIN0= go.opentelemetry.io/otel/trace v1.41.0/go.mod h1:U1NU4ULCoxeDKc09yCWdWe+3QoyweJcISEVa1RBzOis= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.yaml.in/yaml/v2 v2.4.2 h1:DzmwEr2rDGHl7lsFgAHxmNz/1NlQ7xLIrlN2h5d1eGI= +go.yaml.in/yaml/v2 v2.4.2/go.mod h1:081UH+NErpNdqlCXm3TtEran0rJZGxAYx9hb/ELlsPU= go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= @@ -303,8 +323,8 @@ google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c h1: google.golang.org/genproto/googleapis/rpc v0.0.0-20250818200422-3122310a409c/go.mod h1:gw1tLEfykwDz2ET4a12jcXt4couGAm7IwsVaTy0Sflo= google.golang.org/grpc v1.74.2 h1:WoosgB65DlWVC9FqI82dGsZhWFNBSLjQ84bjROOpMu4= google.golang.org/grpc v1.74.2/go.mod h1:CtQ+BGjaAIXHs/5YS3i473GqwBBa1zGQNevxdeBEXrM= -google.golang.org/protobuf v1.36.7 h1:IgrO7UwFQGJdRNXH/sQux4R1Dj1WAKcLElzeeRaXV2A= -google.golang.org/protobuf v1.36.7/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +google.golang.org/protobuf v1.36.8 h1:xHScyCOEuuwZEc6UtSOvPbAT4zRh0xcNRYekJwfqyMc= +google.golang.org/protobuf v1.36.8/go.mod h1:fuxRtAxBytpl4zzqUh6/eyUujkJdNiuEkXntxiD/uRU= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= diff --git a/internal/acp/agent_home.go b/internal/acp/agent_home.go index 2b3f752..bdc39e1 100644 --- a/internal/acp/agent_home.go +++ b/internal/acp/agent_home.go @@ -1,9 +1,11 @@ // agent_home.go 实现 agent CLI 受管 home 目录的物化与重定向 env 注入。 // -// 每个 AgentKind 一个受管 home://。spawn 前把 -// DB 中的配置文件解密落盘(全量覆写受管文件,不清空目录 —— agent 运行期写入的 -// 缓存 / 凭据得以保留),再按 client_type 注入配置目录重定向变量。受管 home 位于 -// DataDir 下,容器场景随持久卷存活。 +// 受管 home 按 user + agent-kind 维度隔离:///。 +// 早期实现仅按 agent-kind 维度(多个用户共享同一 home 与缓存凭据),沙箱化要求 +// per-USER home,故 home 路径以 user_id 为一级目录、agent_kind_id 为二级目录。 +// spawn 前把 DB 中的配置文件解密落盘(全量覆写受管文件,不清空目录 —— agent 运行期 +// 写入的缓存 / 凭据得以保留),再按 client_type 注入配置目录重定向变量。受管 home 位于 +// DataDir 下,容器场景随持久卷存活。旧的 per-kind 目录无害遗留,下次 spawn 用新路径。 package acp import ( @@ -16,9 +18,10 @@ import ( ) // materializeAgentHome 创建受管 home 并物化该 AgentKind 的全部配置文件。 +// home 按 user + agent-kind 隔离://。 // 返回 home 绝对路径。配置文件为空时仍创建目录(agent 需要可写 home)。 -func (s *Supervisor) materializeAgentHome(ctx context.Context, kind *AgentKind) (string, error) { - home, err := filepath.Abs(filepath.Join(s.cfg.AgentHomesDir, kind.ID.String())) +func (s *Supervisor) materializeAgentHome(ctx context.Context, sess *Session, kind *AgentKind) (string, error) { + home, err := filepath.Abs(filepath.Join(s.cfg.AgentHomesDir, sess.UserID.String(), kind.ID.String())) if err != nil { return "", fmt.Errorf("resolve agent home: %w", err) } diff --git a/internal/acp/agent_home_internal_test.go b/internal/acp/agent_home_internal_test.go index 3a0bd0c..fdb8312 100644 --- a/internal/acp/agent_home_internal_test.go +++ b/internal/acp/agent_home_internal_test.go @@ -41,16 +41,50 @@ func TestMaterializeAgentHome(t *testing.T) { cfg: SupervisorConfig{AgentHomesDir: root}, } - home, err := s.materializeAgentHome(context.Background(), kind) + sess := &Session{ID: uuid.New(), UserID: uuid.New()} + home, err := s.materializeAgentHome(context.Background(), sess, kind) require.NoError(t, err) - assert.Equal(t, filepath.Join(root, kind.ID.String()), home) + // per-user home:// + assert.Equal(t, filepath.Join(root, sess.UserID.String(), kind.ID.String()), home) got, err := os.ReadFile(filepath.Join(home, ".codex", "config.toml")) require.NoError(t, err) assert.Equal(t, content, got) // 二次物化幂等(覆写) - _, err = s.materializeAgentHome(context.Background(), kind) + _, err = s.materializeAgentHome(context.Background(), sess, kind) + require.NoError(t, err) +} + +func TestMaterializeAgentHome_PerUserIsolation(t *testing.T) { + t.Parallel() + enc, err := crypto.NewEncryptor([]byte("0123456789abcdef0123456789abcdef")) + require.NoError(t, err) + + kind := &AgentKind{ID: uuid.New(), ClientType: ClientCodex} + encrypted, err := enc.Encrypt([]byte("x = 1")) + require.NoError(t, err) + + root := t.TempDir() + s := &Supervisor{ + repo: cfgRepoStub{files: []*ConfigFile{{AgentKindID: kind.ID, RelPath: ".codex/config.toml", EncryptedContent: encrypted}}}, + crypto: enc, + cfg: SupervisorConfig{AgentHomesDir: root}, + } + + // 同一 agent-kind、两个不同用户 → 两个互不相同的 home 目录。 + sessA := &Session{ID: uuid.New(), UserID: uuid.New()} + sessB := &Session{ID: uuid.New(), UserID: uuid.New()} + homeA, err := s.materializeAgentHome(context.Background(), sessA, kind) + require.NoError(t, err) + homeB, err := s.materializeAgentHome(context.Background(), sessB, kind) + require.NoError(t, err) + + assert.NotEqual(t, homeA, homeB) + // 两个 home 各自物化了配置文件。 + _, err = os.Stat(filepath.Join(homeA, ".codex", "config.toml")) + require.NoError(t, err) + _, err = os.Stat(filepath.Join(homeB, ".codex", "config.toml")) require.NoError(t, err) } @@ -67,7 +101,8 @@ func TestMaterializeAgentHome_RejectsEscape(t *testing.T) { crypto: enc, cfg: SupervisorConfig{AgentHomesDir: t.TempDir()}, } - _, err = s.materializeAgentHome(context.Background(), kind) + sess := &Session{ID: uuid.New(), UserID: uuid.New()} + _, err = s.materializeAgentHome(context.Background(), sess, kind) require.Error(t, err) assert.Contains(t, err.Error(), "escapes agent home") } diff --git a/internal/acp/agentkind_service.go b/internal/acp/agentkind_service.go index ffdda6b..f533fee 100644 --- a/internal/acp/agentkind_service.go +++ b/internal/acp/agentkind_service.go @@ -12,9 +12,16 @@ import ( ) type agentKindService struct { - repo Repository - enc *crypto.Encryptor - audit audit.Recorder + repo Repository + enc *crypto.Encryptor + audit audit.Recorder + modelCheck ModelValidator // 校验 model_id 引用一个启用的 llm_model;可为 nil(跳过校验) +} + +// ModelValidator 校验某 model_id 引用一个存在且启用的 llm_model。装配期由 app.go +// 注入一个委托给 chat 的 adapter,避免 acp 构造期直接依赖 chat。 +type ModelValidator interface { + IsEnabledModel(ctx context.Context, modelID uuid.UUID) (bool, error) } // NewAgentKindService 构造 AgentKindService。 @@ -22,6 +29,9 @@ func NewAgentKindService(repo Repository, enc *crypto.Encryptor, rec audit.Recor return &agentKindService{repo: repo, enc: enc, audit: rec} } +// SetModelValidator 注入 model 校验器(装配期回填)。 +func (s *agentKindService) SetModelValidator(v ModelValidator) { s.modelCheck = v } + func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentKindInput) (*AgentKind, error) { if !c.IsAdmin { return nil, errs.New(errs.CodeForbidden, "admin only") @@ -52,6 +62,12 @@ func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentK if allowlist == nil { allowlist = []string{} } + if err := s.validateBudget(in.MaxCostUSD, in.MaxTokens, in.MaxWallClockSeconds); err != nil { + return nil, err + } + if err := s.validateModel(ctx, in.ModelID); err != nil { + return nil, err + } k := &AgentKind{ ID: uuid.New(), Name: in.Name, DisplayName: in.DisplayName, Description: in.Description, BinaryPath: in.BinaryPath, Args: args, EncryptedEnv: encrypted, Enabled: in.Enabled, @@ -59,6 +75,10 @@ func (s *agentKindService) Create(ctx context.Context, c Caller, in CreateAgentK ClientType: clientType, EncryptedMCPServers: encryptedMCP, CreatedBy: c.UserID, + ModelID: in.ModelID, + MaxCostUSD: in.MaxCostUSD, + MaxTokens: in.MaxTokens, + MaxWallClockSeconds: in.MaxWallClockSeconds, } out, err := s.repo.CreateAgentKind(ctx, k) if err != nil { @@ -146,6 +166,34 @@ func (s *agentKindService) Update(ctx context.Context, c Caller, id uuid.UUID, i cur.Enabled = *in.Enabled changed["enabled"] = *in.Enabled } + if in.SetModelID { + if err := s.validateModel(ctx, in.ModelID); err != nil { + return nil, err + } + cur.ModelID = in.ModelID + changed["model_id"] = "" + } + if in.SetMaxCostUSD { + if err := s.validateBudget(in.MaxCostUSD, nil, nil); err != nil { + return nil, err + } + cur.MaxCostUSD = in.MaxCostUSD + changed["max_cost_usd"] = "" + } + if in.SetMaxTokens { + if err := s.validateBudget(nil, in.MaxTokens, nil); err != nil { + return nil, err + } + cur.MaxTokens = in.MaxTokens + changed["max_tokens"] = "" + } + if in.SetMaxWallClock { + if err := s.validateBudget(nil, nil, in.MaxWallClockSeconds); err != nil { + return nil, err + } + cur.MaxWallClockSeconds = in.MaxWallClockSeconds + changed["max_wall_clock_seconds"] = "" + } if len(changed) == 0 { return cur, nil } @@ -174,6 +222,35 @@ func (s *agentKindService) Delete(ctx context.Context, c Caller, id uuid.UUID) e return nil } +// validateBudget 校验预算上限:非空值必须为正。 +func (s *agentKindService) validateBudget(maxCost *float64, maxTokens *int64, maxWall *int32) error { + if maxCost != nil && *maxCost <= 0 { + return errs.New(errs.CodeInvalidInput, "max_cost_usd must be positive") + } + if maxTokens != nil && *maxTokens <= 0 { + return errs.New(errs.CodeInvalidInput, "max_tokens must be positive") + } + if maxWall != nil && *maxWall <= 0 { + return errs.New(errs.CodeInvalidInput, "max_wall_clock_seconds must be positive") + } + return nil +} + +// validateModel 校验 model_id 引用一个启用的 llm_model(modelCheck 为 nil 时跳过)。 +func (s *agentKindService) validateModel(ctx context.Context, modelID *uuid.UUID) error { + if modelID == nil || s.modelCheck == nil { + return nil + } + ok, err := s.modelCheck.IsEnabledModel(ctx, *modelID) + if err != nil { + return err + } + if !ok { + return errs.New(errs.CodeInvalidInput, "model_id does not reference an enabled model") + } + return nil +} + func (s *agentKindService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) { if s.audit == nil { return diff --git a/internal/acp/agentkind_service_test.go b/internal/acp/agentkind_service_test.go index 4c1a5fa..4b06e37 100644 --- a/internal/acp/agentkind_service_test.go +++ b/internal/acp/agentkind_service_test.go @@ -116,6 +116,15 @@ func (f *fakeAgentKindRepo) InsertSession(context.Context, *acp.Session) (*acp.S func (f *fakeAgentKindRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) { panic("n/a") } +func (f *fakeAgentKindRepo) GetSessionByStepID(context.Context, uuid.UUID) (*acp.Session, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) GetCrashedSessionForResume(context.Context, uuid.UUID) (*acp.Session, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) ResetSessionForResume(context.Context, uuid.UUID) error { + panic("n/a") +} func (f *fakeAgentKindRepo) ListSessionsByUser(context.Context, uuid.UUID, *uuid.UUID) ([]*acp.Session, error) { panic("n/a") } @@ -125,6 +134,9 @@ func (f *fakeAgentKindRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp func (f *fakeAgentKindRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error { panic("n/a") } +func (f *fakeAgentKindRepo) UpdateSessionSandboxMode(context.Context, uuid.UUID, string) error { + panic("n/a") +} func (f *fakeAgentKindRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error { panic("n/a") } @@ -155,6 +167,63 @@ func (f *fakeAgentKindRepo) ListEventsSince(context.Context, uuid.UUID, int64, i func (f *fakeAgentKindRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) { panic("n/a") } +func (f *fakeAgentKindRepo) InsertSessionUsage(context.Context, *acp.SessionUsageRecord) (bool, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) AddSessionUsageTotals(context.Context, uuid.UUID, int64, int64, int64, float64) (int64, int64, int64, float64, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) SumProjectCostUSD(context.Context, uuid.UUID, time.Time) (float64, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) MarkSessionTerminatedReason(context.Context, uuid.UUID, string) error { + panic("n/a") +} +func (f *fakeAgentKindRepo) ListSessionsForReaper(context.Context, time.Time, time.Time) ([]acp.ReaperSession, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) ListSessionUsage(context.Context, uuid.UUID, int32) ([]*acp.SessionUsageRecord, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) SummarizeAcpUsageByUser(context.Context, time.Time, time.Time) ([]acp.AcpUsageUserRow, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) SummarizeAcpUsageByModel(context.Context, time.Time, time.Time) ([]acp.AcpUsageModelRow, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) SummarizeAcpUsageByDay(context.Context, time.Time, time.Time) ([]acp.AcpUsageDayRow, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) SessionRollup(context.Context, acp.DashboardFilter) (acp.SessionRollup, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) SessionsByDay(context.Context, acp.DashboardFilter) ([]acp.SessionDayRow, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) SessionTimeline(context.Context, uuid.UUID) ([]acp.SessionTimelineBucket, error) { + panic("n/a") +} + +func (f *fakeAgentKindRepo) InsertTurn(context.Context, *acp.Turn) (*acp.Turn, error) { panic("n/a") } +func (f *fakeAgentKindRepo) MarkTurnCompleted(context.Context, uuid.UUID, int, string, int) error { + panic("n/a") +} +func (f *fakeAgentKindRepo) MarkTurnAborted(context.Context, uuid.UUID, int) error { panic("n/a") } +func (f *fakeAgentKindRepo) IncrementTurnUpdateCount(context.Context, uuid.UUID, int) error { + panic("n/a") +} +func (f *fakeAgentKindRepo) GetLatestTurnBySession(context.Context, uuid.UUID) (*acp.Turn, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) ListTurnsBySession(context.Context, uuid.UUID) ([]*acp.Turn, error) { + panic("n/a") +} +func (f *fakeAgentKindRepo) UpdateSessionLastStopReason(context.Context, uuid.UUID, string) error { + panic("n/a") +} +func (f *fakeAgentKindRepo) PurgeTurnsBefore(context.Context, time.Time) (int64, error) { + panic("n/a") +} func (f *fakeAgentKindRepo) InTx(context.Context, func(context.Context, pgx.Tx) error) error { panic("n/a") diff --git a/internal/acp/dashboard_handler_test.go b/internal/acp/dashboard_handler_test.go new file mode 100644 index 0000000..16bdc7b --- /dev/null +++ b/internal/acp/dashboard_handler_test.go @@ -0,0 +1,137 @@ +package acp_test + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp" +) + +// mountDashboardHandler builds a Handler whose SessionService is backed by the +// in-memory fakeAcpRepo so run-history endpoints can be exercised without a DB. +func mountDashboardHandler(t *testing.T, callerUID uuid.UUID, isAdmin bool, repo *fakeAcpRepo) chi.Router { + t.Helper() + enc := testEncryptor(t) + ss := acp.NewSessionService(repo, nil, nil, nil, nil, nil, nil, nil, nil, + acp.SessionServiceConfig{}, nil) + r := chi.NewRouter() + h := acp.NewHandler( + nil, // akSvc — not exercised + ss, + nil, // sup — not exercised + repo, + nil, // permSvc — not exercised + fakeResolver{uid: callerUID}, + fakeAdminLookup{admin: map[uuid.UUID]bool{callerUID: isAdmin}}, + enc, + acp.WSConfig{}, + ) + h.Mount(r) + return r +} + +func getJSONArray(t *testing.T, r chi.Router, path, token string) (*httptest.ResponseRecorder, []map[string]any) { + t.Helper() + req := httptest.NewRequest("GET", path, nil) + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + var out []map[string]any + if rr.Body.Len() > 0 { + _ = json.Unmarshal(rr.Body.Bytes(), &out) + } + return rr, out +} + +func TestDashboardHandler_Unauthenticated_401(t *testing.T) { + t.Parallel() + repo := &fakeAcpRepo{} + r := mountDashboardHandler(t, uuid.New(), false, repo) + rr, _ := doJSON(t, r, "GET", "/api/v1/acp/dashboard", "", nil) + assert.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestDashboardHandler_OwnerScoped(t *testing.T) { + t.Parallel() + uid := uuid.New() + repo := &fakeAcpRepo{ + rollupResult: acp.SessionRollup{Total: 2, Succeeded: 2, TotalCostUSD: 0.4}, + byDayResult: []acp.SessionDayRow{{Day: time.Now().UTC(), Total: 2, Succeeded: 2, TotalCostUSD: 0.4}}, + } + r := mountDashboardHandler(t, uid, false, repo) + + rr, body := doJSON(t, r, "GET", "/api/v1/acp/dashboard", "valid", nil) + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + + rollup := body["rollup"].(map[string]any) + assert.Equal(t, float64(2), rollup["total"]) + assert.Equal(t, float64(1), rollup["success_rate"]) + + // 非 admin → repo filter scoped to caller. + require.NotNil(t, repo.rollupFilter.UserID) + assert.Equal(t, uid, *repo.rollupFilter.UserID) +} + +func TestDashboardHandler_Admin_NotScoped_WithProjectFilter(t *testing.T) { + t.Parallel() + uid := uuid.New() + pid := uuid.New() + repo := &fakeAcpRepo{rollupResult: acp.SessionRollup{Total: 5, Succeeded: 4}} + r := mountDashboardHandler(t, uid, true, repo) + + rr, _ := doJSON(t, r, "GET", "/api/v1/acp/dashboard?project_id="+pid.String(), "valid", nil) + require.Equal(t, http.StatusOK, rr.Code) + + assert.Nil(t, repo.rollupFilter.UserID) // admin → 全量 + require.NotNil(t, repo.rollupFilter.ProjectID) + assert.Equal(t, pid, *repo.rollupFilter.ProjectID) +} + +func TestDashboardHandler_BadProjectID_400(t *testing.T) { + t.Parallel() + repo := &fakeAcpRepo{} + r := mountDashboardHandler(t, uuid.New(), true, repo) + rr, _ := doJSON(t, r, "GET", "/api/v1/acp/dashboard?project_id=not-a-uuid", "valid", nil) + assert.Equal(t, http.StatusBadRequest, rr.Code) +} + +func TestTimelineHandler_NonOwner_NotFound(t *testing.T) { + t.Parallel() + sid := uuid.New() + repo := &fakeAcpRepo{} + repo.sessions = []*acp.Session{{ID: sid, UserID: uuid.New()}} + r := mountDashboardHandler(t, uuid.New(), false, repo) + + rr, _ := doJSON(t, r, "GET", "/api/v1/acp/sessions/"+sid.String()+"/timeline", "valid", nil) + assert.Equal(t, http.StatusNotFound, rr.Code) +} + +func TestTimelineHandler_Owner_OK(t *testing.T) { + t.Parallel() + uid := uuid.New() + sid := uuid.New() + repo := &fakeAcpRepo{ + timelineResult: []acp.SessionTimelineBucket{ + {Direction: "out", RPCKind: "request", Method: "session/prompt", EventCount: 3}, + }, + } + repo.sessions = []*acp.Session{{ID: sid, UserID: uid}} + r := mountDashboardHandler(t, uid, false, repo) + + rr, arr := getJSONArray(t, r, "/api/v1/acp/sessions/"+sid.String()+"/timeline", "valid") + require.Equal(t, http.StatusOK, rr.Code, "body=%s", rr.Body.String()) + require.Len(t, arr, 1) + assert.Equal(t, "session/prompt", arr[0]["method"]) + assert.Equal(t, "request", arr[0]["kind"]) + assert.Equal(t, sid, repo.timelineSID) +} diff --git a/internal/acp/domain.go b/internal/acp/domain.go index cb3a197..242bad9 100644 --- a/internal/acp/domain.go +++ b/internal/acp/domain.go @@ -118,6 +118,12 @@ type AgentKind struct { CreatedBy uuid.UUID CreatedAt time.Time UpdatedAt time.Time + // 成本核算:关联的 llm_models 行(价格来源)+ 每 kind 默认预算上限。 + // 全部 NULLABLE:未配置 model 时成本按 0/agent 自报;未配置上限时无限制。 + ModelID *uuid.UUID + MaxCostUSD *float64 + MaxTokens *int64 + MaxWallClockSeconds *int32 } // ConfigFile 是 AgentKind 维度的 agent CLI 配置文件(如 .claude/settings.json、 @@ -153,6 +159,191 @@ type Session struct { LastError *string StartedAt time.Time EndedAt *time.Time + // LastStopReason 是 agent 最近一次回合完成时上报的 stopReason 原始字符串 + // (nil = 尚无完成的回合)。归一化枚举见 NormalizeStopReason,但落库为原值。 + LastStopReason *string + // OrchestratorStepID 非 nil 时表示该 session 由编排器某个 step 驱动;onExit / + // 启动 reaper 据此把崩溃 session 映射回 step 并触发再驱动。manual session 为 nil。 + OrchestratorStepID *uuid.UUID + // 成本核算运行时累加器(落库快照)。 + PromptTokens int64 + CompletionTokens int64 + ThinkingTokens int64 + TotalCostUSD float64 + LastActivityAt time.Time + // 创建时快照的有效预算上限(session 覆盖 > agent-kind 默认 > 全局默认)。 + BudgetMaxCostUSD *float64 + BudgetMaxTokens *int64 + BudgetMaxWallClockSeconds *int32 + // TerminatedReason 非 nil 时记录会话被强制终止的原因(budget_*/reaper_*/manual)。 + TerminatedReason *string +} + +// BudgetCaps 是一次会话的有效预算上限快照。nil 字段 = 该维度无限制。 +type BudgetCaps struct { + MaxCostUSD *float64 + MaxTokens *int64 + MaxWallClockSeconds *int32 +} + +// BreachReason 标识预算被突破的维度,落 acp_sessions.terminated_reason。 +type BreachReason string + +const ( + BreachCost BreachReason = "budget_cost" + BreachTokens BreachReason = "budget_tokens" + BreachWallClock BreachReason = "budget_wall_clock" +) + +// SessionUsageRecord 是 acp_session_usage 表一行(per-turn 账目,镜像 llm_usage)。 +type SessionUsageRecord struct { + ID int64 + SessionID uuid.UUID + UserID uuid.UUID + ProjectID uuid.UUID + AgentKindID uuid.UUID + ModelID *uuid.UUID + PromptTokens int64 + CompletionTokens int64 + ThinkingTokens int64 + CostUSD float64 + SourceEventID *int64 + CreatedAt time.Time +} + +// AcpUsageUserRow 按用户聚合的 ACP 用量。 +type AcpUsageUserRow struct { + UserID uuid.UUID + PromptTokens, CompletionTokens, ThinkingTokens int64 + CostUSD float64 +} + +// AcpUsageModelRow 按模型聚合的 ACP 用量。 +type AcpUsageModelRow struct { + ModelID uuid.UUID + PromptTokens, CompletionTokens, ThinkingTokens int64 + CostUSD float64 +} + +// AcpUsageDayRow 按天聚合的 ACP 用量。 +type AcpUsageDayRow struct { + Day time.Time + PromptTokens, CompletionTokens, ThinkingTokens int64 + CostUSD float64 +} + +// ===== run-history dashboard 读模型 ===== + +// DashboardFilter 限定 run-history 汇总的范围。UserID 非 nil 时仅统计该用户的 +// 会话(owner scope);nil 表示全量(admin scope)。ProjectID/RequirementID +// 为可选过滤;From/To 为半开时间窗 [From, To)。 +type DashboardFilter struct { + UserID *uuid.UUID + ProjectID *uuid.UUID + RequirementID *uuid.UUID + From time.Time + To time.Time +} + +// SessionRollup 是 run-history 汇总:会话计数 + 成功率 + 总成本 + 平均时长。 +type SessionRollup struct { + Total int64 + Succeeded int64 + Crashed int64 + Active int64 + TotalCostUSD float64 + TotalTokens int64 + AvgDurationSeconds float64 +} + +// SuccessRate 返回 succeeded/total(total=0 时为 0),便于 handler/MCP 直接使用。 +func (r SessionRollup) SuccessRate() float64 { + if r.Total == 0 { + return 0 + } + return float64(r.Succeeded) / float64(r.Total) +} + +// SessionDayRow 是 run-history 按天的时间序列一行。 +type SessionDayRow struct { + Day time.Time + Total int64 + Succeeded int64 + Crashed int64 + TotalCostUSD float64 +} + +// SessionTimelineBucket 是单会话事件按 (direction, rpc_kind, method) 分桶的一行。 +type SessionTimelineBucket struct { + Direction string + RPCKind string + Method string + EventCount int64 + FirstAt time.Time + LastAt time.Time +} + +// ReaperSession 是 reaper 需要的最小 session 投影。 +type ReaperSession struct { + ID uuid.UUID + UserID uuid.UUID + StartedAt time.Time + LastActivityAt time.Time + MaxWallClockSeconds *int32 +} + +// StopReason 是回合完成的归一化原因枚举。agent 可能上报枚举外的 vendor-specific +// 值;这些值归一化为 StopOther,但原始字符串仍会原样落库(stop_reason / +// last_stop_reason),避免信息丢失。 +type StopReason string + +const ( + StopEndTurn StopReason = "end_turn" + StopMaxTokens StopReason = "max_tokens" + StopRefusal StopReason = "refusal" + StopCancelled StopReason = "cancelled" + StopOther StopReason = "other" +) + +// NormalizeStopReason 把 agent 上报的原始 stopReason 映射到归一化枚举。 +// 未知值(含空串)映射为 StopOther。 +func NormalizeStopReason(raw string) StopReason { + switch StopReason(raw) { + case StopEndTurn: + return StopEndTurn + case StopMaxTokens: + return StopMaxTokens + case StopRefusal: + return StopRefusal + case StopCancelled: + return StopCancelled + default: + return StopOther + } +} + +// TurnStatus 是 acp_turns.status 枚举。 +type TurnStatus string + +const ( + TurnInProgress TurnStatus = "in_progress" + TurnCompleted TurnStatus = "completed" + TurnAborted TurnStatus = "aborted" +) + +// Turn 是一次 agent 回合的审计记录。一个 session 内 turn_index 单调递增(0-based)。 +// PromptRequestID 是发起该回合的 session/prompt 的 JSON-RPC id(用于响应相关联)。 +// StopReason 在完成前为 nil;存储 agent 上报的原始字符串。 +type Turn struct { + ID int64 + SessionID uuid.UUID + TurnIndex int + PromptRequestID *string + Status TurnStatus + StopReason *string + UpdateCount int + StartedAt time.Time + CompletedAt *time.Time } // Event 是一条入库的 JSON-RPC 消息。Payload 是 JSON-RPC envelope 完整 JSON @@ -204,6 +395,39 @@ type SessionService interface { Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error) List(ctx context.Context, c Caller, f SessionListFilter) ([]*Session, error) Terminate(ctx context.Context, c Caller, id uuid.UUID) error + + // Dashboard 返回 run-history 汇总 + 按天序列。非 admin 调用强制按 caller + // scope(UserID 覆盖为 caller),admin 才能查看全量。 + Dashboard(ctx context.Context, c Caller, in DashboardInput) (*DashboardResult, error) + // Timeline 返回单会话的事件时间线(先经 Get 做 owner/admin 访问校验)。 + Timeline(ctx context.Context, c Caller, sessionID uuid.UUID) ([]SessionTimelineBucket, error) +} + +// DashboardInput 是 run-history 汇总的入参(过滤维度由 service 在 scope 内归一化)。 +type DashboardInput struct { + ProjectID *uuid.UUID + RequirementID *uuid.UUID + From time.Time + To time.Time +} + +// DashboardResult 是 run-history 汇总响应:总览 + 按天序列。 +type DashboardResult struct { + Rollup SessionRollup + ByDay []SessionDayRow + From time.Time + To time.Time +} + +// TurnRunner 是编排器驱动一个阻塞回合所需的窄接口(不扩大 SessionService 的 +// HTTP/MCP 暴露面)。sessionService 同时实现 SessionService 与 TurnRunner。 +// +// - SendPromptAndWait 发送一条 session/prompt 并阻塞等待回合完成,返回原始 +// stopReason(来自 relay.Call 的 server-initiated 请求响应)。 +// - ResumeSession 在崩溃 session 的同一 cwd/worktree 上重新拉起 agent。 +type TurnRunner interface { + SendPromptAndWait(ctx context.Context, sessionID uuid.UUID, prompt string) (stopReason string, err error) + ResumeSession(ctx context.Context, c Caller, sessionID uuid.UUID) (*Session, error) } // SessionListFilter 控制 sessions 列表的过滤维度。 @@ -222,6 +446,9 @@ type CreateSessionInput struct { IssueNumber *int RequirementNumber *int InitialPrompt *string + // OrchestratorStepID 非 nil 时把 session 与编排器 step 反向关联(仅由 orchestrator + // 内部调用设置;HTTP/MCP 入口不暴露)。 + OrchestratorStepID *uuid.UUID } // CreateAgentKindInput 携带创建必需字段。Args / Env 可为 nil。 @@ -237,6 +464,11 @@ type CreateAgentKindInput struct { ToolAllowlist []string ClientType ClientType MCPServers []McpServerSpec + // 成本核算:model 价格来源 + 默认预算上限(均可选)。 + ModelID *uuid.UUID + MaxCostUSD *float64 + MaxTokens *int64 + MaxWallClockSeconds *int32 } // UpdateAgentKindInput 是 patch 输入。Name 不可改(创建后稳定,引用约束)。 @@ -251,6 +483,16 @@ type UpdateAgentKindInput struct { ToolAllowlist []string // nil = 不改;非 nil(含空)= 替换 ClientType *ClientType // nil = 不改 MCPServers []McpServerSpec // nil = 不改;非 nil(含空)= 替换 + // 成本核算字段:三态指针,nil = 不改。SetModelID 控制 ModelID 是否参与更新 + // (ModelID 本身可被显式置空,故用独立 flag 区分"不改"与"清空")。 + SetModelID bool + ModelID *uuid.UUID + SetMaxCostUSD bool + MaxCostUSD *float64 + SetMaxTokens bool + MaxTokens *int64 + SetMaxWallClock bool + MaxWallClockSeconds *int32 } // PermissionStatus 是 acp_permission_requests.status 枚举。 diff --git a/internal/acp/dto.go b/internal/acp/dto.go index 005473d..6f48d5e 100644 --- a/internal/acp/dto.go +++ b/internal/acp/dto.go @@ -21,9 +21,14 @@ type AgentKindAdminDTO struct { ClientType string `json:"client_type"` // MCPServers 明文返回(admin only,编辑所需);条目可能含 header/env 密钥。 MCPServers []McpServerSpec `json:"mcp_servers"` - CreatedBy uuid.UUID `json:"created_by"` - CreatedAt string `json:"created_at"` - UpdatedAt string `json:"updated_at"` + // 成本核算:model 价格来源 + 默认预算上限(均可空)。 + ModelID *string `json:"model_id"` + MaxCostUSD *float64 `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + CreatedBy uuid.UUID `json:"created_by"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` } // AgentKindPublicDTO 是普通用户视图,不暴露 binary_path / args / env_keys。 @@ -46,6 +51,11 @@ type CreateAgentKindReq struct { ToolAllowlist []string `json:"tool_allowlist"` ClientType string `json:"client_type"` MCPServers []McpServerSpec `json:"mcp_servers"` + // 成本核算:model_id 为字符串 UUID(空/缺省 = 不设置);预算上限均可选。 + ModelID *string `json:"model_id"` + MaxCostUSD *float64 `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` } type UpdateAgentKindReq struct { @@ -58,6 +68,12 @@ type UpdateAgentKindReq struct { ToolAllowlist []string `json:"tool_allowlist,omitempty"` ClientType *string `json:"client_type,omitempty"` MCPServers []McpServerSpec `json:"mcp_servers,omitempty"` // nil = 不改;非 nil(含空)= 替换 + // 成本核算三态:字段存在(非 nil)= 设置;缺省(nil)= 不改。 + // ModelID 空串 = 清空;预算上限值 <=0 = 清空。 + ModelID *string `json:"model_id,omitempty"` + MaxCostUSD *float64 `json:"max_cost_usd,omitempty"` + MaxTokens *int64 `json:"max_tokens,omitempty"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds,omitempty"` } // ConfigFileDTO 是 AgentKind 配置文件的对外视图(admin only,content 明文)。 @@ -133,6 +149,15 @@ type SessionDTO struct { LastError *string `json:"last_error"` StartedAt string `json:"started_at"` EndedAt *string `json:"ended_at"` + // 成本核算:运行时累计 + 有效预算上限 + 终止原因。 + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUSD float64 `json:"total_cost_usd"` + BudgetMaxCostUSD *float64 `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` } func sessionToDTO(s *Session) SessionDTO { @@ -167,5 +192,148 @@ func sessionToDTO(s *Session) SessionDTO { LastError: s.LastError, StartedAt: s.StartedAt.UTC().Format("2006-01-02T15:04:05Z"), EndedAt: ended, + + PromptTokens: s.PromptTokens, + CompletionTokens: s.CompletionTokens, + ThinkingTokens: s.ThinkingTokens, + TotalCostUSD: s.TotalCostUSD, + BudgetMaxCostUSD: s.BudgetMaxCostUSD, + BudgetMaxTokens: s.BudgetMaxTokens, + BudgetMaxWallClockSeconds: s.BudgetMaxWallClockSeconds, + TerminatedReason: s.TerminatedReason, + } +} + +// SessionUsageDTO 是单条 per-turn 账目的对外视图。 +type SessionUsageDTO struct { + ID int64 `json:"id"` + ModelID *string `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUSD float64 `json:"cost_usd"` + CreatedAt string `json:"created_at"` +} + +// SessionUsageResponse 是 GET /sessions/{id}/usage 的响应:会话累计 + 最近账目。 +type SessionUsageResponse struct { + SessionID string `json:"session_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUSD float64 `json:"total_cost_usd"` + BudgetMaxCostUSD *float64 `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + Recent []SessionUsageDTO `json:"recent"` +} + +// AcpUsageItem 是 admin ACP 用量聚合的一行(镜像 chat AdminUsageItem)。 +type AcpUsageItem struct { + Key string `json:"key"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUSD float64 `json:"cost_usd"` +} + +// AcpUsageResponse 包裹分组用量项。 +type AcpUsageResponse struct { + Items []AcpUsageItem `json:"items"` +} + +// ===== run-history dashboard DTO ===== + +// DashboardResponse 是 GET /api/v1/acp/dashboard 的响应:总览 + 按天序列。 +type DashboardResponse struct { + From string `json:"from"` + To string `json:"to"` + Rollup SessionRollupDTO `json:"rollup"` + ByDay []SessionDayDTO `json:"by_day"` +} + +// SessionRollupDTO 是会话总览(计数 + 成功率 + 成本 + 平均时长)。 +type SessionRollupDTO struct { + Total int64 `json:"total"` + Succeeded int64 `json:"succeeded"` + Crashed int64 `json:"crashed"` + Active int64 `json:"active"` + SuccessRate float64 `json:"success_rate"` + TotalCostUSD float64 `json:"total_cost_usd"` + TotalTokens int64 `json:"total_tokens"` + AvgDurationSeconds float64 `json:"avg_duration_seconds"` +} + +// SessionDayDTO 是按天的时间序列一行。 +type SessionDayDTO struct { + Day string `json:"day"` + Total int64 `json:"total"` + Succeeded int64 `json:"succeeded"` + Crashed int64 `json:"crashed"` + TotalCostUSD float64 `json:"total_cost_usd"` +} + +// SessionTimelineDTO 是单会话事件时间线一行(按 method/kind 分桶)。 +type SessionTimelineDTO struct { + Direction string `json:"direction"` + Kind string `json:"kind"` + Method string `json:"method"` + EventCount int64 `json:"event_count"` + FirstAt string `json:"first_at"` + LastAt string `json:"last_at"` +} + +func dashboardToResponse(r *DashboardResult) DashboardResponse { + resp := DashboardResponse{ + From: r.From.UTC().Format(time.RFC3339), + To: r.To.UTC().Format(time.RFC3339), + Rollup: SessionRollupDTO{ + Total: r.Rollup.Total, + Succeeded: r.Rollup.Succeeded, + Crashed: r.Rollup.Crashed, + Active: r.Rollup.Active, + SuccessRate: r.Rollup.SuccessRate(), + TotalCostUSD: r.Rollup.TotalCostUSD, + TotalTokens: r.Rollup.TotalTokens, + AvgDurationSeconds: r.Rollup.AvgDurationSeconds, + }, + ByDay: make([]SessionDayDTO, 0, len(r.ByDay)), + } + for _, d := range r.ByDay { + resp.ByDay = append(resp.ByDay, SessionDayDTO{ + Day: d.Day.UTC().Format(time.RFC3339), + Total: d.Total, + Succeeded: d.Succeeded, + Crashed: d.Crashed, + TotalCostUSD: d.TotalCostUSD, + }) + } + return resp +} + +func sessionTimelineToDTO(b SessionTimelineBucket) SessionTimelineDTO { + return SessionTimelineDTO{ + Direction: b.Direction, + Kind: b.RPCKind, + Method: b.Method, + EventCount: b.EventCount, + FirstAt: b.FirstAt.UTC().Format(time.RFC3339), + LastAt: b.LastAt.UTC().Format(time.RFC3339), + } +} + +func sessionUsageToDTO(r *SessionUsageRecord) SessionUsageDTO { + var modelID *string + if r.ModelID != nil { + v := r.ModelID.String() + modelID = &v + } + return SessionUsageDTO{ + ID: r.ID, + ModelID: modelID, + PromptTokens: r.PromptTokens, + CompletionTokens: r.CompletionTokens, + ThinkingTokens: r.ThinkingTokens, + CostUSD: r.CostUSD, + CreatedAt: r.CreatedAt.UTC().Format(time.RFC3339), } } diff --git a/internal/acp/handler.go b/internal/acp/handler.go index b9d11f9..b457ab2 100644 --- a/internal/acp/handler.go +++ b/internal/acp/handler.go @@ -6,6 +6,7 @@ package acp import ( "context" "encoding/json" + "log/slog" "net/http" "sort" "strconv" @@ -83,12 +84,27 @@ func (h *Handler) Mount(r chi.Router) { r.Get("/{id}/events", h.getEvents) r.Get("/{id}/ws", h.sessionWS) r.Get("/{id}/permissions", h.listSessionPermissions) + r.Get("/{id}/usage", h.getSessionUsage) + // run-history 单会话事件时间线(owner 或 admin)。 + r.Get("/{id}/timeline", h.getSessionTimeline) + }) + // run-history dashboard 汇总(owner scope;admin 可全量)。 + r.Group(func(r chi.Router) { + r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) + r.Get("/api/v1/acp/dashboard", h.getDashboard) }) r.Route("/api/v1/acp/permissions", func(r chi.Router) { r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) + // 跨会话审批 inbox:调用者所有会话的待审权限请求(HITL)。 + r.Get("/", h.listPendingPermissions) r.Post("/{reqID}/approve", h.approvePermission) r.Post("/{reqID}/deny", h.denyPermission) }) + // Admin ACP 用量聚合(镜像 chat adminUsage);service 层 requireAdmin。 + r.Group(func(r chi.Router) { + r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) + r.Get("/api/v1/acp/usage", h.adminAcpUsage) + }) } // listSessionPermissions 返回某会话的待审权限请求(owner 或 admin)。 @@ -115,6 +131,25 @@ func (h *Handler) listSessionPermissions(w http.ResponseWriter, r *http.Request) httpx.WriteJSON(w, http.StatusOK, out) } +// listPendingPermissions 返回调用者跨所有会话的待审权限请求(HITL inbox)。 +func (h *Handler) listPendingPermissions(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + reqs, err := h.permSvc.ListPendingForUser(r.Context(), c) + if err != nil { + writeErr(w, r, err) + return + } + out := make([]PermissionRequestDTO, 0, len(reqs)) + for _, p := range reqs { + out = append(out, permissionRequestToDTO(p)) + } + httpx.WriteJSON(w, http.StatusOK, out) +} + type approvePermissionReq struct { OptionID string `json:"option_id"` } @@ -205,17 +240,26 @@ func (h *Handler) createAgentKind(w http.ResponseWriter, r *http.Request) { if req.Enabled != nil { enabled = *req.Enabled } + modelID, perr := parseOptionalUUID(req.ModelID) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad model_id")) + return + } in := CreateAgentKindInput{ - Name: strings.TrimSpace(req.Name), - DisplayName: req.DisplayName, - Description: req.Description, - BinaryPath: strings.TrimSpace(req.BinaryPath), - Args: req.Args, - Env: req.Env, - Enabled: enabled, - ToolAllowlist: req.ToolAllowlist, - ClientType: ClientType(req.ClientType), - MCPServers: req.MCPServers, + Name: strings.TrimSpace(req.Name), + DisplayName: req.DisplayName, + Description: req.Description, + BinaryPath: strings.TrimSpace(req.BinaryPath), + Args: req.Args, + Env: req.Env, + Enabled: enabled, + ToolAllowlist: req.ToolAllowlist, + ClientType: ClientType(req.ClientType), + MCPServers: req.MCPServers, + ModelID: modelID, + MaxCostUSD: req.MaxCostUSD, + MaxTokens: req.MaxTokens, + MaxWallClockSeconds: req.MaxWallClockSeconds, } out, err := h.akSvc.Create(r.Context(), c, in) if err != nil { @@ -278,6 +322,35 @@ func (h *Handler) updateAgentKind(w http.ResponseWriter, r *http.Request) { ct := ClientType(*req.ClientType) in.ClientType = &ct } + if req.ModelID != nil { + in.SetModelID = true + if *req.ModelID == "" { + in.ModelID = nil // 显式清空 + } else { + mid, merr := uuid.Parse(*req.ModelID) + if merr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad model_id")) + return + } + in.ModelID = &mid + } + } + if req.MaxCostUSD != nil { + in.SetMaxCostUSD = true + in.MaxCostUSD = positiveOrNil(req.MaxCostUSD) + } + if req.MaxTokens != nil { + in.SetMaxTokens = true + if *req.MaxTokens > 0 { + in.MaxTokens = req.MaxTokens + } + } + if req.MaxWallClockSeconds != nil { + in.SetMaxWallClock = true + if *req.MaxWallClockSeconds > 0 { + in.MaxWallClockSeconds = req.MaxWallClockSeconds + } + } out, err := h.akSvc.Update(r.Context(), c, id, in) if err != nil { writeErr(w, r, err) @@ -457,6 +530,123 @@ func (h *Handler) getSession(w http.ResponseWriter, r *http.Request) { httpx.WriteJSON(w, http.StatusOK, sessionToDTO(s)) } +// getSessionUsage 返回会话累计成本/token + 最近账目(owner 或 admin)。 +func (h *Handler) getSessionUsage(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + id, perr := uuid.Parse(chi.URLParam(r, "id")) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id")) + return + } + // 复用 sessSvc.Get 做 owner/admin 访问校验(NotFound/Forbidden 由其返回)。 + s, err := h.sessSvc.Get(r.Context(), c, id) + if err != nil { + writeErr(w, r, err) + return + } + const recentLimit = 50 + ledger, err := h.repo.ListSessionUsage(r.Context(), id, recentLimit) + if err != nil { + writeErr(w, r, err) + return + } + recent := make([]SessionUsageDTO, 0, len(ledger)) + for _, rec := range ledger { + recent = append(recent, sessionUsageToDTO(rec)) + } + httpx.WriteJSON(w, http.StatusOK, SessionUsageResponse{ + SessionID: s.ID.String(), + PromptTokens: s.PromptTokens, + CompletionTokens: s.CompletionTokens, + ThinkingTokens: s.ThinkingTokens, + TotalCostUSD: s.TotalCostUSD, + BudgetMaxCostUSD: s.BudgetMaxCostUSD, + BudgetMaxTokens: s.BudgetMaxTokens, + Recent: recent, + }) +} + +// adminAcpUsage 返回 ACP 用量聚合(admin only),group=user|model|day,镜像 chat。 +func (h *Handler) adminAcpUsage(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + if !c.IsAdmin { + writeErr(w, r, errs.New(errs.CodeForbidden, "admin only")) + return + } + q := r.URL.Query() + to := time.Now().UTC() + from := to.AddDate(0, 0, -30) + if v := q.Get("from"); v != "" { + if t, e := time.Parse(time.RFC3339, v); e == nil { + from = t + } + } + if v := q.Get("to"); v != "" { + if t, e := time.Parse(time.RFC3339, v); e == nil { + to = t + } + } + groupBy := q.Get("group") + if groupBy == "" { + groupBy = q.Get("group_by") + } + if groupBy == "" { + groupBy = "day" + } + + items := make([]AcpUsageItem, 0) + switch groupBy { + case "user": + rows, rerr := h.repo.SummarizeAcpUsageByUser(r.Context(), from, to) + if rerr != nil { + writeErr(w, r, rerr) + return + } + for _, row := range rows { + items = append(items, AcpUsageItem{ + Key: row.UserID.String(), PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD, + }) + } + case "model": + rows, rerr := h.repo.SummarizeAcpUsageByModel(r.Context(), from, to) + if rerr != nil { + writeErr(w, r, rerr) + return + } + for _, row := range rows { + items = append(items, AcpUsageItem{ + Key: row.ModelID.String(), PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD, + }) + } + case "day": + rows, rerr := h.repo.SummarizeAcpUsageByDay(r.Context(), from, to) + if rerr != nil { + writeErr(w, r, rerr) + return + } + for _, row := range rows { + items = append(items, AcpUsageItem{ + Key: row.Day.Format(time.RFC3339), PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, ThinkingTokens: row.ThinkingTokens, CostUSD: row.CostUSD, + }) + } + default: + writeErr(w, r, errs.New(errs.CodeInvalidInput, "invalid group")) + return + } + httpx.WriteJSON(w, http.StatusOK, AcpUsageResponse{Items: items}) +} + func (h *Handler) terminateSession(w http.ResponseWriter, r *http.Request) { c, err := h.caller(r) if err != nil { @@ -519,6 +709,80 @@ func (h *Handler) getEvents(w http.ResponseWriter, r *http.Request) { httpx.WriteJSON(w, http.StatusOK, out) } +// getDashboard 返回 run-history 汇总(owner scope;admin 全量)。可选 query: +// project_id / requirement_id / from / to(RFC3339)。 +func (h *Handler) getDashboard(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + q := r.URL.Query() + var in DashboardInput + if v := q.Get("project_id"); v != "" { + id, perr := uuid.Parse(v) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad project_id")) + return + } + in.ProjectID = &id + } + if v := q.Get("requirement_id"); v != "" { + id, perr := uuid.Parse(v) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad requirement_id")) + return + } + in.RequirementID = &id + } + if v := q.Get("from"); v != "" { + t, terr := time.Parse(time.RFC3339, v) + if terr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad from")) + return + } + in.From = t + } + if v := q.Get("to"); v != "" { + t, terr := time.Parse(time.RFC3339, v) + if terr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad to")) + return + } + in.To = t + } + res, err := h.sessSvc.Dashboard(r.Context(), c, in) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, dashboardToResponse(res)) +} + +// getSessionTimeline 返回单会话事件时间线(owner 或 admin)。 +func (h *Handler) getSessionTimeline(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + id, perr := uuid.Parse(chi.URLParam(r, "id")) + if perr != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id")) + return + } + buckets, err := h.sessSvc.Timeline(r.Context(), c, id) + if err != nil { + writeErr(w, r, err) + return + } + out := make([]SessionTimelineDTO, 0, len(buckets)) + for _, b := range buckets { + out = append(out, sessionTimelineToDTO(b)) + } + httpx.WriteJSON(w, http.StatusOK, out) +} + // ===== WebSocket ===== func (h *Handler) sessionWS(w http.ResponseWriter, r *http.Request) { @@ -675,6 +939,17 @@ func (h *Handler) sessionWS(w http.ResponseWriter, r *http.Request) { }) continue } + // 回合相关联:用户从前端发起 session/prompt 时也登记回合,使其完成时 + // 走与 auto-prompt 相同的检测路径。client 自生成的字符串 id 作为相关联键。 + if m.Method == "session/prompt" { + if tr := relay.Tracker(); tr != nil { + if pid, ok := m.IDString(); ok && pid != "" { + if _, err := tr.StartTurn(ctx, pid); err != nil { + slog.Warn("acp.ws.start_turn", "session_id", sess.ID, "err", err.Error()) + } + } + } + } if err := relay.SendClient(m); err != nil { _ = wsjson.Write(ctx, conn, map[string]any{ "kind": "client_error", @@ -702,22 +977,51 @@ func (h *Handler) agentKindToAdminDTO(k *AgentKind) AgentKindAdminDTO { if servers, err := decryptMCPServers(h.enc, k.EncryptedMCPServers); err == nil { mcpServers = servers } - return AgentKindAdminDTO{ - ID: k.ID, - Name: k.Name, - DisplayName: k.DisplayName, - Description: k.Description, - BinaryPath: k.BinaryPath, - Args: append([]string(nil), k.Args...), - EnvKeys: envKeys, - Enabled: k.Enabled, - ToolAllowlist: append([]string(nil), k.ToolAllowlist...), - ClientType: string(k.ClientType), - MCPServers: mcpServers, - CreatedBy: k.CreatedBy, - CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), - UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + var modelID *string + if k.ModelID != nil { + v := k.ModelID.String() + modelID = &v } + return AgentKindAdminDTO{ + ID: k.ID, + Name: k.Name, + DisplayName: k.DisplayName, + Description: k.Description, + BinaryPath: k.BinaryPath, + Args: append([]string(nil), k.Args...), + EnvKeys: envKeys, + Enabled: k.Enabled, + ToolAllowlist: append([]string(nil), k.ToolAllowlist...), + ClientType: string(k.ClientType), + MCPServers: mcpServers, + ModelID: modelID, + MaxCostUSD: k.MaxCostUSD, + MaxTokens: k.MaxTokens, + MaxWallClockSeconds: k.MaxWallClockSeconds, + CreatedBy: k.CreatedBy, + CreatedAt: k.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + UpdatedAt: k.UpdatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } +} + +// parseOptionalUUID 解析一个可选的字符串 UUID。nil/空串 → (nil, nil);非法 → 错误。 +func parseOptionalUUID(s *string) (*uuid.UUID, error) { + if s == nil || *s == "" { + return nil, nil + } + id, err := uuid.Parse(*s) + if err != nil { + return nil, err + } + return &id, nil +} + +// positiveOrNil 返回正数指针,否则 nil(用于"值 <=0 视为清空"语义)。 +func positiveOrNil(f *float64) *float64 { + if f == nil || *f <= 0 { + return nil + } + return f } func agentKindToPublicDTO(k *AgentKind) AgentKindPublicDTO { diff --git a/internal/acp/handler_e2e_test.go b/internal/acp/handler_e2e_test.go index 36b479e..6f9b71b 100644 --- a/internal/acp/handler_e2e_test.go +++ b/internal/acp/handler_e2e_test.go @@ -33,6 +33,7 @@ import ( "github.com/yan1h/agent-coding-workflow/internal/acp" "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" "github.com/yan1h/agent-coding-workflow/internal/project" "github.com/yan1h/agent-coding-workflow/internal/workspace" @@ -152,7 +153,7 @@ func TestHandler_Session_CreateAndWS_E2E(t *testing.T) { paStub := &stubProjectAccess{pj: &project.Project{ID: pid, Slug: "p-test"}} svc := acp.NewSessionService( - repo, wsStub, nil, nil, paStub, sup, + repo, wsStub, nil, nil, paStub, nil, nil, sup, audit.NewPostgresRecorder(pool), acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5}, nil, // mcpTokens — not exercised by handler e2e @@ -276,3 +277,140 @@ func TestHandler_Session_CreateAndWS_E2E(t *testing.T) { } } } + +// TestSession_TurnCompletion_E2E drives a full session with an InitialPrompt +// against the fake-acp-agent (which emits session/update chunks then a +// session/prompt response with stopReason=end_turn). It asserts: +// - an acp_turns row is created and completed with stop_reason=end_turn +// - update_count == FAKE_ACP_PROMPT_UPDATES +// - acp_sessions.last_stop_reason is set to end_turn +// - a test TurnBus subscriber receives turn_completed + session_idle +func TestSession_TurnCompletion_E2E(t *testing.T) { + if testing.Short() { + t.Skip("e2e: spawns subprocess + testcontainer pg") + } + t.Parallel() + ctx := context.Background() + + repo, pool := setupRepo(t) + + uid := mustInsertUser(t, ctx, pool, "turn-e2e@local", false) + pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, uid) + cwd := t.TempDir() + _, err := pool.Exec(ctx, `UPDATE workspaces SET main_path = $1 WHERE id = $2`, cwd, wsid) + require.NoError(t, err) + + bin := fakeAgentBinary(t) + enc := testEncryptor(t) + + const wantUpdates = 4 + kind, err := repo.CreateAgentKind(ctx, &acp.AgentKind{ + ID: uuid.New(), Name: "turn-e2e-fake", DisplayName: "Fake", + BinaryPath: bin, Args: []string{}, Enabled: true, CreatedBy: uid, + // FAKE_ACP_PROMPT_UPDATES via encrypted env: emit N session/update before response. + EncryptedEnv: mustEncryptEnv(t, enc, map[string]string{"FAKE_ACP_PROMPT_UPDATES": "4"}), + }) + require.NoError(t, err) + + wsRow, err := workspace.NewPostgresRepository(pool).GetWorkspaceByID(ctx, wsid) + require.NoError(t, err) + + disp := notify.NewDispatcher(notifyRepoStub{}, slogDiscard()) + sup := acp.NewSupervisor( + repo, audit.NewPostgresRecorder(pool), disp, + nil, nil, nil, enc, + acp.SupervisorConfig{ + SpawnTimeout: 5 * time.Second, + KillGrace: 1 * time.Second, + StderrBufferLines: 50, + StderrTailBytes: 1000, + EventMaxPayload: 65536, + EventTruncateField: 4096, + ShutdownGrace: 5 * time.Second, + }, + slogDiscard(), + ) + defer sup.ShutdownAll(context.Background()) + + // Test TurnBus subscriber records published events. + bus := acp.NewTurnBus(slogDiscard()) + evCh := make(chan acp.TurnEvent, 8) + bus.Subscribe("test", func(_ context.Context, ev acp.TurnEvent) { evCh <- ev }) + sup.SetTurnBus(bus) + + wsStub := &stubWsService{wsRow: wsRow} + paStub := &stubProjectAccess{pj: &project.Project{ID: pid, Slug: "p-turn"}} + svc := acp.NewSessionService( + repo, wsStub, nil, nil, paStub, nil, nil, sup, + audit.NewPostgresRecorder(pool), + acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5}, + nil, + ) + permSvc := acp.NewPermissionService(repo, nil, 0, nil) + sup.SetPermissionService(permSvc) + permSvc.SetRelayLocator(sup) + + prompt := "do the thing" + branch := "main" + sess, err := svc.Create(ctx, acp.Caller{UserID: uid}, acp.CreateSessionInput{ + WorkspaceID: wsid, AgentKindID: kind.ID, Branch: &branch, InitialPrompt: &prompt, + }) + require.NoError(t, err) + + // Poll for the turn to complete. + var completedTurn *acp.Turn + deadline := time.After(15 * time.Second) + for completedTurn == nil { + ts, _ := repo.ListTurnsBySession(ctx, sess.ID) + for _, tn := range ts { + if tn.Status == acp.TurnCompleted { + completedTurn = tn + } + } + if completedTurn != nil { + break + } + select { + case <-deadline: + t.Fatalf("turn never completed; turns=%+v", ts) + case <-time.After(150 * time.Millisecond): + } + } + + require.NotNil(t, completedTurn.StopReason) + assert.Equal(t, "end_turn", *completedTurn.StopReason) + assert.Equal(t, wantUpdates, completedTurn.UpdateCount) + + got, _ := repo.GetSessionByID(ctx, sess.ID) + require.NotNil(t, got.LastStopReason) + assert.Equal(t, "end_turn", *got.LastStopReason) + + // Collect bus events: expect a turn_completed and a session_idle. + var sawCompleted, sawIdle bool + evDeadline := time.After(3 * time.Second) + for !(sawCompleted && sawIdle) { + select { + case ev := <-evCh: + switch ev.Kind { + case acp.TurnCompletedEvent: + sawCompleted = true + assert.Equal(t, acp.StopEndTurn, ev.StopReason) + case acp.SessionIdleEvent: + sawIdle = true + } + case <-evDeadline: + t.Fatalf("did not receive both turn_completed and session_idle (completed=%v idle=%v)", sawCompleted, sawIdle) + } + } +} + +// mustEncryptEnv marshals env to JSON and encrypts it (mirrors acp.encryptEnv, +// which is unexported), so the supervisor can decrypt it back into the spawn env. +func mustEncryptEnv(t *testing.T, enc *crypto.Encryptor, env map[string]string) []byte { + t.Helper() + b, err := json.Marshal(env) + require.NoError(t, err) + out, err := enc.Encrypt(b) + require.NoError(t, err) + return out +} diff --git a/internal/acp/handlers/handlers.go b/internal/acp/handlers/handlers.go index f86fea8..5e5c6ed 100644 --- a/internal/acp/handlers/handlers.go +++ b/internal/acp/handlers/handlers.go @@ -43,9 +43,16 @@ type FsError struct { type FsHandler struct { Read *FsReadHandler Write *FsWriteHandler + List *FsListHandler + Grep *GrepHandler } // NewFsHandler constructs a composite FsHandler with default sub-handlers. func NewFsHandler() *FsHandler { - return &FsHandler{Read: NewFsReadHandler(), Write: NewFsWriteHandler()} + return &FsHandler{ + Read: NewFsReadHandler(), + Write: NewFsWriteHandler(), + List: NewFsListHandler(), + Grep: NewGrepHandler(), + } } diff --git a/internal/acp/handlers/server_fs_list.go b/internal/acp/handlers/server_fs_list.go new file mode 100644 index 0000000..65d68a3 --- /dev/null +++ b/internal/acp/handlers/server_fs_list.go @@ -0,0 +1,88 @@ +package handlers + +import ( + "context" + "encoding/json" + "errors" + "io/fs" + "os" + "sort" +) + +// DefaultListMaxEntries caps the number of directory entries returned to bound +// payload size for large directories. +const DefaultListMaxEntries = 1000 + +// FsListHandler handles fs/list: a bounded directory listing inside the worktree. +type FsListHandler struct { + MaxEntries int +} + +// NewFsListHandler constructs an FsListHandler with default caps. +func NewFsListHandler() *FsListHandler { return &FsListHandler{MaxEntries: DefaultListMaxEntries} } + +func (h *FsListHandler) maxEntries() int { + if h.MaxEntries > 0 { + return h.MaxEntries + } + return DefaultListMaxEntries +} + +type fsListParams struct { + SessionID string `json:"sessionId"` + Path string `json:"path"` +} + +type fsDirEntry struct { + Name string `json:"name"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` +} + +type fsListResult struct { + Entries []fsDirEntry `json:"entries"` + Truncated bool `json:"truncated"` +} + +// Handle lists the directory at sess.CwdPath/path. Returns FsError for path +// escape (-32001) or read failure. Results are sorted (dirs first, then name). +func (h *FsListHandler) Handle(_ context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult { + var p fsListParams + if err := json.Unmarshal(paramsJSON, &p); err != nil { + return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}} + } + abs, err := resolveSafePath(sess.CwdPath, p.Path) + if err != nil { + return FsResult{Err: &FsError{Code: -32001, Message: err.Error()}} + } + dirents, err := os.ReadDir(abs) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return FsResult{Err: &FsError{Code: -32603, Message: "directory not found"}} + } + return FsResult{Err: &FsError{Code: -32603, Message: "list: " + err.Error()}} + } + + max := h.maxEntries() + out := fsListResult{Entries: make([]fsDirEntry, 0, len(dirents))} + for _, de := range dirents { + if len(out.Entries) >= max { + out.Truncated = true + break + } + e := fsDirEntry{Name: de.Name(), IsDir: de.IsDir()} + if info, ierr := de.Info(); ierr == nil && !de.IsDir() { + e.Size = info.Size() + } + out.Entries = append(out.Entries, e) + } + sort.SliceStable(out.Entries, func(i, j int) bool { + if out.Entries[i].IsDir != out.Entries[j].IsDir { + return out.Entries[i].IsDir // dirs first + } + return out.Entries[i].Name < out.Entries[j].Name + }) + + res, _ := json.Marshal(out) + return FsResult{OK: res} +} diff --git a/internal/acp/handlers/server_fs_list_test.go b/internal/acp/handlers/server_fs_list_test.go new file mode 100644 index 0000000..8bb7511 --- /dev/null +++ b/internal/acp/handlers/server_fs_list_test.go @@ -0,0 +1,82 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" +) + +func TestFsList_HappyPath(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.txt"), []byte("hi"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(tmp, "sub"), 0o755)) + + h := handlers.NewFsListHandler() + sess := handlers.SessionContext{CwdPath: tmp} + res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "path": "."})) + require.Nil(t, res.Err) + + var out struct { + Entries []struct { + Name string `json:"name"` + IsDir bool `json:"is_dir"` + Size int64 `json:"size"` + } `json:"entries"` + Truncated bool `json:"truncated"` + } + require.NoError(t, json.Unmarshal(res.OK, &out)) + require.Len(t, out.Entries, 2) + // Dirs sort first. + assert.Equal(t, "sub", out.Entries[0].Name) + assert.True(t, out.Entries[0].IsDir) + assert.Equal(t, "a.txt", out.Entries[1].Name) + assert.Equal(t, int64(2), out.Entries[1].Size) +} + +func TestFsList_PathOutsideWorktree(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + h := handlers.NewFsListHandler() + sess := handlers.SessionContext{CwdPath: tmp} + outside := filepath.Join(filepath.VolumeName(tmp)+string(filepath.Separator), "etc") + res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "path": outside})) + require.NotNil(t, res.Err) + assert.Equal(t, -32001, res.Err.Code) +} + +func TestFsList_NotFound(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + h := handlers.NewFsListHandler() + sess := handlers.SessionContext{CwdPath: tmp} + res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "path": "missing"})) + require.NotNil(t, res.Err) + assert.Equal(t, -32603, res.Err.Code) +} + +func TestFsList_MaxEntriesCap(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + for i := 0; i < 5; i++ { + require.NoError(t, os.WriteFile(filepath.Join(tmp, string(rune('a'+i))+".txt"), []byte("x"), 0o644)) + } + h := &handlers.FsListHandler{MaxEntries: 2} + sess := handlers.SessionContext{CwdPath: tmp} + res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "path": "."})) + require.Nil(t, res.Err) + var out struct { + Entries []any `json:"entries"` + Truncated bool `json:"truncated"` + } + require.NoError(t, json.Unmarshal(res.OK, &out)) + require.Len(t, out.Entries, 2) + require.True(t, out.Truncated) +} diff --git a/internal/acp/handlers/server_grep.go b/internal/acp/handlers/server_grep.go new file mode 100644 index 0000000..4d64d21 --- /dev/null +++ b/internal/acp/handlers/server_grep.go @@ -0,0 +1,209 @@ +package handlers + +import ( + "bufio" + "context" + "encoding/json" + "io/fs" + "os" + "path/filepath" + "regexp" + "strings" +) + +// Grep caps. These bound the cost of a single grep request to avoid a DoS via a +// crafted pattern / huge tree. All are overridable on the handler. +const ( + DefaultGrepMaxMatches = 200 + DefaultGrepMaxFiles = 5000 + DefaultGrepMaxFileSize = 1 << 20 // 1 MiB: skip larger files + DefaultGrepMaxLineLen = 1000 // truncate long matched lines +) + +// GrepHandler handles fs/grep: a bounded regexp search under the worktree using a +// pure-Go walk (no shell, no git grep interpolation) confined to the pathsafe +// root. Binary files and oversize files are skipped. +type GrepHandler struct { + MaxMatches int + MaxFiles int + MaxFileSize int64 +} + +// NewGrepHandler constructs a GrepHandler with default caps. +func NewGrepHandler() *GrepHandler { + return &GrepHandler{ + MaxMatches: DefaultGrepMaxMatches, + MaxFiles: DefaultGrepMaxFiles, + MaxFileSize: DefaultGrepMaxFileSize, + } +} + +func (h *GrepHandler) maxMatches() int { + if h.MaxMatches > 0 { + return h.MaxMatches + } + return DefaultGrepMaxMatches +} + +func (h *GrepHandler) maxFiles() int { + if h.MaxFiles > 0 { + return h.MaxFiles + } + return DefaultGrepMaxFiles +} + +func (h *GrepHandler) maxFileSize() int64 { + if h.MaxFileSize > 0 { + return h.MaxFileSize + } + return DefaultGrepMaxFileSize +} + +type fsGrepParams struct { + SessionID string `json:"sessionId"` + Pattern string `json:"pattern"` + Path string `json:"path,omitempty"` // sub-path to scope the search; defaults to cwd root + CaseSensitive bool `json:"case_sensitive,omitempty"` +} + +type grepMatch struct { + File string `json:"file"` // worktree-relative + Line int `json:"line"` // 1-based + Text string `json:"text"` +} + +type fsGrepResult struct { + Matches []grepMatch `json:"matches"` + Truncated bool `json:"truncated"` +} + +// Handle runs a regexp search. Returns FsError for path escape (-32001), invalid +// pattern (-32602), or read failure. Match/file caps are honored. +func (h *GrepHandler) Handle(ctx context.Context, sess SessionContext, paramsJSON json.RawMessage) FsResult { + var p fsGrepParams + if err := json.Unmarshal(paramsJSON, &p); err != nil { + return FsResult{Err: &FsError{Code: -32602, Message: "invalid params: " + err.Error()}} + } + if strings.TrimSpace(p.Pattern) == "" { + return FsResult{Err: &FsError{Code: -32602, Message: "pattern required"}} + } + expr := p.Pattern + if !p.CaseSensitive { + expr = "(?i)" + expr + } + re, err := regexp.Compile(expr) + if err != nil { + return FsResult{Err: &FsError{Code: -32602, Message: "invalid pattern: " + err.Error()}} + } + + searchPath := p.Path + if searchPath == "" { + searchPath = "." + } + root, err := resolveSafePath(sess.CwdPath, searchPath) + if err != nil { + return FsResult{Err: &FsError{Code: -32001, Message: err.Error()}} + } + + cwd := filepath.Clean(sess.CwdPath) + out := fsGrepResult{Matches: make([]grepMatch, 0, 16)} + maxMatches := h.maxMatches() + maxFiles := h.maxFiles() + maxSize := h.maxFileSize() + filesScanned := 0 + + walkErr := filepath.WalkDir(root, func(path string, d fs.DirEntry, werr error) error { + if werr != nil { + return nil // skip unreadable entries + } + select { + case <-ctx.Done(): + return filepath.SkipAll + default: + } + if d.IsDir() { + if isSkippedDir(d.Name()) { + return filepath.SkipDir + } + return nil + } + if filesScanned >= maxFiles { + out.Truncated = true + return filepath.SkipAll + } + info, ierr := d.Info() + if ierr != nil || info.Size() > maxSize { + return nil + } + filesScanned++ + rel, rerr := filepath.Rel(cwd, path) + if rerr != nil { + rel = path + } + rel = filepath.ToSlash(rel) + if grepFile(path, rel, re, &out, maxMatches) { + out.Truncated = true + return filepath.SkipAll + } + return nil + }) + if walkErr != nil && walkErr != filepath.SkipAll { + return FsResult{Err: &FsError{Code: -32603, Message: "grep walk: " + walkErr.Error()}} + } + + res, _ := json.Marshal(out) + return FsResult{OK: res} +} + +// grepFile scans one file line-by-line. Returns true when the global match cap +// was reached (caller should stop the walk). +func grepFile(abs, rel string, re *regexp.Regexp, out *fsGrepResult, maxMatches int) bool { + f, err := os.Open(abs) + if err != nil { + return false + } + defer f.Close() + + sc := bufio.NewScanner(f) + sc.Buffer(make([]byte, 0, 64*1024), 1<<20) + lineNo := 0 + for sc.Scan() { + lineNo++ + line := sc.Bytes() + // Skip binary content (NUL in line). + if hasNUL(line) { + return false + } + if re.Match(line) { + text := string(line) + if len(text) > DefaultGrepMaxLineLen { + text = text[:DefaultGrepMaxLineLen] + "...[truncated]" + } + out.Matches = append(out.Matches, grepMatch{File: rel, Line: lineNo, Text: text}) + if len(out.Matches) >= maxMatches { + return true + } + } + } + return false +} + +func hasNUL(b []byte) bool { + for _, c := range b { + if c == 0 { + return true + } + } + return false +} + +// skippedDirs are never descended into during grep. +var skippedDirs = map[string]struct{}{ + ".git": {}, "node_modules": {}, "vendor": {}, "dist": {}, "build": {}, + ".venv": {}, "__pycache__": {}, "third_party": {}, +} + +func isSkippedDir(name string) bool { + _, ok := skippedDirs[name] + return ok +} diff --git a/internal/acp/handlers/server_grep_test.go b/internal/acp/handlers/server_grep_test.go new file mode 100644 index 0000000..306d21c --- /dev/null +++ b/internal/acp/handlers/server_grep_test.go @@ -0,0 +1,125 @@ +package handlers_test + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" +) + +func TestGrep_HappyPath(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.go"), []byte("package a\n\nfunc Foo() {}\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "b.go"), []byte("package b\n\nfunc Bar() {}\n"), 0o644)) + + h := handlers.NewGrepHandler() + sess := handlers.SessionContext{CwdPath: tmp} + res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "func Foo"})) + require.Nil(t, res.Err) + + var out struct { + Matches []struct { + File string `json:"file"` + Line int `json:"line"` + Text string `json:"text"` + } `json:"matches"` + Truncated bool `json:"truncated"` + } + require.NoError(t, json.Unmarshal(res.OK, &out)) + require.Len(t, out.Matches, 1) + assert.Equal(t, "a.go", out.Matches[0].File) + assert.Equal(t, 3, out.Matches[0].Line) + assert.Contains(t, out.Matches[0].Text, "func Foo") +} + +func TestGrep_CaseInsensitiveByDefault(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.go"), []byte("HELLO world\n"), 0o644)) + h := handlers.NewGrepHandler() + sess := handlers.SessionContext{CwdPath: tmp} + res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "hello"})) + require.Nil(t, res.Err) + var out struct { + Matches []any `json:"matches"` + } + require.NoError(t, json.Unmarshal(res.OK, &out)) + require.Len(t, out.Matches, 1) +} + +func TestGrep_PathOutsideWorktree(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + h := handlers.NewGrepHandler() + sess := handlers.SessionContext{CwdPath: tmp} + outside := filepath.Join(filepath.VolumeName(tmp)+string(filepath.Separator), "etc") + res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "x", "path": outside})) + require.NotNil(t, res.Err) + assert.Equal(t, -32001, res.Err.Code) +} + +func TestGrep_InvalidPattern(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + h := handlers.NewGrepHandler() + sess := handlers.SessionContext{CwdPath: tmp} + res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "[invalid("})) + require.NotNil(t, res.Err) + assert.Equal(t, -32602, res.Err.Code) +} + +func TestGrep_EmptyPattern(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + h := handlers.NewGrepHandler() + sess := handlers.SessionContext{CwdPath: tmp} + res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": ""})) + require.NotNil(t, res.Err) + assert.Equal(t, -32602, res.Err.Code) +} + +func TestGrep_MaxMatchesCap(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + // 10 matching lines; cap at 3. + content := "m\nm\nm\nm\nm\nm\nm\nm\nm\nm\n" + require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.txt"), []byte(content), 0o644)) + h := &handlers.GrepHandler{MaxMatches: 3, MaxFiles: 100, MaxFileSize: 1 << 20} + sess := handlers.SessionContext{CwdPath: tmp} + res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "m"})) + require.Nil(t, res.Err) + var out struct { + Matches []any `json:"matches"` + Truncated bool `json:"truncated"` + } + require.NoError(t, json.Unmarshal(res.OK, &out)) + require.Len(t, out.Matches, 3) + require.True(t, out.Truncated) +} + +func TestGrep_SkipsGitDir(t *testing.T) { + t.Parallel() + tmp := t.TempDir() + require.NoError(t, os.MkdirAll(filepath.Join(tmp, ".git"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, ".git", "config"), []byte("secret pattern\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(tmp, "a.txt"), []byte("normal pattern\n"), 0o644)) + h := handlers.NewGrepHandler() + sess := handlers.SessionContext{CwdPath: tmp} + res := h.Handle(context.Background(), sess, mustJSON(t, map[string]any{"sessionId": "x", "pattern": "pattern"})) + require.Nil(t, res.Err) + var out struct { + Matches []struct { + File string `json:"file"` + } `json:"matches"` + } + require.NoError(t, json.Unmarshal(res.OK, &out)) + require.Len(t, out.Matches, 1) + assert.Equal(t, "a.txt", out.Matches[0].File) +} diff --git a/internal/acp/mcp_bridge.go b/internal/acp/mcp_bridge.go index 2e59189..af645ce 100644 --- a/internal/acp/mcp_bridge.go +++ b/internal/acp/mcp_bridge.go @@ -12,15 +12,17 @@ import ( ) // MCPBridge 适配 AgentKind/Session/Permission 三个服务到 mcp.ACPBridge。 +// repo 仅用于只读的回合列表(ListSessionTurns)——scope 校验先经 sessions.Get 完成。 type MCPBridge struct { kinds AgentKindService sessions SessionService perms *PermissionService + repo Repository } // NewMCPBridge 构造桥接适配器,由 app.go 装配 mcp.ServerDeps 时调用。 -func NewMCPBridge(kinds AgentKindService, sessions SessionService, perms *PermissionService) *MCPBridge { - return &MCPBridge{kinds: kinds, sessions: sessions, perms: perms} +func NewMCPBridge(kinds AgentKindService, sessions SessionService, perms *PermissionService, repo Repository) *MCPBridge { + return &MCPBridge{kinds: kinds, sessions: sessions, perms: perms, repo: repo} } var _ mcp.ACPBridge = (*MCPBridge)(nil) @@ -109,6 +111,86 @@ func (b *MCPBridge) ListPendingPermissions(ctx context.Context, userID uuid.UUID return out, nil } +// ListPendingApprovals 返回调用者跨所有会话的待审权限请求(HITL inbox,read-only)。 +func (b *MCPBridge) ListPendingApprovals(ctx context.Context, userID uuid.UUID, isAdmin bool) ([]mcp.ACPPermission, error) { + ps, err := b.perms.ListPendingForUser(ctx, Caller{UserID: userID, IsAdmin: isAdmin}) + if err != nil { + return nil, err + } + out := make([]mcp.ACPPermission, 0, len(ps)) + for _, p := range ps { + out = append(out, mcp.ACPPermission{ + ID: p.ID, + SessionID: p.SessionID, + AgentRequestID: p.AgentRequestID, + ToolName: p.ToolName, + Status: string(p.Status), + CreatedAt: p.CreatedAt, + }) + } + return out, nil +} + +// ListSessionTurns 返回会话的回合列表(只读)。先经 sessions.Get 做用户 + 项目 +// scope 校验(与 GetSession 同语义),再用 repo 读 acp_turns。 +func (b *MCPBridge) ListSessionTurns(ctx context.Context, userID uuid.UUID, isAdmin bool, sessionID uuid.UUID) ([]mcp.ACPTurn, error) { + if _, err := b.sessions.Get(ctx, Caller{UserID: userID, IsAdmin: isAdmin}, sessionID); err != nil { + return nil, err + } + ts, err := b.repo.ListTurnsBySession(ctx, sessionID) + if err != nil { + return nil, err + } + out := make([]mcp.ACPTurn, 0, len(ts)) + for _, t := range ts { + out = append(out, mcp.ACPTurn{ + TurnIndex: t.TurnIndex, + Status: string(t.Status), + StopReason: t.StopReason, + UpdateCount: t.UpdateCount, + StartedAt: t.StartedAt, + CompletedAt: t.CompletedAt, + }) + } + return out, nil +} + +// GetRunDashboard 返回 run-history 汇总(owner scope;admin 全量,read-only)。 +func (b *MCPBridge) GetRunDashboard(ctx context.Context, userID uuid.UUID, isAdmin bool, in mcp.ACPRunDashboardInput) (*mcp.ACPRunDashboard, error) { + res, err := b.sessions.Dashboard(ctx, Caller{UserID: userID, IsAdmin: isAdmin}, DashboardInput{ + ProjectID: in.ProjectID, + RequirementID: in.RequirementID, + From: in.From, + To: in.To, + }) + if err != nil { + return nil, err + } + out := &mcp.ACPRunDashboard{ + From: res.From, + To: res.To, + Total: res.Rollup.Total, + Succeeded: res.Rollup.Succeeded, + Crashed: res.Rollup.Crashed, + Active: res.Rollup.Active, + SuccessRate: res.Rollup.SuccessRate(), + TotalCostUSD: res.Rollup.TotalCostUSD, + TotalTokens: res.Rollup.TotalTokens, + AvgDurationSeconds: res.Rollup.AvgDurationSeconds, + ByDay: make([]mcp.ACPRunDayRow, 0, len(res.ByDay)), + } + for _, d := range res.ByDay { + out.ByDay = append(out.ByDay, mcp.ACPRunDayRow{ + Day: d.Day, + Total: d.Total, + Succeeded: d.Succeeded, + Crashed: d.Crashed, + TotalCostUSD: d.TotalCostUSD, + }) + } + return out, nil +} + func agentKindToMCP(k *AgentKind) mcp.ACPAgentKind { return mcp.ACPAgentKind{ ID: k.ID, @@ -122,18 +204,25 @@ func agentKindToMCP(k *AgentKind) mcp.ACPAgentKind { func sessionToMCP(s *Session) mcp.ACPSession { return mcp.ACPSession{ - ID: s.ID, - WorkspaceID: s.WorkspaceID, - ProjectID: s.ProjectID, - AgentKindID: s.AgentKindID, - UserID: s.UserID, - IssueID: s.IssueID, - RequirementID: s.RequirementID, - Branch: s.Branch, - IsMainWorktree: s.IsMainWorktree, - Status: string(s.Status), - LastError: s.LastError, - StartedAt: s.StartedAt, - EndedAt: s.EndedAt, + ID: s.ID, + WorkspaceID: s.WorkspaceID, + ProjectID: s.ProjectID, + AgentKindID: s.AgentKindID, + UserID: s.UserID, + IssueID: s.IssueID, + RequirementID: s.RequirementID, + Branch: s.Branch, + IsMainWorktree: s.IsMainWorktree, + Status: string(s.Status), + LastError: s.LastError, + LastStopReason: s.LastStopReason, + StartedAt: s.StartedAt, + EndedAt: s.EndedAt, + PromptTokens: s.PromptTokens, + CompletionTokens: s.CompletionTokens, + ThinkingTokens: s.ThinkingTokens, + TotalCostUSD: s.TotalCostUSD, + BudgetMaxCostUSD: s.BudgetMaxCostUSD, + BudgetMaxTokens: s.BudgetMaxTokens, } } diff --git a/internal/acp/permission_hitl_test.go b/internal/acp/permission_hitl_test.go new file mode 100644 index 0000000..677319b --- /dev/null +++ b/internal/acp/permission_hitl_test.go @@ -0,0 +1,153 @@ +package acp_test + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp" + "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" + "github.com/yan1h/agent-coding-workflow/internal/infra/notify" +) + +// spyNotifier captures Dispatch calls for the HITL pending path. +type spyNotifier struct { + mu sync.Mutex + msgs []notify.Message +} + +func (s *spyNotifier) Dispatch(_ context.Context, m notify.Message) error { + s.mu.Lock() + defer s.mu.Unlock() + s.msgs = append(s.msgs, m) + return nil +} +func (s *spyNotifier) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.msgs) +} +func (s *spyNotifier) first() notify.Message { + s.mu.Lock() + defer s.mu.Unlock() + return s.msgs[0] +} + +// spyMetrics captures permission decision outcomes. +type spyMetrics struct { + mu sync.Mutex + outcomes []string +} + +func (s *spyMetrics) RecordPermissionDecision(outcome string) { + s.mu.Lock() + defer s.mu.Unlock() + s.outcomes = append(s.outcomes, outcome) +} +func (s *spyMetrics) snapshot() []string { + s.mu.Lock() + defer s.mu.Unlock() + out := make([]string, len(s.outcomes)) + copy(out, s.outcomes) + return out +} + +func contains(ss []string, want string) bool { + for _, s := range ss { + if s == want { + return true + } + } + return false +} + +func TestPermission_Pending_DispatchesNotifyBeforeBlocking(t *testing.T) { + repo := newPermFakeRepo() + uid := uuid.New() + sid := uuid.New() + repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid} + + spy := &spyNotifier{} + met := &spyMetrics{} + svc := acp.NewPermissionService(repo, nil, time.Minute, nil) + svc.SetNotifier(spy) + svc.SetMetrics(met) + + sess := handlers.SessionContext{SessionID: sid, UserID: uid} // no allowlist -> pending + + resCh := make(chan string, 1) + go func() { + resCh <- svc.Decide(context.Background(), sess, "req-1", + []byte(`{"name":"danger.tool"}`), optionsAllowReject()) + }() + + // Notification must fire while Decide is still blocked (before resolve). + require.Eventually(t, func() bool { return spy.count() == 1 }, time.Second, 5*time.Millisecond) + m := spy.first() + require.Equal(t, "acp.permission_pending", m.Topic) + require.Equal(t, uid, m.UserID) + require.Equal(t, notify.SeverityWarning, m.Severity) + require.Equal(t, "/approvals", m.Link) + require.Equal(t, sid.String(), m.Metadata["session_id"]) + require.Equal(t, "danger.tool", m.Metadata["tool_name"]) + + // Resolve so Decide unblocks; exactly one dispatch (not on resolve). + var reqID uuid.UUID + require.Eventually(t, func() bool { + reqID = repo.onlyReqID() + return reqID != uuid.Nil + }, time.Second, 5*time.Millisecond) + require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: uid}, reqID, true, "allow_once")) + <-resCh + require.Equal(t, 1, spy.count(), "dispatch must fire once (pending), not on resolve") + + // Metrics: approved recorded. + require.True(t, contains(met.snapshot(), "approved")) +} + +func TestPermission_Metrics_AutoAndExpired(t *testing.T) { + repo := newPermFakeRepo() + uid := uuid.New() + sid := uuid.New() + repo.sessions[sid] = &acp.Session{ID: sid, UserID: uid} + + met := &spyMetrics{} + svc := acp.NewPermissionService(repo, nil, 30*time.Millisecond, nil) + svc.SetMetrics(met) + + // auto path + svc.Decide(context.Background(), handlers.SessionContext{ + SessionID: sid, UserID: uid, ToolAllowlist: []string{"*"}, + }, "req-auto", []byte(`{"name":"safe.tool"}`), optionsAllowReject()) + require.True(t, contains(met.snapshot(), "auto")) + + // expired path (no allowlist, times out) + chosen := svc.Decide(context.Background(), handlers.SessionContext{ + SessionID: sid, UserID: uid, + }, "req-exp", []byte(`{"name":"danger.tool"}`), optionsAllowReject()) + require.Equal(t, "", chosen) + require.True(t, contains(met.snapshot(), "expired")) +} + +func TestPermission_ListPendingForUser(t *testing.T) { + repo := newPermFakeRepo() + uid := uuid.New() + other := uuid.New() + sidA := uuid.New() + sidB := uuid.New() + repo.sessions[sidA] = &acp.Session{ID: sidA, UserID: uid} + repo.sessions[sidB] = &acp.Session{ID: sidB, UserID: other} + // Two pending for uid (across sessions), one for other. + repo.reqs[uuid.New()] = &acp.PermissionRequest{ID: uuid.New(), SessionID: sidA, Status: acp.PermissionPending} + repo.reqs[uuid.New()] = &acp.PermissionRequest{ID: uuid.New(), SessionID: sidA, Status: acp.PermissionPending} + repo.reqs[uuid.New()] = &acp.PermissionRequest{ID: uuid.New(), SessionID: sidB, Status: acp.PermissionPending} + + svc := acp.NewPermissionService(repo, nil, time.Minute, nil) + got, err := svc.ListPendingForUser(context.Background(), acp.Caller{UserID: uid}) + require.NoError(t, err) + require.Len(t, got, 2) +} diff --git a/internal/acp/permission_service.go b/internal/acp/permission_service.go index 2aea883..21abf53 100644 --- a/internal/acp/permission_service.go +++ b/internal/acp/permission_service.go @@ -24,17 +24,42 @@ import ( "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/infra/notify" ) // DefaultPermissionTimeout 是未配置时的人工决策超时;到期默认拒绝。 const DefaultPermissionTimeout = 5 * time.Minute +// permissionTopic 是 HITL 待审权限通知的 topic(inbox + 跨通道路由用)。 +const permissionTopic = "acp.permission_pending" + +// PermissionNotifier 是 PermissionService 投递"待人工审批"通知所需的窄接口。 +// notify.Dispatcher 满足它;用窄接口便于测试断言 Dispatch 调用。 +type PermissionNotifier interface { + Dispatch(ctx context.Context, msg notify.Message) error +} + +// PermissionMetrics 是 PermissionService 记录决策计数所需的窄接口。 +// metrics.Metrics 满足它;nil 时不记录(部分测试 / metrics 关闭)。 +type PermissionMetrics interface { + RecordPermissionDecision(outcome string) +} + // RelayLocator 让 PermissionService 按 session id 取到 *Relay 以推送 WS 事件。 // Supervisor 满足该接口(GetRelay)。用窄接口避免 permSvc 依赖整个 Supervisor。 type RelayLocator interface { GetRelay(sid uuid.UUID) *Relay } +// ProjectMaintainer 让 PermissionService 判定某用户是否对会话所属 project 有写权限 +// (project maintainer:owner / global-admin / project admin|member 角色)。允许任意 +// project maintainer 处理待审权限请求,而不仅是会话 owner(autonomy roadmap §11)。 +// app 层用 projectAccessAdapter.ResolveByID 实现;nil 时退化为仅 owner/admin。 +type ProjectMaintainer interface { + // CanWriteProject 报告 (callerID, isAdmin) 是否对 projectID 有写权限。 + CanWriteProject(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) +} + // decision 是投递给阻塞中 Decide 的人工/系统决策结果。 type decision struct { optionID string @@ -58,8 +83,27 @@ type PermissionService struct { locMu sync.RWMutex loc RelayLocator + + // HITL:待审权限通知 + 决策计数。装配期经 SetNotifier/SetMetrics 注入, + // 二者均可为 nil(部分测试 / metrics 关闭)。 + notify PermissionNotifier + metrics PermissionMetrics + + // maintainer 允许任意 project maintainer(非仅会话 owner)处理待审权限请求。 + // 装配期经 SetProjectMaintainer 注入;nil 时退化为仅 owner/global-admin。 + maintainer ProjectMaintainer } +// SetNotifier 注入待审权限通知投递器(装配期,best-effort)。 +func (s *PermissionService) SetNotifier(n PermissionNotifier) { s.notify = n } + +// SetMetrics 注入权限决策计数器(装配期)。 +func (s *PermissionService) SetMetrics(m PermissionMetrics) { s.metrics = m } + +// SetProjectMaintainer 注入 project maintainer 判定器(装配期)。注入后,会话 +// 所属 project 的任意 maintainer 均可处理该会话的待审权限请求。 +func (s *PermissionService) SetProjectMaintainer(m ProjectMaintainer) { s.maintainer = m } + // NewPermissionService 构造 PermissionService。timeout<=0 时用 DefaultPermissionTimeout。 func NewPermissionService(repo Repository, rec audit.Recorder, timeout time.Duration, log *slog.Logger) *PermissionService { if log == nil { @@ -109,6 +153,7 @@ func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionCon s.log.Error("acp.permission.insert_auto", "session_id", sess.SessionID, "err", err.Error()) } s.recordDecision(ctx, sess.UserID, sess.SessionID, toolName, "auto", nil) + s.recordMetric("auto") return chosen } @@ -140,6 +185,10 @@ func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionCon Payload: mustJSON(permissionRequestPayload(row)), }) + // HITL:在阻塞前 best-effort 推送 inbox 通知,使关闭页面的用户也能被提醒去 + // /approvals 审批(fire-and-forget,错误不影响 deny-on-timeout 安全默认)。 + s.notifyPending(ctx, sess, row, toolName) + select { case d := <-w.ch: return d.optionID @@ -147,6 +196,7 @@ func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionCon // 超时 → CAS 标记 expired(系统决策,decided_by=NULL)。 if _, ok, _ := s.repo.DecidePermissionRequest(context.WithoutCancel(ctx), reqID, PermissionExpired, nil, nil); ok { s.fanout(sess.SessionID, resolvedEnvelope(reqID, PermissionExpired, "")) + s.recordMetric("expired") } return "" case <-ctx.Done(): @@ -155,7 +205,8 @@ func (s *PermissionService) Decide(ctx context.Context, sess handlers.SessionCon } } -// Resolve 落地一次人工决策(approve/deny)。鉴权:session owner 或 admin。 +// Resolve 落地一次人工决策(approve/deny)。鉴权:session owner、global-admin,或 +// 会话所属 project 的任意 maintainer(autonomy roadmap §11)。 func (s *PermissionService) Resolve(ctx context.Context, c Caller, reqID uuid.UUID, approve bool, optionID string) error { req, err := s.repo.GetPermissionRequestByID(ctx, reqID) if err != nil { @@ -165,7 +216,7 @@ func (s *PermissionService) Resolve(ctx context.Context, c Caller, reqID uuid.UU if err != nil { return err } - if !c.IsAdmin && sessRow.UserID != c.UserID { + if !s.canManageSession(ctx, c, sessRow) { return errs.New(errs.CodeAcpSessionNotOwned, "无权处理该会话的权限请求") } @@ -200,21 +251,50 @@ func (s *PermissionService) Resolve(ctx context.Context, c Caller, reqID uuid.UU s.wake(reqID, decision{optionID: chosen}) s.fanout(req.SessionID, resolvedEnvelope(reqID, status, chosen)) s.recordDecision(ctx, c.UserID, req.SessionID, req.ToolName, string(status), &c.UserID) + if approve { + s.recordMetric("approved") + } else { + s.recordMetric("denied") + } return nil } +// ListPendingForUser 返回该用户跨所有会话的待审权限请求(HITL 跨会话审批 inbox)。 +// 包装 repo.ListPendingPermissionRequestsByUser(已 JOIN acp_sessions.user_id)。 +// admin 仅看自己拥有会话的待审项(与现有 inbox 语义一致;全局视图走 dashboard)。 +func (s *PermissionService) ListPendingForUser(ctx context.Context, c Caller) ([]*PermissionRequest, error) { + return s.repo.ListPendingPermissionRequestsByUser(ctx, c.UserID) +} + // ListPendingBySession 返回某会话的待审请求。鉴权同 Resolve。 func (s *PermissionService) ListPendingBySession(ctx context.Context, c Caller, sessionID uuid.UUID) ([]*PermissionRequest, error) { sessRow, err := s.repo.GetSessionByID(ctx, sessionID) if err != nil { return nil, err } - if !c.IsAdmin && sessRow.UserID != c.UserID { + if !s.canManageSession(ctx, c, sessRow) { return nil, errs.New(errs.CodeAcpSessionNotOwned, "无权查看该会话") } return s.repo.ListPendingPermissionRequestsBySession(ctx, sessionID) } +// canManageSession 报告 caller 是否可处理/查看该会话的权限请求:session owner、 +// global-admin,或会话所属 project 的任意 maintainer(注入 ProjectMaintainer 时)。 +// maintainer 查询失败按"无权"处理(fail-closed),但 owner/admin 短路不受影响。 +func (s *PermissionService) canManageSession(ctx context.Context, c Caller, sess *Session) bool { + if c.IsAdmin || sess.UserID == c.UserID { + return true + } + if s.maintainer == nil { + return false + } + ok, err := s.maintainer.CanWriteProject(ctx, c.UserID, c.IsAdmin, sess.ProjectID) + if err != nil { + return false + } + return ok +} + // CancelSession 在会话终止时把其全部 pending 请求标 expired,并唤醒阻塞的 Decide // goroutine 使其立即返回(agent 收默认拒绝)。supervisor.onExit 调用。 func (s *PermissionService) CancelSession(ctx context.Context, sessionID uuid.UUID) { @@ -291,6 +371,37 @@ func (s *PermissionService) recordDecision(ctx context.Context, actor, sessionID }) } +// recordMetric 记录一次权限决策计数(auto/approved/denied/expired)。metrics 为 nil 时 no-op。 +func (s *PermissionService) recordMetric(outcome string) { + if s.metrics == nil { + return + } + s.metrics.RecordPermissionDecision(outcome) +} + +// notifyPending best-effort 向会话所有者推送"待人工审批"通知。错误仅记日志, +// 绝不影响 Decide 的阻塞 / deny-on-timeout 安全默认。 +func (s *PermissionService) notifyPending(ctx context.Context, sess handlers.SessionContext, row *PermissionRequest, toolName string) { + if s.notify == nil { + return + } + if err := s.notify.Dispatch(context.WithoutCancel(ctx), notify.Message{ + UserID: sess.UserID, + Topic: permissionTopic, + Severity: notify.SeverityWarning, + Title: "Approval needed", + Body: "An agent is requesting permission to run a tool and needs your approval.", + Link: "/approvals", + Metadata: map[string]any{ + "request_id": row.ID.String(), + "session_id": sess.SessionID.String(), + "tool_name": toolName, + }, + }); err != nil { + s.log.Warn("acp.permission.notify_pending_failed", "session_id", sess.SessionID, "err", err.Error()) + } +} + func permissionRequestPayload(p *PermissionRequest) map[string]any { return map[string]any{ "request_id": p.ID.String(), diff --git a/internal/acp/permission_service_test.go b/internal/acp/permission_service_test.go index d7b2ed2..8d028f5 100644 --- a/internal/acp/permission_service_test.go +++ b/internal/acp/permission_service_test.go @@ -61,6 +61,13 @@ func (r *permFakeRepo) GetSessionByID(_ context.Context, id uuid.UUID) (*acp.Ses out := *s return &out, nil } +func (r *permFakeRepo) GetSessionByStepID(context.Context, uuid.UUID) (*acp.Session, error) { + return nil, errs.New(errs.CodeAcpSessionNotFound, "not found") +} +func (r *permFakeRepo) GetCrashedSessionForResume(context.Context, uuid.UUID) (*acp.Session, error) { + return nil, errs.New(errs.CodeAcpSessionNotFound, "not found") +} +func (r *permFakeRepo) ResetSessionForResume(context.Context, uuid.UUID) error { return nil } func (r *permFakeRepo) DecidePermissionRequest(_ context.Context, id uuid.UUID, status acp.PermissionStatus, chosen *string, decidedBy *uuid.UUID) (*acp.PermissionRequest, bool, error) { r.mu.Lock() @@ -89,6 +96,24 @@ func (r *permFakeRepo) ExpirePendingPermissionRequestsBySession(_ context.Contex return n, nil } +func (r *permFakeRepo) ListPendingPermissionRequestsByUser(_ context.Context, userID uuid.UUID) ([]*acp.PermissionRequest, error) { + r.mu.Lock() + defer r.mu.Unlock() + var out []*acp.PermissionRequest + for _, p := range r.reqs { + if p.Status != acp.PermissionPending { + continue + } + sess, ok := r.sessions[p.SessionID] + if !ok || sess.UserID != userID { + continue + } + cp := *p + out = append(out, &cp) + } + return out, nil +} + func (r *permFakeRepo) statusOf(id uuid.UUID) acp.PermissionStatus { r.mu.Lock() defer r.mu.Unlock() @@ -310,3 +335,68 @@ func TestPermission_CancelSession_UnblocksDecide(t *testing.T) { } require.Equal(t, acp.PermissionExpired, repo.statusOf(repo.onlyReqID())) } + +// fakeMaintainer 满足 acp.ProjectMaintainer:仅当 projectID 命中 allow 集合时放行。 +type fakeMaintainer struct { + allow map[uuid.UUID]bool +} + +func (f fakeMaintainer) CanWriteProject(_ context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) { + if isAdmin { + return true, nil + } + return f.allow[projectID], nil +} + +// 注入 ProjectMaintainer 后:会话所属 project 的任意 maintainer(非会话 owner) +// 也可处理待审权限请求(autonomy roadmap §11)。 +func TestPermission_Resolve_ProjectMaintainer_Allowed(t *testing.T) { + repo := newPermFakeRepo() + owner := uuid.New() + maintainer := uuid.New() + pid := uuid.New() + sid := uuid.New() + repo.sessions[sid] = &acp.Session{ID: sid, UserID: owner, ProjectID: pid} + svc := acp.NewPermissionService(repo, nil, time.Minute, nil) + svc.SetProjectMaintainer(fakeMaintainer{allow: map[uuid.UUID]bool{pid: true}}) + sess := handlers.SessionContext{SessionID: sid, UserID: owner} + + go svc.Decide(context.Background(), sess, "req-1", + []byte(`{"name":"danger.tool"}`), optionsAllowReject()) + var reqID uuid.UUID + require.Eventually(t, func() bool { + reqID = repo.onlyReqID() + return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending + }, time.Second, 5*time.Millisecond) + + // 非 owner 但是 project maintainer → 可处理。 + require.NoError(t, svc.Resolve(context.Background(), acp.Caller{UserID: maintainer}, reqID, true, "allow_once")) + require.Equal(t, acp.PermissionApproved, repo.statusOf(reqID)) +} + +// 非 maintainer 的外部用户仍被拒(fail-closed);项目不在 allow 集合时也拒。 +func TestPermission_Resolve_NonMaintainer_Forbidden(t *testing.T) { + repo := newPermFakeRepo() + owner := uuid.New() + pid := uuid.New() + otherPid := uuid.New() + sid := uuid.New() + repo.sessions[sid] = &acp.Session{ID: sid, UserID: owner, ProjectID: pid} + svc := acp.NewPermissionService(repo, nil, time.Minute, nil) + // maintainer 仅对 otherPid 有权,而会话属于 pid。 + svc.SetProjectMaintainer(fakeMaintainer{allow: map[uuid.UUID]bool{otherPid: true}}) + sess := handlers.SessionContext{SessionID: sid, UserID: owner} + + go svc.Decide(context.Background(), sess, "req-1", + []byte(`{"name":"danger.tool"}`), optionsAllowReject()) + var reqID uuid.UUID + require.Eventually(t, func() bool { + reqID = repo.onlyReqID() + return reqID != uuid.Nil && repo.statusOf(reqID) == acp.PermissionPending + }, time.Second, 5*time.Millisecond) + + err := svc.Resolve(context.Background(), acp.Caller{UserID: uuid.New()}, reqID, true, "allow_once") + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeAcpSessionNotOwned, ae.Code) +} diff --git a/internal/acp/queries/agent_kind_config_files.sql b/internal/acp/queries/agent_kind_config_files.sql index a1096c0..cb2ff87 100644 --- a/internal/acp/queries/agent_kind_config_files.sql +++ b/internal/acp/queries/agent_kind_config_files.sql @@ -1,5 +1,5 @@ -- name: ListAgentKindConfigFiles :many -SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at +SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at, key_version FROM acp_agent_kind_config_files WHERE agent_kind_id = $1 ORDER BY rel_path ASC; @@ -12,7 +12,7 @@ ON CONFLICT (agent_kind_id, rel_path) DO UPDATE SET encrypted_content = EXCLUDED.encrypted_content, updated_by = EXCLUDED.updated_by, updated_at = now() -RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at; +RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at, key_version; -- name: DeleteAgentKindConfigFile :execrows DELETE FROM acp_agent_kind_config_files diff --git a/internal/acp/queries/agent_kinds.sql b/internal/acp/queries/agent_kinds.sql index fb183ce..8aadf42 100644 --- a/internal/acp/queries/agent_kinds.sql +++ b/internal/acp/queries/agent_kinds.sql @@ -1,50 +1,61 @@ -- name: CreateAgentKind :one INSERT INTO acp_agent_kinds ( - id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist, client_type, encrypted_mcp_servers -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers; + enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version; -- name: GetAgentKindByID :one SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers + enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version FROM acp_agent_kinds WHERE id = $1; -- name: GetAgentKindByName :one SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers + enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version FROM acp_agent_kinds WHERE name = $1; -- name: ListAgentKinds :many SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers + enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version FROM acp_agent_kinds ORDER BY created_at DESC; -- name: ListEnabledAgentKinds :many SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers + enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version FROM acp_agent_kinds WHERE enabled = TRUE ORDER BY name ASC; -- name: UpdateAgentKind :one UPDATE acp_agent_kinds -SET display_name = $2, - description = $3, - binary_path = $4, - args = $5, - encrypted_env = $6, - enabled = $7, - tool_allowlist = $8, - client_type = $9, - encrypted_mcp_servers = $10, - updated_at = now() +SET display_name = $2, + description = $3, + binary_path = $4, + args = $5, + encrypted_env = $6, + enabled = $7, + tool_allowlist = $8, + client_type = $9, + encrypted_mcp_servers = $10, + model_id = $11, + max_cost_usd = $12, + max_tokens = $13, + max_wall_clock_seconds = $14, + updated_at = now() WHERE id = $1 RETURNING id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers; + enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version; -- name: DeleteAgentKind :exec DELETE FROM acp_agent_kinds WHERE id = $1; diff --git a/internal/acp/queries/dashboard.sql b/internal/acp/queries/dashboard.sql new file mode 100644 index 0000000..cdd63e2 --- /dev/null +++ b/internal/acp/queries/dashboard.sql @@ -0,0 +1,59 @@ +-- dashboard.sql: run-history dashboard 读模型(只读聚合)。 +-- - SessionTimeline: 单会话的 RPC 事件按 (direction, rpc_kind, method) 分桶, +-- 给出条数 + 首/末时间戳,用于会话时间线概览。 +-- - SessionRollup: 在 owner/admin scope + 可选 project/requirement 过滤下, +-- 统计会话总数、成功率(exited 视为成功)、总成本、平均时长。 +-- - SessionsByDay: 按天的 总数 / 成功 / 失败 / 成本 时间序列。 +-- scope 约定(与 ListSessionsByUser/ListAllSessions 一致): +-- $1 user_id 为 NULL 时不按用户过滤(admin 全量),非 NULL 时仅该用户。 + +-- name: SessionTimeline :many +-- 单会话事件时间线:按 method/kind 分桶聚合(条数 + 首末时间)。 +SELECT direction, + rpc_kind, + COALESCE(method, '') AS method, + COUNT(*)::bigint AS event_count, + MIN(created_at)::timestamptz AS first_at, + MAX(created_at)::timestamptz AS last_at +FROM acp_events +WHERE session_id = $1 +GROUP BY direction, rpc_kind, COALESCE(method, '') +ORDER BY first_at ASC; + +-- name: SessionRollup :one +-- owner/admin scope + 可选 project/requirement + 时间窗内的会话汇总。 +-- success = status='exited';avg_duration_seconds 仅统计已结束(ended_at 非空)会话。 +SELECT + COUNT(*)::bigint AS total, + COUNT(*) FILTER (WHERE status = 'exited')::bigint AS succeeded, + COUNT(*) FILTER (WHERE status = 'crashed')::bigint AS crashed, + COUNT(*) FILTER (WHERE status IN ('starting','running'))::bigint AS active, + COALESCE(SUM(cost_usd), 0)::numeric AS total_cost_usd, + COALESCE(SUM(tokens_total), 0)::bigint AS total_tokens, + COALESCE( + AVG(EXTRACT(EPOCH FROM (ended_at - started_at))) + FILTER (WHERE ended_at IS NOT NULL), + 0)::float8 AS avg_duration_seconds +FROM acp_sessions +WHERE ($1::uuid IS NULL OR user_id = $1) + AND ($2::uuid IS NULL OR project_id = $2) + AND ($3::uuid IS NULL OR requirement_id = $3) + AND started_at >= $4 + AND started_at < $5; + +-- name: SessionsByDay :many +-- 按天的会话时间序列(总数 / 成功 / 失败 / 成本),同 SessionRollup 的 scope。 +SELECT + date_trunc('day', started_at)::timestamptz AS day, + COUNT(*)::bigint AS total, + COUNT(*) FILTER (WHERE status = 'exited')::bigint AS succeeded, + COUNT(*) FILTER (WHERE status = 'crashed')::bigint AS crashed, + COALESCE(SUM(cost_usd), 0)::numeric AS total_cost_usd +FROM acp_sessions +WHERE ($1::uuid IS NULL OR user_id = $1) + AND ($2::uuid IS NULL OR project_id = $2) + AND ($3::uuid IS NULL OR requirement_id = $3) + AND started_at >= $4 + AND started_at < $5 +GROUP BY day +ORDER BY day ASC; diff --git a/internal/acp/queries/session_usage.sql b/internal/acp/queries/session_usage.sql new file mode 100644 index 0000000..9e7d030 --- /dev/null +++ b/internal/acp/queries/session_usage.sql @@ -0,0 +1,9 @@ +-- name: InsertSessionUsage :one +-- 写一条 per-turn 用量账目。source_event_id 非空时按唯一索引去重(同一事件 +-- 重复 Observe 不会重复入账);返回受影响行数判定是否实际插入。 +INSERT INTO acp_session_usage ( + session_id, user_id, project_id, agent_kind_id, model_id, + prompt_tokens, completion_tokens, thinking_tokens, cost_usd, source_event_id +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +ON CONFLICT (source_event_id) WHERE source_event_id IS NOT NULL DO NOTHING +RETURNING id; diff --git a/internal/acp/queries/sessions.sql b/internal/acp/queries/sessions.sql index 7815c4c..be55a57 100644 --- a/internal/acp/queries/sessions.sql +++ b/internal/acp/queries/sessions.sql @@ -1,26 +1,75 @@ -- name: InsertSession :one INSERT INTO acp_sessions ( id, workspace_id, project_id, agent_kind_id, user_id, - issue_id, requirement_id, branch, cwd_path, is_main_worktree, status -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + issue_id, requirement_id, branch, cwd_path, is_main_worktree, status, + orchestrator_step_id, + budget_max_cost_usd, budget_max_tokens, budget_max_wall_clock_seconds +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, workspace_id, project_id, agent_kind_id, user_id, issue_id, requirement_id, agent_session_id, branch, cwd_path, is_main_worktree, status, - pid, exit_code, last_error, started_at, ended_at; + pid, exit_code, last_error, started_at, ended_at, last_stop_reason, + orchestrator_step_id, + prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd, + last_activity_at, budget_max_cost_usd, budget_max_tokens, + budget_max_wall_clock_seconds, terminated_reason, sandbox_mode, + cost_usd, tokens_total; -- name: GetSessionByID :one SELECT id, workspace_id, project_id, agent_kind_id, user_id, issue_id, requirement_id, agent_session_id, branch, cwd_path, is_main_worktree, status, - pid, exit_code, last_error, started_at, ended_at + pid, exit_code, last_error, started_at, ended_at, last_stop_reason, + orchestrator_step_id, + prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd, + last_activity_at, budget_max_cost_usd, budget_max_tokens, + budget_max_wall_clock_seconds, terminated_reason, sandbox_mode, + cost_usd, tokens_total FROM acp_sessions WHERE id = $1; +-- name: GetSessionByStepID :one +SELECT id, workspace_id, project_id, agent_kind_id, user_id, + issue_id, requirement_id, agent_session_id, + branch, cwd_path, is_main_worktree, status, + pid, exit_code, last_error, started_at, ended_at, last_stop_reason, + orchestrator_step_id, + prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd, + last_activity_at, budget_max_cost_usd, budget_max_tokens, + budget_max_wall_clock_seconds, terminated_reason, sandbox_mode, + cost_usd, tokens_total +FROM acp_sessions +WHERE orchestrator_step_id = $1 +ORDER BY started_at DESC +LIMIT 1; + +-- name: GetCrashedSessionForResume :one +-- 返回某 step 最近一次处于可恢复终态(crashed/exited)的 session。 +SELECT id, workspace_id, project_id, agent_kind_id, user_id, + issue_id, requirement_id, agent_session_id, + branch, cwd_path, is_main_worktree, status, + pid, exit_code, last_error, started_at, ended_at, last_stop_reason, + orchestrator_step_id, + prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd, + last_activity_at, budget_max_cost_usd, budget_max_tokens, + budget_max_wall_clock_seconds, terminated_reason, sandbox_mode, + cost_usd, tokens_total +FROM acp_sessions +WHERE orchestrator_step_id = $1 + AND status IN ('crashed','exited') +ORDER BY started_at DESC +LIMIT 1; + -- name: ListSessionsByUser :many SELECT id, workspace_id, project_id, agent_kind_id, user_id, issue_id, requirement_id, agent_session_id, branch, cwd_path, is_main_worktree, status, - pid, exit_code, last_error, started_at, ended_at + pid, exit_code, last_error, started_at, ended_at, last_stop_reason, + orchestrator_step_id, + prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd, + last_activity_at, budget_max_cost_usd, budget_max_tokens, + budget_max_wall_clock_seconds, terminated_reason, sandbox_mode, + cost_usd, tokens_total FROM acp_sessions WHERE user_id = $1 AND ($2::uuid IS NULL OR requirement_id = $2) @@ -30,7 +79,12 @@ ORDER BY started_at DESC; SELECT id, workspace_id, project_id, agent_kind_id, user_id, issue_id, requirement_id, agent_session_id, branch, cwd_path, is_main_worktree, status, - pid, exit_code, last_error, started_at, ended_at + pid, exit_code, last_error, started_at, ended_at, last_stop_reason, + orchestrator_step_id, + prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd, + last_activity_at, budget_max_cost_usd, budget_max_tokens, + budget_max_wall_clock_seconds, terminated_reason, sandbox_mode, + cost_usd, tokens_total FROM acp_sessions WHERE ($1::uuid IS NULL OR requirement_id = $1) ORDER BY started_at DESC; @@ -40,6 +94,17 @@ UPDATE acp_sessions SET status = 'running', agent_session_id = $2, pid = $3 WHERE id = $1; +-- name: UpdateSessionLastStopReason :exec +UPDATE acp_sessions +SET last_stop_reason = $2 +WHERE id = $1; + +-- name: UpdateSessionSandboxMode :exec +-- 记录本 session 运行所用的沙箱模式(审计/取证)。 +UPDATE acp_sessions +SET sandbox_mode = $2 +WHERE id = $1; + -- name: UpdateSessionFinished :exec UPDATE acp_sessions SET status = $2, exit_code = $3, last_error = $4, ended_at = now() @@ -50,6 +115,20 @@ UPDATE acp_sessions SET status = 'crashed', last_error = $2, ended_at = now() WHERE id = $1 AND status IN ('starting', 'running'); +-- name: ResetSessionForResume :exec +-- 把崩溃/退出的 session 复用为新一轮:回到 starting、清空上次运行时字段。 +-- 编排器 resume 在重新 Spawn 前调用,使同一行 session 在同 cwd/worktree 上复用。 +UPDATE acp_sessions +SET status = 'starting', + agent_session_id = NULL, + pid = NULL, + exit_code = NULL, + last_error = NULL, + ended_at = NULL, + terminated_reason = NULL, + last_activity_at = now() +WHERE id = $1; + -- name: CountActiveSessions :one SELECT COUNT(*) FROM acp_sessions WHERE status IN ('starting', 'running'); @@ -72,3 +151,88 @@ WHERE id = $1 AND active_main_session_id = $2; -- name: AcquireMainWorktree :execrows UPDATE workspaces SET active_main_session_id = $1 WHERE id = $2 AND active_main_session_id IS NULL; + +-- name: AddSessionUsageTotals :one +-- 单 round-trip 累加 session 运行时 token/cost 总量并刷新 last_activity_at, +-- 返回累加后的权威总量供预算判定。 +-- 同时把 dashboard rollup 列(cost_usd / tokens_total)与运行时累加器对齐, +-- 使 run-history dashboard 在会话进行中及退出后都能读到 agent 上报的成本。 +UPDATE acp_sessions +SET prompt_tokens = prompt_tokens + $2, + completion_tokens = completion_tokens + $3, + thinking_tokens = thinking_tokens + $4, + total_cost_usd = total_cost_usd + $5, + cost_usd = cost_usd + $5, + tokens_total = tokens_total + $2 + $3 + $4, + last_activity_at = now() +WHERE id = $1 +RETURNING prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd; + +-- name: SumProjectCostUSD :one +-- 某 project 自 since 起的 ACP 累计花费(per-project 软预算)。 +SELECT COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd +FROM acp_session_usage +WHERE project_id = $1 AND created_at >= $2; + +-- name: MarkSessionTerminatedReason :exec +-- 写入终止原因;已有非空 terminated_reason 时不覆盖(reaper / budget 互斥保护)。 +UPDATE acp_sessions +SET terminated_reason = $2 +WHERE id = $1 AND terminated_reason IS NULL; + +-- name: ListSessionsForReaper :many +-- 返回应被回收的活跃 session: +-- 超过自身 wall-clock 上限(budget_max_wall_clock_seconds 非空且 started_at 过早),或 +-- 超过全局 wall-clock 上限($1::timestamptz = now - 全局上限),或 +-- 空闲超时(last_activity_at < $2::timestamptz)。 +SELECT id, user_id, started_at, last_activity_at, budget_max_wall_clock_seconds +FROM acp_sessions +WHERE status IN ('starting','running') + AND ( + (budget_max_wall_clock_seconds IS NOT NULL + AND started_at < now() - make_interval(secs => budget_max_wall_clock_seconds)) + OR ($1::timestamptz IS NOT NULL AND started_at < $1::timestamptz) + OR last_activity_at < $2::timestamptz + ); + +-- name: SummarizeAcpUsageByUser :many +SELECT user_id, + SUM(prompt_tokens)::bigint AS prompt_tokens, + SUM(completion_tokens)::bigint AS completion_tokens, + SUM(thinking_tokens)::bigint AS thinking_tokens, + SUM(cost_usd)::numeric AS cost_usd +FROM acp_session_usage +WHERE created_at >= $1 AND created_at < $2 +GROUP BY user_id +ORDER BY cost_usd DESC; + +-- name: SummarizeAcpUsageByModel :many +SELECT model_id, + SUM(prompt_tokens)::bigint AS prompt_tokens, + SUM(completion_tokens)::bigint AS completion_tokens, + SUM(thinking_tokens)::bigint AS thinking_tokens, + SUM(cost_usd)::numeric AS cost_usd +FROM acp_session_usage +WHERE created_at >= $1 AND created_at < $2 +GROUP BY model_id +ORDER BY cost_usd DESC; + +-- name: SummarizeAcpUsageByDay :many +SELECT date_trunc('day', created_at)::timestamptz AS day, + SUM(prompt_tokens)::bigint AS prompt_tokens, + SUM(completion_tokens)::bigint AS completion_tokens, + SUM(thinking_tokens)::bigint AS thinking_tokens, + SUM(cost_usd)::numeric AS cost_usd +FROM acp_session_usage +WHERE created_at >= $1 AND created_at < $2 +GROUP BY day +ORDER BY day; + +-- name: ListSessionUsageBySession :many +SELECT id, session_id, user_id, project_id, agent_kind_id, model_id, + prompt_tokens, completion_tokens, thinking_tokens, cost_usd, + source_event_id, created_at +FROM acp_session_usage +WHERE session_id = $1 +ORDER BY created_at DESC +LIMIT $2; diff --git a/internal/acp/queries/turns.sql b/internal/acp/queries/turns.sql new file mode 100644 index 0000000..06b0aac --- /dev/null +++ b/internal/acp/queries/turns.sql @@ -0,0 +1,39 @@ +-- name: InsertTurn :one +INSERT INTO acp_turns ( + session_id, turn_index, prompt_request_id, status +) VALUES ($1, $2, $3, $4) +RETURNING id, session_id, turn_index, prompt_request_id, status, + stop_reason, update_count, started_at, completed_at; + +-- name: MarkTurnCompleted :exec +UPDATE acp_turns +SET status = 'completed', stop_reason = $3, update_count = $4, completed_at = now() +WHERE session_id = $1 AND turn_index = $2 AND status = 'in_progress'; + +-- name: MarkTurnAborted :exec +UPDATE acp_turns +SET status = 'aborted', completed_at = now() +WHERE session_id = $1 AND turn_index = $2 AND status = 'in_progress'; + +-- name: IncrementTurnUpdateCount :exec +UPDATE acp_turns +SET update_count = update_count + 1 +WHERE session_id = $1 AND turn_index = $2 AND status = 'in_progress'; + +-- name: GetLatestTurnBySession :one +SELECT id, session_id, turn_index, prompt_request_id, status, + stop_reason, update_count, started_at, completed_at +FROM acp_turns +WHERE session_id = $1 +ORDER BY turn_index DESC +LIMIT 1; + +-- name: ListTurnsBySession :many +SELECT id, session_id, turn_index, prompt_request_id, status, + stop_reason, update_count, started_at, completed_at +FROM acp_turns +WHERE session_id = $1 +ORDER BY turn_index ASC; + +-- name: PurgeTurnsBefore :execrows +DELETE FROM acp_turns WHERE started_at < $1; diff --git a/internal/acp/relay.go b/internal/acp/relay.go index bce8fcb..3495c5a 100644 --- a/internal/acp/relay.go +++ b/internal/acp/relay.go @@ -71,6 +71,14 @@ type Relay struct { log *slog.Logger cfg RelayConfig + // 回合完成检测:被动解析 session/update + session/prompt 响应的 stopReason。 + // 由 supervisor.Spawn 通过 SetTracker 注入;可为 nil(部分测试)。 + tracker *TurnTracker + + // 成本核算:被动解析 agent→client 消息中的 token/cost 用量并累加 + 预算判定。 + // 由 supervisor.Spawn 通过 SetUsageSink 注入;可为 nil(部分测试 / 未配置)。 + usageSink usageSink + // 终止控制 closed atomic.Bool done chan struct{} @@ -106,6 +114,17 @@ func NewRelay(sessionID uuid.UUID, dec *Decoder, enc *Encoder, } } +// SetTracker 注入回合追踪器。由 supervisor.Spawn 在 NewRelay 之后调用。 +func (r *Relay) SetTracker(t *TurnTracker) { r.tracker = t } + +// SetUsageSink 注入成本核算 sink。由 supervisor.Spawn 在 NewRelay 之后调用; +// 可为 nil(部分测试 / 未配置 model 价格时由 supervisor 自行决定是否注入)。 +func (r *Relay) SetUsageSink(s usageSink) { r.usageSink = s } + +// Tracker 返回回合追踪器(可能为 nil)。session_service / handler 在发送 +// session/prompt 前用它登记回合 id。 +func (r *Relay) Tracker() *TurnTracker { return r.tracker } + // Subscribe 注册一个 WS tab 订阅。 func (r *Relay) Subscribe(userID uuid.UUID) *Subscriber { sub := &Subscriber{ @@ -228,6 +247,12 @@ func (r *Relay) reader(ctx context.Context, sess handlers.SessionContext) { envelope := r.persistAndEnvelope(ctx, msg, "out") if envelope != nil { r.fanout(envelope) + // 成本核算:在事件落库后用持久化的 event id 喂给 usageSink。usageSink + // 内部仅在真实可计费回合时累加,并在预算突破时以 detached goroutine 触发 + // kill(绝不阻塞 reader)。 + if r.usageSink != nil { + r.usageSink.Observe(ctx, msg, envelope.ID) + } } switch { @@ -238,10 +263,23 @@ func (r *Relay) reader(ctx context.Context, sess handlers.SessionContext) { } else { r.log.Warn("acp.relay.orphan_response", "session_id", r.sessionID, "id", id) } + } else if sid, ok := msg.IDString(); ok && r.tracker.IsTrackedPrompt(sid) { + // 字符串-id 的 session/prompt 响应:回合完成检测。 + // 仅当 id 匹配当前开放回合时拦截;其它字符串 id 仍走既有 fanout(上方已 fanout)。 + if msg.Error != nil { + // JSON-RPC error 响应:本回合失败,标 aborted 并发 session_idle(reason=error), + // 而非误记为 completed(stopReason 空)。agent 仍存活,可接受下一条 prompt。 + r.tracker.Abort(ctx, "error") + } else { + r.tracker.OnPromptResponse(ctx, sid, ParseStopReason(msg.Result)) + } } - // String IDs go to clients via fanout (handled above); no server action. + // 其它 String IDs go to clients via fanout (handled above); no server action. case msg.IsNotification(): - // Already persisted + fanout'd. No reply. + // Already persisted + fanout'd. No reply. 回合检测:累加 update 计数。 + if msg.Method == "session/update" { + r.tracker.OnUpdate(ctx) + } case msg.IsRequest(): go r.handleAgentRequest(ctx, sess, msg) } @@ -272,6 +310,12 @@ func (r *Relay) handleAgentRequest(ctx context.Context, sess handlers.SessionCon case "fs/write_text_file": out := r.fsHandler.Write.Handle(ctx, sess, req.Params) resp = r.fsResultToResponse(req.ID, out) + case "fs/list": + out := r.fsHandler.List.Handle(ctx, sess, req.Params) + resp = r.fsResultToResponse(req.ID, out) + case "fs/grep": + out := r.fsHandler.Grep.Handle(ctx, sess, req.Params) + resp = r.fsResultToResponse(req.ID, out) case "session/request_permission": out := r.permHandler.Handle(ctx, sess, string(req.ID), req.Params) resp = r.fsResultToResponse(req.ID, out) diff --git a/internal/acp/relay_discovery_test.go b/internal/acp/relay_discovery_test.go new file mode 100644 index 0000000..62f21a6 --- /dev/null +++ b/internal/acp/relay_discovery_test.go @@ -0,0 +1,107 @@ +// relay_discovery_test.go: relay dispatch tests for the fs/list + fs/grep agent +// discovery handlers (mirrors TestRelay_AgentFsRequest_Echoed). +package acp_test + +import ( + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp" +) + +func relayRoundTrip(t *testing.T, rig *relayTestRig, id int64, method string, params map[string]any) *acp.Message { + t.Helper() + agentDec := acp.NewDecoder(rig.agentReader, 0) + agentEnc := acp.NewEncoder(rig.agentWriter) + req, err := acp.NewRequestInt(id, method, params) + require.NoError(t, err) + require.NoError(t, agentEnc.Encode(req)) + + respCh := make(chan *acp.Message, 1) + errCh := make(chan error, 1) + go func() { + m, derr := agentDec.DecodeMessage() + if derr != nil { + errCh <- derr + return + } + respCh <- m + }() + select { + case resp := <-respCh: + return resp + case err := <-errCh: + if errors.Is(err, io.EOF) { + t.Fatal("agent saw EOF before relay responded") + } + t.Fatalf("agent decoder error: %v", err) + case <-time.After(3 * time.Second): + t.Fatalf("relay did not respond to %s within 3s", method) + } + return nil +} + +func TestRelay_AgentFsList_Dispatched(t *testing.T) { + t.Parallel() + rig := newRelayTestRig(t) + cancel, doneCh := rig.runRelay(t) + t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) }) + + require.NoError(t, os.WriteFile(filepath.Join(rig.sess.CwdPath, "f.txt"), []byte("hi"), 0o644)) + + resp := relayRoundTrip(t, rig, 201, "fs/list", map[string]any{"path": ".", "sessionId": "a"}) + require.True(t, resp.IsResponse()) + require.Nil(t, resp.Error) + var out struct { + Entries []struct { + Name string `json:"name"` + } `json:"entries"` + } + require.NoError(t, json.Unmarshal(resp.Result, &out)) + require.Len(t, out.Entries, 1) + assert.Equal(t, "f.txt", out.Entries[0].Name) +} + +func TestRelay_AgentFsGrep_Dispatched(t *testing.T) { + t.Parallel() + rig := newRelayTestRig(t) + cancel, doneCh := rig.runRelay(t) + t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) }) + + require.NoError(t, os.WriteFile(filepath.Join(rig.sess.CwdPath, "code.go"), []byte("package x\nfunc Needle() {}\n"), 0o644)) + + resp := relayRoundTrip(t, rig, 202, "fs/grep", map[string]any{"pattern": "Needle", "sessionId": "a"}) + require.True(t, resp.IsResponse()) + require.Nil(t, resp.Error) + var out struct { + Matches []struct { + File string `json:"file"` + Line int `json:"line"` + } `json:"matches"` + } + require.NoError(t, json.Unmarshal(resp.Result, &out)) + require.Len(t, out.Matches, 1) + assert.Equal(t, "code.go", out.Matches[0].File) + assert.Equal(t, 2, out.Matches[0].Line) +} + +func TestRelay_AgentFsGrep_PathEscapeRejected(t *testing.T) { + t.Parallel() + rig := newRelayTestRig(t) + cancel, doneCh := rig.runRelay(t) + t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) }) + + outside := filepath.Join(filepath.VolumeName(rig.sess.CwdPath)+string(filepath.Separator), "etc") + resp := relayRoundTrip(t, rig, 203, "fs/grep", map[string]any{"pattern": "x", "path": outside, "sessionId": "a"}) + require.True(t, resp.IsResponse()) + require.NotNil(t, resp.Error) + assert.Equal(t, -32001, resp.Error.Code) +} diff --git a/internal/acp/relay_test.go b/internal/acp/relay_test.go index 5147f93..6846b14 100644 --- a/internal/acp/relay_test.go +++ b/internal/acp/relay_test.go @@ -31,6 +31,18 @@ import ( type fakeEventRepo struct { mu sync.Mutex events []*acp.Event + + // 回合状态机:relay turn 测试用。 + turns []*acp.Turn + lastStopReason *string + + // 成本核算:relay usage 测试用。 + usageLedger []*acp.SessionUsageRecord + totPrompt int64 + totComplete int64 + totThink int64 + totCost float64 + dedupSources map[int64]struct{} } func (f *fakeEventRepo) InsertEvent(_ context.Context, e *acp.Event) (*acp.Event, error) { @@ -81,6 +93,15 @@ func (f *fakeEventRepo) InsertSession(context.Context, *acp.Session) (*acp.Sessi func (f *fakeEventRepo) GetSessionByID(context.Context, uuid.UUID) (*acp.Session, error) { panic("n/a") } +func (f *fakeEventRepo) GetSessionByStepID(context.Context, uuid.UUID) (*acp.Session, error) { + panic("n/a") +} +func (f *fakeEventRepo) GetCrashedSessionForResume(context.Context, uuid.UUID) (*acp.Session, error) { + panic("n/a") +} +func (f *fakeEventRepo) ResetSessionForResume(context.Context, uuid.UUID) error { + panic("n/a") +} func (f *fakeEventRepo) ListSessionsByUser(context.Context, uuid.UUID, *uuid.UUID) ([]*acp.Session, error) { panic("n/a") } @@ -90,6 +111,9 @@ func (f *fakeEventRepo) ListAllSessions(context.Context, *uuid.UUID) ([]*acp.Ses func (f *fakeEventRepo) UpdateSessionRunning(context.Context, uuid.UUID, string, int32) error { panic("n/a") } +func (f *fakeEventRepo) UpdateSessionSandboxMode(context.Context, uuid.UUID, string) error { + panic("n/a") +} func (f *fakeEventRepo) UpdateSessionFinished(context.Context, uuid.UUID, acp.SessionStatus, *int32, *string) error { panic("n/a") } @@ -115,6 +139,67 @@ func (f *fakeEventRepo) ListEventsSince(context.Context, uuid.UUID, int64, int32 func (f *fakeEventRepo) PurgeEventsBefore(context.Context, time.Time) (int64, error) { panic("n/a") } +func (f *fakeEventRepo) InsertSessionUsage(_ context.Context, rec *acp.SessionUsageRecord) (bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + if rec.SourceEventID != nil { + if f.dedupSources == nil { + f.dedupSources = map[int64]struct{}{} + } + if _, seen := f.dedupSources[*rec.SourceEventID]; seen { + return false, nil + } + f.dedupSources[*rec.SourceEventID] = struct{}{} + } + cp := *rec + f.usageLedger = append(f.usageLedger, &cp) + return true, nil +} +func (f *fakeEventRepo) AddSessionUsageTotals(_ context.Context, _ uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.totPrompt += dp + f.totComplete += dc + f.totThink += dt + f.totCost += dCost + return f.totPrompt, f.totComplete, f.totThink, f.totCost, nil +} +func (f *fakeEventRepo) SumProjectCostUSD(context.Context, uuid.UUID, time.Time) (float64, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.totCost, nil +} +func (f *fakeEventRepo) MarkSessionTerminatedReason(context.Context, uuid.UUID, string) error { + return nil +} +func (f *fakeEventRepo) ListSessionsForReaper(context.Context, time.Time, time.Time) ([]acp.ReaperSession, error) { + panic("n/a") +} +func (f *fakeEventRepo) ListSessionUsage(context.Context, uuid.UUID, int32) ([]*acp.SessionUsageRecord, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]*acp.SessionUsageRecord, len(f.usageLedger)) + copy(out, f.usageLedger) + return out, nil +} +func (f *fakeEventRepo) SummarizeAcpUsageByUser(context.Context, time.Time, time.Time) ([]acp.AcpUsageUserRow, error) { + panic("n/a") +} +func (f *fakeEventRepo) SummarizeAcpUsageByModel(context.Context, time.Time, time.Time) ([]acp.AcpUsageModelRow, error) { + panic("n/a") +} +func (f *fakeEventRepo) SummarizeAcpUsageByDay(context.Context, time.Time, time.Time) ([]acp.AcpUsageDayRow, error) { + panic("n/a") +} +func (f *fakeEventRepo) SessionRollup(context.Context, acp.DashboardFilter) (acp.SessionRollup, error) { + panic("n/a") +} +func (f *fakeEventRepo) SessionsByDay(context.Context, acp.DashboardFilter) ([]acp.SessionDayRow, error) { + panic("n/a") +} +func (f *fakeEventRepo) SessionTimeline(context.Context, uuid.UUID) ([]acp.SessionTimelineBucket, error) { + panic("n/a") +} func (f *fakeEventRepo) ListConfigFiles(context.Context, uuid.UUID) ([]*acp.ConfigFile, error) { panic("n/a") } @@ -133,6 +218,83 @@ func (f *fakeEventRepo) WithTx(pgx.Tx) acp.Repository { return f } +// ----- Turn 方法(relay turn 测试用,功能性实现)----- + +func (f *fakeEventRepo) InsertTurn(_ context.Context, t *acp.Turn) (*acp.Turn, error) { + f.mu.Lock() + defer f.mu.Unlock() + cp := *t + cp.ID = int64(len(f.turns) + 1) + cp.Status = acp.TurnInProgress + f.turns = append(f.turns, &cp) + out := cp + return &out, nil +} +func (f *fakeEventRepo) MarkTurnCompleted(_ context.Context, _ uuid.UUID, turnIndex int, stop string, updateCount int) error { + f.mu.Lock() + defer f.mu.Unlock() + for _, t := range f.turns { + if t.TurnIndex == turnIndex { + t.Status = acp.TurnCompleted + s := stop + t.StopReason = &s + t.UpdateCount = updateCount + } + } + return nil +} +func (f *fakeEventRepo) MarkTurnAborted(_ context.Context, _ uuid.UUID, turnIndex int) error { + f.mu.Lock() + defer f.mu.Unlock() + for _, t := range f.turns { + if t.TurnIndex == turnIndex { + t.Status = acp.TurnAborted + } + } + return nil +} +func (f *fakeEventRepo) IncrementTurnUpdateCount(context.Context, uuid.UUID, int) error { return nil } +func (f *fakeEventRepo) GetLatestTurnBySession(context.Context, uuid.UUID) (*acp.Turn, error) { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.turns) == 0 { + return nil, nil + } + out := *f.turns[len(f.turns)-1] + return &out, nil +} +func (f *fakeEventRepo) ListTurnsBySession(context.Context, uuid.UUID) ([]*acp.Turn, error) { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]*acp.Turn, len(f.turns)) + copy(out, f.turns) + return out, nil +} +func (f *fakeEventRepo) UpdateSessionLastStopReason(_ context.Context, _ uuid.UUID, reason string) error { + f.mu.Lock() + defer f.mu.Unlock() + r := reason + f.lastStopReason = &r + return nil +} +func (f *fakeEventRepo) PurgeTurnsBefore(context.Context, time.Time) (int64, error) { return 0, nil } + +func (f *fakeEventRepo) turnSnapshot() []*acp.Turn { + f.mu.Lock() + defer f.mu.Unlock() + out := make([]*acp.Turn, len(f.turns)) + for i, t := range f.turns { + cp := *t + out[i] = &cp + } + return out +} +func (f *fakeEventRepo) lastStop() *string { + f.mu.Lock() + defer f.mu.Unlock() + return f.lastStopReason +} + // relayTestRig wires up a Relay with two io.Pipe pairs that simulate the agent // subprocess. Returns: // - r: the relay under test @@ -470,3 +632,108 @@ func (f *fakeEventRepo) ExpirePendingPermissionRequestsBySession(context.Context func (f *fakeEventRepo) ExpireStalePermissionRequests(context.Context, time.Time) (int64, error) { return 0, nil } + +// ---------- Turn detection ---------- + +// TestRelay_TurnDetection feeds a session/update notification then a string-id +// session/prompt response with stopReason, asserting the tracker hooks fire: +// the turn is created, completed with the stop reason, and update_count tracked. +func TestRelay_TurnDetection(t *testing.T) { + t.Parallel() + rig := newRelayTestRig(t) + + bus := &fakeBus{} + tr := acp.NewTurnTracker(rig.repo, bus, rig.sess, nil) + rig.r.SetTracker(tr) + + cancel, doneCh := rig.runRelay(t) + t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) }) + + // Register the turn (as session_service would before sending the prompt). + _, err := tr.StartTurn(context.Background(), "client-init") + require.NoError(t, err) + + agentEnc := acp.NewEncoder(rig.agentWriter) + + // Agent emits two session/update notifications then the prompt response. + notif, err := acp.NewNotification("session/update", map[string]any{ + "sessionId": "agent-sess-1", + "update": map[string]any{"sessionUpdate": "agent_message_chunk"}, + }) + require.NoError(t, err) + require.NoError(t, agentEnc.Encode(notif)) + require.NoError(t, agentEnc.Encode(notif)) + + idJSON, _ := json.Marshal("client-init") + resp := &acp.Message{ + JSONRPC: "2.0", ID: idJSON, + Result: json.RawMessage(`{"stopReason":"max_tokens"}`), + } + require.NoError(t, agentEnc.Encode(resp)) + + // The turn must complete with stop_reason=max_tokens and update_count=2. + require.Eventually(t, func() bool { + ts := rig.repo.turnSnapshot() + if len(ts) != 1 { + return false + } + return ts[0].Status == acp.TurnCompleted && + ts[0].StopReason != nil && *ts[0].StopReason == "max_tokens" && + ts[0].UpdateCount == 2 + }, 3*time.Second, 20*time.Millisecond, "turn not completed as expected") + + // last_stop_reason persisted on the session. + require.Eventually(t, func() bool { + ls := rig.repo.lastStop() + return ls != nil && *ls == "max_tokens" + }, 2*time.Second, 20*time.Millisecond) + + // Bus received turn_completed then session_idle. + require.Eventually(t, func() bool { + return len(bus.snapshot()) >= 2 + }, 2*time.Second, 20*time.Millisecond) + evs := bus.snapshot() + assert.Equal(t, acp.TurnCompletedEvent, evs[0].Kind) + assert.Equal(t, acp.SessionIdleEvent, evs[1].Kind) + assert.Equal(t, acp.StopMaxTokens, evs[0].StopReason) +} + +// TestRelay_IntResponseStillRoutesToInflight is a regression guard: with a +// tracker installed, an int64-id response must still route to inflight (the +// existing Call mechanism), not the turn path. +func TestRelay_IntResponseStillRoutesToInflight(t *testing.T) { + t.Parallel() + rig := newRelayTestRig(t) + rig.r.SetTracker(acp.NewTurnTracker(rig.repo, &fakeBus{}, rig.sess, nil)) + cancel, doneCh := rig.runRelay(t) + t.Cleanup(func() { rig.shutdown(t, cancel, doneCh) }) + + agentDec := acp.NewDecoder(rig.agentReader, 0) + agentEnc := acp.NewEncoder(rig.agentWriter) + agentDone := make(chan error, 1) + go func() { + req, err := agentDec.DecodeMessage() + if err != nil { + agentDone <- err + return + } + respMsg, err := acp.NewResponseOK(req.ID, map[string]any{"protocolVersion": 1}) + if err != nil { + agentDone <- err + return + } + agentDone <- agentEnc.Encode(respMsg) + }() + + ctx, cancelCall := context.WithTimeout(context.Background(), 3*time.Second) + defer cancelCall() + resp, err := rig.r.Call(ctx, "initialize", map[string]any{"protocolVersion": 1}) + require.NoError(t, err) + require.True(t, resp.IsResponse()) + select { + case err := <-agentDone: + require.NoError(t, err) + case <-time.After(2 * time.Second): + t.Fatal("agent goroutine did not finish") + } +} diff --git a/internal/acp/repository.go b/internal/acp/repository.go index a1992df..c05331b 100644 --- a/internal/acp/repository.go +++ b/internal/acp/repository.go @@ -36,14 +36,22 @@ type Repository interface { // Session InsertSession(ctx context.Context, s *Session) (*Session, error) GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, error) + // GetSessionByStepID 返回某编排器 step 最近的 session(任意状态);无则 NotFound。 + GetSessionByStepID(ctx context.Context, stepID uuid.UUID) (*Session, error) + // GetCrashedSessionForResume 返回某编排器 step 最近一次 crashed/exited 的 session(用于 resume)。 + GetCrashedSessionForResume(ctx context.Context, stepID uuid.UUID) (*Session, error) // requirementID 非 nil 时仅返回关联该需求的 sessions。 ListSessionsByUser(ctx context.Context, userID uuid.UUID, requirementID *uuid.UUID) ([]*Session, error) ListAllSessions(ctx context.Context, requirementID *uuid.UUID) ([]*Session, error) UpdateSessionRunning(ctx context.Context, id uuid.UUID, agentSessionID string, pid int32) error + // UpdateSessionSandboxMode 记录本 session 运行所用的沙箱模式(审计/取证)。 + UpdateSessionSandboxMode(ctx context.Context, id uuid.UUID, mode string) error UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) error // MarkSessionFailedIfActive 把仍处于 starting/running 的 session 标记为 crashed // 并写入 lastErr;已是终态时不更新(守卫式,避免覆盖 onExit 写入的终态)。 MarkSessionFailedIfActive(ctx context.Context, id uuid.UUID, lastErr string) (bool, error) + // ResetSessionForResume 把崩溃/退出 session 复用为新一轮(回 starting、清运行时字段)。 + ResetSessionForResume(ctx context.Context, id uuid.UUID) error CountActiveSessions(ctx context.Context) (int64, error) CountActiveSessionsByUser(ctx context.Context, userID uuid.UUID) (int64, error) ResetStuckSessionsOnRestart(ctx context.Context) ([]*Session, error) @@ -57,6 +65,32 @@ type Repository interface { ListEventsSince(ctx context.Context, sessionID uuid.UUID, sinceID int64, limit int32) ([]*Event, error) PurgeEventsBefore(ctx context.Context, before time.Time) (int64, error) + // 成本核算 / 预算 / reaper + InsertSessionUsage(ctx context.Context, r *SessionUsageRecord) (inserted bool, err error) + AddSessionUsageTotals(ctx context.Context, sid uuid.UUID, dp, dc, dt int64, dCost float64) (totPrompt, totCompletion, totThinking int64, totCost float64, err error) + SumProjectCostUSD(ctx context.Context, projectID uuid.UUID, since time.Time) (float64, error) + MarkSessionTerminatedReason(ctx context.Context, sid uuid.UUID, reason string) error + ListSessionsForReaper(ctx context.Context, wallClockStartedBefore, idleSince time.Time) ([]ReaperSession, error) + ListSessionUsage(ctx context.Context, sessionID uuid.UUID, limit int32) ([]*SessionUsageRecord, error) + SummarizeAcpUsageByUser(ctx context.Context, from, to time.Time) ([]AcpUsageUserRow, error) + SummarizeAcpUsageByModel(ctx context.Context, from, to time.Time) ([]AcpUsageModelRow, error) + SummarizeAcpUsageByDay(ctx context.Context, from, to time.Time) ([]AcpUsageDayRow, error) + + // run-history dashboard(只读聚合) + SessionRollup(ctx context.Context, f DashboardFilter) (SessionRollup, error) + SessionsByDay(ctx context.Context, f DashboardFilter) ([]SessionDayRow, error) + SessionTimeline(ctx context.Context, sessionID uuid.UUID) ([]SessionTimelineBucket, error) + + // Turn(回合完成检测) + InsertTurn(ctx context.Context, t *Turn) (*Turn, error) + MarkTurnCompleted(ctx context.Context, sessionID uuid.UUID, turnIndex int, stop string, updateCount int) error + MarkTurnAborted(ctx context.Context, sessionID uuid.UUID, turnIndex int) error + IncrementTurnUpdateCount(ctx context.Context, sessionID uuid.UUID, turnIndex int) error + GetLatestTurnBySession(ctx context.Context, sessionID uuid.UUID) (*Turn, error) + ListTurnsBySession(ctx context.Context, sessionID uuid.UUID) ([]*Turn, error) + UpdateSessionLastStopReason(ctx context.Context, sessionID uuid.UUID, reason string) error + PurgeTurnsBefore(ctx context.Context, before time.Time) (int64, error) + // PermissionRequest InsertPermissionRequest(ctx context.Context, p *PermissionRequest) (*PermissionRequest, error) GetPermissionRequestByID(ctx context.Context, id uuid.UUID) (*PermissionRequest, error) @@ -159,6 +193,10 @@ func (r *pgRepo) CreateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, ToolAllowlist: normalizeStrSlice(k.ToolAllowlist), ClientType: string(k.ClientType), EncryptedMcpServers: k.EncryptedMCPServers, + ModelID: toPgUUIDPtr(k.ModelID), + MaxCostUsd: float64PtrToNumeric(k.MaxCostUSD), + MaxTokens: k.MaxTokens, + MaxWallClockSeconds: k.MaxWallClockSeconds, }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "create agent kind") @@ -218,6 +256,10 @@ func (r *pgRepo) UpdateAgentKind(ctx context.Context, k *AgentKind) (*AgentKind, ToolAllowlist: normalizeStrSlice(k.ToolAllowlist), ClientType: string(k.ClientType), EncryptedMcpServers: k.EncryptedMCPServers, + ModelID: toPgUUIDPtr(k.ModelID), + MaxCostUsd: float64PtrToNumeric(k.MaxCostUSD), + MaxTokens: k.MaxTokens, + MaxWallClockSeconds: k.MaxWallClockSeconds, }) if err != nil { return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpAgentKindNotFound, "agent kind not found") @@ -300,6 +342,10 @@ func rowToAgentKind(row acpsqlc.AcpAgentKind) *AgentKind { CreatedBy: fromPgUUID(row.CreatedBy), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, + ModelID: fromPgUUIDPtr(row.ModelID), + MaxCostUSD: numericToFloat64Ptr(row.MaxCostUsd), + MaxTokens: row.MaxTokens, + MaxWallClockSeconds: row.MaxWallClockSeconds, } } @@ -328,17 +374,21 @@ func normalizeStrSlice(s []string) []string { func (r *pgRepo) InsertSession(ctx context.Context, s *Session) (*Session, error) { row, err := r.q.InsertSession(ctx, acpsqlc.InsertSessionParams{ - ID: toPgUUID(s.ID), - WorkspaceID: toPgUUID(s.WorkspaceID), - ProjectID: toPgUUID(s.ProjectID), - AgentKindID: toPgUUID(s.AgentKindID), - UserID: toPgUUID(s.UserID), - IssueID: toPgUUIDPtr(s.IssueID), - RequirementID: toPgUUIDPtr(s.RequirementID), - Branch: s.Branch, - CwdPath: s.CwdPath, - IsMainWorktree: s.IsMainWorktree, - Status: string(s.Status), + ID: toPgUUID(s.ID), + WorkspaceID: toPgUUID(s.WorkspaceID), + ProjectID: toPgUUID(s.ProjectID), + AgentKindID: toPgUUID(s.AgentKindID), + UserID: toPgUUID(s.UserID), + IssueID: toPgUUIDPtr(s.IssueID), + RequirementID: toPgUUIDPtr(s.RequirementID), + Branch: s.Branch, + CwdPath: s.CwdPath, + IsMainWorktree: s.IsMainWorktree, + Status: string(s.Status), + OrchestratorStepID: toPgUUIDPtr(s.OrchestratorStepID), + BudgetMaxCostUsd: float64PtrToNumeric(s.BudgetMaxCostUSD), + BudgetMaxTokens: s.BudgetMaxTokens, + BudgetMaxWallClockSeconds: s.BudgetMaxWallClockSeconds, }) if err != nil { return nil, errs.Wrap(err, errs.CodeInternal, "insert session") @@ -354,6 +404,22 @@ func (r *pgRepo) GetSessionByID(ctx context.Context, id uuid.UUID) (*Session, er return rowToSession(row), nil } +func (r *pgRepo) GetSessionByStepID(ctx context.Context, stepID uuid.UUID) (*Session, error) { + row, err := r.q.GetSessionByStepID(ctx, toPgUUID(stepID)) + if err != nil { + return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpSessionNotFound, "session not found for step") + } + return rowToSession(row), nil +} + +func (r *pgRepo) GetCrashedSessionForResume(ctx context.Context, stepID uuid.UUID) (*Session, error) { + row, err := r.q.GetCrashedSessionForResume(ctx, toPgUUID(stepID)) + if err != nil { + return nil, pgxNoRowsToErrNotFound(err, errs.CodeAcpSessionNotFound, "no crashed session to resume") + } + return rowToSession(row), nil +} + func (r *pgRepo) ListSessionsByUser(ctx context.Context, userID uuid.UUID, requirementID *uuid.UUID) ([]*Session, error) { rows, err := r.q.ListSessionsByUser(ctx, acpsqlc.ListSessionsByUserParams{ UserID: toPgUUID(userID), @@ -392,6 +458,17 @@ func (r *pgRepo) UpdateSessionRunning(ctx context.Context, id uuid.UUID, agentSe return nil } +func (r *pgRepo) UpdateSessionSandboxMode(ctx context.Context, id uuid.UUID, mode string) error { + m := mode + if err := r.q.UpdateSessionSandboxMode(ctx, acpsqlc.UpdateSessionSandboxModeParams{ + ID: toPgUUID(id), + SandboxMode: &m, + }); err != nil { + return errs.Wrap(err, errs.CodeInternal, "update session sandbox mode") + } + return nil +} + func (r *pgRepo) UpdateSessionFinished(ctx context.Context, id uuid.UUID, status SessionStatus, exitCode *int32, lastErr *string) error { if err := r.q.UpdateSessionFinished(ctx, acpsqlc.UpdateSessionFinishedParams{ ID: toPgUUID(id), @@ -415,6 +492,13 @@ func (r *pgRepo) MarkSessionFailedIfActive(ctx context.Context, id uuid.UUID, la return n > 0, nil } +func (r *pgRepo) ResetSessionForResume(ctx context.Context, id uuid.UUID) error { + if err := r.q.ResetSessionForResume(ctx, toPgUUID(id)); err != nil { + return errs.Wrap(err, errs.CodeInternal, "reset session for resume") + } + return nil +} + func (r *pgRepo) CountActiveSessions(ctx context.Context) (int64, error) { n, err := r.q.CountActiveSessions(ctx) if err != nil { @@ -480,23 +564,34 @@ func rowToSession(row acpsqlc.AcpSession) *Session { endedAt = &t } return &Session{ - ID: fromPgUUID(row.ID), - WorkspaceID: fromPgUUID(row.WorkspaceID), - ProjectID: fromPgUUID(row.ProjectID), - AgentKindID: fromPgUUID(row.AgentKindID), - UserID: fromPgUUID(row.UserID), - IssueID: fromPgUUIDPtr(row.IssueID), - RequirementID: fromPgUUIDPtr(row.RequirementID), - AgentSessionID: row.AgentSessionID, - Branch: row.Branch, - CwdPath: row.CwdPath, - IsMainWorktree: row.IsMainWorktree, - Status: SessionStatus(row.Status), - PID: row.Pid, - ExitCode: row.ExitCode, - LastError: row.LastError, - StartedAt: row.StartedAt.Time, - EndedAt: endedAt, + ID: fromPgUUID(row.ID), + WorkspaceID: fromPgUUID(row.WorkspaceID), + ProjectID: fromPgUUID(row.ProjectID), + AgentKindID: fromPgUUID(row.AgentKindID), + UserID: fromPgUUID(row.UserID), + IssueID: fromPgUUIDPtr(row.IssueID), + RequirementID: fromPgUUIDPtr(row.RequirementID), + AgentSessionID: row.AgentSessionID, + Branch: row.Branch, + CwdPath: row.CwdPath, + IsMainWorktree: row.IsMainWorktree, + Status: SessionStatus(row.Status), + PID: row.Pid, + ExitCode: row.ExitCode, + LastError: row.LastError, + StartedAt: row.StartedAt.Time, + EndedAt: endedAt, + LastStopReason: row.LastStopReason, + OrchestratorStepID: fromPgUUIDPtr(row.OrchestratorStepID), + PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, + ThinkingTokens: row.ThinkingTokens, + TotalCostUSD: numericToFloat64(row.TotalCostUsd), + LastActivityAt: row.LastActivityAt.Time, + BudgetMaxCostUSD: numericToFloat64Ptr(row.BudgetMaxCostUsd), + BudgetMaxTokens: row.BudgetMaxTokens, + BudgetMaxWallClockSeconds: row.BudgetMaxWallClockSeconds, + TerminatedReason: row.TerminatedReason, } } @@ -556,6 +651,113 @@ func rowToEvent(row acpsqlc.AcpEvent) *Event { } } +// ===== Turn(回合完成检测)===== + +func (r *pgRepo) InsertTurn(ctx context.Context, t *Turn) (*Turn, error) { + row, err := r.q.InsertTurn(ctx, acpsqlc.InsertTurnParams{ + SessionID: toPgUUID(t.SessionID), + TurnIndex: int32(t.TurnIndex), + PromptRequestID: t.PromptRequestID, + Status: string(t.Status), + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "insert acp turn") + } + return rowToTurn(row), nil +} + +func (r *pgRepo) MarkTurnCompleted(ctx context.Context, sessionID uuid.UUID, turnIndex int, stop string, updateCount int) error { + if err := r.q.MarkTurnCompleted(ctx, acpsqlc.MarkTurnCompletedParams{ + SessionID: toPgUUID(sessionID), + TurnIndex: int32(turnIndex), + StopReason: &stop, + UpdateCount: int32(updateCount), + }); err != nil { + return errs.Wrap(err, errs.CodeInternal, "mark turn completed") + } + return nil +} + +func (r *pgRepo) MarkTurnAborted(ctx context.Context, sessionID uuid.UUID, turnIndex int) error { + if err := r.q.MarkTurnAborted(ctx, acpsqlc.MarkTurnAbortedParams{ + SessionID: toPgUUID(sessionID), + TurnIndex: int32(turnIndex), + }); err != nil { + return errs.Wrap(err, errs.CodeInternal, "mark turn aborted") + } + return nil +} + +func (r *pgRepo) IncrementTurnUpdateCount(ctx context.Context, sessionID uuid.UUID, turnIndex int) error { + if err := r.q.IncrementTurnUpdateCount(ctx, acpsqlc.IncrementTurnUpdateCountParams{ + SessionID: toPgUUID(sessionID), + TurnIndex: int32(turnIndex), + }); err != nil { + return errs.Wrap(err, errs.CodeInternal, "increment turn update count") + } + return nil +} + +func (r *pgRepo) GetLatestTurnBySession(ctx context.Context, sessionID uuid.UUID) (*Turn, error) { + row, err := r.q.GetLatestTurnBySession(ctx, toPgUUID(sessionID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, errs.Wrap(err, errs.CodeInternal, "get latest turn by session") + } + return rowToTurn(row), nil +} + +func (r *pgRepo) ListTurnsBySession(ctx context.Context, sessionID uuid.UUID) ([]*Turn, error) { + rows, err := r.q.ListTurnsBySession(ctx, toPgUUID(sessionID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list turns by session") + } + out := make([]*Turn, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToTurn(row)) + } + return out, nil +} + +func (r *pgRepo) UpdateSessionLastStopReason(ctx context.Context, sessionID uuid.UUID, reason string) error { + if err := r.q.UpdateSessionLastStopReason(ctx, acpsqlc.UpdateSessionLastStopReasonParams{ + ID: toPgUUID(sessionID), + LastStopReason: &reason, + }); err != nil { + return errs.Wrap(err, errs.CodeInternal, "update session last stop reason") + } + return nil +} + +func (r *pgRepo) PurgeTurnsBefore(ctx context.Context, before time.Time) (int64, error) { + n, err := r.q.PurgeTurnsBefore(ctx, pgtype.Timestamptz{Time: before, Valid: true}) + if err != nil { + return 0, errs.Wrap(err, errs.CodeInternal, "purge acp turns") + } + return n, nil +} + +func rowToTurn(row acpsqlc.AcpTurn) *Turn { + var completedAt *time.Time + if row.CompletedAt.Valid { + t := row.CompletedAt.Time + completedAt = &t + } + return &Turn{ + ID: row.ID, + SessionID: fromPgUUID(row.SessionID), + TurnIndex: int(row.TurnIndex), + PromptRequestID: row.PromptRequestID, + Status: TurnStatus(row.Status), + StopReason: row.StopReason, + UpdateCount: int(row.UpdateCount), + StartedAt: row.StartedAt.Time, + CompletedAt: completedAt, + } +} + // ===== PermissionRequest ===== func (r *pgRepo) InsertPermissionRequest(ctx context.Context, p *PermissionRequest) (*PermissionRequest, error) { diff --git a/internal/acp/repository_test.go b/internal/acp/repository_test.go index fcaf2d4..26e2df0 100644 --- a/internal/acp/repository_test.go +++ b/internal/acp/repository_test.go @@ -234,6 +234,86 @@ func TestPgRepo_Event_OrderAndPurge(t *testing.T) { assert.Equal(t, int64(3), n) } +func TestPgRepo_Turn_Lifecycle(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, pool := setupRepo(t) + + uid := mustInsertUser(t, ctx, pool, "u6@local", false) + pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, uid) + k, _ := repo.CreateAgentKind(ctx, &acp.AgentKind{ + ID: uuid.New(), Name: "kind6", DisplayName: "x", + BinaryPath: "x", Enabled: true, CreatedBy: uid, + }) + sess, _ := repo.InsertSession(ctx, &acp.Session{ + ID: uuid.New(), WorkspaceID: wsid, ProjectID: pid, + AgentKindID: k.ID, UserID: uid, + Branch: "main", CwdPath: "/tmp", IsMainWorktree: true, Status: acp.SessionRunning, + }) + + // 新 session 无回合 + latest, err := repo.GetLatestTurnBySession(ctx, sess.ID) + require.NoError(t, err) + assert.Nil(t, latest) + + // 插入回合 0 + pidStr := "client-init" + turn, err := repo.InsertTurn(ctx, &acp.Turn{ + SessionID: sess.ID, TurnIndex: 0, PromptRequestID: &pidStr, Status: acp.TurnInProgress, + }) + require.NoError(t, err) + assert.Equal(t, 0, turn.TurnIndex) + assert.Equal(t, acp.TurnInProgress, turn.Status) + + // IncrementTurnUpdateCount ×2(开放回合) + require.NoError(t, repo.IncrementTurnUpdateCount(ctx, sess.ID, 0)) + require.NoError(t, repo.IncrementTurnUpdateCount(ctx, sess.ID, 0)) + + // 完成回合:MarkTurnCompleted 用内存计数覆盖 update_count + require.NoError(t, repo.MarkTurnCompleted(ctx, sess.ID, 0, "end_turn", 5)) + require.NoError(t, repo.UpdateSessionLastStopReason(ctx, sess.ID, "end_turn")) + + ts, err := repo.ListTurnsBySession(ctx, sess.ID) + require.NoError(t, err) + require.Len(t, ts, 1) + assert.Equal(t, acp.TurnCompleted, ts[0].Status) + require.NotNil(t, ts[0].StopReason) + assert.Equal(t, "end_turn", *ts[0].StopReason) + assert.Equal(t, 5, ts[0].UpdateCount) + require.NotNil(t, ts[0].CompletedAt) + + // last_stop_reason round-trips on session SELECT + got, _ := repo.GetSessionByID(ctx, sess.ID) + require.NotNil(t, got.LastStopReason) + assert.Equal(t, "end_turn", *got.LastStopReason) + + // GetLatestTurnBySession 返回 index 最大的回合 + latest, err = repo.GetLatestTurnBySession(ctx, sess.ID) + require.NoError(t, err) + require.NotNil(t, latest) + assert.Equal(t, 0, latest.TurnIndex) + + // UNIQUE(session_id, turn_index) 约束 + _, err = repo.InsertTurn(ctx, &acp.Turn{SessionID: sess.ID, TurnIndex: 0, Status: acp.TurnInProgress}) + require.Error(t, err, "duplicate (session_id, turn_index) must violate UNIQUE") + + // 插入并中止回合 1 + _, err = repo.InsertTurn(ctx, &acp.Turn{SessionID: sess.ID, TurnIndex: 1, Status: acp.TurnInProgress}) + require.NoError(t, err) + require.NoError(t, repo.MarkTurnAborted(ctx, sess.ID, 1)) + latest, _ = repo.GetLatestTurnBySession(ctx, sess.ID) + assert.Equal(t, 1, latest.TurnIndex) + assert.Equal(t, acp.TurnAborted, latest.Status) + + // Purge before now+1m → 全删 + purged, err := repo.PurgeTurnsBefore(ctx, time.Now().Add(time.Minute)) + require.NoError(t, err) + assert.Equal(t, int64(2), purged) + // 幂等 + purged2, _ := repo.PurgeTurnsBefore(ctx, time.Now().Add(time.Minute)) + assert.Equal(t, int64(0), purged2) +} + // ===== test helpers ===== func mustInsertUser(t *testing.T, ctx context.Context, pool *pgxpool.Pool, email string, admin bool) uuid.UUID { @@ -259,3 +339,141 @@ func mustInsertProjectWorkspace(t *testing.T, ctx context.Context, pool *pgxpool require.NoError(t, err) return pid, wsid } + +// TestPgRepo_UsagePersist_BumpsDashboardColumns 验证 AddSessionUsageTotals 同时 +// 把 dashboard rollup 列(cost_usd / tokens_total)与运行时累加器对齐。 +func TestPgRepo_UsagePersist_BumpsDashboardColumns(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, pool := setupRepo(t) + + uid := mustInsertUser(t, ctx, pool, "dash-usage@local", false) + pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, uid) + k, _ := repo.CreateAgentKind(ctx, &acp.AgentKind{ + ID: uuid.New(), Name: "kind-dash-usage", DisplayName: "x", + BinaryPath: "x", Enabled: true, CreatedBy: uid, + }) + sess, _ := repo.InsertSession(ctx, &acp.Session{ + ID: uuid.New(), WorkspaceID: wsid, ProjectID: pid, + AgentKindID: k.ID, UserID: uid, + Branch: "main", CwdPath: "/tmp", IsMainWorktree: true, Status: acp.SessionRunning, + }) + + // 两次回合累加。 + _, _, _, _, err := repo.AddSessionUsageTotals(ctx, sess.ID, 100, 50, 10, 0.30) + require.NoError(t, err) + _, _, _, totCost, err := repo.AddSessionUsageTotals(ctx, sess.ID, 200, 40, 0, 0.20) + require.NoError(t, err) + assert.InDelta(t, 0.50, totCost, 1e-9) + + got, err := repo.GetSessionByID(ctx, sess.ID) + require.NoError(t, err) + assert.InDelta(t, 0.50, got.TotalCostUSD, 1e-9) + // 运行时累加器 totals。 + assert.Equal(t, int64(300), got.PromptTokens) + assert.Equal(t, int64(90), got.CompletionTokens) + assert.Equal(t, int64(10), got.ThinkingTokens) + + // dashboard 汇总应读到与运行时一致的 cost / tokens。 + rollup, err := repo.SessionRollup(ctx, acp.DashboardFilter{ + UserID: &uid, From: time.Now().Add(-time.Hour), To: time.Now().Add(time.Hour), + }) + require.NoError(t, err) + assert.Equal(t, int64(1), rollup.Total) + assert.Equal(t, int64(1), rollup.Active) + assert.InDelta(t, 0.50, rollup.TotalCostUSD, 1e-9) + assert.Equal(t, int64(400), rollup.TotalTokens) // 100+50+10 + 200+40+0 +} + +// TestPgRepo_Dashboard_RollupTimelineByDay 覆盖三个 dashboard 查询: +// SessionRollup(按状态计数 + 成本 + scope)、SessionsByDay、SessionTimeline。 +func TestPgRepo_Dashboard_RollupTimelineByDay(t *testing.T) { + t.Parallel() + ctx := context.Background() + repo, pool := setupRepo(t) + + owner := mustInsertUser(t, ctx, pool, "dash-owner@local", false) + other := mustInsertUser(t, ctx, pool, "dash-other@local", false) + pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, owner) + k, _ := repo.CreateAgentKind(ctx, &acp.AgentKind{ + ID: uuid.New(), Name: "kind-dash", DisplayName: "x", + BinaryPath: "x", Enabled: true, CreatedBy: owner, + }) + + mk := func(u uuid.UUID, status acp.SessionStatus) *acp.Session { + s, err := repo.InsertSession(ctx, &acp.Session{ + ID: uuid.New(), WorkspaceID: wsid, ProjectID: pid, + AgentKindID: k.ID, UserID: u, + Branch: "b", CwdPath: "/tmp", IsMainWorktree: false, Status: status, + }) + require.NoError(t, err) + return s + } + + exited := mk(owner, acp.SessionRunning) + crashed := mk(owner, acp.SessionRunning) + mk(other, acp.SessionRunning) // 另一用户:owner-scope 下不计入 + + // owner 的两个会话各累加成本并结束。 + _, _, _, _, err := repo.AddSessionUsageTotals(ctx, exited.ID, 100, 100, 0, 1.00) + require.NoError(t, err) + _, _, _, _, err = repo.AddSessionUsageTotals(ctx, crashed.ID, 50, 50, 0, 0.50) + require.NoError(t, err) + ec := int32(0) + require.NoError(t, repo.UpdateSessionFinished(ctx, exited.ID, acp.SessionExited, &ec, nil)) + require.NoError(t, repo.UpdateSessionFinished(ctx, crashed.ID, acp.SessionCrashed, &ec, nil)) + + from := time.Now().Add(-time.Hour) + to := time.Now().Add(time.Hour) + + // owner scope:只统计 owner 的 2 个会话。 + ownerScope := acp.DashboardFilter{UserID: &owner, From: from, To: to} + rollup, err := repo.SessionRollup(ctx, ownerScope) + require.NoError(t, err) + assert.Equal(t, int64(2), rollup.Total) + assert.Equal(t, int64(1), rollup.Succeeded) // exited + assert.Equal(t, int64(1), rollup.Crashed) + assert.Equal(t, int64(0), rollup.Active) + assert.InDelta(t, 1.50, rollup.TotalCostUSD, 1e-9) + assert.True(t, rollup.AvgDurationSeconds >= 0) + + // admin scope(UserID nil):跨用户 3 个会话。 + adminRollup, err := repo.SessionRollup(ctx, acp.DashboardFilter{From: from, To: to}) + require.NoError(t, err) + assert.Equal(t, int64(3), adminRollup.Total) + + // SessionsByDay:owner scope 当天 1 行,total=2。 + byDay, err := repo.SessionsByDay(ctx, ownerScope) + require.NoError(t, err) + require.Len(t, byDay, 1) + assert.Equal(t, int64(2), byDay[0].Total) + assert.Equal(t, int64(1), byDay[0].Succeeded) + assert.InDelta(t, 1.50, byDay[0].TotalCostUSD, 1e-9) + + // SessionTimeline:插入两类事件,按桶聚合。 + method := "session/update" + for i := 0; i < 3; i++ { + _, err := repo.InsertEvent(ctx, &acp.Event{ + SessionID: exited.ID, Direction: acp.DirectionOut, RPCKind: acp.RPCNotification, + Method: &method, Payload: []byte(`{}`), PayloadSize: 2, + }) + require.NoError(t, err) + } + reqMethod := "session/prompt" + _, err = repo.InsertEvent(ctx, &acp.Event{ + SessionID: exited.ID, Direction: acp.DirectionOut, RPCKind: acp.RPCRequest, + Method: &reqMethod, Payload: []byte(`{}`), PayloadSize: 2, + }) + require.NoError(t, err) + + timeline, err := repo.SessionTimeline(ctx, exited.ID) + require.NoError(t, err) + require.Len(t, timeline, 2) // (out,notification,session/update) + (out,request,session/prompt) + total := int64(0) + for _, b := range timeline { + total += b.EventCount + assert.False(t, b.FirstAt.IsZero()) + assert.False(t, b.LastAt.IsZero()) + } + assert.Equal(t, int64(4), total) +} diff --git a/internal/acp/sandbox/sandbox.go b/internal/acp/sandbox/sandbox.go new file mode 100644 index 0000000..c6b88e9 --- /dev/null +++ b/internal/acp/sandbox/sandbox.go @@ -0,0 +1,115 @@ +// Package sandbox provides a pluggable per-session execution sandbox that sits +// between acp.session_service.Create and acp.Supervisor.Spawn. It confines an +// agent child process to a low-privilege UID, bind-mounts only that session's +// worktree + the per-user agent home, applies OS resource limits, and routes +// egress through an allowlist proxy — all WITHOUT touching proc.Group's +// Setpgid-based tree-kill (Apply only ADDS to cmd.SysProcAttr, never replaces). +// +// Three modes, config-gated via acp.sandbox.mode: +// - none : no-op (default; Windows dev + CI + any non-Linux box) +// - uid : drop to a low-priv UID + setrlimit (+ optional namespace/bind +// mounts) — Linux only +// - container : run the agent inside a rootless runc/bwrap or docker container +// on an egress-restricted network — Linux only +// +// The Linux-specific mechanics live in build-tagged files; this file holds the +// cross-platform contract and the factory so app.go and supervisor.go compile +// on every platform. +package sandbox + +import "os/exec" + +// Mode selects the sandbox strategy. +type Mode string + +const ( + // ModeNone disables sandboxing (no-op Apply). Default everywhere except + // production Linux. + ModeNone Mode = "none" + // ModeUID drops to a low-priv UID + setrlimit (+ optional bind mounts). + ModeUID Mode = "uid" + // ModeContainer runs the child inside a rootless container. + ModeContainer Mode = "container" +) + +// Limits captures the OS resource limits applied to the child (and its tree). +// A zero field means "do not set this limit" (inherit the parent's). +type Limits struct { + AddressSpaceBytes uint64 // RLIMIT_AS — caps total virtual memory (anti big-alloc) + NProc uint64 // RLIMIT_NPROC — caps process/thread count (anti fork-bomb) + CPUSeconds uint64 // RLIMIT_CPU — caps CPU seconds + PIDs uint64 // cgroup pids.max (container mode); mirrors NProc for uid + DiskBytes uint64 // RLIMIT_FSIZE — caps max file size the child can write +} + +// Spec is the per-spawn sandbox request, derived from the session + agent kind. +type Spec struct { + Mode Mode + UID int // target low-priv uid (uid mode); base+offset computed by caller + GID int // target low-priv gid + HomeDir string // per-user agent home (writable, bind-mounted in) + WorktreeDir string // this session's worktree (writable, bind-mounted in) + DataRoot string // /data root to mask everything else under + Rlimits Limits + ProxyURL string // HTTP(S)_PROXY value injected into the child env (egress allowlist) + NoProxy string // NO_PROXY value (e.g. localhost,127.0.0.1) +} + +// Sandbox applies a Spec to an exec.Cmd before it is started. +type Sandbox interface { + // Apply mutates cmd.SysProcAttr (Credential, namespace/chroot flags) and + // cmd.Env (HTTP(S)_PROXY/NO_PROXY) BEFORE proc.Group.Prepare and cmd.Start. + // It returns a cleanup closure (unmount binds, remove ephemeral dirs, stop + // container) that the supervisor calls from monitorProcess AFTER + // group.Close(). cleanup is always non-nil (a no-op when there is nothing to + // clean) and must be safe to call exactly once. Apply MUST NOT set Setpgid — + // that is proc.Group's job; on Linux it ADDS to the existing SysProcAttr. + Apply(cmd *exec.Cmd, spec Spec) (cleanup func(), err error) +} + +// New returns the Sandbox implementation for the given mode. On non-Linux +// platforms (or mode=none) it always returns the no-op sandbox. The actual +// uid/container constructors are provided by build-tagged files; newPlatform +// returns nil when the mode is unsupported on this OS, in which case we fall +// back to no-op so dev/CI never break. +func New(mode Mode) Sandbox { + switch mode { + case ModeUID, ModeContainer: + if sb := newPlatform(mode); sb != nil { + return sb + } + // Unsupported on this platform → safe no-op fallback. + return noopSandbox{} + default: + return noopSandbox{} + } +} + +// noopSandbox makes no changes and returns a no-op cleanup. It is the default +// and the fallback for unsupported modes/platforms. +type noopSandbox struct{} + +func (noopSandbox) Apply(_ *exec.Cmd, _ Spec) (func(), error) { + return func() {}, nil +} + +// injectProxyEnv appends egress-proxy env vars to cmd.Env when a proxy URL is +// configured. Shared by uid + container modes (both honor HTTP(S)_PROXY). Kept +// here so the cross-platform contract is testable without build tags. +func injectProxyEnv(cmd *exec.Cmd, spec Spec) { + if spec.ProxyURL == "" { + return + } + noProxy := spec.NoProxy + if noProxy == "" { + noProxy = "localhost,127.0.0.1,::1" + } + cmd.Env = append(cmd.Env, + "HTTP_PROXY="+spec.ProxyURL, + "HTTPS_PROXY="+spec.ProxyURL, + "http_proxy="+spec.ProxyURL, + "https_proxy="+spec.ProxyURL, + "NO_PROXY="+noProxy, + "no_proxy="+noProxy, + ) +} diff --git a/internal/acp/sandbox/sandbox_container_linux.go b/internal/acp/sandbox/sandbox_container_linux.go new file mode 100644 index 0000000..a30f8cc --- /dev/null +++ b/internal/acp/sandbox/sandbox_container_linux.go @@ -0,0 +1,118 @@ +//go:build linux + +package sandbox + +import ( + "fmt" + "os/exec" + "strconv" + "time" + + "github.com/google/uuid" +) + +// containerSandbox runs the agent binary inside a rootless container with an +// egress-restricted network and the two writable paths bind-mounted in. This is +// the hard isolation boundary (UID + filesystem mask + network policy + cgroup +// limits) — unlike uid mode, a non-cooperating binary cannot bypass the egress +// allowlist because the container network only routes through the proxy/network +// policy. +// +// It rewrites cmd to `docker run --rm --name --user uid:gid --read-only +// --network --pids-limit --memory --cpus --mount --mount +// -w ` where the image is the same +// runtime image (so the agent CLI is present). The cleanup closure runs +// `docker stop ` so proc.Group's tree-kill (which signals the `docker run` +// client's pgid) is reinforced by stopping the actual container — otherwise +// killing the client could orphan the container. +// +// Tree-kill invariant: Apply does NOT set Setpgid (proc.Group owns it). docker +// run becomes the group leader; killing -pgid signals it, and cleanup() issues +// docker stop for the container it spawned. The mapping client->container is via +// the deterministic --name we assign. +// +// The container runtime + egress network are provisioned by the deployment +// (Dockerfile installs the runtime; docker-compose defines the egress-net + the +// forward-proxy sidecar). Image/network/runtime are read from the spec's +// DataRoot convention or sensible defaults; here we keep them parameterized via +// package-level defaults so app.go can override without a code change later. +type containerSandbox struct{} + +// container runtime defaults; overridable by deployment via env in a later pass. +const ( + defaultRuntime = "docker" + defaultImage = "agent-coding-workflow-sandbox:latest" + defaultEgressNet = "egress-net" + containerStopWait = 5 * time.Second +) + +func (s *containerSandbox) Apply(cmd *exec.Cmd, spec Spec) (func(), error) { + if spec.WorktreeDir == "" { + return func() {}, fmt.Errorf("sandbox container: worktree dir required") + } + runtime, err := exec.LookPath(defaultRuntime) + if err != nil { + // Runtime missing: fall back to no-op cleanup but surface the error so + // the supervisor can decide (it currently logs and proceeds unsandboxed + // only if configured to; otherwise spawn fails). + return func() {}, fmt.Errorf("sandbox container: %s not found: %w", defaultRuntime, err) + } + + name := "acw-sbx-" + uuid.NewString() + orig := append([]string{cmd.Path}, cmd.Args[1:]...) + + runArgs := []string{ + "run", "--rm", "--name", name, + "--read-only", + "--network", defaultEgressNet, + "-w", spec.WorktreeDir, + "--mount", "type=bind,source=" + spec.WorktreeDir + ",target=" + spec.WorktreeDir, + } + if spec.HomeDir != "" { + runArgs = append(runArgs, + "--mount", "type=bind,source="+spec.HomeDir+",target="+spec.HomeDir, + "-e", "HOME="+spec.HomeDir) + } + if spec.UID > 0 { + gid := spec.GID + if gid <= 0 { + gid = spec.UID + } + runArgs = append(runArgs, "--user", strconv.Itoa(spec.UID)+":"+strconv.Itoa(gid)) + } + if spec.Rlimits.AddressSpaceBytes > 0 { + runArgs = append(runArgs, "--memory", strconv.FormatUint(spec.Rlimits.AddressSpaceBytes, 10)) + } + if spec.Rlimits.PIDs > 0 { + runArgs = append(runArgs, "--pids-limit", strconv.FormatUint(spec.Rlimits.PIDs, 10)) + } else if spec.Rlimits.NProc > 0 { + runArgs = append(runArgs, "--pids-limit", strconv.FormatUint(spec.Rlimits.NProc, 10)) + } + if spec.Rlimits.CPUSeconds > 0 { + // docker has no direct CPU-seconds cap; approximate with --cpus=1 and + // rely on the wall-clock/budget reaper for hard CPU-time bounds. + runArgs = append(runArgs, "--cpus", "1") + } + // Egress proxy env (the proxy itself enforces the allowlist on egress-net). + if spec.ProxyURL != "" { + noProxy := spec.NoProxy + if noProxy == "" { + noProxy = "localhost,127.0.0.1,::1" + } + runArgs = append(runArgs, + "-e", "HTTP_PROXY="+spec.ProxyURL, + "-e", "HTTPS_PROXY="+spec.ProxyURL, + "-e", "NO_PROXY="+noProxy) + } + runArgs = append(runArgs, defaultImage) + runArgs = append(runArgs, orig...) + + cmd.Path = runtime + cmd.Args = append([]string{runtime}, runArgs...) + + cleanup := func() { + stop := exec.Command(runtime, "stop", "-t", strconv.Itoa(int(containerStopWait.Seconds())), name) + _ = stop.Run() // best-effort; --rm removes it after stop + } + return cleanup, nil +} diff --git a/internal/acp/sandbox/sandbox_linux.go b/internal/acp/sandbox/sandbox_linux.go new file mode 100644 index 0000000..6c7bfe2 --- /dev/null +++ b/internal/acp/sandbox/sandbox_linux.go @@ -0,0 +1,15 @@ +//go:build linux + +package sandbox + +// newPlatform dispatches the Linux sandbox modes to their concrete impls. +func newPlatform(mode Mode) Sandbox { + switch mode { + case ModeUID: + return &uidSandbox{} + case ModeContainer: + return &containerSandbox{} + default: + return nil + } +} diff --git a/internal/acp/sandbox/sandbox_other.go b/internal/acp/sandbox/sandbox_other.go new file mode 100644 index 0000000..f320ad1 --- /dev/null +++ b/internal/acp/sandbox/sandbox_other.go @@ -0,0 +1,9 @@ +//go:build !linux + +package sandbox + +// newPlatform returns nil on non-Linux platforms: UID drop, setrlimit and +// bind-mount namespaces are Linux-only, so Windows dev and macOS always fall +// back to the no-op sandbox (see New). This is the documented contract that +// mode=none is the only supported mode off Linux. +func newPlatform(_ Mode) Sandbox { return nil } diff --git a/internal/acp/sandbox/sandbox_test.go b/internal/acp/sandbox/sandbox_test.go new file mode 100644 index 0000000..68760fb --- /dev/null +++ b/internal/acp/sandbox/sandbox_test.go @@ -0,0 +1,59 @@ +package sandbox + +import ( + "os/exec" + "runtime" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestNew_NoneAlwaysNoop(t *testing.T) { + sb := New(ModeNone) + require.IsType(t, noopSandbox{}, sb) +} + +func TestNew_UnsupportedPlatformFallsBackToNoop(t *testing.T) { + if runtime.GOOS == "linux" { + t.Skip("uid/container are supported on linux; this asserts the non-linux fallback") + } + // On non-linux, uid/container must fall back to no-op so dev/CI never break. + require.IsType(t, noopSandbox{}, New(ModeUID)) + require.IsType(t, noopSandbox{}, New(ModeContainer)) +} + +func TestNoopSandbox_MutatesNothing(t *testing.T) { + cmd := exec.Command("echo", "hi") + before := cmd.SysProcAttr + cleanup, err := noopSandbox{}.Apply(cmd, Spec{Mode: ModeNone, ProxyURL: "http://proxy:8080"}) + require.NoError(t, err) + require.NotNil(t, cleanup) + // no-op must NOT inject proxy env nor touch SysProcAttr. + require.Equal(t, before, cmd.SysProcAttr) + require.Empty(t, cmd.Env) + // cleanup is safe to call. + cleanup() +} + +func TestInjectProxyEnv(t *testing.T) { + cmd := exec.Command("echo", "hi") + cmd.Env = []string{"PATH=/usr/bin"} + injectProxyEnv(cmd, Spec{ProxyURL: "http://proxy:3128"}) + require.Contains(t, cmd.Env, "HTTP_PROXY=http://proxy:3128") + require.Contains(t, cmd.Env, "HTTPS_PROXY=http://proxy:3128") + require.Contains(t, cmd.Env, "NO_PROXY=localhost,127.0.0.1,::1") + // original env preserved. + require.Contains(t, cmd.Env, "PATH=/usr/bin") +} + +func TestInjectProxyEnv_NoProxyURLIsNoop(t *testing.T) { + cmd := exec.Command("echo", "hi") + injectProxyEnv(cmd, Spec{}) + require.Empty(t, cmd.Env) +} + +func TestInjectProxyEnv_CustomNoProxy(t *testing.T) { + cmd := exec.Command("echo", "hi") + injectProxyEnv(cmd, Spec{ProxyURL: "http://p", NoProxy: "internal.local"}) + require.Contains(t, cmd.Env, "NO_PROXY=internal.local") +} diff --git a/internal/acp/sandbox/sandbox_uid_linux.go b/internal/acp/sandbox/sandbox_uid_linux.go new file mode 100644 index 0000000..9aaf4ce --- /dev/null +++ b/internal/acp/sandbox/sandbox_uid_linux.go @@ -0,0 +1,92 @@ +//go:build linux + +package sandbox + +import ( + "fmt" + "os/exec" + "strconv" + "syscall" +) + +// uidSandbox confines the agent child to a low-privilege UID/GID and applies OS +// resource limits, while leaving proc.Group's Setpgid (tree-kill) intact. +// +// Tree-kill invariant: we ONLY add Credential to the existing SysProcAttr. We do +// NOT touch Setpgid (proc.Group sets it after Apply via group.Prepare). The +// negative-pgid SIGTERM/SIGKILL path keeps working because the new process group +// leader is the same child — it simply runs as the low-priv uid now. +// +// Resource limits are applied by wrapping the real binary in `prlimit(1)`: +// `prlimit --as=N --nproc=N --cpu=N --fsize=N -- `. prlimit +// sets the limits on the child it execs, so they apply to the agent and (since +// limits are inherited across fork) its whole subtree — exactly the tree the +// pgid covers. Wrapping is additive to Credential/Setpgid and does not change +// the process-group topology (prlimit execs the target in place, becoming the +// group leader). If prlimit is unavailable, UID drop still applies and we log no +// rlimits (best-effort; container mode is the hard boundary). +// +// Bind-mount masking (CLONE_NEWNS + remount /data with only HomeDir+WorktreeDir +// visible) is intentionally NOT auto-enabled here: it requires the server to +// hold CAP_SYS_ADMIN / a user namespace, which the rootless production container +// (USER app) does not have by default. The robust isolation path is +// ModeContainer. uidSandbox therefore provides UID drop + rlimits + egress proxy +// as the minimum viable confinement, and documents that filesystem masking is a +// container-mode guarantee. +type uidSandbox struct{} + +func (s *uidSandbox) Apply(cmd *exec.Cmd, spec Spec) (func(), error) { + if spec.UID <= 0 { + return func() {}, fmt.Errorf("sandbox uid: invalid target uid %d", spec.UID) + } + + // UID/GID drop — ADDITIVE to whatever proc.Group will set (Setpgid). + if cmd.SysProcAttr == nil { + cmd.SysProcAttr = &syscall.SysProcAttr{} + } + gid := spec.GID + if gid <= 0 { + gid = spec.UID + } + cmd.SysProcAttr.Credential = &syscall.Credential{ + Uid: uint32(spec.UID), + Gid: uint32(gid), + } + + // Resource limits via prlimit wrapper (if available). + if prlimit, err := exec.LookPath("prlimit"); err == nil { + args := prlimitArgs(spec.Rlimits) + if len(args) > 0 { + // Re-point the command through prlimit, preserving the original + // binary + args after the `--` separator. + orig := append([]string{cmd.Path}, cmd.Args[1:]...) + cmd.Path = prlimit + cmd.Args = append(append([]string{prlimit}, args...), append([]string{"--"}, orig...)...) + } + } + + // Egress allowlist: inject HTTP(S)_PROXY/NO_PROXY into the stripped child env. + injectProxyEnv(cmd, spec) + + // No ephemeral resources to tear down in uid mode (no bind mounts here); + // return a no-op cleanup. + return func() {}, nil +} + +// prlimitArgs builds the prlimit flags for the non-zero limits in l. +func prlimitArgs(l Limits) []string { + var args []string + if l.AddressSpaceBytes > 0 { + args = append(args, "--as="+strconv.FormatUint(l.AddressSpaceBytes, 10)) + } + if l.NProc > 0 { + args = append(args, "--nproc="+strconv.FormatUint(l.NProc, 10)) + } + if l.CPUSeconds > 0 { + args = append(args, "--cpu="+strconv.FormatUint(l.CPUSeconds, 10)) + } + if l.DiskBytes > 0 { + args = append(args, "--fsize="+strconv.FormatUint(l.DiskBytes, 10)) + } + return args +} diff --git a/internal/acp/session_brief_internal_test.go b/internal/acp/session_brief_internal_test.go new file mode 100644 index 0000000..b101416 --- /dev/null +++ b/internal/acp/session_brief_internal_test.go @@ -0,0 +1,148 @@ +package acp + +import ( + "context" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/brief" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// recordingAudit 捕获 Record 调用,供断言 brief_composed 是否写入。 +type recordingAudit struct { + mu sync.Mutex + entries []audit.Entry +} + +func (r *recordingAudit) Record(_ context.Context, e audit.Entry) error { + r.mu.Lock() + defer r.mu.Unlock() + r.entries = append(r.entries, e) + return nil +} + +func (r *recordingAudit) actions() []string { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]string, 0, len(r.entries)) + for _, e := range r.entries { + out = append(out, e.Action) + } + return out +} + +// fakeComposer 是 brief.Composer 的测试替身:返回固定文本并记录是否被调用。 +type fakeComposer struct { + res brief.BriefResult + err error + called bool + gotIn brief.BriefInput +} + +func (f *fakeComposer) ComposeTaskBrief(_ context.Context, in brief.BriefInput) (brief.BriefResult, error) { + f.called = true + f.gotIn = in + return f.res, f.err +} + +// fakeArtifactAccess 是 ArtifactAccess 的测试替身。 +type fakeArtifactAccess struct { + arts []*project.Artifact +} + +func (f fakeArtifactAccess) ListLatestArtifacts(_ context.Context, _ uuid.UUID) ([]*project.Artifact, error) { + return f.arts, nil +} + +func testSession() *Session { + return &Session{ + ID: uuid.New(), + ProjectID: uuid.New(), + Branch: "req/demo-7", + CwdPath: "/tmp/wt", + } +} + +func TestComposeInitialPrompt_RequirementContextComposed(t *testing.T) { + t.Parallel() + rec := &recordingAudit{} + fc := &fakeComposer{res: brief.BriefResult{ + Text: "你是一名资深产品规划助手。\n\n## 当前需求\n需求 #7:导出报表", + Tokens: 20, + Sections: []string{"role", "requirement_core"}, + }} + s := &sessionService{ + audit: rec, + composer: fc, + aa: fakeArtifactAccess{arts: []*project.Artifact{{Phase: project.PhasePlanning, Version: 2}}}, + cfg: SessionServiceConfig{BriefTokenBudget: 6000}, + } + + req := &project.Requirement{ID: uuid.New(), Number: 7, Title: "导出报表", Phase: project.PhasePlanning} + c := Caller{UserID: uuid.New()} + out := s.composeInitialPrompt(context.Background(), c, testSession(), nil, req, nil, "请开始") + + require.True(t, fc.called) + assert.Contains(t, out, "需求 #7:导出报表") + assert.Contains(t, out, "资深产品规划助手") + // 组装入参带上了 requirement + artifacts + cwd + 用户文本 + budget。 + assert.Equal(t, req, fc.gotIn.Requirement) + assert.Equal(t, "请开始", fc.gotIn.UserPrompt) + assert.Equal(t, "/tmp/wt", fc.gotIn.RepoCwd) + assert.Equal(t, 6000, fc.gotIn.TokenBudget) + assert.Equal(t, brief.KindACPSession, fc.gotIn.Kind) + assert.Len(t, fc.gotIn.Artifacts, 1) + // 写了 brief_composed audit。 + assert.Contains(t, rec.actions(), "acp.session.brief_composed") +} + +func TestComposeInitialPrompt_IssueContextComposed(t *testing.T) { + t.Parallel() + rec := &recordingAudit{} + fc := &fakeComposer{res: brief.BriefResult{Text: "## 当前 Issue\nIssue #3:bug", Tokens: 8}} + s := &sessionService{audit: rec, composer: fc} + + iss := &project.Issue{ID: uuid.New(), Number: 3, Title: "bug"} + out := s.composeInitialPrompt(context.Background(), Caller{UserID: uuid.New()}, testSession(), nil, nil, iss, "fix it") + + require.True(t, fc.called) + assert.Contains(t, out, "Issue #3:bug") + assert.Equal(t, iss, fc.gotIn.Issue) + // issue-only 时不调用 ArtifactAccess(aa 为 nil 也不 panic)。 +} + +func TestComposeInitialPrompt_NoPMContextFallsBackToRaw(t *testing.T) { + t.Parallel() + fc := &fakeComposer{res: brief.BriefResult{Text: "SHOULD NOT BE USED"}} + s := &sessionService{audit: &recordingAudit{}, composer: fc} + + out := s.composeInitialPrompt(context.Background(), Caller{UserID: uuid.New()}, testSession(), nil, nil, nil, "raw text only") + assert.Equal(t, "raw text only", out) + assert.False(t, fc.called, "composer must not be called without PM context") +} + +func TestComposeInitialPrompt_NilComposerFallsBackToRaw(t *testing.T) { + t.Parallel() + s := &sessionService{audit: &recordingAudit{}, composer: nil} + req := &project.Requirement{ID: uuid.New(), Number: 7, Title: "x", Phase: project.PhasePlanning} + out := s.composeInitialPrompt(context.Background(), Caller{UserID: uuid.New()}, testSession(), nil, req, nil, "raw") + assert.Equal(t, "raw", out) +} + +func TestComposeInitialPrompt_EmptyComposedTextFallsBackToRaw(t *testing.T) { + t.Parallel() + rec := &recordingAudit{} + fc := &fakeComposer{res: brief.BriefResult{Text: ""}} + s := &sessionService{audit: rec, composer: fc} + req := &project.Requirement{ID: uuid.New(), Number: 1, Title: "t", Phase: project.PhasePlanning} + out := s.composeInitialPrompt(context.Background(), Caller{UserID: uuid.New()}, testSession(), nil, req, nil, "fallback raw") + assert.Equal(t, "fallback raw", out) + // 空简报不写 brief_composed audit。 + assert.NotContains(t, rec.actions(), "acp.session.brief_composed") +} diff --git a/internal/acp/session_service.go b/internal/acp/session_service.go index 600a410..b952aa2 100644 --- a/internal/acp/session_service.go +++ b/internal/acp/session_service.go @@ -6,6 +6,7 @@ import ( "context" "encoding/json" "fmt" + "log/slog" "strings" "time" @@ -13,6 +14,7 @@ import ( "github.com/jackc/pgx/v5" "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/brief" "github.com/yan1h/agent-coding-workflow/internal/infra/errs" "github.com/yan1h/agent-coding-workflow/internal/mcp" "github.com/yan1h/agent-coding-workflow/internal/project" @@ -25,6 +27,7 @@ type SessionServiceConfig struct { MaxActivePerUser int SystemTokenTTL time.Duration MCPPublicURL string + BriefTokenBudget int // 初始 prompt 简报的 token 硬上限(<=0 用 brief 默认值) } // ProjectAccess 是 acp 模块对 project 模块的窄接口;adapter 在 app.go 实现。 @@ -34,26 +37,37 @@ type ProjectAccess interface { GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Requirement, error) } +// ArtifactAccess 是 acp 模块拉取某 requirement 各阶段最新产物的窄接口;adapter 在 +// app.go 实现(基于 project.Repository.ListArtifactsByRequirement,按阶段取最大版本)。 +type ArtifactAccess interface { + ListLatestArtifacts(ctx context.Context, requirementID uuid.UUID) ([]*project.Artifact, error) +} + type sessionService struct { repo Repository wsSvc workspace.WorkspaceService wtSvc workspace.WorktreeService wsRepo workspace.Repository pa ProjectAccess + aa ArtifactAccess // 可为 nil(无产物注入) + composer brief.Composer // 可为 nil(退回 raw passthrough) sup *Supervisor audit audit.Recorder cfg SessionServiceConfig mcpTokens mcp.TokenService // spec §6.1 — system token issuance } -// NewSessionService 构造 SessionService。 +// NewSessionService 构造 SessionService。aa / composer 可为 nil:缺任一时 +// initial prompt 退回原始透传(不组装简报),保持旧行为。 func NewSessionService(repo Repository, wsSvc workspace.WorkspaceService, wtSvc workspace.WorktreeService, wsRepo workspace.Repository, - pa ProjectAccess, sup *Supervisor, rec audit.Recorder, + pa ProjectAccess, aa ArtifactAccess, composer brief.Composer, + sup *Supervisor, rec audit.Recorder, cfg SessionServiceConfig, mcpTokens mcp.TokenService) SessionService { return &sessionService{ repo: repo, wsSvc: wsSvc, wtSvc: wtSvc, wsRepo: wsRepo, - pa: pa, sup: sup, audit: rec, cfg: cfg, mcpTokens: mcpTokens, + pa: pa, aa: aa, composer: composer, sup: sup, audit: rec, + cfg: cfg, mcpTokens: mcpTokens, } } @@ -199,28 +213,41 @@ func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionI worktreeID = &wid } - // 6. PM 关联(best-effort:拿不到 ID 不阻塞 session 创建) - var issueID, reqID *uuid.UUID + // 6. PM 关联(best-effort:拿不到 ID 不阻塞 session 创建)。 + // 同时保留解析出的 issue/requirement 对象,供初始 prompt 组装简报复用。 + var ( + issueID, reqID *uuid.UUID + pmIssue *project.Issue + pmReq *project.Requirement + ) if in.IssueNumber != nil { if iss, _ := s.pa.GetIssueByNumber(ctx, ws.ProjectID, *in.IssueNumber); iss != nil { id := iss.ID issueID = &id + pmIssue = iss } } if in.RequirementNumber != nil { if req, _ := s.pa.GetRequirementByNumber(ctx, ws.ProjectID, *in.RequirementNumber); req != nil { id := req.ID reqID = &id + pmReq = req } } // 7. INSERT acp_sessions + system MCP token(spec §6.1 atomic guarantee) + // 预算快照:当前无 session 级覆盖入口,故直接继承 agent-kind 默认上限。快照到 + // session 行使 supervisor / reaper 读取自洽,不必每次回查 agent-kind。 sess := &Session{ ID: sessionID, WorkspaceID: ws.ID, ProjectID: ws.ProjectID, AgentKindID: kind.ID, UserID: c.UserID, IssueID: issueID, RequirementID: reqID, Branch: branch, CwdPath: cwdPath, IsMainWorktree: isMain, - Status: SessionStarting, + Status: SessionStarting, + OrchestratorStepID: in.OrchestratorStepID, + BudgetMaxCostUSD: kind.MaxCostUSD, + BudgetMaxTokens: kind.MaxTokens, + BudgetMaxWallClockSeconds: kind.MaxWallClockSeconds, } var ( out *Session @@ -279,14 +306,91 @@ func (s *sessionService) Create(ctx context.Context, c Caller, in CreateSessionI } }() - // 10. initial prompt:握手成功后异步转发 + // 10. initial prompt:握手成功后异步转发。先在请求路径同步组装服务端简报 + // (需求/Issue + 阶段产物 + 仓库定向 + 操作者自由文本),失败/无 PM 上下文时 + // 退回原始透传,保持旧行为。 if in.InitialPrompt != nil && *in.InitialPrompt != "" { - go s.sendInitialPrompt(out.ID, *in.InitialPrompt) + text := s.composeInitialPrompt(ctx, c, out, pj, pmReq, pmIssue, *in.InitialPrompt) + go s.sendInitialPrompt(out.ID, text) } return out, nil } +// composeInitialPrompt 组装 ACP session 的初始 prompt 简报。无 composer 或无 PM 上下文 +// (既无 requirement 也无 issue)时返回原始 userPrompt(raw passthrough)。组装失败也 +// 静默退回原始文本,绝不阻塞 session。成功组装后写一条 audit。 +func (s *sessionService) composeInitialPrompt(ctx context.Context, c Caller, sess *Session, + pj *project.Project, req *project.Requirement, iss *project.Issue, userPrompt string) string { + + if s.composer == nil || (req == nil && iss == nil) { + return userPrompt + } + + var artifacts []*project.Artifact + if req != nil && s.aa != nil { + if arts, err := s.aa.ListLatestArtifacts(ctx, req.ID); err == nil { + artifacts = arts + } + } + + res, err := s.composer.ComposeTaskBrief(ctx, brief.BriefInput{ + Project: pj, + Requirement: req, + Issue: iss, + Artifacts: artifacts, + RepoCwd: sess.CwdPath, + Branch: sess.Branch, + UserPrompt: userPrompt, + TokenBudget: s.cfg.BriefTokenBudget, + Kind: brief.KindACPSession, + }) + if err != nil || res.Text == "" { + return userPrompt + } + + s.recordAudit(ctx, c, "acp.session.brief_composed", sess.ID.String(), map[string]any{ + "requirement_id": idOrNil(req), + "issue_id": issueIDOrNil(iss), + "artifact_versions": artifactVersions(artifacts), + "brief_tokens": res.Tokens, + "truncated": res.Truncated, + "sections": res.Sections, + }) + return res.Text +} + +func idOrNil(req *project.Requirement) *uuid.UUID { + if req == nil { + return nil + } + id := req.ID + return &id +} + +func issueIDOrNil(iss *project.Issue) *uuid.UUID { + if iss == nil { + return nil + } + id := iss.ID + return &id +} + +// artifactVersions 把产物列表归约为 {phase: version} 映射(每阶段取出现的最大版本), +// 仅用于 audit 元数据。 +func artifactVersions(arts []*project.Artifact) map[string]int { + out := make(map[string]int, len(arts)) + for _, a := range arts { + if a == nil { + continue + } + if v, ok := out[string(a.Phase)]; !ok || a.Version > v { + out[string(a.Phase)] = a.Version + } + } + return out +} + // findOrCreateWorktree 在指定 workspace 内查找 branch 同名 worktree, // 不存在则创建。caller 已通过 wsSvc.Get 完成鉴权。 func (s *sessionService) findOrCreateWorktree(ctx context.Context, c workspace.Caller, wsID uuid.UUID, branch string) (*workspace.Worktree, error) { @@ -342,11 +446,167 @@ func (s *sessionService) sendInitialPrompt(sid uuid.UUID, text string) { msg := &Message{ JSONRPC: "2.0", ID: idJSON, Method: "session/prompt", Params: params, } + // 回合相关联:在发送前登记回合 id("client-init"),使响应的 stopReason 可被 + // 相关联到这个回合。tracker 可能为 nil(部分测试)——StartTurn 已做 nil 守卫。 + if tr := relay.Tracker(); tr != nil { + if _, err := tr.StartTurn(context.Background(), "client-init"); err != nil { + slog.Warn("acp.session.start_turn", "session_id", sid, "err", err.Error()) + } + } _ = relay.SendClient(msg) return } } +// 编译期断言:sessionService 同时实现 SessionService 与 TurnRunner(编排器依赖后者)。 +var _ TurnRunner = (*sessionService)(nil) + +// promptResult 是 session/prompt 响应 Result 中我们关心的字段。 +type promptResult struct { + StopReason string `json:"stopReason"` +} + +// SendPromptAndWait 发送一条 session/prompt 并阻塞等待回合完成,返回 agent 上报的原始 +// stopReason。这是编排器驱动一个阻塞回合的核心原语:先轮询等待 supervisor handshake +// 完成(relay + agent_session_id 就绪,复用 sendInitialPrompt 的就绪检测),随后走 +// server-initiated relay.Call('session/prompt') 取响应里的 stopReason。 +// +// ctx 控制整体超时(编排器以 cfg.TurnTimeout 约束,须 < jobReaper LeaseTimeout)。 +func (s *sessionService) SendPromptAndWait(ctx context.Context, sessionID uuid.UUID, prompt string) (string, error) { + relay, agentSessionID, err := s.waitForRelayReady(ctx, sessionID) + if err != nil { + return "", err + } + + params := map[string]any{ + "sessionId": agentSessionID, + "prompt": []map[string]any{{"type": "text", "text": prompt}}, + } + resp, err := relay.Call(ctx, "session/prompt", params) + if err != nil { + return "", errs.Wrap(err, errs.CodeInternal, "session/prompt call") + } + var pr promptResult + if len(resp.Result) > 0 { + if uerr := json.Unmarshal(resp.Result, &pr); uerr != nil { + return "", errs.Wrap(uerr, errs.CodeInternal, "parse session/prompt result") + } + } + if pr.StopReason != "" { + // 落库最近一次 stopReason(best-effort),供观测/网关复用。 + _ = s.repo.UpdateSessionLastStopReason(context.WithoutCancel(ctx), sessionID, pr.StopReason) + } + return pr.StopReason, nil +} + +// waitForRelayReady 轮询直到 relay 与 agent_session_id 同时就绪或 ctx 截止。 +// 200ms 一次,与 sendInitialPrompt 一致。 +func (s *sessionService) waitForRelayReady(ctx context.Context, sessionID uuid.UUID) (*Relay, string, error) { + tick := time.NewTicker(200 * time.Millisecond) + defer tick.Stop() + for { + relay := s.sup.GetRelay(sessionID) + if relay != nil { + sess, _ := s.repo.GetSessionByID(ctx, sessionID) + if sess != nil && sess.AgentSessionID != nil { + return relay, *sess.AgentSessionID, nil + } + } + select { + case <-ctx.Done(): + return nil, "", ctx.Err() + case <-tick.C: + } + } +} + +// ResumeSession 在崩溃/退出 session 的同一 cwd/worktree 上重新拉起 agent。复用原 +// session 的 workspace/branch/cwd/main 标记与编排器 step 关联:重新占用 worktree +// (main → AcquireMainWorktree CAS;子 worktree → 按 holder Acquire),签发新 system +// token,再 sup.Spawn。Spawn 失败时回滚 worktree(releaseOnFailure)。 +// +// 返回的是被复用并重置为 starting 状态的同一行 session(同 ID),供编排器随后 +// SendPromptAndWait。仅供编排器内部调用(caller 为 run.owner)。 +func (s *sessionService) ResumeSession(ctx context.Context, c Caller, sessionID uuid.UUID) (*Session, error) { + sess, err := s.repo.GetSessionByID(ctx, sessionID) + if err != nil { + return nil, err + } + if sess.Status.IsActive() { + // 已经活跃:无需 resume,直接返回。 + return sess, nil + } + + kind, err := s.repo.GetAgentKindByID(ctx, sess.AgentKindID) + if err != nil { + return nil, err + } + + sessCaller := workspace.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin, SessionID: &sess.ID} + if sess.IsMainWorktree { + ok, aerr := s.repo.AcquireMainWorktree(ctx, sess.ID, sess.WorkspaceID) + if aerr != nil { + return nil, aerr + } + if !ok { + return nil, errs.New(errs.CodeAcpSessionBranchConflict, "main worktree in use; cannot resume") + } + } else { + wt, ferr := s.findOrCreateWorktree(ctx, sessCaller, sess.WorkspaceID, sess.Branch) + if ferr != nil { + return nil, ferr + } + if _, aerr := s.wtSvc.Acquire(ctx, sessCaller, wt.ID); aerr != nil { + return nil, aerr + } + } + + // 重新签发 system token + 标记 session 回到 starting(清空上次终态字段)。 + var ( + tokenRes mcp.IssueResult + ) + txErr := s.repo.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { + if rerr := s.repo.WithTx(tx).ResetSessionForResume(txCtx, sess.ID); rerr != nil { + return rerr + } + res, ierr := s.mcpTokens.IssueSystemTokenWithTx(txCtx, tx, mcp.IssueSystemTokenInput{ + UserID: c.UserID, + ACPSessionID: sess.ID, + ProjectIDs: []uuid.UUID{sess.ProjectID}, + ExpiresIn: s.cfg.SystemTokenTTL, + }) + if ierr != nil { + return ierr + } + tokenRes = res + return nil + }) + if txErr != nil { + s.releaseOnFailure(ctx, sess, sessCaller, nil) + return nil, txErr + } + sess.Status = SessionStarting + + extraEnv := map[string]string{ + EnvMCPToken: tokenRes.Plaintext, + EnvMCPURL: s.cfg.MCPPublicURL, + } + if _, serr := s.sup.Spawn(ctx, sess, kind, extraEnv); serr != nil { + if _, merr := s.repo.MarkSessionFailedIfActive(ctx, sess.ID, serr.Error()); merr != nil { + s.sup.log.Error("acp.resume.mark_session", "session_id", sess.ID, "err", merr.Error()) + } + _ = s.mcpTokens.RevokeBySession(ctx, sess.ID) + s.releaseOnFailure(ctx, sess, sessCaller, nil) + return nil, serr + } + + s.recordAudit(ctx, c, "acp.session.resume", sess.ID.String(), map[string]any{ + "branch": sess.Branch, + "cwd_path": sess.CwdPath, + }) + return sess, nil +} + func (s *sessionService) Get(ctx context.Context, c Caller, id uuid.UUID) (*Session, error) { sess, err := s.repo.GetSessionByID(ctx, id) if err != nil { @@ -380,6 +640,52 @@ func (s *sessionService) Terminate(ctx context.Context, c Caller, id uuid.UUID) return nil } +// defaultDashboardWindow 是 run-history 汇总在调用方未指定时间窗时的默认回溯。 +const defaultDashboardWindow = 30 * 24 * time.Hour + +// Dashboard 返回 run-history 汇总 + 按天序列。非 admin 强制按 caller scope。 +func (s *sessionService) Dashboard(ctx context.Context, c Caller, in DashboardInput) (*DashboardResult, error) { + to := in.To + if to.IsZero() { + to = time.Now().UTC() + } + from := in.From + if from.IsZero() { + from = to.Add(-defaultDashboardWindow) + } + if !from.Before(to) { + return nil, errs.New(errs.CodeInvalidInput, "from must be before to") + } + f := DashboardFilter{ + ProjectID: in.ProjectID, + RequirementID: in.RequirementID, + From: from, + To: to, + } + // 非 admin 只能看自己的会话:UserID 固定为 caller。admin 留空(全量)。 + if !c.IsAdmin { + uid := c.UserID + f.UserID = &uid + } + rollup, err := s.repo.SessionRollup(ctx, f) + if err != nil { + return nil, err + } + byDay, err := s.repo.SessionsByDay(ctx, f) + if err != nil { + return nil, err + } + return &DashboardResult{Rollup: rollup, ByDay: byDay, From: from, To: to}, nil +} + +// Timeline 返回单会话事件时间线(先经 Get 做 owner/admin 访问校验)。 +func (s *sessionService) Timeline(ctx context.Context, c Caller, sessionID uuid.UUID) ([]SessionTimelineBucket, error) { + if _, err := s.Get(ctx, c, sessionID); err != nil { + return nil, err + } + return s.repo.SessionTimeline(ctx, sessionID) +} + func (s *sessionService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) { if s.audit == nil { return diff --git a/internal/acp/session_service_test.go b/internal/acp/session_service_test.go index 4f505d9..27b835a 100644 --- a/internal/acp/session_service_test.go +++ b/internal/acp/session_service_test.go @@ -123,6 +123,14 @@ type fakeAcpRepo struct { // when fn returns nil (simulating real transaction semantics). txStaging []*acp.Session inTx bool + + // run-history dashboard: 记录入参 filter 供断言;返回预置结果。 + rollupFilter acp.DashboardFilter + byDayFilter acp.DashboardFilter + rollupResult acp.SessionRollup + byDayResult []acp.SessionDayRow + timelineResult []acp.SessionTimelineBucket + timelineSID uuid.UUID } func (r *fakeAcpRepo) InTx(_ context.Context, fn func(context.Context, pgx.Tx) error) error { @@ -175,6 +183,43 @@ func (r *fakeAcpRepo) GetSessionByID(_ context.Context, id uuid.UUID) (*acp.Sess } return nil, fmt.Errorf("not found") } +func (r *fakeAcpRepo) GetSessionByStepID(_ context.Context, stepID uuid.UUID) (*acp.Session, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, s := range r.sessions { + if s.OrchestratorStepID != nil && *s.OrchestratorStepID == stepID { + return s, nil + } + } + return nil, fmt.Errorf("not found") +} +func (r *fakeAcpRepo) GetCrashedSessionForResume(_ context.Context, stepID uuid.UUID) (*acp.Session, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, s := range r.sessions { + if s.OrchestratorStepID != nil && *s.OrchestratorStepID == stepID && + (s.Status == acp.SessionCrashed || s.Status == acp.SessionExited) { + return s, nil + } + } + return nil, fmt.Errorf("not found") +} +func (r *fakeAcpRepo) ResetSessionForResume(_ context.Context, id uuid.UUID) error { + r.mu.Lock() + defer r.mu.Unlock() + for _, s := range r.sessions { + if s.ID == id { + s.Status = acp.SessionStarting + s.AgentSessionID = nil + s.PID = nil + s.ExitCode = nil + s.LastError = nil + s.EndedAt = nil + return nil + } + } + return fmt.Errorf("not found") +} func (r *fakeAcpRepo) GetAgentKindByID(_ context.Context, id uuid.UUID) (*acp.AgentKind, error) { for _, k := range r.kinds { if k.ID == id { @@ -252,6 +297,9 @@ func (r *fakeAcpRepo) ListAllSessions(_ context.Context, reqID *uuid.UUID) ([]*a func (r *fakeAcpRepo) UpdateSessionRunning(_ context.Context, _ uuid.UUID, _ string, _ int32) error { return nil } +func (r *fakeAcpRepo) UpdateSessionSandboxMode(_ context.Context, _ uuid.UUID, _ string) error { + return nil +} func (r *fakeAcpRepo) UpdateSessionFinished(_ context.Context, _ uuid.UUID, _ acp.SessionStatus, _ *int32, _ *string) error { return nil } @@ -276,6 +324,69 @@ func (r *fakeAcpRepo) ListEventsSince(_ context.Context, _ uuid.UUID, _ int64, _ return nil, nil } func (r *fakeAcpRepo) PurgeEventsBefore(_ context.Context, _ time.Time) (int64, error) { return 0, nil } +func (r *fakeAcpRepo) InsertSessionUsage(_ context.Context, _ *acp.SessionUsageRecord) (bool, error) { + return true, nil +} +func (r *fakeAcpRepo) AddSessionUsageTotals(_ context.Context, _ uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) { + return dp, dc, dt, dCost, nil +} +func (r *fakeAcpRepo) SumProjectCostUSD(_ context.Context, _ uuid.UUID, _ time.Time) (float64, error) { + return 0, nil +} +func (r *fakeAcpRepo) MarkSessionTerminatedReason(_ context.Context, _ uuid.UUID, _ string) error { + return nil +} +func (r *fakeAcpRepo) ListSessionsForReaper(_ context.Context, _, _ time.Time) ([]acp.ReaperSession, error) { + return nil, nil +} +func (r *fakeAcpRepo) ListSessionUsage(_ context.Context, _ uuid.UUID, _ int32) ([]*acp.SessionUsageRecord, error) { + return nil, nil +} +func (r *fakeAcpRepo) SummarizeAcpUsageByUser(_ context.Context, _, _ time.Time) ([]acp.AcpUsageUserRow, error) { + return nil, nil +} +func (r *fakeAcpRepo) SummarizeAcpUsageByModel(_ context.Context, _, _ time.Time) ([]acp.AcpUsageModelRow, error) { + return nil, nil +} +func (r *fakeAcpRepo) SummarizeAcpUsageByDay(_ context.Context, _, _ time.Time) ([]acp.AcpUsageDayRow, error) { + return nil, nil +} +func (r *fakeAcpRepo) SessionRollup(_ context.Context, f acp.DashboardFilter) (acp.SessionRollup, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.rollupFilter = f + return r.rollupResult, nil +} +func (r *fakeAcpRepo) SessionsByDay(_ context.Context, f acp.DashboardFilter) ([]acp.SessionDayRow, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.byDayFilter = f + return r.byDayResult, nil +} +func (r *fakeAcpRepo) SessionTimeline(_ context.Context, sid uuid.UUID) ([]acp.SessionTimelineBucket, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.timelineSID = sid + return r.timelineResult, nil +} +func (r *fakeAcpRepo) InsertTurn(_ context.Context, t *acp.Turn) (*acp.Turn, error) { return t, nil } +func (r *fakeAcpRepo) MarkTurnCompleted(_ context.Context, _ uuid.UUID, _ int, _ string, _ int) error { + return nil +} +func (r *fakeAcpRepo) MarkTurnAborted(_ context.Context, _ uuid.UUID, _ int) error { return nil } +func (r *fakeAcpRepo) IncrementTurnUpdateCount(_ context.Context, _ uuid.UUID, _ int) error { + return nil +} +func (r *fakeAcpRepo) GetLatestTurnBySession(_ context.Context, _ uuid.UUID) (*acp.Turn, error) { + return nil, nil +} +func (r *fakeAcpRepo) ListTurnsBySession(_ context.Context, _ uuid.UUID) ([]*acp.Turn, error) { + return nil, nil +} +func (r *fakeAcpRepo) UpdateSessionLastStopReason(_ context.Context, _ uuid.UUID, _ string) error { + return nil +} +func (r *fakeAcpRepo) PurgeTurnsBefore(_ context.Context, _ time.Time) (int64, error) { return 0, nil } // fakeMCPTokenSvc satisfies mcp.TokenService for unit tests. type fakeMCPTokenSvc struct { @@ -382,7 +493,7 @@ func TestSessionService_Create_IssuesSystemToken(t *testing.T) { ) svc := acp.NewSessionService( - repo, wsSvc, nil, nil, pa, sup, nil, + repo, wsSvc, nil, nil, pa, nil, nil, sup, nil, acp.SessionServiceConfig{ MaxActiveGlobal: 10, MaxActivePerUser: 5, @@ -441,7 +552,7 @@ func TestSessionService_Create_TokenIssueFails_RollsBack(t *testing.T) { ) svc := acp.NewSessionService( - repo, wsSvc, nil, nil, pa, sup, nil, + repo, wsSvc, nil, nil, pa, nil, nil, sup, nil, acp.SessionServiceConfig{ MaxActiveGlobal: 10, MaxActivePerUser: 5, @@ -495,7 +606,7 @@ func TestSessionService_Create_SpawnFails_MarksSessionCrashed(t *testing.T) { ) svc := acp.NewSessionService( - repo, wsSvc, nil, nil, pa, sup, nil, + repo, wsSvc, nil, nil, pa, nil, nil, sup, nil, acp.SessionServiceConfig{ MaxActiveGlobal: 10, MaxActivePerUser: 5, @@ -535,7 +646,7 @@ func TestSessionService_Create_MutuallyExclusiveInputs(t *testing.T) { akID := uuid.New() svc := acp.NewSessionService( - &fakeAcpRepo{}, nil, nil, nil, nil, nil, nil, + &fakeAcpRepo{}, nil, nil, nil, nil, nil, nil, nil, nil, acp.SessionServiceConfig{}, nil, ) @@ -598,7 +709,7 @@ func TestSessionService_List_RequirementFilter(t *testing.T) { } svc := acp.NewSessionService( - repo, nil, nil, nil, nil, nil, nil, + repo, nil, nil, nil, nil, nil, nil, nil, nil, acp.SessionServiceConfig{}, nil, ) @@ -616,3 +727,191 @@ func TestSessionService_List_RequirementFilter(t *testing.T) { require.NoError(t, err) require.Len(t, list, 2) } + +// TestSessionService_ResumeSession_ReacquiresMainWorktree 验证:在崩溃的 main-worktree +// session 上 ResumeSession 会重新占用 main worktree(CAS)、重新签发 system token、把 +// session 复位为 starting,然后尝试 spawn。binary 为空致 spawn 失败,但前置的占用/签发/ +// 复位都应已发生。 +func TestSessionService_ResumeSession_ReacquiresMainWorktree(t *testing.T) { + t.Parallel() + + uid := uuid.New() + wsID := uuid.New() + pid := uuid.New() + akID := uuid.New() + sessID := uuid.New() + + exitCode := int32(1) + crashed := &acp.Session{ + ID: sessID, WorkspaceID: wsID, ProjectID: pid, AgentKindID: akID, UserID: uid, + Branch: "main", CwdPath: "/tmp/main", IsMainWorktree: true, + Status: acp.SessionCrashed, ExitCode: &exitCode, + } + repo := &fakeAcpRepo{ + kinds: []*acp.AgentKind{{ID: akID, Name: "claude", Enabled: true}}, + sessions: []*acp.Session{crashed}, + } + mcpTokens := &fakeMCPTokenSvc{} + sup := acp.NewSupervisor( + repo, nil, nil, nil, nil, mcpTokens, nil, + acp.SupervisorConfig{SpawnTimeout: 2 * time.Second, KillGrace: time.Second}, + nil, + ) + svc := acp.NewSessionService( + repo, &fakeWsSvc{ws: &workspace.Workspace{ID: wsID, ProjectID: pid, DefaultBranch: "main", MainPath: "/tmp/main"}}, + nil, nil, fakeProjectAccess{pj: &project.Project{ID: pid, Slug: "p"}}, nil, nil, sup, nil, + acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5, SystemTokenTTL: time.Hour, MCPPublicURL: "http://x/mcp"}, + mcpTokens, + ) + + tr, ok := svc.(acp.TurnRunner) + require.True(t, ok, "sessionService must implement acp.TurnRunner") + + // spawn 会失败(binary 空),ResumeSession 返回错误是预期的。 + _, _ = tr.ResumeSession(context.Background(), acp.Caller{UserID: uid}, sessID) + + // 但 token 应已重新签发(占用 + InTx 复位在 spawn 之前)。 + mcpTokens.mu.Lock() + issuedCount := len(mcpTokens.issued) + mcpTokens.mu.Unlock() + require.GreaterOrEqual(t, issuedCount, 1, "resume should re-issue a system token") + assert.Equal(t, sessID, mcpTokens.issued[0].ACPSessionID) +} + +// TestSessionService_ResumeSession_ActiveSessionNoOp 验证:对已活跃 session 调用 +// ResumeSession 直接返回该 session,不重新占用/签发。 +func TestSessionService_ResumeSession_ActiveSessionNoOp(t *testing.T) { + t.Parallel() + + uid := uuid.New() + wsID := uuid.New() + pid := uuid.New() + akID := uuid.New() + sessID := uuid.New() + + active := &acp.Session{ + ID: sessID, WorkspaceID: wsID, ProjectID: pid, AgentKindID: akID, UserID: uid, + Branch: "main", CwdPath: "/tmp/main", IsMainWorktree: true, Status: acp.SessionRunning, + } + repo := &fakeAcpRepo{ + kinds: []*acp.AgentKind{{ID: akID, Name: "claude", Enabled: true}}, + sessions: []*acp.Session{active}, + } + mcpTokens := &fakeMCPTokenSvc{} + sup := acp.NewSupervisor(repo, nil, nil, nil, nil, mcpTokens, nil, + acp.SupervisorConfig{SpawnTimeout: time.Second, KillGrace: time.Second}, nil) + svc := acp.NewSessionService(repo, &fakeWsSvc{ws: &workspace.Workspace{ID: wsID, ProjectID: pid, DefaultBranch: "main", MainPath: "/tmp/main"}}, + nil, nil, fakeProjectAccess{pj: &project.Project{ID: pid, Slug: "p"}}, nil, nil, sup, nil, + acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5, SystemTokenTTL: time.Hour, MCPPublicURL: "http://x/mcp"}, + mcpTokens) + + tr := svc.(acp.TurnRunner) + out, err := tr.ResumeSession(context.Background(), acp.Caller{UserID: uid}, sessID) + require.NoError(t, err) + require.NotNil(t, out) + assert.Equal(t, sessID, out.ID) + + mcpTokens.mu.Lock() + defer mcpTokens.mu.Unlock() + assert.Len(t, mcpTokens.issued, 0, "active session resume must not re-issue tokens") +} + +// ===== run-history dashboard ===== + +func newDashboardSvc(repo *fakeAcpRepo) acp.SessionService { + return acp.NewSessionService(repo, nil, nil, nil, nil, nil, nil, nil, nil, + acp.SessionServiceConfig{}, nil) +} + +func TestDashboard_NonAdmin_ScopedToCaller(t *testing.T) { + t.Parallel() + uid := uuid.New() + repo := &fakeAcpRepo{ + rollupResult: acp.SessionRollup{Total: 4, Succeeded: 3, Crashed: 1, TotalCostUSD: 1.5, TotalTokens: 900}, + byDayResult: []acp.SessionDayRow{{Day: time.Now().UTC(), Total: 4, Succeeded: 3, Crashed: 1, TotalCostUSD: 1.5}}, + } + svc := newDashboardSvc(repo) + + res, err := svc.Dashboard(context.Background(), acp.Caller{UserID: uid}, acp.DashboardInput{}) + require.NoError(t, err) + require.NotNil(t, res) + + // 非 admin 强制按 caller scope。 + require.NotNil(t, repo.rollupFilter.UserID) + assert.Equal(t, uid, *repo.rollupFilter.UserID) + require.NotNil(t, repo.byDayFilter.UserID) + assert.Equal(t, uid, *repo.byDayFilter.UserID) + + // 默认时间窗为半开且 from 早于 to。 + assert.True(t, repo.rollupFilter.From.Before(repo.rollupFilter.To)) + + // rollup 透传 + 成功率计算。 + assert.Equal(t, int64(4), res.Rollup.Total) + assert.InDelta(t, 0.75, res.Rollup.SuccessRate(), 1e-9) + assert.InDelta(t, 1.5, res.Rollup.TotalCostUSD, 1e-9) + assert.Len(t, res.ByDay, 1) +} + +func TestDashboard_Admin_NoUserScope_WithFilters(t *testing.T) { + t.Parallel() + pid := uuid.New() + reqID := uuid.New() + from := time.Now().Add(-48 * time.Hour).UTC() + to := time.Now().UTC() + repo := &fakeAcpRepo{rollupResult: acp.SessionRollup{Total: 10, Succeeded: 10}} + svc := newDashboardSvc(repo) + + res, err := svc.Dashboard(context.Background(), acp.Caller{UserID: uuid.New(), IsAdmin: true}, + acp.DashboardInput{ProjectID: &pid, RequirementID: &reqID, From: from, To: to}) + require.NoError(t, err) + + // admin → 无 user scope;project/requirement/时间窗透传。 + assert.Nil(t, repo.rollupFilter.UserID) + require.NotNil(t, repo.rollupFilter.ProjectID) + assert.Equal(t, pid, *repo.rollupFilter.ProjectID) + require.NotNil(t, repo.rollupFilter.RequirementID) + assert.Equal(t, reqID, *repo.rollupFilter.RequirementID) + assert.Equal(t, from, repo.rollupFilter.From) + assert.Equal(t, to, repo.rollupFilter.To) + assert.InDelta(t, 1.0, res.Rollup.SuccessRate(), 1e-9) +} + +func TestDashboard_InvalidWindow(t *testing.T) { + t.Parallel() + to := time.Now().UTC() + from := to.Add(time.Hour) // from after to + svc := newDashboardSvc(&fakeAcpRepo{}) + _, err := svc.Dashboard(context.Background(), acp.Caller{UserID: uuid.New()}, + acp.DashboardInput{From: from, To: to}) + require.Error(t, err) +} + +func TestTimeline_OwnerAccess(t *testing.T) { + t.Parallel() + uid := uuid.New() + sid := uuid.New() + buckets := []acp.SessionTimelineBucket{ + {Direction: "out", RPCKind: "request", Method: "session/prompt", EventCount: 2}, + {Direction: "in", RPCKind: "notification", Method: "session/update", EventCount: 5}, + } + repo := &fakeAcpRepo{timelineResult: buckets} + repo.sessions = []*acp.Session{{ID: sid, UserID: uid}} + svc := newDashboardSvc(repo) + + got, err := svc.Timeline(context.Background(), acp.Caller{UserID: uid}, sid) + require.NoError(t, err) + assert.Equal(t, sid, repo.timelineSID) + require.Len(t, got, 2) + assert.Equal(t, "session/prompt", got[0].Method) +} + +func TestTimeline_NonOwner_NotFound(t *testing.T) { + t.Parallel() + sid := uuid.New() + repo := &fakeAcpRepo{} + repo.sessions = []*acp.Session{{ID: sid, UserID: uuid.New()}} + svc := newDashboardSvc(repo) + + _, err := svc.Timeline(context.Background(), acp.Caller{UserID: uuid.New()}, sid) + require.Error(t, err) // Get 拒绝 → 不泄漏会话 +} diff --git a/internal/acp/sqlc/agent_kind_config_files.sql.go b/internal/acp/sqlc/agent_kind_config_files.sql.go index 5ef777c..4b38e05 100644 --- a/internal/acp/sqlc/agent_kind_config_files.sql.go +++ b/internal/acp/sqlc/agent_kind_config_files.sql.go @@ -30,7 +30,7 @@ func (q *Queries) DeleteAgentKindConfigFile(ctx context.Context, arg DeleteAgent } const listAgentKindConfigFiles = `-- name: ListAgentKindConfigFiles :many -SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at +SELECT id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at, key_version FROM acp_agent_kind_config_files WHERE agent_kind_id = $1 ORDER BY rel_path ASC @@ -53,6 +53,7 @@ func (q *Queries) ListAgentKindConfigFiles(ctx context.Context, agentKindID pgty &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.KeyVersion, ); err != nil { return nil, err } @@ -72,7 +73,7 @@ ON CONFLICT (agent_kind_id, rel_path) DO UPDATE SET encrypted_content = EXCLUDED.encrypted_content, updated_by = EXCLUDED.updated_by, updated_at = now() -RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at +RETURNING id, agent_kind_id, rel_path, encrypted_content, updated_by, created_at, updated_at, key_version ` type UpsertAgentKindConfigFileParams struct { @@ -100,6 +101,7 @@ func (q *Queries) UpsertAgentKindConfigFile(ctx context.Context, arg UpsertAgent &i.UpdatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.KeyVersion, ) return i, err } diff --git a/internal/acp/sqlc/agent_kinds.sql.go b/internal/acp/sqlc/agent_kinds.sql.go index 16af105..d135a13 100644 --- a/internal/acp/sqlc/agent_kinds.sql.go +++ b/internal/acp/sqlc/agent_kinds.sql.go @@ -24,25 +24,31 @@ func (q *Queries) CountAgentKindUsage(ctx context.Context, agentKindID pgtype.UU const createAgentKind = `-- name: CreateAgentKind :one INSERT INTO acp_agent_kinds ( - id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist, client_type, encrypted_mcp_servers -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + id, name, display_name, description, binary_path, args, encrypted_env, enabled, created_by, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16) RETURNING id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers + enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version ` type CreateAgentKindParams struct { - ID pgtype.UUID `json:"id"` - Name string `json:"name"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` - CreatedBy pgtype.UUID `json:"created_by"` - ToolAllowlist []string `json:"tool_allowlist"` - ClientType string `json:"client_type"` - EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + ToolAllowlist []string `json:"tool_allowlist"` + ClientType string `json:"client_type"` + EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` } func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams) (AcpAgentKind, error) { @@ -59,6 +65,10 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams arg.ToolAllowlist, arg.ClientType, arg.EncryptedMcpServers, + arg.ModelID, + arg.MaxCostUsd, + arg.MaxTokens, + arg.MaxWallClockSeconds, ) var i AcpAgentKind err := row.Scan( @@ -76,6 +86,11 @@ func (q *Queries) CreateAgentKind(ctx context.Context, arg CreateAgentKindParams &i.ToolAllowlist, &i.ClientType, &i.EncryptedMcpServers, + &i.ModelID, + &i.MaxCostUsd, + &i.MaxTokens, + &i.MaxWallClockSeconds, + &i.KeyVersion, ) return i, err } @@ -91,7 +106,8 @@ func (q *Queries) DeleteAgentKind(ctx context.Context, id pgtype.UUID) error { const getAgentKindByID = `-- name: GetAgentKindByID :one SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers + enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version FROM acp_agent_kinds WHERE id = $1 ` @@ -114,13 +130,19 @@ func (q *Queries) GetAgentKindByID(ctx context.Context, id pgtype.UUID) (AcpAgen &i.ToolAllowlist, &i.ClientType, &i.EncryptedMcpServers, + &i.ModelID, + &i.MaxCostUsd, + &i.MaxTokens, + &i.MaxWallClockSeconds, + &i.KeyVersion, ) return i, err } const getAgentKindByName = `-- name: GetAgentKindByName :one SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers + enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version FROM acp_agent_kinds WHERE name = $1 ` @@ -143,13 +165,19 @@ func (q *Queries) GetAgentKindByName(ctx context.Context, name string) (AcpAgent &i.ToolAllowlist, &i.ClientType, &i.EncryptedMcpServers, + &i.ModelID, + &i.MaxCostUsd, + &i.MaxTokens, + &i.MaxWallClockSeconds, + &i.KeyVersion, ) return i, err } const listAgentKinds = `-- name: ListAgentKinds :many SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers + enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version FROM acp_agent_kinds ORDER BY created_at DESC ` @@ -178,6 +206,11 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) { &i.ToolAllowlist, &i.ClientType, &i.EncryptedMcpServers, + &i.ModelID, + &i.MaxCostUsd, + &i.MaxTokens, + &i.MaxWallClockSeconds, + &i.KeyVersion, ); err != nil { return nil, err } @@ -191,7 +224,8 @@ func (q *Queries) ListAgentKinds(ctx context.Context) ([]AcpAgentKind, error) { const listEnabledAgentKinds = `-- name: ListEnabledAgentKinds :many SELECT id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers + enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version FROM acp_agent_kinds WHERE enabled = TRUE ORDER BY name ASC @@ -221,6 +255,11 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er &i.ToolAllowlist, &i.ClientType, &i.EncryptedMcpServers, + &i.ModelID, + &i.MaxCostUsd, + &i.MaxTokens, + &i.MaxWallClockSeconds, + &i.KeyVersion, ); err != nil { return nil, err } @@ -234,32 +273,41 @@ func (q *Queries) ListEnabledAgentKinds(ctx context.Context) ([]AcpAgentKind, er const updateAgentKind = `-- name: UpdateAgentKind :one UPDATE acp_agent_kinds -SET display_name = $2, - description = $3, - binary_path = $4, - args = $5, - encrypted_env = $6, - enabled = $7, - tool_allowlist = $8, - client_type = $9, - encrypted_mcp_servers = $10, - updated_at = now() +SET display_name = $2, + description = $3, + binary_path = $4, + args = $5, + encrypted_env = $6, + enabled = $7, + tool_allowlist = $8, + client_type = $9, + encrypted_mcp_servers = $10, + model_id = $11, + max_cost_usd = $12, + max_tokens = $13, + max_wall_clock_seconds = $14, + updated_at = now() WHERE id = $1 RETURNING id, name, display_name, description, binary_path, args, encrypted_env, - enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers + enabled, created_by, created_at, updated_at, tool_allowlist, client_type, encrypted_mcp_servers, + model_id, max_cost_usd, max_tokens, max_wall_clock_seconds, key_version ` type UpdateAgentKindParams struct { - ID pgtype.UUID `json:"id"` - DisplayName string `json:"display_name"` - Description string `json:"description"` - BinaryPath string `json:"binary_path"` - Args []string `json:"args"` - EncryptedEnv []byte `json:"encrypted_env"` - Enabled bool `json:"enabled"` - ToolAllowlist []string `json:"tool_allowlist"` - ClientType string `json:"client_type"` - EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ID pgtype.UUID `json:"id"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + ToolAllowlist []string `json:"tool_allowlist"` + ClientType string `json:"client_type"` + EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` } func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams) (AcpAgentKind, error) { @@ -274,6 +322,10 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams arg.ToolAllowlist, arg.ClientType, arg.EncryptedMcpServers, + arg.ModelID, + arg.MaxCostUsd, + arg.MaxTokens, + arg.MaxWallClockSeconds, ) var i AcpAgentKind err := row.Scan( @@ -291,6 +343,11 @@ func (q *Queries) UpdateAgentKind(ctx context.Context, arg UpdateAgentKindParams &i.ToolAllowlist, &i.ClientType, &i.EncryptedMcpServers, + &i.ModelID, + &i.MaxCostUsd, + &i.MaxTokens, + &i.MaxWallClockSeconds, + &i.KeyVersion, ) return i, err } diff --git a/internal/acp/sqlc/dashboard.sql.go b/internal/acp/sqlc/dashboard.sql.go new file mode 100644 index 0000000..9c2842e --- /dev/null +++ b/internal/acp/sqlc/dashboard.sql.go @@ -0,0 +1,201 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: dashboard.sql + +package acpsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const sessionRollup = `-- name: SessionRollup :one +SELECT + COUNT(*)::bigint AS total, + COUNT(*) FILTER (WHERE status = 'exited')::bigint AS succeeded, + COUNT(*) FILTER (WHERE status = 'crashed')::bigint AS crashed, + COUNT(*) FILTER (WHERE status IN ('starting','running'))::bigint AS active, + COALESCE(SUM(cost_usd), 0)::numeric AS total_cost_usd, + COALESCE(SUM(tokens_total), 0)::bigint AS total_tokens, + COALESCE( + AVG(EXTRACT(EPOCH FROM (ended_at - started_at))) + FILTER (WHERE ended_at IS NOT NULL), + 0)::float8 AS avg_duration_seconds +FROM acp_sessions +WHERE ($1::uuid IS NULL OR user_id = $1) + AND ($2::uuid IS NULL OR project_id = $2) + AND ($3::uuid IS NULL OR requirement_id = $3) + AND started_at >= $4 + AND started_at < $5 +` + +type SessionRollupParams struct { + Column1 pgtype.UUID `json:"column_1"` + Column2 pgtype.UUID `json:"column_2"` + Column3 pgtype.UUID `json:"column_3"` + StartedAt pgtype.Timestamptz `json:"started_at"` + StartedAt_2 pgtype.Timestamptz `json:"started_at_2"` +} + +type SessionRollupRow struct { + Total int64 `json:"total"` + Succeeded int64 `json:"succeeded"` + Crashed int64 `json:"crashed"` + Active int64 `json:"active"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + TotalTokens int64 `json:"total_tokens"` + AvgDurationSeconds float64 `json:"avg_duration_seconds"` +} + +// owner/admin scope + 可选 project/requirement + 时间窗内的会话汇总。 +// success = status='exited';avg_duration_seconds 仅统计已结束(ended_at 非空)会话。 +func (q *Queries) SessionRollup(ctx context.Context, arg SessionRollupParams) (SessionRollupRow, error) { + row := q.db.QueryRow(ctx, sessionRollup, + arg.Column1, + arg.Column2, + arg.Column3, + arg.StartedAt, + arg.StartedAt_2, + ) + var i SessionRollupRow + err := row.Scan( + &i.Total, + &i.Succeeded, + &i.Crashed, + &i.Active, + &i.TotalCostUsd, + &i.TotalTokens, + &i.AvgDurationSeconds, + ) + return i, err +} + +const sessionTimeline = `-- name: SessionTimeline :many + +SELECT direction, + rpc_kind, + COALESCE(method, '') AS method, + COUNT(*)::bigint AS event_count, + MIN(created_at)::timestamptz AS first_at, + MAX(created_at)::timestamptz AS last_at +FROM acp_events +WHERE session_id = $1 +GROUP BY direction, rpc_kind, COALESCE(method, '') +ORDER BY first_at ASC +` + +type SessionTimelineRow struct { + Direction string `json:"direction"` + RpcKind string `json:"rpc_kind"` + Method string `json:"method"` + EventCount int64 `json:"event_count"` + FirstAt pgtype.Timestamptz `json:"first_at"` + LastAt pgtype.Timestamptz `json:"last_at"` +} + +// dashboard.sql: run-history dashboard 读模型(只读聚合)。 +// - SessionTimeline: 单会话的 RPC 事件按 (direction, rpc_kind, method) 分桶, +// 给出条数 + 首/末时间戳,用于会话时间线概览。 +// - SessionRollup: 在 owner/admin scope + 可选 project/requirement 过滤下, +// 统计会话总数、成功率(exited 视为成功)、总成本、平均时长。 +// - SessionsByDay: 按天的 总数 / 成功 / 失败 / 成本 时间序列。 +// +// scope 约定(与 ListSessionsByUser/ListAllSessions 一致): +// +// $1 user_id 为 NULL 时不按用户过滤(admin 全量),非 NULL 时仅该用户。 +// +// 单会话事件时间线:按 method/kind 分桶聚合(条数 + 首末时间)。 +func (q *Queries) SessionTimeline(ctx context.Context, sessionID pgtype.UUID) ([]SessionTimelineRow, error) { + rows, err := q.db.Query(ctx, sessionTimeline, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SessionTimelineRow + for rows.Next() { + var i SessionTimelineRow + if err := rows.Scan( + &i.Direction, + &i.RpcKind, + &i.Method, + &i.EventCount, + &i.FirstAt, + &i.LastAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const sessionsByDay = `-- name: SessionsByDay :many +SELECT + date_trunc('day', started_at)::timestamptz AS day, + COUNT(*)::bigint AS total, + COUNT(*) FILTER (WHERE status = 'exited')::bigint AS succeeded, + COUNT(*) FILTER (WHERE status = 'crashed')::bigint AS crashed, + COALESCE(SUM(cost_usd), 0)::numeric AS total_cost_usd +FROM acp_sessions +WHERE ($1::uuid IS NULL OR user_id = $1) + AND ($2::uuid IS NULL OR project_id = $2) + AND ($3::uuid IS NULL OR requirement_id = $3) + AND started_at >= $4 + AND started_at < $5 +GROUP BY day +ORDER BY day ASC +` + +type SessionsByDayParams struct { + Column1 pgtype.UUID `json:"column_1"` + Column2 pgtype.UUID `json:"column_2"` + Column3 pgtype.UUID `json:"column_3"` + StartedAt pgtype.Timestamptz `json:"started_at"` + StartedAt_2 pgtype.Timestamptz `json:"started_at_2"` +} + +type SessionsByDayRow struct { + Day pgtype.Timestamptz `json:"day"` + Total int64 `json:"total"` + Succeeded int64 `json:"succeeded"` + Crashed int64 `json:"crashed"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` +} + +// 按天的会话时间序列(总数 / 成功 / 失败 / 成本),同 SessionRollup 的 scope。 +func (q *Queries) SessionsByDay(ctx context.Context, arg SessionsByDayParams) ([]SessionsByDayRow, error) { + rows, err := q.db.Query(ctx, sessionsByDay, + arg.Column1, + arg.Column2, + arg.Column3, + arg.StartedAt, + arg.StartedAt_2, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SessionsByDayRow + for rows.Next() { + var i SessionsByDayRow + if err := rows.Scan( + &i.Day, + &i.Total, + &i.Succeeded, + &i.Crashed, + &i.TotalCostUsd, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/acp/sqlc/models.go b/internal/acp/sqlc/models.go index 66e4d1f..0fca477 100644 --- a/internal/acp/sqlc/models.go +++ b/internal/acp/sqlc/models.go @@ -8,6 +8,7 @@ import ( "net/netip" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type AcpAgentKind struct { @@ -25,6 +26,11 @@ type AcpAgentKind struct { ToolAllowlist []string `json:"tool_allowlist"` ClientType string `json:"client_type"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` } type AcpAgentKindConfigFile struct { @@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct { UpdatedBy pgtype.UUID `json:"updated_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type AcpEvent struct { @@ -64,23 +71,64 @@ type AcpPermissionRequest struct { } type AcpSession struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ProjectID pgtype.UUID `json:"project_id"` - AgentKindID pgtype.UUID `json:"agent_kind_id"` - UserID pgtype.UUID `json:"user_id"` - IssueID pgtype.UUID `json:"issue_id"` - RequirementID pgtype.UUID `json:"requirement_id"` - AgentSessionID *string `json:"agent_session_id"` - Branch string `json:"branch"` - CwdPath string `json:"cwd_path"` - IsMainWorktree bool `json:"is_main_worktree"` - Status string `json:"status"` - Pid *int32 `json:"pid"` - ExitCode *int32 `json:"exit_code"` - LastError *string `json:"last_error"` - StartedAt pgtype.Timestamptz `json:"started_at"` - EndedAt pgtype.Timestamptz `json:"ended_at"` + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` } type AuditLog struct { @@ -94,6 +142,81 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Conversation struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` @@ -110,6 +233,14 @@ type Conversation struct { RequirementID pgtype.UUID `json:"requirement_id"` } +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type GitCredential struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -119,6 +250,7 @@ type GitCredential struct { Fingerprint *string `json:"fingerprint"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type Issue struct { @@ -135,6 +267,17 @@ type Issue struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Job struct { @@ -162,6 +305,7 @@ type LlmEndpoint struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type LlmModel struct { @@ -252,6 +396,50 @@ type Notification struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + type Project struct { ID pgtype.UUID `json:"id"` Slug string `json:"slug"` @@ -264,6 +452,30 @@ type Project struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type PromptTemplate struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` @@ -299,6 +511,16 @@ type RequirementArtifact struct { SourceMessageID *int64 `json:"source_message_id"` CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type User struct { @@ -354,6 +576,7 @@ type WorkspaceRunProfile struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type WorkspaceWorktree struct { diff --git a/internal/acp/sqlc/session_usage.sql.go b/internal/acp/sqlc/session_usage.sql.go new file mode 100644 index 0000000..31da78b --- /dev/null +++ b/internal/acp/sqlc/session_usage.sql.go @@ -0,0 +1,54 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: session_usage.sql + +package acpsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const insertSessionUsage = `-- name: InsertSessionUsage :one +INSERT INTO acp_session_usage ( + session_id, user_id, project_id, agent_kind_id, model_id, + prompt_tokens, completion_tokens, thinking_tokens, cost_usd, source_event_id +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +ON CONFLICT (source_event_id) WHERE source_event_id IS NOT NULL DO NOTHING +RETURNING id +` + +type InsertSessionUsageParams struct { + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` +} + +// 写一条 per-turn 用量账目。source_event_id 非空时按唯一索引去重(同一事件 +// 重复 Observe 不会重复入账);返回受影响行数判定是否实际插入。 +func (q *Queries) InsertSessionUsage(ctx context.Context, arg InsertSessionUsageParams) (int64, error) { + row := q.db.QueryRow(ctx, insertSessionUsage, + arg.SessionID, + arg.UserID, + arg.ProjectID, + arg.AgentKindID, + arg.ModelID, + arg.PromptTokens, + arg.CompletionTokens, + arg.ThinkingTokens, + arg.CostUsd, + arg.SourceEventID, + ) + var id int64 + err := row.Scan(&id) + return id, err +} diff --git a/internal/acp/sqlc/sessions.sql.go b/internal/acp/sqlc/sessions.sql.go index 3ca1427..c6482f6 100644 --- a/internal/acp/sqlc/sessions.sql.go +++ b/internal/acp/sqlc/sessions.sql.go @@ -29,6 +29,56 @@ func (q *Queries) AcquireMainWorktree(ctx context.Context, arg AcquireMainWorktr return result.RowsAffected(), nil } +const addSessionUsageTotals = `-- name: AddSessionUsageTotals :one +UPDATE acp_sessions +SET prompt_tokens = prompt_tokens + $2, + completion_tokens = completion_tokens + $3, + thinking_tokens = thinking_tokens + $4, + total_cost_usd = total_cost_usd + $5, + cost_usd = cost_usd + $5, + tokens_total = tokens_total + $2 + $3 + $4, + last_activity_at = now() +WHERE id = $1 +RETURNING prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd +` + +type AddSessionUsageTotalsParams struct { + ID pgtype.UUID `json:"id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` +} + +type AddSessionUsageTotalsRow struct { + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` +} + +// 单 round-trip 累加 session 运行时 token/cost 总量并刷新 last_activity_at, +// 返回累加后的权威总量供预算判定。 +// 同时把 dashboard rollup 列(cost_usd / tokens_total)与运行时累加器对齐, +// 使 run-history dashboard 在会话进行中及退出后都能读到 agent 上报的成本。 +func (q *Queries) AddSessionUsageTotals(ctx context.Context, arg AddSessionUsageTotalsParams) (AddSessionUsageTotalsRow, error) { + row := q.db.QueryRow(ctx, addSessionUsageTotals, + arg.ID, + arg.PromptTokens, + arg.CompletionTokens, + arg.ThinkingTokens, + arg.TotalCostUsd, + ) + var i AddSessionUsageTotalsRow + err := row.Scan( + &i.PromptTokens, + &i.CompletionTokens, + &i.ThinkingTokens, + &i.TotalCostUsd, + ) + return i, err +} + const countActiveSessions = `-- name: CountActiveSessions :one SELECT COUNT(*) FROM acp_sessions WHERE status IN ('starting', 'running') ` @@ -52,11 +102,73 @@ func (q *Queries) CountActiveSessionsByUser(ctx context.Context, userID pgtype.U return count, err } +const getCrashedSessionForResume = `-- name: GetCrashedSessionForResume :one +SELECT id, workspace_id, project_id, agent_kind_id, user_id, + issue_id, requirement_id, agent_session_id, + branch, cwd_path, is_main_worktree, status, + pid, exit_code, last_error, started_at, ended_at, last_stop_reason, + orchestrator_step_id, + prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd, + last_activity_at, budget_max_cost_usd, budget_max_tokens, + budget_max_wall_clock_seconds, terminated_reason, sandbox_mode, + cost_usd, tokens_total +FROM acp_sessions +WHERE orchestrator_step_id = $1 + AND status IN ('crashed','exited') +ORDER BY started_at DESC +LIMIT 1 +` + +// 返回某 step 最近一次处于可恢复终态(crashed/exited)的 session。 +func (q *Queries) GetCrashedSessionForResume(ctx context.Context, orchestratorStepID pgtype.UUID) (AcpSession, error) { + row := q.db.QueryRow(ctx, getCrashedSessionForResume, orchestratorStepID) + var i AcpSession + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProjectID, + &i.AgentKindID, + &i.UserID, + &i.IssueID, + &i.RequirementID, + &i.AgentSessionID, + &i.Branch, + &i.CwdPath, + &i.IsMainWorktree, + &i.Status, + &i.Pid, + &i.ExitCode, + &i.LastError, + &i.StartedAt, + &i.EndedAt, + &i.LastStopReason, + &i.OrchestratorStepID, + &i.PromptTokens, + &i.CompletionTokens, + &i.ThinkingTokens, + &i.TotalCostUsd, + &i.LastActivityAt, + &i.BudgetMaxCostUsd, + &i.BudgetMaxTokens, + &i.BudgetMaxWallClockSeconds, + &i.TerminatedReason, + &i.SandboxMode, + &i.CostUsd, + &i.TokensTotal, + ) + return i, err +} + const getSessionByID = `-- name: GetSessionByID :one SELECT id, workspace_id, project_id, agent_kind_id, user_id, issue_id, requirement_id, agent_session_id, branch, cwd_path, is_main_worktree, status, - pid, exit_code, last_error, started_at, ended_at + pid, exit_code, last_error, started_at, ended_at, last_stop_reason, + orchestrator_step_id, + prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd, + last_activity_at, budget_max_cost_usd, budget_max_tokens, + budget_max_wall_clock_seconds, terminated_reason, sandbox_mode, + cost_usd, tokens_total FROM acp_sessions WHERE id = $1 ` @@ -82,6 +194,75 @@ func (q *Queries) GetSessionByID(ctx context.Context, id pgtype.UUID) (AcpSessio &i.LastError, &i.StartedAt, &i.EndedAt, + &i.LastStopReason, + &i.OrchestratorStepID, + &i.PromptTokens, + &i.CompletionTokens, + &i.ThinkingTokens, + &i.TotalCostUsd, + &i.LastActivityAt, + &i.BudgetMaxCostUsd, + &i.BudgetMaxTokens, + &i.BudgetMaxWallClockSeconds, + &i.TerminatedReason, + &i.SandboxMode, + &i.CostUsd, + &i.TokensTotal, + ) + return i, err +} + +const getSessionByStepID = `-- name: GetSessionByStepID :one +SELECT id, workspace_id, project_id, agent_kind_id, user_id, + issue_id, requirement_id, agent_session_id, + branch, cwd_path, is_main_worktree, status, + pid, exit_code, last_error, started_at, ended_at, last_stop_reason, + orchestrator_step_id, + prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd, + last_activity_at, budget_max_cost_usd, budget_max_tokens, + budget_max_wall_clock_seconds, terminated_reason, sandbox_mode, + cost_usd, tokens_total +FROM acp_sessions +WHERE orchestrator_step_id = $1 +ORDER BY started_at DESC +LIMIT 1 +` + +func (q *Queries) GetSessionByStepID(ctx context.Context, orchestratorStepID pgtype.UUID) (AcpSession, error) { + row := q.db.QueryRow(ctx, getSessionByStepID, orchestratorStepID) + var i AcpSession + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.ProjectID, + &i.AgentKindID, + &i.UserID, + &i.IssueID, + &i.RequirementID, + &i.AgentSessionID, + &i.Branch, + &i.CwdPath, + &i.IsMainWorktree, + &i.Status, + &i.Pid, + &i.ExitCode, + &i.LastError, + &i.StartedAt, + &i.EndedAt, + &i.LastStopReason, + &i.OrchestratorStepID, + &i.PromptTokens, + &i.CompletionTokens, + &i.ThinkingTokens, + &i.TotalCostUsd, + &i.LastActivityAt, + &i.BudgetMaxCostUsd, + &i.BudgetMaxTokens, + &i.BudgetMaxWallClockSeconds, + &i.TerminatedReason, + &i.SandboxMode, + &i.CostUsd, + &i.TokensTotal, ) return i, err } @@ -89,26 +270,37 @@ func (q *Queries) GetSessionByID(ctx context.Context, id pgtype.UUID) (AcpSessio const insertSession = `-- name: InsertSession :one INSERT INTO acp_sessions ( id, workspace_id, project_id, agent_kind_id, user_id, - issue_id, requirement_id, branch, cwd_path, is_main_worktree, status -) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) + issue_id, requirement_id, branch, cwd_path, is_main_worktree, status, + orchestrator_step_id, + budget_max_cost_usd, budget_max_tokens, budget_max_wall_clock_seconds +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15) RETURNING id, workspace_id, project_id, agent_kind_id, user_id, issue_id, requirement_id, agent_session_id, branch, cwd_path, is_main_worktree, status, - pid, exit_code, last_error, started_at, ended_at + pid, exit_code, last_error, started_at, ended_at, last_stop_reason, + orchestrator_step_id, + prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd, + last_activity_at, budget_max_cost_usd, budget_max_tokens, + budget_max_wall_clock_seconds, terminated_reason, sandbox_mode, + cost_usd, tokens_total ` type InsertSessionParams struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ProjectID pgtype.UUID `json:"project_id"` - AgentKindID pgtype.UUID `json:"agent_kind_id"` - UserID pgtype.UUID `json:"user_id"` - IssueID pgtype.UUID `json:"issue_id"` - RequirementID pgtype.UUID `json:"requirement_id"` - Branch string `json:"branch"` - CwdPath string `json:"cwd_path"` - IsMainWorktree bool `json:"is_main_worktree"` - Status string `json:"status"` + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` } func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (AcpSession, error) { @@ -124,6 +316,10 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (A arg.CwdPath, arg.IsMainWorktree, arg.Status, + arg.OrchestratorStepID, + arg.BudgetMaxCostUsd, + arg.BudgetMaxTokens, + arg.BudgetMaxWallClockSeconds, ) var i AcpSession err := row.Scan( @@ -144,6 +340,20 @@ func (q *Queries) InsertSession(ctx context.Context, arg InsertSessionParams) (A &i.LastError, &i.StartedAt, &i.EndedAt, + &i.LastStopReason, + &i.OrchestratorStepID, + &i.PromptTokens, + &i.CompletionTokens, + &i.ThinkingTokens, + &i.TotalCostUsd, + &i.LastActivityAt, + &i.BudgetMaxCostUsd, + &i.BudgetMaxTokens, + &i.BudgetMaxWallClockSeconds, + &i.TerminatedReason, + &i.SandboxMode, + &i.CostUsd, + &i.TokensTotal, ) return i, err } @@ -152,7 +362,12 @@ const listAllSessions = `-- name: ListAllSessions :many SELECT id, workspace_id, project_id, agent_kind_id, user_id, issue_id, requirement_id, agent_session_id, branch, cwd_path, is_main_worktree, status, - pid, exit_code, last_error, started_at, ended_at + pid, exit_code, last_error, started_at, ended_at, last_stop_reason, + orchestrator_step_id, + prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd, + last_activity_at, budget_max_cost_usd, budget_max_tokens, + budget_max_wall_clock_seconds, terminated_reason, sandbox_mode, + cost_usd, tokens_total FROM acp_sessions WHERE ($1::uuid IS NULL OR requirement_id = $1) ORDER BY started_at DESC @@ -185,6 +400,68 @@ func (q *Queries) ListAllSessions(ctx context.Context, dollar_1 pgtype.UUID) ([] &i.LastError, &i.StartedAt, &i.EndedAt, + &i.LastStopReason, + &i.OrchestratorStepID, + &i.PromptTokens, + &i.CompletionTokens, + &i.ThinkingTokens, + &i.TotalCostUsd, + &i.LastActivityAt, + &i.BudgetMaxCostUsd, + &i.BudgetMaxTokens, + &i.BudgetMaxWallClockSeconds, + &i.TerminatedReason, + &i.SandboxMode, + &i.CostUsd, + &i.TokensTotal, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSessionUsageBySession = `-- name: ListSessionUsageBySession :many +SELECT id, session_id, user_id, project_id, agent_kind_id, model_id, + prompt_tokens, completion_tokens, thinking_tokens, cost_usd, + source_event_id, created_at +FROM acp_session_usage +WHERE session_id = $1 +ORDER BY created_at DESC +LIMIT $2 +` + +type ListSessionUsageBySessionParams struct { + SessionID pgtype.UUID `json:"session_id"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListSessionUsageBySession(ctx context.Context, arg ListSessionUsageBySessionParams) ([]AcpSessionUsage, error) { + rows, err := q.db.Query(ctx, listSessionUsageBySession, arg.SessionID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AcpSessionUsage + for rows.Next() { + var i AcpSessionUsage + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.UserID, + &i.ProjectID, + &i.AgentKindID, + &i.ModelID, + &i.PromptTokens, + &i.CompletionTokens, + &i.ThinkingTokens, + &i.CostUsd, + &i.SourceEventID, + &i.CreatedAt, ); err != nil { return nil, err } @@ -200,7 +477,12 @@ const listSessionsByUser = `-- name: ListSessionsByUser :many SELECT id, workspace_id, project_id, agent_kind_id, user_id, issue_id, requirement_id, agent_session_id, branch, cwd_path, is_main_worktree, status, - pid, exit_code, last_error, started_at, ended_at + pid, exit_code, last_error, started_at, ended_at, last_stop_reason, + orchestrator_step_id, + prompt_tokens, completion_tokens, thinking_tokens, total_cost_usd, + last_activity_at, budget_max_cost_usd, budget_max_tokens, + budget_max_wall_clock_seconds, terminated_reason, sandbox_mode, + cost_usd, tokens_total FROM acp_sessions WHERE user_id = $1 AND ($2::uuid IS NULL OR requirement_id = $2) @@ -239,6 +521,76 @@ func (q *Queries) ListSessionsByUser(ctx context.Context, arg ListSessionsByUser &i.LastError, &i.StartedAt, &i.EndedAt, + &i.LastStopReason, + &i.OrchestratorStepID, + &i.PromptTokens, + &i.CompletionTokens, + &i.ThinkingTokens, + &i.TotalCostUsd, + &i.LastActivityAt, + &i.BudgetMaxCostUsd, + &i.BudgetMaxTokens, + &i.BudgetMaxWallClockSeconds, + &i.TerminatedReason, + &i.SandboxMode, + &i.CostUsd, + &i.TokensTotal, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSessionsForReaper = `-- name: ListSessionsForReaper :many +SELECT id, user_id, started_at, last_activity_at, budget_max_wall_clock_seconds +FROM acp_sessions +WHERE status IN ('starting','running') + AND ( + (budget_max_wall_clock_seconds IS NOT NULL + AND started_at < now() - make_interval(secs => budget_max_wall_clock_seconds)) + OR ($1::timestamptz IS NOT NULL AND started_at < $1::timestamptz) + OR last_activity_at < $2::timestamptz + ) +` + +type ListSessionsForReaperParams struct { + Column1 pgtype.Timestamptz `json:"column_1"` + Column2 pgtype.Timestamptz `json:"column_2"` +} + +type ListSessionsForReaperRow struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + StartedAt pgtype.Timestamptz `json:"started_at"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` +} + +// 返回应被回收的活跃 session: +// +// 超过自身 wall-clock 上限(budget_max_wall_clock_seconds 非空且 started_at 过早),或 +// 超过全局 wall-clock 上限($1::timestamptz = now - 全局上限),或 +// 空闲超时(last_activity_at < $2::timestamptz)。 +func (q *Queries) ListSessionsForReaper(ctx context.Context, arg ListSessionsForReaperParams) ([]ListSessionsForReaperRow, error) { + rows, err := q.db.Query(ctx, listSessionsForReaper, arg.Column1, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListSessionsForReaperRow + for rows.Next() { + var i ListSessionsForReaperRow + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.StartedAt, + &i.LastActivityAt, + &i.BudgetMaxWallClockSeconds, ); err != nil { return nil, err } @@ -269,6 +621,23 @@ func (q *Queries) MarkSessionFailedIfActive(ctx context.Context, arg MarkSession return result.RowsAffected(), nil } +const markSessionTerminatedReason = `-- name: MarkSessionTerminatedReason :exec +UPDATE acp_sessions +SET terminated_reason = $2 +WHERE id = $1 AND terminated_reason IS NULL +` + +type MarkSessionTerminatedReasonParams struct { + ID pgtype.UUID `json:"id"` + TerminatedReason *string `json:"terminated_reason"` +} + +// 写入终止原因;已有非空 terminated_reason 时不覆盖(reaper / budget 互斥保护)。 +func (q *Queries) MarkSessionTerminatedReason(ctx context.Context, arg MarkSessionTerminatedReasonParams) error { + _, err := q.db.Exec(ctx, markSessionTerminatedReason, arg.ID, arg.TerminatedReason) + return err +} + const releaseMainWorktree = `-- name: ReleaseMainWorktree :execrows UPDATE workspaces SET active_main_session_id = NULL WHERE id = $1 AND active_main_session_id = $2 @@ -287,6 +656,26 @@ func (q *Queries) ReleaseMainWorktree(ctx context.Context, arg ReleaseMainWorktr return result.RowsAffected(), nil } +const resetSessionForResume = `-- name: ResetSessionForResume :exec +UPDATE acp_sessions +SET status = 'starting', + agent_session_id = NULL, + pid = NULL, + exit_code = NULL, + last_error = NULL, + ended_at = NULL, + terminated_reason = NULL, + last_activity_at = now() +WHERE id = $1 +` + +// 把崩溃/退出的 session 复用为新一轮:回到 starting、清空上次运行时字段。 +// 编排器 resume 在重新 Spawn 前调用,使同一行 session 在同 cwd/worktree 上复用。 +func (q *Queries) ResetSessionForResume(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, resetSessionForResume, id) + return err +} + const resetStuckSessionsOnRestart = `-- name: ResetStuckSessionsOnRestart :many UPDATE acp_sessions SET status = 'crashed', @@ -332,6 +721,178 @@ func (q *Queries) ResetStuckSessionsOnRestart(ctx context.Context) ([]ResetStuck return items, nil } +const sumProjectCostUSD = `-- name: SumProjectCostUSD :one +SELECT COALESCE(SUM(cost_usd), 0)::numeric AS cost_usd +FROM acp_session_usage +WHERE project_id = $1 AND created_at >= $2 +` + +type SumProjectCostUSDParams struct { + ProjectID pgtype.UUID `json:"project_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +// 某 project 自 since 起的 ACP 累计花费(per-project 软预算)。 +func (q *Queries) SumProjectCostUSD(ctx context.Context, arg SumProjectCostUSDParams) (pgtype.Numeric, error) { + row := q.db.QueryRow(ctx, sumProjectCostUSD, arg.ProjectID, arg.CreatedAt) + var cost_usd pgtype.Numeric + err := row.Scan(&cost_usd) + return cost_usd, err +} + +const summarizeAcpUsageByDay = `-- name: SummarizeAcpUsageByDay :many +SELECT date_trunc('day', created_at)::timestamptz AS day, + SUM(prompt_tokens)::bigint AS prompt_tokens, + SUM(completion_tokens)::bigint AS completion_tokens, + SUM(thinking_tokens)::bigint AS thinking_tokens, + SUM(cost_usd)::numeric AS cost_usd +FROM acp_session_usage +WHERE created_at >= $1 AND created_at < $2 +GROUP BY day +ORDER BY day +` + +type SummarizeAcpUsageByDayParams struct { + CreatedAt pgtype.Timestamptz `json:"created_at"` + CreatedAt_2 pgtype.Timestamptz `json:"created_at_2"` +} + +type SummarizeAcpUsageByDayRow struct { + Day pgtype.Timestamptz `json:"day"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` +} + +func (q *Queries) SummarizeAcpUsageByDay(ctx context.Context, arg SummarizeAcpUsageByDayParams) ([]SummarizeAcpUsageByDayRow, error) { + rows, err := q.db.Query(ctx, summarizeAcpUsageByDay, arg.CreatedAt, arg.CreatedAt_2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SummarizeAcpUsageByDayRow + for rows.Next() { + var i SummarizeAcpUsageByDayRow + if err := rows.Scan( + &i.Day, + &i.PromptTokens, + &i.CompletionTokens, + &i.ThinkingTokens, + &i.CostUsd, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const summarizeAcpUsageByModel = `-- name: SummarizeAcpUsageByModel :many +SELECT model_id, + SUM(prompt_tokens)::bigint AS prompt_tokens, + SUM(completion_tokens)::bigint AS completion_tokens, + SUM(thinking_tokens)::bigint AS thinking_tokens, + SUM(cost_usd)::numeric AS cost_usd +FROM acp_session_usage +WHERE created_at >= $1 AND created_at < $2 +GROUP BY model_id +ORDER BY cost_usd DESC +` + +type SummarizeAcpUsageByModelParams struct { + CreatedAt pgtype.Timestamptz `json:"created_at"` + CreatedAt_2 pgtype.Timestamptz `json:"created_at_2"` +} + +type SummarizeAcpUsageByModelRow struct { + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` +} + +func (q *Queries) SummarizeAcpUsageByModel(ctx context.Context, arg SummarizeAcpUsageByModelParams) ([]SummarizeAcpUsageByModelRow, error) { + rows, err := q.db.Query(ctx, summarizeAcpUsageByModel, arg.CreatedAt, arg.CreatedAt_2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SummarizeAcpUsageByModelRow + for rows.Next() { + var i SummarizeAcpUsageByModelRow + if err := rows.Scan( + &i.ModelID, + &i.PromptTokens, + &i.CompletionTokens, + &i.ThinkingTokens, + &i.CostUsd, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const summarizeAcpUsageByUser = `-- name: SummarizeAcpUsageByUser :many +SELECT user_id, + SUM(prompt_tokens)::bigint AS prompt_tokens, + SUM(completion_tokens)::bigint AS completion_tokens, + SUM(thinking_tokens)::bigint AS thinking_tokens, + SUM(cost_usd)::numeric AS cost_usd +FROM acp_session_usage +WHERE created_at >= $1 AND created_at < $2 +GROUP BY user_id +ORDER BY cost_usd DESC +` + +type SummarizeAcpUsageByUserParams struct { + CreatedAt pgtype.Timestamptz `json:"created_at"` + CreatedAt_2 pgtype.Timestamptz `json:"created_at_2"` +} + +type SummarizeAcpUsageByUserRow struct { + UserID pgtype.UUID `json:"user_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` +} + +func (q *Queries) SummarizeAcpUsageByUser(ctx context.Context, arg SummarizeAcpUsageByUserParams) ([]SummarizeAcpUsageByUserRow, error) { + rows, err := q.db.Query(ctx, summarizeAcpUsageByUser, arg.CreatedAt, arg.CreatedAt_2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SummarizeAcpUsageByUserRow + for rows.Next() { + var i SummarizeAcpUsageByUserRow + if err := rows.Scan( + &i.UserID, + &i.PromptTokens, + &i.CompletionTokens, + &i.ThinkingTokens, + &i.CostUsd, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const updateSessionFinished = `-- name: UpdateSessionFinished :exec UPDATE acp_sessions SET status = $2, exit_code = $3, last_error = $4, ended_at = now() @@ -355,6 +916,22 @@ func (q *Queries) UpdateSessionFinished(ctx context.Context, arg UpdateSessionFi return err } +const updateSessionLastStopReason = `-- name: UpdateSessionLastStopReason :exec +UPDATE acp_sessions +SET last_stop_reason = $2 +WHERE id = $1 +` + +type UpdateSessionLastStopReasonParams struct { + ID pgtype.UUID `json:"id"` + LastStopReason *string `json:"last_stop_reason"` +} + +func (q *Queries) UpdateSessionLastStopReason(ctx context.Context, arg UpdateSessionLastStopReasonParams) error { + _, err := q.db.Exec(ctx, updateSessionLastStopReason, arg.ID, arg.LastStopReason) + return err +} + const updateSessionRunning = `-- name: UpdateSessionRunning :exec UPDATE acp_sessions SET status = 'running', agent_session_id = $2, pid = $3 @@ -371,3 +948,20 @@ func (q *Queries) UpdateSessionRunning(ctx context.Context, arg UpdateSessionRun _, err := q.db.Exec(ctx, updateSessionRunning, arg.ID, arg.AgentSessionID, arg.Pid) return err } + +const updateSessionSandboxMode = `-- name: UpdateSessionSandboxMode :exec +UPDATE acp_sessions +SET sandbox_mode = $2 +WHERE id = $1 +` + +type UpdateSessionSandboxModeParams struct { + ID pgtype.UUID `json:"id"` + SandboxMode *string `json:"sandbox_mode"` +} + +// 记录本 session 运行所用的沙箱模式(审计/取证)。 +func (q *Queries) UpdateSessionSandboxMode(ctx context.Context, arg UpdateSessionSandboxModeParams) error { + _, err := q.db.Exec(ctx, updateSessionSandboxMode, arg.ID, arg.SandboxMode) + return err +} diff --git a/internal/acp/sqlc/turns.sql.go b/internal/acp/sqlc/turns.sql.go new file mode 100644 index 0000000..1451de5 --- /dev/null +++ b/internal/acp/sqlc/turns.sql.go @@ -0,0 +1,180 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: turns.sql + +package acpsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getLatestTurnBySession = `-- name: GetLatestTurnBySession :one +SELECT id, session_id, turn_index, prompt_request_id, status, + stop_reason, update_count, started_at, completed_at +FROM acp_turns +WHERE session_id = $1 +ORDER BY turn_index DESC +LIMIT 1 +` + +func (q *Queries) GetLatestTurnBySession(ctx context.Context, sessionID pgtype.UUID) (AcpTurn, error) { + row := q.db.QueryRow(ctx, getLatestTurnBySession, sessionID) + var i AcpTurn + err := row.Scan( + &i.ID, + &i.SessionID, + &i.TurnIndex, + &i.PromptRequestID, + &i.Status, + &i.StopReason, + &i.UpdateCount, + &i.StartedAt, + &i.CompletedAt, + ) + return i, err +} + +const incrementTurnUpdateCount = `-- name: IncrementTurnUpdateCount :exec +UPDATE acp_turns +SET update_count = update_count + 1 +WHERE session_id = $1 AND turn_index = $2 AND status = 'in_progress' +` + +type IncrementTurnUpdateCountParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` +} + +func (q *Queries) IncrementTurnUpdateCount(ctx context.Context, arg IncrementTurnUpdateCountParams) error { + _, err := q.db.Exec(ctx, incrementTurnUpdateCount, arg.SessionID, arg.TurnIndex) + return err +} + +const insertTurn = `-- name: InsertTurn :one +INSERT INTO acp_turns ( + session_id, turn_index, prompt_request_id, status +) VALUES ($1, $2, $3, $4) +RETURNING id, session_id, turn_index, prompt_request_id, status, + stop_reason, update_count, started_at, completed_at +` + +type InsertTurnParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` +} + +func (q *Queries) InsertTurn(ctx context.Context, arg InsertTurnParams) (AcpTurn, error) { + row := q.db.QueryRow(ctx, insertTurn, + arg.SessionID, + arg.TurnIndex, + arg.PromptRequestID, + arg.Status, + ) + var i AcpTurn + err := row.Scan( + &i.ID, + &i.SessionID, + &i.TurnIndex, + &i.PromptRequestID, + &i.Status, + &i.StopReason, + &i.UpdateCount, + &i.StartedAt, + &i.CompletedAt, + ) + return i, err +} + +const listTurnsBySession = `-- name: ListTurnsBySession :many +SELECT id, session_id, turn_index, prompt_request_id, status, + stop_reason, update_count, started_at, completed_at +FROM acp_turns +WHERE session_id = $1 +ORDER BY turn_index ASC +` + +func (q *Queries) ListTurnsBySession(ctx context.Context, sessionID pgtype.UUID) ([]AcpTurn, error) { + rows, err := q.db.Query(ctx, listTurnsBySession, sessionID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AcpTurn + for rows.Next() { + var i AcpTurn + if err := rows.Scan( + &i.ID, + &i.SessionID, + &i.TurnIndex, + &i.PromptRequestID, + &i.Status, + &i.StopReason, + &i.UpdateCount, + &i.StartedAt, + &i.CompletedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const markTurnAborted = `-- name: MarkTurnAborted :exec +UPDATE acp_turns +SET status = 'aborted', completed_at = now() +WHERE session_id = $1 AND turn_index = $2 AND status = 'in_progress' +` + +type MarkTurnAbortedParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` +} + +func (q *Queries) MarkTurnAborted(ctx context.Context, arg MarkTurnAbortedParams) error { + _, err := q.db.Exec(ctx, markTurnAborted, arg.SessionID, arg.TurnIndex) + return err +} + +const markTurnCompleted = `-- name: MarkTurnCompleted :exec +UPDATE acp_turns +SET status = 'completed', stop_reason = $3, update_count = $4, completed_at = now() +WHERE session_id = $1 AND turn_index = $2 AND status = 'in_progress' +` + +type MarkTurnCompletedParams struct { + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` +} + +func (q *Queries) MarkTurnCompleted(ctx context.Context, arg MarkTurnCompletedParams) error { + _, err := q.db.Exec(ctx, markTurnCompleted, + arg.SessionID, + arg.TurnIndex, + arg.StopReason, + arg.UpdateCount, + ) + return err +} + +const purgeTurnsBefore = `-- name: PurgeTurnsBefore :execrows +DELETE FROM acp_turns WHERE started_at < $1 +` + +func (q *Queries) PurgeTurnsBefore(ctx context.Context, startedAt pgtype.Timestamptz) (int64, error) { + result, err := q.db.Exec(ctx, purgeTurnsBefore, startedAt) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} diff --git a/internal/acp/supervisor.go b/internal/acp/supervisor.go index 65bf907..8c889a8 100644 --- a/internal/acp/supervisor.go +++ b/internal/acp/supervisor.go @@ -17,6 +17,7 @@ import ( "log/slog" "os" "os/exec" + "path/filepath" "sync" "sync/atomic" "time" @@ -24,6 +25,7 @@ import ( "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" + "github.com/yan1h/agent-coding-workflow/internal/acp/sandbox" "github.com/yan1h/agent-coding-workflow/internal/audit" "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" @@ -44,6 +46,25 @@ type SupervisorConfig struct { // AgentHomesDir 是 agent CLI 受管 home 的根目录(/agent-homes)。 // 空串时跳过配置物化与重定向 env 注入(部分测试)。 AgentHomesDir string + // DefaultProjectBudgetUSD 是 per-project ACP 累计花费的全局软上限(0 = 不限)。 + // 当 session 与 agent-kind 均未设置成本上限时,用作 fallback 的预算判定基线。 + DefaultProjectBudgetUSD float64 + + // ===== 沙箱(per-session 隔离)===== + // Sandbox 是 pluggable 沙箱实现(none|uid|container),在 cmd/env 构建后、 + // proc.Group.Prepare 之前 Apply。为 nil 时等同 mode=none(无隔离)。 + Sandbox sandbox.Sandbox + // SandboxMode 记录当前 mode,用于落库 acp_sessions.sandbox_mode(审计/取证)。 + SandboxMode sandbox.Mode + // SandboxBaseUID 是 per-session 低权 UID 的基址:实际 uid = BaseUID + offset, + // offset 由 session 派生(保证不同活跃 session 不复用同一 uid)。0 = 不分配。 + SandboxBaseUID int + // DataRoot 是要被屏蔽的 /data 根(除本 session worktree + per-user home 外)。 + DataRoot string + // EgressProxyURL 是注入子进程 env 的 HTTP(S)_PROXY(egress 白名单代理)。 + EgressProxyURL string + // SandboxRlimits 是套用到子进程(及其进程树)的 OS 资源上限。 + SandboxRlimits sandbox.Limits } // Supervisor 是 ACP 子进程总管。 @@ -51,22 +72,87 @@ type Supervisor struct { mu sync.RWMutex procs map[uuid.UUID]*Process - repo Repository - audit audit.Recorder - notify *notify.Dispatcher - wtSvc workspace.WorktreeService - wsRepo workspace.Repository - mcpTokens mcp.TokenService // spec §6.3 — onExit revokes system token - crypto *crypto.Encryptor - permSvc *PermissionService // 工具调用权限审批;可为 nil(部分测试) - cfg SupervisorConfig - log *slog.Logger + repo Repository + audit audit.Recorder + notify *notify.Dispatcher + wtSvc workspace.WorktreeService + wsRepo workspace.Repository + mcpTokens mcp.TokenService // spec §6.3 — onExit revokes system token + crypto *crypto.Encryptor + permSvc *PermissionService // 工具调用权限审批;可为 nil(部分测试) + turnBus TurnBus // 回合完成事件总线;可为 nil(部分测试) + orchRedrive OrchestratorRedrive // 崩溃的编排器 session 再驱动回调;可为 nil + prices ModelPriceLookup // model 价格查询;可为 nil(无 model 价格时成本=0/agent 自报) + agentsMD AgentsMDRenderer // AGENTS.md 渲染回调(project memory);可为 nil + metrics SessionExitRecorder // 会话退出计数;可为 nil(metrics 关闭) + cfg SupervisorConfig + log *slog.Logger } +// SessionExitRecorder 是 Supervisor 上报会话退出指标所需的窄接口。 +// metrics.Metrics 满足它;nil 时不记录。 +type SessionExitRecorder interface { + RecordSessionExit(status string) +} + +// SetMetrics 注入会话退出计数器(装配期)。 +func (s *Supervisor) SetMetrics(m SessionExitRecorder) { s.metrics = m } + +// AgentsMDRenderer renders the AGENTS.md content for a (project, workspace) from +// project memory. Injected via SetAgentsMDRenderer to avoid an acp → +// projectmemory import cycle. May be nil (no seeding). +type AgentsMDRenderer func(ctx context.Context, projectID uuid.UUID, wsID *uuid.UUID) (string, error) + +// SetAgentsMDRenderer 注入 AGENTS.md 渲染回调(装配期)。 +func (s *Supervisor) SetAgentsMDRenderer(fn AgentsMDRenderer) { s.agentsMD = fn } + +// agentsMDFileName is the generated file written into the worktree root. +const agentsMDFileName = "AGENTS.md" + +// seedAgentsMD renders project memory into /AGENTS.md before spawn. Skipped +// when no renderer is configured, CwdPath is empty, or ProjectID is nil. Failures +// are logged, never fatal — the agent still spawns without seeded knowledge. +func (s *Supervisor) seedAgentsMD(ctx context.Context, sess *Session) { + if s.agentsMD == nil || sess.CwdPath == "" || sess.ProjectID == uuid.Nil { + return + } + var wsID *uuid.UUID + if sess.WorkspaceID != uuid.Nil { + id := sess.WorkspaceID + wsID = &id + } + content, err := s.agentsMD(ctx, sess.ProjectID, wsID) + if err != nil { + s.log.Warn("acp.agents_md.render_failed", "session_id", sess.ID, "err", err.Error()) + return + } + dst := filepath.Join(sess.CwdPath, agentsMDFileName) + if err := os.WriteFile(dst, []byte(content), 0o644); err != nil { + s.log.Warn("acp.agents_md.write_failed", "session_id", sess.ID, "path", dst, "err", err.Error()) + } +} + +// OrchestratorRedrive 在一个由编排器 step 驱动的 session 崩溃退出时被调用,由编排器 +// 据此把对应 step 重新入队(带 backoff),让回合在同一 worktree 上恢复。注入为 func +// 以避免 acp → orchestrator 的 import 循环(与 notify dispatcher / permission service +// 的注入方式一致)。实现必须是非阻塞、不持 supervisor 锁的纯 DB 写(jobs Enqueue)。 +type OrchestratorRedrive func(ctx context.Context, stepID uuid.UUID, reason string) + +// SetOrchestratorRedrive 回填编排器再驱动回调(装配期)。 +func (s *Supervisor) SetOrchestratorRedrive(fn OrchestratorRedrive) { s.orchRedrive = fn } + // SetPermissionService 注入权限审批服务。与 Supervisor 互相依赖,故装配时回填 // (permSvc 需要 Supervisor 作为 RelayLocator,Supervisor 需要 permSvc 注入 handler)。 func (s *Supervisor) SetPermissionService(p *PermissionService) { s.permSvc = p } +// SetTurnBus 注入回合事件总线。Spawn 时每个 relay 构造一个 TurnTracker 复用它。 +// 可为 nil——此时回合仍落库(若 repo 可用),但不发布内部事件。 +func (s *Supervisor) SetTurnBus(b TurnBus) { s.turnBus = b } + +// SetModelPriceLookup 注入 model 价格查询(成本核算用)。装配期回填,避免 acp → +// chat 的构造期依赖。可为 nil——此时无 model 价格的会话成本为 0 或取 agent 自报。 +func (s *Supervisor) SetModelPriceLookup(p ModelPriceLookup) { s.prices = p } + // NewSupervisor 构造空 Supervisor。Start 不需要 — supervisor 是被动的(spawn 时 // 起 goroutine,无主循环)。Stop 在 ShutdownAll 中实现。 func NewSupervisor(repo Repository, rec audit.Recorder, disp *notify.Dispatcher, @@ -114,6 +200,10 @@ type Process struct { // 返回后立即取消),故 relay 的 reader/writer/persist/permission Decide 在整个 // 会话生命周期内都使用未取消的 ctx。onExit 调用 relayCancel 完成 relay teardown。 relayCancel context.CancelFunc + + // sandboxCleanup 拆除沙箱临时资源(卸载 bind mount、停止容器、删临时目录)。 + // 由 monitorProcess 在 group.Close() 之后调用,仅调用一次。可为 nil(mode=none)。 + sandboxCleanup func() } // stderrRing 是固定行数 ring buffer,monitor 退出时取 tail 写 last_error。 @@ -163,14 +253,20 @@ func (s *Supervisor) GetRelay(sid uuid.UUID) *Relay { // handshake 失败时调用 Kill 回滚并返回错误。调用方(SessionService)负责前置的 // worktree 占用与 acp_sessions 行 INSERT;本方法仅负责 OS 层资源与协议层握手。 func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, extraEnv map[string]string) (*Process, error) { + // AGENTS.md seeding:在 spawn 前把 project memory 渲染成 worktree 根的 + // AGENTS.md,让 agent 启动即有结构化项目知识。best-effort,失败仅告警不阻断。 + s.seedAgentsMD(ctx, sess) + // 受管 home:物化 DB 中的配置文件并注入重定向 env(优先级最低,可被 // AgentKind env / extraEnv 覆盖)。AgentHomesDir 未配置时跳过(部分测试)。 envMap := map[string]string{} + var agentHome string if s.cfg.AgentHomesDir != "" { - home, err := s.materializeAgentHome(ctx, kind) + home, err := s.materializeAgentHome(ctx, sess, kind) if err != nil { return nil, fmt.Errorf("materialize agent home: %w", err) } + agentHome = home envMap = agentHomeEnv(home, kind.ClientType) } @@ -197,6 +293,28 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, cmd.Dir = sess.CwdPath cmd.Env = osEnv + // 沙箱:在 cmd/env 构建完成后、proc.Group.Prepare(Setpgid)之前 Apply。 + // Apply 只 ADD 到 cmd.SysProcAttr(Credential / namespace),绝不替换, + // 故不破坏后续 group.Prepare 的 Setpgid 与负 pgid 整树终止。返回的 cleanup + // 在 monitorProcess 的 group.Close() 之后调用一次。mode=none 时为 no-op。 + sandboxCleanup := func() {} + if s.cfg.Sandbox != nil { + spec := s.buildSandboxSpec(sess, agentHome) + cleanup, serr := s.cfg.Sandbox.Apply(cmd, spec) + if serr != nil { + return nil, fmt.Errorf("sandbox apply: %w", serr) + } + if cleanup != nil { + sandboxCleanup = cleanup + } + // 记录本 session 运行所用的 sandbox mode(审计/取证)。best-effort。 + if s.cfg.SandboxMode != "" { + if err := s.repo.UpdateSessionSandboxMode(ctx, sess.ID, string(s.cfg.SandboxMode)); err != nil { + s.log.Warn("acp.sandbox.record_mode", "session_id", sess.ID, "err", err.Error()) + } + } + } + // 进程树管理:Prepare 须在 Start 前(Unix Setpgid),Adopt 须在 Start 后 // (Windows Job Object)。终止时整树清理,避免 agent 派生的子孙进程残留。 group := procgrp.NewGroup() @@ -243,6 +361,50 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, s.log, ) + // 回合追踪器:被动解析 session/update + session/prompt 响应。复用同一 acpRepo + // 与共享 TurnBus,无需额外连接池。onExit 通过 relay.Tracker().Abort 闭合悬挂回合。 + sessCtx := handlers.SessionContext{ + SessionID: sess.ID, + WorkspaceID: sess.WorkspaceID, + UserID: sess.UserID, + CwdPath: sess.CwdPath, + AgentSessionID: sess.AgentSessionID, + ToolAllowlist: kind.ToolAllowlist, + } + relay.SetTracker(NewTurnTracker(s.repo, s.turnBus, sessCtx, s.log)) + + // 成本核算:构造 per-session 累加器 + usage sink,seeded with 会话快照的有效 + // 预算上限与 agent-kind 的 model 价格来源。预算突破时以 detached goroutine 标记 + // terminated_reason 后 Kill(绝不阻塞 relay.reader,且 Kill 幂等)。 + caps := BudgetCaps{ + MaxCostUSD: sess.BudgetMaxCostUSD, + MaxTokens: sess.BudgetMaxTokens, + MaxWallClockSeconds: sess.BudgetMaxWallClockSeconds, + } + acc := newUsageAccumulator(AccumulatorParams{ + Repo: s.repo, + Prices: s.prices, + Log: s.log, + SessionID: sess.ID, + UserID: sess.UserID, + ProjectID: sess.ProjectID, + AgentKindID: sess.AgentKindID, + ModelID: kind.ModelID, + StartedAt: time.Now(), + Caps: caps, + ProjectBudgetUSD: s.cfg.DefaultProjectBudgetUSD, + }) + sid := sess.ID + killForBudget := func(reason BreachReason) { + bgCtx := context.Background() + if err := s.repo.MarkSessionTerminatedReason(bgCtx, sid, string(reason)); err != nil { + s.log.Error("acp.budget.mark_terminated", "session_id", sid, "err", err.Error()) + } + s.log.Warn("acp.budget.breach_kill", "session_id", sid, "reason", string(reason)) + s.Kill(bgCtx, sid, s.cfg.KillGrace) + } + relay.SetUsageSink(newAccumulatorSink(acc, killForBudget, s.log)) + // relay 的会话级 context:独立于调用方的 spawn ctx(生产调用方在 Spawn 返回后 // 立即 cancel spawn ctx)。relay 的 reader/writer/persist/permission Decide 必须 // 在整个会话生命周期内使用未取消的 ctx,否则 prompt 被丢、事件停止持久化、权限 @@ -250,20 +412,21 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, relayCtx, relayCancel := context.WithCancel(context.Background()) proc := &Process{ - SessionID: sess.ID, - WorkspaceID: sess.WorkspaceID, - UserID: sess.UserID, - IsMain: sess.IsMainWorktree, - Cmd: cmd, - group: group, - Stdin: stdin, - Stdout: stdout, - Stderr: stderr, - Relay: relay, - StderrBuf: newStderrRing(s.cfg.StderrBufferLines), - StartedAt: time.Now(), - done: make(chan struct{}), - relayCancel: relayCancel, + SessionID: sess.ID, + WorkspaceID: sess.WorkspaceID, + UserID: sess.UserID, + IsMain: sess.IsMainWorktree, + Cmd: cmd, + group: group, + Stdin: stdin, + Stdout: stdout, + Stderr: stderr, + Relay: relay, + StderrBuf: newStderrRing(s.cfg.StderrBufferLines), + StartedAt: time.Now(), + done: make(chan struct{}), + relayCancel: relayCancel, + sandboxCleanup: sandboxCleanup, } s.mu.Lock() @@ -272,14 +435,7 @@ func (s *Supervisor) Spawn(ctx context.Context, sess *Session, kind *AgentKind, go s.drainStderr(proc) go s.monitorProcess(proc) - go relay.Run(relayCtx, handlers.SessionContext{ - SessionID: sess.ID, - WorkspaceID: sess.WorkspaceID, - UserID: sess.UserID, - CwdPath: sess.CwdPath, - AgentSessionID: sess.AgentSessionID, - ToolAllowlist: kind.ToolAllowlist, - }) + go relay.Run(relayCtx, sessCtx) // 握手仍走调用方的 spawn ctx,保留 SpawnTimeout 截止语义。 if err := s.handshake(ctx, sess, kind, relay, proc, extraEnv); err != nil { @@ -354,6 +510,30 @@ func (s *Supervisor) BuildSpawnEnv(kind *AgentKind, extraEnv map[string]string) return out, nil } +// buildSandboxSpec 从 session + 受管 home 派生本次 spawn 的沙箱 Spec。 +// per-session UID = BaseUID + offset,offset 由 session ID 派生(低 16 位), +// 保证不同 session 大概率落在不同 uid(碰撞仅影响隔离粒度,不影响正确性)。 +// HomeDir = 受管 home(per-user),WorktreeDir = session cwd,二者为唯一可写路径。 +func (s *Supervisor) buildSandboxSpec(sess *Session, agentHome string) sandbox.Spec { + uid := 0 + if s.cfg.SandboxBaseUID > 0 { + // 取 session ID 的低 16 位作为 offset,限制 uid 漂移范围。 + b := sess.ID + offset := int(b[14])<<8 | int(b[15]) + uid = s.cfg.SandboxBaseUID + offset + } + return sandbox.Spec{ + Mode: s.cfg.SandboxMode, + UID: uid, + GID: uid, + HomeDir: agentHome, + WorktreeDir: sess.CwdPath, + DataRoot: s.cfg.DataRoot, + Rlimits: s.cfg.SandboxRlimits, + ProxyURL: s.cfg.EgressProxyURL, + } +} + // Kill 终止指定 session 的子进程。在 Unix 平台上先发 SIGTERM 等待 grace 时间, // 仍未退出再 Process.Kill;Windows 没有可靠 SIGTERM 实现,直接 Process.Kill。 // 始终阻塞直到 monitorProcess 关闭 done channel。Kill 是幂等的——目标 session @@ -440,6 +620,12 @@ func (s *Supervisor) monitorProcess(proc *Process) { // 进程已 Wait 返回,此处 Close 不影响 onExit-must-not-relock 不变量。 proc.group.Close() + // 沙箱清理:在 group.Close() 之后调用一次(卸载 bind mount、停容器、删临时目录)。 + // 不持 supervisor.mu,遵守 onExit-must-not-relock 不变量。mode=none 时为 no-op。 + if proc.sandboxCleanup != nil { + proc.sandboxCleanup() + } + status := SessionExited if !proc.KilledByUs.Load() { status = SessionCrashed @@ -451,6 +637,9 @@ func (s *Supervisor) monitorProcess(proc *Process) { // 不能再 acquire s.mu(monitorProcess 已 delete map 后调用本函数,但 Kill // 也持锁等 done,会形成 monitor → Kill → onExit 的链式 deadlock)。 func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionStatus, exitCode int32, waitErr error) { + if s.metrics != nil { + s.metrics.RecordSessionExit(string(status)) + } stderrTail := proc.StderrBuf.Tail(s.cfg.StderrTailBytes) var lastErr *string if status == SessionCrashed && stderrTail != "" { @@ -460,6 +649,14 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt s.log.Error("acp.on_exit.update_session", "session_id", proc.SessionID, "err", err.Error()) } + // 成本/预算终止:若 terminated_reason 已被预算门或 reaper 写入,发 audit + notify。 + // 读 DB(UpdateSessionFinished 之后)拿最新 terminated_reason;不覆盖既有值。 + if finished, gerr := s.repo.GetSessionByID(ctx, proc.SessionID); gerr == nil && finished != nil && + finished.TerminatedReason != nil && *finished.TerminatedReason != "" { + s.recordBudgetTerminated(ctx, proc.UserID, proc.SessionID, *finished.TerminatedReason, + finished.TotalCostUSD, finished.PromptTokens+finished.CompletionTokens+finished.ThinkingTokens) + } + // Release worktree。主 worktree 走 acp 表自己的 CAS;子 worktree 按 holder // 释放(holder = "session:",见 workspace.Caller.Holder),与启动 reaper // 一致,不需要知道 worktree ID。 @@ -530,6 +727,17 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt } } + // 回合检测:闭合任何开放的 in_progress 回合并发布 session_idle,确保崩溃/被杀时 + // 编排层也能收到空闲信号。Abort 只触碰 repo+bus(不持 supervisor.mu),且必须在 + // Relay.Close 之前——Close 后 tracker 仍可用,但语义上回合应在 teardown 前闭合。 + if tracker := proc.Relay.Tracker(); tracker != nil { + reason := string(StopCancelled) + if status == SessionCrashed { + reason = "crashed" + } + tracker.Abort(ctx, reason) + } + // 取消 relay 的会话级 context:停止 reader/writer,并让仍阻塞在 Decide / 已派生 // 的 handleAgentRequest goroutine 收到 ctx.Done 后退出。在 Relay.Close 之前调, // 二者共同完成 teardown(relayCancel 停 ctx 派生的 goroutine,Close 关 r.done + @@ -540,4 +748,46 @@ func (s *Supervisor) onExit(ctx context.Context, proc *Process, status SessionSt // 最后关 relay:广播 session_terminated 给 WS subscribers。 proc.Relay.Close(string(status), &exitCode) + + // 编排器再驱动:若该 session 由编排器某 step 驱动且本次为崩溃退出,把对应 step + // 重新入队(带 backoff),让回合在同一 worktree 上恢复。只做 DB 读 + jobs Enqueue, + // 不持 supervisor 锁(保持 onExit-must-not-relock 不变量)。 + if status == SessionCrashed && s.orchRedrive != nil { + if sess, gerr := s.repo.GetSessionByID(ctx, proc.SessionID); gerr == nil && + sess != nil && sess.OrchestratorStepID != nil { + s.orchRedrive(ctx, *sess.OrchestratorStepID, "session_crashed") + } + } +} + +// recordBudgetTerminated 在会话因预算/reaper 被强制终止时发 audit + owner notify。 +func (s *Supervisor) recordBudgetTerminated(ctx context.Context, userID, sessionID uuid.UUID, reason string, totalCost float64, totalTokens int64) { + if s.audit != nil { + uid := userID + _ = s.audit.Record(ctx, audit.Entry{ + UserID: &uid, + Action: "acp.session.budget_exceeded", + TargetType: "acp_session", + TargetID: sessionID.String(), + Metadata: map[string]any{ + "reason": reason, + "total_cost_usd": totalCost, + "total_tokens": totalTokens, + }, + }) + } + if s.notify != nil { + _ = s.notify.Dispatch(ctx, notify.Message{ + UserID: userID, + Topic: "acp.session_budget_exceeded", + Severity: notify.SeverityWarning, + Title: "ACP session terminated (budget/reaper)", + Body: fmt.Sprintf("Session %s was terminated: %s.", sessionID, reason), + Metadata: map[string]any{ + "session_id": sessionID.String(), + "reason": reason, + "total_cost_usd": totalCost, + }, + }) + } } diff --git a/internal/acp/turn.go b/internal/acp/turn.go new file mode 100644 index 0000000..eedf30e --- /dev/null +++ b/internal/acp/turn.go @@ -0,0 +1,242 @@ +// turn.go 实现 TurnTracker:单 relay 维度的回合状态机,外加 stopReason 解析辅助。 +// +// 回合(turn)定义:一次 session/prompt 请求到其响应之间的窗口。relay reader 在解析到 +// - session/update 通知 → OnUpdate(累加 update_count,内存计数,完成时一次性写库) +// - 该 prompt 的字符串-id 响应 → OnPromptResponse(标完成 + 写 last_stop_reason + 发事件) +// +// MVP 假设:同一 session 同时只有一个开放回合。若在已有开放回合时收到新的 StartTurn, +// 自动中止前一个(auto-abort),避免悬挂。所有状态由 mu 保护;Abort 只触碰 repo+bus, +// 不持有 supervisor 锁,故可安全地从 onExit 调用(不违反 onExit-must-not-relock 不变量)。 +package acp + +import ( + "context" + "encoding/json" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" +) + +// TurnRepo 是 TurnTracker 需要的最小 repo 子集,由 acp.Repository 满足。 +type TurnRepo interface { + InsertTurn(ctx context.Context, t *Turn) (*Turn, error) + MarkTurnCompleted(ctx context.Context, sessionID uuid.UUID, turnIndex int, stop string, updateCount int) error + MarkTurnAborted(ctx context.Context, sessionID uuid.UUID, turnIndex int) error + GetLatestTurnBySession(ctx context.Context, sessionID uuid.UUID) (*Turn, error) + UpdateSessionLastStopReason(ctx context.Context, sessionID uuid.UUID, reason string) error +} + +// TurnTracker 是单 relay 的回合状态机。 +type TurnTracker struct { + repo TurnRepo + bus TurnBus + sess handlers.SessionContext + log *slog.Logger + + mu sync.Mutex + open bool // 是否有开放回合 + turnIndex int // 当前/最近回合的 index + promptID string // 当前开放回合的 prompt 请求 id(用于响应相关联) + updateCount int // 当前回合的 session/update 计数(内存累计,完成时落库) + nextIndex int // 下一个回合的 index(构造时由 GetLatestTurnBySession 播种) + seeded bool // nextIndex 是否已播种 +} + +// NewTurnTracker 构造一个 TurnTracker。bus / repo 为 nil 时方法均为安全 no-op +// (部分测试可不注入)。 +func NewTurnTracker(repo TurnRepo, bus TurnBus, sess handlers.SessionContext, log *slog.Logger) *TurnTracker { + if log == nil { + log = slog.Default() + } + return &TurnTracker{repo: repo, bus: bus, sess: sess, log: log} +} + +// seedLocked 懒播种 nextIndex:从 DB 取最近回合 index+1。失败时从 0 开始(best-effort)。 +// 调用方必须已持有 t.mu。 +func (t *TurnTracker) seedLocked(ctx context.Context) { + if t.seeded { + return + } + t.seeded = true + if t.repo == nil { + return + } + latest, err := t.repo.GetLatestTurnBySession(ctx, t.sess.SessionID) + if err != nil { + t.log.Warn("acp.turn.seed_index", "session_id", t.sess.SessionID, "err", err.Error()) + return + } + if latest != nil { + t.nextIndex = latest.TurnIndex + 1 + } +} + +// StartTurn 在发送 session/prompt 之前调用,登记一个 in_progress 回合并把 promptRequestID +// 与之关联。若已有开放回合,先 auto-abort 之(MVP 单回合假设)。返回新回合的 index。 +func (t *TurnTracker) StartTurn(ctx context.Context, promptRequestID string) (int, error) { + if t == nil { + return 0, nil + } + t.mu.Lock() + defer t.mu.Unlock() + t.seedLocked(ctx) + + // 已有开放回合 → auto-abort,避免悬挂。 + if t.open { + t.abortLocked(ctx, string(StopCancelled)) + } + + idx := t.nextIndex + if t.repo != nil { + var pid *string + if promptRequestID != "" { + p := promptRequestID + pid = &p + } + if _, err := t.repo.InsertTurn(ctx, &Turn{ + SessionID: t.sess.SessionID, + TurnIndex: idx, + PromptRequestID: pid, + Status: TurnInProgress, + }); err != nil { + return 0, err + } + } + t.open = true + t.turnIndex = idx + t.promptID = promptRequestID + t.updateCount = 0 + t.nextIndex = idx + 1 + return idx, nil +} + +// OnUpdate 在收到一条 session/update 通知时调用,累加当前开放回合的 update_count +// (内存计数,避免每 chunk 一次 UPDATE;完成时一次性落库)。无开放回合时 no-op。 +func (t *TurnTracker) OnUpdate(_ context.Context) { + if t == nil { + return + } + t.mu.Lock() + defer t.mu.Unlock() + if t.open { + t.updateCount++ + } +} + +// IsTrackedPrompt 报告 id 是否为当前开放回合关联的 prompt 请求 id。relay reader 用它 +// 判断一条字符串-id 响应是否应触发 OnPromptResponse(而非沿用既有 fanout 路径)。 +func (t *TurnTracker) IsTrackedPrompt(id string) bool { + if t == nil || id == "" { + return false + } + t.mu.Lock() + defer t.mu.Unlock() + return t.open && t.promptID == id +} + +// OnPromptResponse 在收到匹配当前回合的 session/prompt 响应时调用:标完成、写 +// session.last_stop_reason、发布 turn_completed 与 session_idle 事件。id 不匹配时 no-op。 +func (t *TurnTracker) OnPromptResponse(ctx context.Context, promptRequestID string, rawStopReason string) { + if t == nil { + return + } + t.mu.Lock() + if !t.open || t.promptID != promptRequestID { + t.mu.Unlock() + return + } + idx := t.turnIndex + updateCount := t.updateCount + t.open = false + t.promptID = "" + t.mu.Unlock() + + norm := NormalizeStopReason(rawStopReason) + completedAt := time.Now() + + if t.repo != nil { + if err := t.repo.MarkTurnCompleted(ctx, t.sess.SessionID, idx, rawStopReason, updateCount); err != nil { + t.log.Error("acp.turn.mark_completed", "session_id", t.sess.SessionID, "turn_index", idx, "err", err.Error()) + } + if err := t.repo.UpdateSessionLastStopReason(ctx, t.sess.SessionID, rawStopReason); err != nil { + t.log.Error("acp.turn.update_last_stop_reason", "session_id", t.sess.SessionID, "err", err.Error()) + } + } + + if t.bus != nil { + base := TurnEvent{ + SessionID: t.sess.SessionID, + UserID: t.sess.UserID, + WorkspaceID: t.sess.WorkspaceID, + TurnIndex: idx, + StopReason: norm, + RawStopReason: rawStopReason, + CompletedAt: completedAt, + } + completed := base + completed.Kind = TurnCompletedEvent + t.bus.Publish(ctx, completed) + idle := base + idle.Kind = SessionIdleEvent + t.bus.Publish(ctx, idle) + } +} + +// Abort 中止当前开放回合(如果有)并发布 session_idle 事件。从 supervisor.onExit +// 在子进程退出时调用,确保崩溃/被杀时也能闭合悬挂回合。无开放回合时 no-op。 +func (t *TurnTracker) Abort(ctx context.Context, reason string) { + if t == nil { + return + } + t.mu.Lock() + defer t.mu.Unlock() + if !t.open { + return + } + t.abortLocked(ctx, reason) +} + +// abortLocked 闭合当前开放回合并发布 session_idle。调用方必须已持有 t.mu。 +func (t *TurnTracker) abortLocked(ctx context.Context, reason string) { + idx := t.turnIndex + t.open = false + t.promptID = "" + + if t.repo != nil { + if err := t.repo.MarkTurnAborted(ctx, t.sess.SessionID, idx); err != nil { + t.log.Error("acp.turn.mark_aborted", "session_id", t.sess.SessionID, "turn_index", idx, "err", err.Error()) + } + } + if t.bus != nil { + norm := NormalizeStopReason(reason) + t.bus.Publish(ctx, TurnEvent{ + SessionID: t.sess.SessionID, + UserID: t.sess.UserID, + WorkspaceID: t.sess.WorkspaceID, + TurnIndex: idx, + Kind: SessionIdleEvent, + StopReason: norm, + RawStopReason: reason, + CompletedAt: time.Now(), + }) + } +} + +// ParseStopReason 从 session/prompt 响应的 Result JSON 中提取 stopReason 字段。 +// 字段缺失或解析失败时返回空串(调用方归一化为 StopOther)。 +func ParseStopReason(result json.RawMessage) string { + if len(result) == 0 { + return "" + } + var r struct { + StopReason string `json:"stopReason"` + } + if err := json.Unmarshal(result, &r); err != nil { + return "" + } + return r.StopReason +} diff --git a/internal/acp/turn_test.go b/internal/acp/turn_test.go new file mode 100644 index 0000000..3973529 --- /dev/null +++ b/internal/acp/turn_test.go @@ -0,0 +1,248 @@ +package acp_test + +import ( + "context" + "encoding/json" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp" + "github.com/yan1h/agent-coding-workflow/internal/acp/handlers" +) + +// ----- NormalizeStopReason ----- + +func TestNormalizeStopReason(t *testing.T) { + t.Parallel() + cases := []struct { + raw string + want acp.StopReason + }{ + {"end_turn", acp.StopEndTurn}, + {"max_tokens", acp.StopMaxTokens}, + {"refusal", acp.StopRefusal}, + {"cancelled", acp.StopCancelled}, + {"vendor_specific_thing", acp.StopOther}, + {"", acp.StopOther}, + } + for _, c := range cases { + assert.Equal(t, c.want, acp.NormalizeStopReason(c.raw), "raw=%q", c.raw) + } +} + +func TestParseStopReason(t *testing.T) { + t.Parallel() + assert.Equal(t, "end_turn", acp.ParseStopReason(json.RawMessage(`{"stopReason":"end_turn"}`))) + assert.Equal(t, "max_tokens", acp.ParseStopReason(json.RawMessage(`{"stopReason":"max_tokens","other":1}`))) + assert.Equal(t, "", acp.ParseStopReason(json.RawMessage(`{}`))) + assert.Equal(t, "", acp.ParseStopReason(nil)) + assert.Equal(t, "", acp.ParseStopReason(json.RawMessage(`not json`))) +} + +// ----- fakeTurnRepo: 记录 turn 调用 ----- + +type fakeTurnRepo struct { + mu sync.Mutex + inserted []*acp.Turn + completed []completedCall + aborted []int + lastStopUpdates []string +} + +type completedCall struct { + turnIndex int + stop string + updateCount int +} + +func (f *fakeTurnRepo) InsertTurn(_ context.Context, t *acp.Turn) (*acp.Turn, error) { + f.mu.Lock() + defer f.mu.Unlock() + cp := *t + cp.ID = int64(len(f.inserted) + 1) + cp.Status = acp.TurnInProgress + f.inserted = append(f.inserted, &cp) + out := cp + return &out, nil +} +func (f *fakeTurnRepo) MarkTurnCompleted(_ context.Context, _ uuid.UUID, turnIndex int, stop string, updateCount int) error { + f.mu.Lock() + defer f.mu.Unlock() + f.completed = append(f.completed, completedCall{turnIndex, stop, updateCount}) + return nil +} +func (f *fakeTurnRepo) MarkTurnAborted(_ context.Context, _ uuid.UUID, turnIndex int) error { + f.mu.Lock() + defer f.mu.Unlock() + f.aborted = append(f.aborted, turnIndex) + return nil +} +func (f *fakeTurnRepo) GetLatestTurnBySession(context.Context, uuid.UUID) (*acp.Turn, error) { + return nil, nil +} +func (f *fakeTurnRepo) UpdateSessionLastStopReason(_ context.Context, _ uuid.UUID, reason string) error { + f.mu.Lock() + defer f.mu.Unlock() + f.lastStopUpdates = append(f.lastStopUpdates, reason) + return nil +} + +// ----- fakeBus: 记录发布的事件,保留顺序 ----- + +type fakeBus struct { + mu sync.Mutex + events []acp.TurnEvent +} + +func (b *fakeBus) Subscribe(string, func(context.Context, acp.TurnEvent)) {} +func (b *fakeBus) Publish(_ context.Context, ev acp.TurnEvent) { + b.mu.Lock() + defer b.mu.Unlock() + b.events = append(b.events, ev) +} +func (b *fakeBus) snapshot() []acp.TurnEvent { + b.mu.Lock() + defer b.mu.Unlock() + out := make([]acp.TurnEvent, len(b.events)) + copy(out, b.events) + return out +} + +func newTestSessCtx() handlers.SessionContext { + return handlers.SessionContext{ + SessionID: uuid.New(), + WorkspaceID: uuid.New(), + UserID: uuid.New(), + } +} + +// ----- TurnTracker lifecycle ----- + +func TestTurnTracker_Lifecycle(t *testing.T) { + t.Parallel() + repo := &fakeTurnRepo{} + bus := &fakeBus{} + sess := newTestSessCtx() + tr := acp.NewTurnTracker(repo, bus, sess, nil) + + ctx := context.Background() + idx, err := tr.StartTurn(ctx, "client-init") + require.NoError(t, err) + assert.Equal(t, 0, idx) + require.Len(t, repo.inserted, 1) + assert.Equal(t, 0, repo.inserted[0].TurnIndex) + + // 3 个 update + const n = 3 + for i := 0; i < n; i++ { + tr.OnUpdate(ctx) + } + + // id 不匹配 → 不应完成 + tr.OnPromptResponse(ctx, "wrong-id", "end_turn") + assert.Empty(t, repo.completed, "mismatched id must not complete") + + // 匹配 → 完成 + tr.OnPromptResponse(ctx, "client-init", "end_turn") + require.Len(t, repo.completed, 1) + assert.Equal(t, 0, repo.completed[0].turnIndex) + assert.Equal(t, "end_turn", repo.completed[0].stop) + assert.Equal(t, n, repo.completed[0].updateCount, "update_count must equal observed updates") + + require.Len(t, repo.lastStopUpdates, 1) + assert.Equal(t, "end_turn", repo.lastStopUpdates[0]) + + // 事件顺序:turn_completed 然后 session_idle + evs := bus.snapshot() + require.Len(t, evs, 2) + assert.Equal(t, acp.TurnCompletedEvent, evs[0].Kind) + assert.Equal(t, acp.SessionIdleEvent, evs[1].Kind) + assert.Equal(t, acp.StopEndTurn, evs[0].StopReason) + assert.Equal(t, "end_turn", evs[0].RawStopReason) + assert.Equal(t, sess.SessionID, evs[0].SessionID) + assert.Equal(t, sess.UserID, evs[0].UserID) + + // 重复响应 → no-op(回合已闭合) + tr.OnPromptResponse(ctx, "client-init", "end_turn") + assert.Len(t, repo.completed, 1, "duplicate response must be ignored") +} + +func TestTurnTracker_UnknownStopReason(t *testing.T) { + t.Parallel() + repo := &fakeTurnRepo{} + bus := &fakeBus{} + tr := acp.NewTurnTracker(repo, bus, newTestSessCtx(), nil) + ctx := context.Background() + + _, err := tr.StartTurn(ctx, "p1") + require.NoError(t, err) + tr.OnPromptResponse(ctx, "p1", "weird_vendor_reason") + + require.Len(t, repo.completed, 1) + // 原始字符串原样落库 + assert.Equal(t, "weird_vendor_reason", repo.completed[0].stop) + assert.Equal(t, "weird_vendor_reason", repo.lastStopUpdates[0]) + // 归一化为 other + evs := bus.snapshot() + require.Len(t, evs, 2) + assert.Equal(t, acp.StopOther, evs[0].StopReason) + assert.Equal(t, "weird_vendor_reason", evs[0].RawStopReason) +} + +func TestTurnTracker_Abort(t *testing.T) { + t.Parallel() + repo := &fakeTurnRepo{} + bus := &fakeBus{} + tr := acp.NewTurnTracker(repo, bus, newTestSessCtx(), nil) + ctx := context.Background() + + _, err := tr.StartTurn(ctx, "p1") + require.NoError(t, err) + tr.Abort(ctx, "crashed") + + require.Len(t, repo.aborted, 1) + assert.Equal(t, 0, repo.aborted[0]) + evs := bus.snapshot() + require.Len(t, evs, 1) + assert.Equal(t, acp.SessionIdleEvent, evs[0].Kind) + + // 无开放回合再 Abort → no-op + tr.Abort(ctx, "crashed") + assert.Len(t, repo.aborted, 1, "abort with no open turn must be no-op") +} + +func TestTurnTracker_AutoAbortPriorTurn(t *testing.T) { + t.Parallel() + repo := &fakeTurnRepo{} + bus := &fakeBus{} + tr := acp.NewTurnTracker(repo, bus, newTestSessCtx(), nil) + ctx := context.Background() + + idx0, err := tr.StartTurn(ctx, "p1") + require.NoError(t, err) + assert.Equal(t, 0, idx0) + // 第二次 StartTurn 在前一个开放时 → auto-abort 前一个 + idx1, err := tr.StartTurn(ctx, "p2") + require.NoError(t, err) + assert.Equal(t, 1, idx1) + require.Len(t, repo.aborted, 1) + assert.Equal(t, 0, repo.aborted[0], "prior open turn must be auto-aborted") + require.Len(t, repo.inserted, 2) +} + +func TestTurnTracker_NilSafe(t *testing.T) { + t.Parallel() + var tr *acp.TurnTracker + ctx := context.Background() + // 所有方法在 nil receiver 上安全 no-op + _, err := tr.StartTurn(ctx, "x") + assert.NoError(t, err) + tr.OnUpdate(ctx) + tr.OnPromptResponse(ctx, "x", "end_turn") + tr.Abort(ctx, "x") + assert.False(t, tr.IsTrackedPrompt("x")) +} diff --git a/internal/acp/turnbus.go b/internal/acp/turnbus.go new file mode 100644 index 0000000..b3367df --- /dev/null +++ b/internal/acp/turnbus.go @@ -0,0 +1,131 @@ +// turnbus.go 实现 TurnBus:进程内的极简 observer / pub-sub,作为"回合完成 / +// 会话空闲"的内部事件原语。与 notify.Dispatcher(面向用户的外发通知)解耦—— +// TurnBus 是给未来的编排层(auto-continue / gate / hand-off)订阅用的内部信号, +// 不落库、不外发。 +// +// 关键不变量:Publish 必须永不阻塞 relay reader 热路径。订阅者以 fire-and-forget +// 方式在独立 goroutine 中调用,并用 recover() 隔离 panic——一个慢/有 bug 的 +// 订阅者不能拖垮协议 I/O。语义对齐 notify.Dispatcher 的非阻塞 fan-out。 +package acp + +import ( + "context" + "fmt" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" +) + +// TurnEventKind 标识 TurnEvent 的类别。 +type TurnEventKind string + +const ( + // TurnCompletedEvent 表示一次回合正常完成(收到 session/prompt 响应的 stopReason)。 + TurnCompletedEvent TurnEventKind = "turn_completed" + // SessionIdleEvent 表示会话当前空闲(回合完成或被中止后),编排层可据此决策。 + SessionIdleEvent TurnEventKind = "session_idle" +) + +// TurnEvent 是 TurnBus 上发布的事件载荷。RawStopReason 是 agent 上报的原始字符串 +// (可能为空,例如 abort 路径),StopReason 是其归一化枚举。 +type TurnEvent struct { + SessionID uuid.UUID + UserID uuid.UUID + WorkspaceID uuid.UUID + TurnIndex int + Kind TurnEventKind + StopReason StopReason + RawStopReason string + CompletedAt time.Time +} + +// TurnBus 是回合事件的进程内 pub-sub 抽象。 +type TurnBus interface { + // Subscribe 注册一个具名订阅者。name 仅用于日志/排障。重复 name 会追加, + // 不去重(与 notify 一致,调用方自行保证唯一)。 + Subscribe(name string, fn func(context.Context, TurnEvent)) + // Publish 把事件 fire-and-forget 派发给所有订阅者,永不阻塞调用方。 + Publish(ctx context.Context, ev TurnEvent) +} + +type deliver struct { + ctx context.Context + ev TurnEvent +} + +type subscriber struct { + name string + fn func(context.Context, TurnEvent) + ch chan deliver +} + +// subBuffer 是每个订阅者的投递缓冲容量。 +const subBuffer = 256 + +// defaultTurnBus 是 TurnBus 的默认实现:每个订阅者一个常驻 worker goroutine,从带缓冲 +// channel 顺序消费事件——因此对同一订阅者保证 per-subscriber FIFO(turn_completed 必先于 +// 同一次 Publish 序列里随后的 session_idle 到达)。panic 用 recover() 隔离。Publish 以非阻塞 +// 方式投递(缓冲满则丢弃并告警),永不阻塞 relay reader 热路径。 +type defaultTurnBus struct { + mu sync.RWMutex + subs []*subscriber + log *slog.Logger +} + +// NewTurnBus 构造默认 TurnBus。 +func NewTurnBus(log *slog.Logger) TurnBus { + if log == nil { + log = slog.Default() + } + return &defaultTurnBus{log: log} +} + +func (b *defaultTurnBus) Subscribe(name string, fn func(context.Context, TurnEvent)) { + if fn == nil { + return + } + s := &subscriber{name: name, fn: fn, ch: make(chan deliver, subBuffer)} + b.mu.Lock() + b.subs = append(b.subs, s) + b.mu.Unlock() + // 每个订阅者一个常驻 worker,顺序消费保证 FIFO。worker 与 app 同生命周期。 + go b.worker(s) +} + +func (b *defaultTurnBus) worker(s *subscriber) { + for d := range s.ch { + b.dispatch(s, d) + } +} + +func (b *defaultTurnBus) dispatch(s *subscriber, d deliver) { + defer func() { + if rec := recover(); rec != nil { + b.log.Error("acp.turnbus.subscriber_panic", + "subscriber", s.name, + "session_id", d.ev.SessionID, + "panic", fmt.Sprintf("%v", rec)) + } + }() + s.fn(d.ctx, d.ev) +} + +func (b *defaultTurnBus) Publish(ctx context.Context, ev TurnEvent) { + b.mu.RLock() + subs := b.subs + b.mu.RUnlock() + + // 解绑调用方 ctx 的取消(relay teardown 会 cancel ctx,但订阅者应能完成它的短暂处理)。 + d := deliver{ctx: context.WithoutCancel(ctx), ev: ev} + for _, s := range subs { + select { + case s.ch <- d: + default: + // 缓冲满:丢弃以保证 Publish 永不阻塞 relay reader(正常负载不应发生)。 + b.log.Warn("acp.turnbus.drop_full", + "subscriber", s.name, "session_id", ev.SessionID, "kind", string(ev.Kind)) + } + } +} diff --git a/internal/acp/turnbus_test.go b/internal/acp/turnbus_test.go new file mode 100644 index 0000000..94d00b7 --- /dev/null +++ b/internal/acp/turnbus_test.go @@ -0,0 +1,75 @@ +package acp_test + +import ( + "context" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/acp" +) + +func TestTurnBus_FanoutToMultipleSubscribers(t *testing.T) { + t.Parallel() + bus := acp.NewTurnBus(nil) + + var wg sync.WaitGroup + wg.Add(2) + var a, b atomic.Int32 + bus.Subscribe("a", func(_ context.Context, _ acp.TurnEvent) { a.Add(1); wg.Done() }) + bus.Subscribe("b", func(_ context.Context, _ acp.TurnEvent) { b.Add(1); wg.Done() }) + + bus.Publish(context.Background(), acp.TurnEvent{SessionID: uuid.New(), Kind: acp.TurnCompletedEvent}) + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("subscribers not invoked in time") + } + assert.Equal(t, int32(1), a.Load()) + assert.Equal(t, int32(1), b.Load()) +} + +func TestTurnBus_PanicIsolatedAndNonBlocking(t *testing.T) { + t.Parallel() + bus := acp.NewTurnBus(nil) + + var good atomic.Int32 + var wg sync.WaitGroup + wg.Add(1) + bus.Subscribe("panicky", func(_ context.Context, _ acp.TurnEvent) { panic("boom") }) + bus.Subscribe("good", func(_ context.Context, _ acp.TurnEvent) { good.Add(1); wg.Done() }) + + // Publish 必须立即返回(非阻塞),且 panicky 订阅者的 panic 被 recover 隔离, + // 不影响 good 订阅者执行。 + bus.Publish(context.Background(), acp.TurnEvent{Kind: acp.SessionIdleEvent}) + + done := make(chan struct{}) + go func() { wg.Wait(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("good subscriber not invoked despite panicky peer") + } + assert.Equal(t, int32(1), good.Load()) +} + +func TestTurnBus_PublishDoesNotBlockOnSlowSubscriber(t *testing.T) { + t.Parallel() + bus := acp.NewTurnBus(nil) + release := make(chan struct{}) + bus.Subscribe("slow", func(_ context.Context, _ acp.TurnEvent) { <-release }) + + start := time.Now() + bus.Publish(context.Background(), acp.TurnEvent{Kind: acp.SessionIdleEvent}) + // Publish 应远快于订阅者的阻塞时长。 + require.Less(t, time.Since(start), 500*time.Millisecond, "Publish must not block on slow subscriber") + close(release) +} diff --git a/internal/acp/usage.go b/internal/acp/usage.go new file mode 100644 index 0000000..c783dfe --- /dev/null +++ b/internal/acp/usage.go @@ -0,0 +1,396 @@ +// usage.go implements ACP session cost accounting: +// - ExtractUsage: a tolerant parser pulling per-turn billable token usage out +// of agent messages (session/prompt RESPONSE result.usage, snake/camel) and +// opportunistic cost out of usage_update notifications. +// - usageAccumulator: applies one parsed turn — prices it (ModelPriceLookup or +// agent self-reported cost fallback), persists ledger + running totals via +// the repo, and reports a budget breach when a cap is now exceeded. +// +// Billing source of truth (spec §7 risk #1): claude-agent-acp returns billable +// usage in the session/prompt response ({stopReason, usage:{input_tokens,...}}). +// The ACP usage_update notification carries a context-window GAUGE (used/size), +// NOT a per-turn delta — summing it would massively overcount, so it is used +// only for the optional cost{amount} fallback, never for token deltas. +package acp + +import ( + "context" + "encoding/json" + "log/slog" + "sync" + "time" + + "github.com/google/uuid" +) + +// ParsedUsage is one extracted turn. HasUsage=false means no billable usage was +// present (ordinary chunk / malformed payload) and the caller must skip it. +type ParsedUsage struct { + PromptTokens int + CompletionTokens int + ThinkingTokens int + // CostUSD is the agent-self-reported cost (usage_update.cost.amount), used as + // a pricing fallback when no model price is configured. nil = not reported. + CostUSD *float64 + HasUsage bool +} + +// ModelPrice is the per-million-token price triple for a model. +type ModelPrice struct { + PromptPerM float64 + CompletionPerM float64 + ThinkingPerM float64 + Found bool +} + +// ModelPriceLookup resolves a model_id to its prices. Implemented in app.go by +// an adapter over the chat endpoint/model service. +type ModelPriceLookup interface { + PriceForModel(ctx context.Context, modelID uuid.UUID) (ModelPrice, error) +} + +// UsageAccumulator applies parsed turns to one session: persists the ledger + +// running totals and reports budget breaches. +type UsageAccumulator interface { + // Observe applies one parsed turn. sourceEventID (>0) de-dups via the ledger + // unique index. Returns a non-empty breach reason when a cap is now exceeded. + Observe(ctx context.Context, p ParsedUsage, sourceEventID int64) (breach BreachReason, breached bool) +} + +// usageSink is fed raw messages from relay.reader after persistence; it extracts +// usage and forwards to the UsageAccumulator. Separated from the accumulator so +// the relay does not depend on pricing/repo details. +type usageSink interface { + Observe(ctx context.Context, msg *Message, eventID int64) +} + +// accumulatorSink adapts a UsageAccumulator to the relay's usageSink: it parses +// each agent->client message and, on a real billable turn, forwards to Observe. +// A breach invokes onBreach exactly once on a detached goroutine (so the relay +// reader never blocks on sup.Kill). +type accumulatorSink struct { + acc UsageAccumulator + onBreach func(reason BreachReason) + fired sync.Once + log *slog.Logger +} + +// newAccumulatorSink builds the relay usage sink. +func newAccumulatorSink(acc UsageAccumulator, onBreach func(reason BreachReason), log *slog.Logger) *accumulatorSink { + if log == nil { + log = slog.Default() + } + return &accumulatorSink{acc: acc, onBreach: onBreach, log: log} +} + +func (s *accumulatorSink) Observe(ctx context.Context, msg *Message, eventID int64) { + if s == nil || s.acc == nil { + return + } + p := ExtractUsage(msg) + if !p.HasUsage { + return + } + breach, ok := s.acc.Observe(ctx, p, eventID) + if !ok || s.onBreach == nil { + return + } + // Fire the kill exactly once, detached so the reader keeps draining. + s.fired.Do(func() { + reason := breach + go s.onBreach(reason) + }) +} + +// ===== ExtractUsage ===== + +// promptResponseUsage matches both snake_case (claude-agent-acp stable) and +// camelCase (unstable v2 Usage) usage objects on a session/prompt response. +type promptResponseResult struct { + StopReason string `json:"stopReason"` + Usage *struct { + // snake_case (stable) + InputTokens *int `json:"input_tokens"` + OutputTokens *int `json:"output_tokens"` + // camelCase (unstable v2) + InputTokensCamel *int `json:"inputTokens"` + OutputTokensCamel *int `json:"outputTokens"` + ThoughtTokens *int `json:"thoughtTokens"` + CachedReadTokens *int `json:"cachedReadTokens"` + CachedWriteTokens *int `json:"cachedWriteTokens"` + // thinking_tokens snake fallback + ThinkingTokens *int `json:"thinking_tokens"` + } `json:"usage"` +} + +// usageUpdateParams matches a session/update notification carrying usage_update. +type usageUpdateParams struct { + Update *struct { + SessionUpdate string `json:"sessionUpdate"` + Cost *struct { + Amount float64 `json:"amount"` + Currency string `json:"currency"` + } `json:"cost"` + } `json:"update"` +} + +// ExtractUsage is the tolerant entry point. It inspects an agent->client message +// for billable usage. Never panics on garbage — returns HasUsage=false instead. +func ExtractUsage(msg *Message) ParsedUsage { + if msg == nil { + return ParsedUsage{} + } + // 1) session/prompt RESPONSE result.usage — the reliable billable source. + if len(msg.Result) > 0 { + if p, ok := parsePromptResponseUsage(msg.Result); ok { + return p + } + } + // 2) usage_update notification — opportunistic cost only (used/size is a + // context-window gauge, NOT a billable token delta; do not sum it). + if msg.Method == "session/update" && len(msg.Params) > 0 { + if p, ok := parseUsageUpdateCost(msg.Params); ok { + return p + } + } + return ParsedUsage{} +} + +func parsePromptResponseUsage(result json.RawMessage) (ParsedUsage, bool) { + var r promptResponseResult + if err := json.Unmarshal(result, &r); err != nil || r.Usage == nil { + return ParsedUsage{}, false + } + u := r.Usage + prompt := firstNonNil(u.InputTokens, u.InputTokensCamel) + completion := firstNonNil(u.OutputTokens, u.OutputTokensCamel) + thinking := firstNonNil(u.ThoughtTokens, u.ThinkingTokens) + // cachedReadTokens/cachedWriteTokens are reported but not separately priced; + // fold cached writes into prompt-equivalent only if no input token present. + if prompt == 0 && completion == 0 && thinking == 0 { + // No billable token signal in the usage object. + return ParsedUsage{}, false + } + return ParsedUsage{ + PromptTokens: prompt, + CompletionTokens: completion, + ThinkingTokens: thinking, + HasUsage: true, + }, true +} + +func parseUsageUpdateCost(params json.RawMessage) (ParsedUsage, bool) { + var p usageUpdateParams + if err := json.Unmarshal(params, &p); err != nil || p.Update == nil { + return ParsedUsage{}, false + } + if p.Update.SessionUpdate != "usage_update" || p.Update.Cost == nil { + return ParsedUsage{}, false + } + amt := p.Update.Cost.Amount + if amt <= 0 { + return ParsedUsage{}, false + } + return ParsedUsage{CostUSD: &amt, HasUsage: true}, true +} + +func firstNonNil(a, b *int) int { + if a != nil { + return *a + } + if b != nil { + return *b + } + return 0 +} + +// ===== usageAccumulator (per-session) ===== + +// usageRepo is the narrow repo surface the accumulator needs. +type usageRepo interface { + InsertSessionUsage(ctx context.Context, r *SessionUsageRecord) (bool, error) + AddSessionUsageTotals(ctx context.Context, sid uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) + SumProjectCostUSD(ctx context.Context, projectID uuid.UUID, since time.Time) (float64, error) +} + +// pgUsageAccumulator is the concrete per-session accumulator. Thread-safe: the +// relay reader is single-goroutine today, but Observe is guarded so future +// concurrent callers (or tests) stay correct. +type pgUsageAccumulator struct { + repo usageRepo + prices ModelPriceLookup + log *slog.Logger + now func() time.Time + mu sync.Mutex + dedup map[int64]struct{} + + sessionID uuid.UUID + userID uuid.UUID + projectID uuid.UUID + agentKindID uuid.UUID + modelID *uuid.UUID + startedAt time.Time + caps BudgetCaps + // projectBudgetUSD (>0) enforces a cumulative per-project ACP spend soft cap + // over [projectSince, now]; 0 disables it. + projectBudgetUSD float64 + projectSince time.Time +} + +// AccumulatorParams seeds a per-session accumulator. +type AccumulatorParams struct { + Repo usageRepo + Prices ModelPriceLookup + Log *slog.Logger + SessionID uuid.UUID + UserID uuid.UUID + ProjectID uuid.UUID + AgentKindID uuid.UUID + ModelID *uuid.UUID + StartedAt time.Time + Caps BudgetCaps + // ProjectBudgetUSD, when >0, enforces a per-project soft cap on cumulative + // ACP spend since ProjectSince. + ProjectBudgetUSD float64 + ProjectSince time.Time + Now func() time.Time +} + +func newUsageAccumulator(p AccumulatorParams) *pgUsageAccumulator { + log := p.Log + if log == nil { + log = slog.Default() + } + now := p.Now + if now == nil { + now = time.Now + } + since := p.ProjectSince + if since.IsZero() { + since = now().Add(-30 * 24 * time.Hour) + } + return &pgUsageAccumulator{ + repo: p.Repo, + prices: p.Prices, + log: log, + now: now, + dedup: map[int64]struct{}{}, + sessionID: p.SessionID, + userID: p.UserID, + projectID: p.ProjectID, + agentKindID: p.AgentKindID, + modelID: p.ModelID, + startedAt: p.StartedAt, + caps: p.Caps, + projectBudgetUSD: p.ProjectBudgetUSD, + projectSince: since, + } +} + +func (a *pgUsageAccumulator) Observe(ctx context.Context, p ParsedUsage, sourceEventID int64) (BreachReason, bool) { + if !p.HasUsage { + return "", false + } + a.mu.Lock() + defer a.mu.Unlock() + + // In-memory de-dup guard (DB unique index is the durable de-dup). + if sourceEventID > 0 { + if _, seen := a.dedup[sourceEventID]; seen { + return "", false + } + } + + cost := a.price(ctx, p) + + // Persist ledger row first (idempotent per source_event_id), then accumulate + // session totals only when the ledger row was actually inserted (no re-add on + // a duplicate event). + var srcID *int64 + if sourceEventID > 0 { + v := sourceEventID + srcID = &v + } + inserted, err := a.repo.InsertSessionUsage(ctx, &SessionUsageRecord{ + SessionID: a.sessionID, + UserID: a.userID, + ProjectID: a.projectID, + AgentKindID: a.agentKindID, + ModelID: a.modelID, + PromptTokens: int64(p.PromptTokens), + CompletionTokens: int64(p.CompletionTokens), + ThinkingTokens: int64(p.ThinkingTokens), + CostUSD: cost, + SourceEventID: srcID, + }) + if err != nil { + a.log.Error("acp.usage.insert_ledger", "session_id", a.sessionID, "err", err.Error()) + return "", false + } + if !inserted { + // Duplicate event id — already accounted. + if sourceEventID > 0 { + a.dedup[sourceEventID] = struct{}{} + } + return "", false + } + if sourceEventID > 0 { + a.dedup[sourceEventID] = struct{}{} + } + + totPrompt, totCompletion, totThinking, totCost, err := a.repo.AddSessionUsageTotals( + ctx, a.sessionID, + int64(p.PromptTokens), int64(p.CompletionTokens), int64(p.ThinkingTokens), cost) + if err != nil { + a.log.Error("acp.usage.add_totals", "session_id", a.sessionID, "err", err.Error()) + return "", false + } + + return a.checkBreach(ctx, totPrompt, totCompletion, totThinking, totCost) +} + +// price computes the turn cost: prefer the configured model price; otherwise +// fall back to the agent-self-reported cost; otherwise 0 (unpriced). +func (a *pgUsageAccumulator) price(ctx context.Context, p ParsedUsage) float64 { + if a.modelID != nil && a.prices != nil { + if mp, err := a.prices.PriceForModel(ctx, *a.modelID); err == nil && mp.Found { + return (float64(p.PromptTokens)*mp.PromptPerM + + float64(p.CompletionTokens)*mp.CompletionPerM + + float64(p.ThinkingTokens)*mp.ThinkingPerM) / 1_000_000 + } + } + if p.CostUSD != nil { + return *p.CostUSD + } + return 0 +} + +func (a *pgUsageAccumulator) checkBreach(ctx context.Context, totPrompt, totCompletion, totThinking int64, totCost float64) (BreachReason, bool) { + // Wall-clock: enforced here too (the reaper is the periodic backstop). + if a.caps.MaxWallClockSeconds != nil { + elapsed := a.now().Sub(a.startedAt) + if elapsed >= time.Duration(*a.caps.MaxWallClockSeconds)*time.Second { + return BreachWallClock, true + } + } + if a.caps.MaxTokens != nil { + if totPrompt+totCompletion+totThinking >= *a.caps.MaxTokens { + return BreachTokens, true + } + } + if a.caps.MaxCostUSD != nil && totCost >= *a.caps.MaxCostUSD { + return BreachCost, true + } + // Per-project soft cap (advisory kill, slight overshoot tolerated under + // concurrency — spec §7 risk). Only queried when a project budget is set. + if a.projectBudgetUSD > 0 { + if projCost, err := a.repo.SumProjectCostUSD(ctx, a.projectID, a.projectSince); err == nil { + if projCost >= a.projectBudgetUSD { + return BreachCost, true + } + } else { + a.log.Warn("acp.usage.project_cost_check", "project_id", a.projectID, "err", err.Error()) + } + } + return "", false +} diff --git a/internal/acp/usage_repo.go b/internal/acp/usage_repo.go new file mode 100644 index 0000000..6a1dfbe --- /dev/null +++ b/internal/acp/usage_repo.go @@ -0,0 +1,317 @@ +package acp + +import ( + "context" + "errors" + "fmt" + "math/big" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + + acpsqlc "github.com/yan1h/agent-coding-workflow/internal/acp/sqlc" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +func isNoRows(err error) bool { return errors.Is(err, pgx.ErrNoRows) } + +// ===== NUMERIC <-> float64 转换 helper(复制 chat/repository.go 的实现)===== + +func numericToFloat64(n pgtype.Numeric) float64 { + if !n.Valid { + return 0 + } + f8, err := n.Float64Value() + if err != nil { + return 0 + } + return f8.Float64 +} + +func numericToFloat64Ptr(n pgtype.Numeric) *float64 { + if !n.Valid { + return nil + } + v := numericToFloat64(n) + return &v +} + +func float64ToNumeric(f float64) pgtype.Numeric { + var n pgtype.Numeric + if err := n.ScanScientific(fmt.Sprintf("%.10e", f)); err != nil { + bi := new(big.Int) + bi.SetInt64(int64(f)) + return pgtype.Numeric{Int: bi, Exp: 0, Valid: true} + } + return n +} + +func float64PtrToNumeric(f *float64) pgtype.Numeric { + if f == nil { + return pgtype.Numeric{} + } + return float64ToNumeric(*f) +} + +// ===== 成本核算 / 预算 / reaper 仓储方法 ===== + +func (r *pgRepo) InsertSessionUsage(ctx context.Context, rec *SessionUsageRecord) (bool, error) { + var srcID *int64 + if rec.SourceEventID != nil { + v := *rec.SourceEventID + srcID = &v + } + id, err := r.q.InsertSessionUsage(ctx, acpsqlc.InsertSessionUsageParams{ + SessionID: toPgUUID(rec.SessionID), + UserID: toPgUUID(rec.UserID), + ProjectID: toPgUUID(rec.ProjectID), + AgentKindID: toPgUUID(rec.AgentKindID), + ModelID: toPgUUIDPtr(rec.ModelID), + PromptTokens: rec.PromptTokens, + CompletionTokens: rec.CompletionTokens, + ThinkingTokens: rec.ThinkingTokens, + CostUsd: float64ToNumeric(rec.CostUSD), + SourceEventID: srcID, + }) + if err != nil { + // ON CONFLICT DO NOTHING + no row -> not inserted (idempotent de-dup). + if isNoRows(err) { + return false, nil + } + return false, errs.Wrap(err, errs.CodeInternal, "insert session usage") + } + rec.ID = id + return true, nil +} + +func (r *pgRepo) AddSessionUsageTotals(ctx context.Context, sid uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) { + row, err := r.q.AddSessionUsageTotals(ctx, acpsqlc.AddSessionUsageTotalsParams{ + ID: toPgUUID(sid), + PromptTokens: dp, + CompletionTokens: dc, + ThinkingTokens: dt, + TotalCostUsd: float64ToNumeric(dCost), + }) + if err != nil { + return 0, 0, 0, 0, errs.Wrap(err, errs.CodeInternal, "add session usage totals") + } + return row.PromptTokens, row.CompletionTokens, row.ThinkingTokens, numericToFloat64(row.TotalCostUsd), nil +} + +func (r *pgRepo) SumProjectCostUSD(ctx context.Context, projectID uuid.UUID, since time.Time) (float64, error) { + n, err := r.q.SumProjectCostUSD(ctx, acpsqlc.SumProjectCostUSDParams{ + ProjectID: toPgUUID(projectID), + CreatedAt: pgtype.Timestamptz{Time: since, Valid: true}, + }) + if err != nil { + return 0, errs.Wrap(err, errs.CodeInternal, "sum project cost") + } + return numericToFloat64(n), nil +} + +func (r *pgRepo) MarkSessionTerminatedReason(ctx context.Context, sid uuid.UUID, reason string) error { + if err := r.q.MarkSessionTerminatedReason(ctx, acpsqlc.MarkSessionTerminatedReasonParams{ + ID: toPgUUID(sid), + TerminatedReason: &reason, + }); err != nil { + return errs.Wrap(err, errs.CodeInternal, "mark session terminated reason") + } + return nil +} + +func (r *pgRepo) ListSessionsForReaper(ctx context.Context, wallClockStartedBefore, idleSince time.Time) ([]ReaperSession, error) { + var wallTS pgtype.Timestamptz + if !wallClockStartedBefore.IsZero() { + wallTS = pgtype.Timestamptz{Time: wallClockStartedBefore, Valid: true} + } + rows, err := r.q.ListSessionsForReaper(ctx, acpsqlc.ListSessionsForReaperParams{ + Column1: wallTS, + Column2: pgtype.Timestamptz{Time: idleSince, Valid: true}, + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list sessions for reaper") + } + out := make([]ReaperSession, 0, len(rows)) + for _, row := range rows { + out = append(out, ReaperSession{ + ID: fromPgUUID(row.ID), + UserID: fromPgUUID(row.UserID), + StartedAt: row.StartedAt.Time, + LastActivityAt: row.LastActivityAt.Time, + MaxWallClockSeconds: row.BudgetMaxWallClockSeconds, + }) + } + return out, nil +} + +func (r *pgRepo) ListSessionUsage(ctx context.Context, sessionID uuid.UUID, limit int32) ([]*SessionUsageRecord, error) { + if limit <= 0 { + limit = 50 + } + rows, err := r.q.ListSessionUsageBySession(ctx, acpsqlc.ListSessionUsageBySessionParams{ + SessionID: toPgUUID(sessionID), + Limit: limit, + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list session usage") + } + out := make([]*SessionUsageRecord, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToSessionUsage(row)) + } + return out, nil +} + +func rowToSessionUsage(row acpsqlc.AcpSessionUsage) *SessionUsageRecord { + var srcID *int64 + if row.SourceEventID != nil { + v := *row.SourceEventID + srcID = &v + } + return &SessionUsageRecord{ + ID: row.ID, + SessionID: fromPgUUID(row.SessionID), + UserID: fromPgUUID(row.UserID), + ProjectID: fromPgUUID(row.ProjectID), + AgentKindID: fromPgUUID(row.AgentKindID), + ModelID: fromPgUUIDPtr(row.ModelID), + PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, + ThinkingTokens: row.ThinkingTokens, + CostUSD: numericToFloat64(row.CostUsd), + SourceEventID: srcID, + CreatedAt: row.CreatedAt.Time, + } +} + +func (r *pgRepo) SummarizeAcpUsageByUser(ctx context.Context, from, to time.Time) ([]AcpUsageUserRow, error) { + rows, err := r.q.SummarizeAcpUsageByUser(ctx, acpsqlc.SummarizeAcpUsageByUserParams{ + CreatedAt: pgtype.Timestamptz{Time: from, Valid: true}, + CreatedAt_2: pgtype.Timestamptz{Time: to, Valid: true}, + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "summarize acp usage by user") + } + out := make([]AcpUsageUserRow, 0, len(rows)) + for _, row := range rows { + out = append(out, AcpUsageUserRow{ + UserID: fromPgUUID(row.UserID), + PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, + ThinkingTokens: row.ThinkingTokens, + CostUSD: numericToFloat64(row.CostUsd), + }) + } + return out, nil +} + +func (r *pgRepo) SummarizeAcpUsageByModel(ctx context.Context, from, to time.Time) ([]AcpUsageModelRow, error) { + rows, err := r.q.SummarizeAcpUsageByModel(ctx, acpsqlc.SummarizeAcpUsageByModelParams{ + CreatedAt: pgtype.Timestamptz{Time: from, Valid: true}, + CreatedAt_2: pgtype.Timestamptz{Time: to, Valid: true}, + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "summarize acp usage by model") + } + out := make([]AcpUsageModelRow, 0, len(rows)) + for _, row := range rows { + out = append(out, AcpUsageModelRow{ + ModelID: fromPgUUID(row.ModelID), + PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, + ThinkingTokens: row.ThinkingTokens, + CostUSD: numericToFloat64(row.CostUsd), + }) + } + return out, nil +} + +func (r *pgRepo) SummarizeAcpUsageByDay(ctx context.Context, from, to time.Time) ([]AcpUsageDayRow, error) { + rows, err := r.q.SummarizeAcpUsageByDay(ctx, acpsqlc.SummarizeAcpUsageByDayParams{ + CreatedAt: pgtype.Timestamptz{Time: from, Valid: true}, + CreatedAt_2: pgtype.Timestamptz{Time: to, Valid: true}, + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "summarize acp usage by day") + } + out := make([]AcpUsageDayRow, 0, len(rows)) + for _, row := range rows { + out = append(out, AcpUsageDayRow{ + Day: row.Day.Time, + PromptTokens: row.PromptTokens, + CompletionTokens: row.CompletionTokens, + ThinkingTokens: row.ThinkingTokens, + CostUSD: numericToFloat64(row.CostUsd), + }) + } + return out, nil +} + +// ===== run-history dashboard 聚合 ===== + +func (r *pgRepo) SessionRollup(ctx context.Context, f DashboardFilter) (SessionRollup, error) { + row, err := r.q.SessionRollup(ctx, acpsqlc.SessionRollupParams{ + Column1: toPgUUIDPtr(f.UserID), + Column2: toPgUUIDPtr(f.ProjectID), + Column3: toPgUUIDPtr(f.RequirementID), + StartedAt: pgtype.Timestamptz{Time: f.From, Valid: true}, + StartedAt_2: pgtype.Timestamptz{Time: f.To, Valid: true}, + }) + if err != nil { + return SessionRollup{}, errs.Wrap(err, errs.CodeInternal, "session rollup") + } + return SessionRollup{ + Total: row.Total, + Succeeded: row.Succeeded, + Crashed: row.Crashed, + Active: row.Active, + TotalCostUSD: numericToFloat64(row.TotalCostUsd), + TotalTokens: row.TotalTokens, + AvgDurationSeconds: row.AvgDurationSeconds, + }, nil +} + +func (r *pgRepo) SessionsByDay(ctx context.Context, f DashboardFilter) ([]SessionDayRow, error) { + rows, err := r.q.SessionsByDay(ctx, acpsqlc.SessionsByDayParams{ + Column1: toPgUUIDPtr(f.UserID), + Column2: toPgUUIDPtr(f.ProjectID), + Column3: toPgUUIDPtr(f.RequirementID), + StartedAt: pgtype.Timestamptz{Time: f.From, Valid: true}, + StartedAt_2: pgtype.Timestamptz{Time: f.To, Valid: true}, + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "sessions by day") + } + out := make([]SessionDayRow, 0, len(rows)) + for _, row := range rows { + out = append(out, SessionDayRow{ + Day: row.Day.Time, + Total: row.Total, + Succeeded: row.Succeeded, + Crashed: row.Crashed, + TotalCostUSD: numericToFloat64(row.TotalCostUsd), + }) + } + return out, nil +} + +func (r *pgRepo) SessionTimeline(ctx context.Context, sessionID uuid.UUID) ([]SessionTimelineBucket, error) { + rows, err := r.q.SessionTimeline(ctx, toPgUUID(sessionID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "session timeline") + } + out := make([]SessionTimelineBucket, 0, len(rows)) + for _, row := range rows { + out = append(out, SessionTimelineBucket{ + Direction: row.Direction, + RPCKind: row.RpcKind, + Method: row.Method, + EventCount: row.EventCount, + FirstAt: row.FirstAt.Time, + LastAt: row.LastAt.Time, + }) + } + return out, nil +} diff --git a/internal/acp/usage_test.go b/internal/acp/usage_test.go new file mode 100644 index 0000000..224a94f --- /dev/null +++ b/internal/acp/usage_test.go @@ -0,0 +1,260 @@ +package acp + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func msgWithResult(t *testing.T, result string) *Message { + t.Helper() + return &Message{JSONRPC: "2.0", ID: json.RawMessage(`"client-prompt-1"`), Result: json.RawMessage(result)} +} + +func msgNotification(t *testing.T, method, params string) *Message { + t.Helper() + return &Message{JSONRPC: "2.0", Method: method, Params: json.RawMessage(params)} +} + +func TestExtractUsage(t *testing.T) { + tests := []struct { + name string + msg *Message + wantHas bool + wantPrompt int + wantComp int + wantThink int + wantCost *float64 + }{ + { + name: "snake_case prompt response", + msg: msgWithResult(t, `{"stopReason":"end_turn","usage":{"input_tokens":120,"output_tokens":48}}`), + wantHas: true, + wantPrompt: 120, + wantComp: 48, + }, + { + name: "camelCase v2 usage with thoughtTokens + cached", + msg: msgWithResult(t, `{"stopReason":"end_turn","usage":{"inputTokens":200,"outputTokens":90,"thoughtTokens":15,"cachedReadTokens":50}}`), + wantHas: true, + wantPrompt: 200, + wantComp: 90, + wantThink: 15, + }, + { + name: "usage_update notification with cost only", + msg: msgNotification(t, "session/update", `{"update":{"sessionUpdate":"usage_update","used":1000,"size":200000,"cost":{"amount":0.0123,"currency":"USD"}}}`), + wantHas: true, + wantCost: f64ptr(0.0123), + }, + { + name: "usage_update without cost -> no usage (gauge ignored)", + msg: msgNotification(t, "session/update", `{"update":{"sessionUpdate":"usage_update","used":1000,"size":200000}}`), + wantHas: false, + }, + { + name: "ordinary agent_message_chunk -> no usage", + msg: msgNotification(t, "session/update", `{"update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"hi"}}}`), + wantHas: false, + }, + { + name: "malformed result -> no panic, no usage", + msg: msgWithResult(t, `{"usage":not-json`), + wantHas: false, + }, + { + name: "result without usage object -> no usage", + msg: msgWithResult(t, `{"stopReason":"end_turn"}`), + wantHas: false, + }, + { + name: "nil message -> no usage", + msg: nil, + wantHas: false, + }, + } + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + got := ExtractUsage(tc.msg) + assert.Equal(t, tc.wantHas, got.HasUsage) + if !tc.wantHas { + return + } + assert.Equal(t, tc.wantPrompt, got.PromptTokens) + assert.Equal(t, tc.wantComp, got.CompletionTokens) + assert.Equal(t, tc.wantThink, got.ThinkingTokens) + if tc.wantCost == nil { + assert.Nil(t, got.CostUSD) + } else { + require.NotNil(t, got.CostUSD) + assert.InDelta(t, *tc.wantCost, *got.CostUSD, 1e-9) + } + }) + } +} + +func f64ptr(f float64) *float64 { return &f } +func i64ptr(i int64) *int64 { return &i } + +// ===== usageAccumulator ===== + +type fakeUsageRepo struct { + ledger []*SessionUsageRecord + dedup map[int64]struct{} + totPrompt int64 + totComp int64 + totThink int64 + totCost float64 + projectCost float64 +} + +func (f *fakeUsageRepo) InsertSessionUsage(_ context.Context, r *SessionUsageRecord) (bool, error) { + if r.SourceEventID != nil { + if f.dedup == nil { + f.dedup = map[int64]struct{}{} + } + if _, ok := f.dedup[*r.SourceEventID]; ok { + return false, nil + } + f.dedup[*r.SourceEventID] = struct{}{} + } + cp := *r + f.ledger = append(f.ledger, &cp) + return true, nil +} + +func (f *fakeUsageRepo) AddSessionUsageTotals(_ context.Context, _ uuid.UUID, dp, dc, dt int64, dCost float64) (int64, int64, int64, float64, error) { + f.totPrompt += dp + f.totComp += dc + f.totThink += dt + f.totCost += dCost + return f.totPrompt, f.totComp, f.totThink, f.totCost, nil +} + +func (f *fakeUsageRepo) SumProjectCostUSD(_ context.Context, _ uuid.UUID, _ time.Time) (float64, error) { + return f.projectCost, nil +} + +type fakePriceLookup struct { + price ModelPrice +} + +func (f fakePriceLookup) PriceForModel(_ context.Context, _ uuid.UUID) (ModelPrice, error) { + return f.price, nil +} + +func TestAccumulator_PricesViaModelLookup(t *testing.T) { + repo := &fakeUsageRepo{} + mid := uuid.New() + acc := newUsageAccumulator(AccumulatorParams{ + Repo: repo, + Prices: fakePriceLookup{price: ModelPrice{PromptPerM: 3, CompletionPerM: 15, ThinkingPerM: 0, Found: true}}, + ModelID: &mid, + Caps: BudgetCaps{}, + }) + _, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 1_000_000, CompletionTokens: 1_000_000, HasUsage: true}, 1) + assert.False(t, breached) + require.Len(t, repo.ledger, 1) + // 3 USD (prompt) + 15 USD (completion) = 18. + assert.InDelta(t, 18.0, repo.ledger[0].CostUSD, 1e-6) + assert.InDelta(t, 18.0, repo.totCost, 1e-6) +} + +func TestAccumulator_FallsBackToAgentCost(t *testing.T) { + repo := &fakeUsageRepo{} + acc := newUsageAccumulator(AccumulatorParams{Repo: repo}) // no model price + cost := 0.5 + _, _ = acc.Observe(context.Background(), ParsedUsage{CostUSD: &cost, HasUsage: true}, 1) + require.Len(t, repo.ledger, 1) + assert.InDelta(t, 0.5, repo.ledger[0].CostUSD, 1e-9) +} + +func TestAccumulator_SkipsWhenNoUsage(t *testing.T) { + repo := &fakeUsageRepo{} + acc := newUsageAccumulator(AccumulatorParams{Repo: repo}) + _, breached := acc.Observe(context.Background(), ParsedUsage{HasUsage: false}, 1) + assert.False(t, breached) + assert.Empty(t, repo.ledger) +} + +func TestAccumulator_DedupBySourceEventID(t *testing.T) { + repo := &fakeUsageRepo{} + mid := uuid.New() + acc := newUsageAccumulator(AccumulatorParams{ + Repo: repo, + Prices: fakePriceLookup{price: ModelPrice{CompletionPerM: 10, Found: true}}, + ModelID: &mid, + }) + p := ParsedUsage{CompletionTokens: 1000, HasUsage: true} + _, _ = acc.Observe(context.Background(), p, 7) + _, _ = acc.Observe(context.Background(), p, 7) // same event id -> no-op + assert.Len(t, repo.ledger, 1) + assert.InDelta(t, 0.01, repo.totCost, 1e-9) +} + +func TestAccumulator_BreachCost(t *testing.T) { + repo := &fakeUsageRepo{} + mid := uuid.New() + acc := newUsageAccumulator(AccumulatorParams{ + Repo: repo, + Prices: fakePriceLookup{price: ModelPrice{CompletionPerM: 1000, Found: true}}, + ModelID: &mid, + Caps: BudgetCaps{MaxCostUSD: f64ptr(0.5)}, + }) + // 1000 completion tokens * 1000/M = 1.0 USD >= 0.5 cap. + reason, breached := acc.Observe(context.Background(), ParsedUsage{CompletionTokens: 1000, HasUsage: true}, 1) + assert.True(t, breached) + assert.Equal(t, BreachCost, reason) +} + +func TestAccumulator_BreachTokens(t *testing.T) { + repo := &fakeUsageRepo{} + acc := newUsageAccumulator(AccumulatorParams{ + Repo: repo, + Caps: BudgetCaps{MaxTokens: i64ptr(1000)}, + }) + reason, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 600, CompletionTokens: 600, HasUsage: true}, 1) + assert.True(t, breached) + assert.Equal(t, BreachTokens, reason) +} + +func TestAccumulator_BreachWallClock(t *testing.T) { + repo := &fakeUsageRepo{} + start := time.Now().Add(-2 * time.Hour) + acc := newUsageAccumulator(AccumulatorParams{ + Repo: repo, + StartedAt: start, + Caps: BudgetCaps{MaxWallClockSeconds: i32ptr(60)}, // 1 minute cap, 2h elapsed + }) + reason, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 1, HasUsage: true}, 1) + assert.True(t, breached) + assert.Equal(t, BreachWallClock, reason) +} + +func TestAccumulator_BreachProjectBudget(t *testing.T) { + repo := &fakeUsageRepo{projectCost: 100} // already over the project cap + acc := newUsageAccumulator(AccumulatorParams{ + Repo: repo, + ProjectBudgetUSD: 50, + }) + reason, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 1, HasUsage: true}, 1) + assert.True(t, breached) + assert.Equal(t, BreachCost, reason) +} + +func TestAccumulator_NoBreachUnderCaps(t *testing.T) { + repo := &fakeUsageRepo{} + acc := newUsageAccumulator(AccumulatorParams{ + Repo: repo, + Caps: BudgetCaps{MaxTokens: i64ptr(1_000_000), MaxCostUSD: f64ptr(100)}, + }) + _, breached := acc.Observe(context.Background(), ParsedUsage{PromptTokens: 10, CompletionTokens: 10, HasUsage: true}, 1) + assert.False(t, breached) +} + +func i32ptr(i int32) *int32 { return &i } diff --git a/internal/app/acl_test.go b/internal/app/acl_test.go new file mode 100644 index 0000000..b0d5155 --- /dev/null +++ b/internal/app/acl_test.go @@ -0,0 +1,48 @@ +package app + +import ( + "testing" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// projectACL(app 层中央 ACL)必须与 project_service 的 assertReadable/assertWritable +// 语义一致——两侧任一漂移都会让 workspace/ACP/chat 作用域与 project 鉴权分叉 +// (autonomy roadmap §11)。本白盒测试直接覆盖 projectACL 的角色叠加矩阵。 +func TestProjectACL_RoleMatrix(t *testing.T) { + owner := uuid.New() + stranger := uuid.New() + + private := &project.Project{ID: uuid.New(), OwnerID: owner, Visibility: project.VisibilityPrivate} + internal := &project.Project{ID: uuid.New(), OwnerID: owner, Visibility: project.VisibilityInternal} + + cases := []struct { + name string + pj *project.Project + caller uuid.UUID + isAdmin bool + role project.Role + wantRead, wantWrt bool + }{ + {"owner_private", private, owner, false, project.RoleAdmin, true, true}, + {"global_admin_private", private, stranger, true, project.RoleNone, true, true}, + {"member_private_write", private, stranger, false, project.RoleMember, true, true}, + {"admin_role_private_write", private, stranger, false, project.RoleAdmin, true, true}, + {"viewer_private_read_only", private, stranger, false, project.RoleViewer, true, false}, + {"nonmember_private_denied", private, stranger, false, project.RoleNone, false, false}, + {"nonmember_internal_read", internal, stranger, false, project.RoleNone, true, false}, + {"viewer_internal_read", internal, stranger, false, project.RoleViewer, true, false}, + {"member_internal_write", internal, stranger, false, project.RoleMember, true, true}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + gotRead, gotWrite := projectACL(tc.pj, tc.caller, tc.isAdmin, tc.role) + if gotRead != tc.wantRead || gotWrite != tc.wantWrt { + t.Fatalf("projectACL(%s)=read:%v,write:%v want read:%v,write:%v", + tc.name, gotRead, gotWrite, tc.wantRead, tc.wantWrt) + } + }) + } +} diff --git a/internal/app/acp_integration_test.go b/internal/app/acp_integration_test.go index d395707..b973674 100644 --- a/internal/app/acp_integration_test.go +++ b/internal/app/acp_integration_test.go @@ -186,7 +186,7 @@ func newACPIntegrationSuite(t *testing.T) *acpIntegrationSuite { paStub := &acpStubProjectAccess{} sessSvc := acp.NewSessionService( - acpRepo, wsStub, nil, nil, paStub, sup, auditRec, + acpRepo, wsStub, nil, nil, paStub, nil, nil, sup, auditRec, acp.SessionServiceConfig{MaxActiveGlobal: 20, MaxActivePerUser: 3}, nil, // mcpTokens — not exercised by integration tests ) diff --git a/internal/app/app.go b/internal/app/app.go index 9f4f48b..9c3466e 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -26,29 +26,41 @@ import ( "net/http" "os" "path/filepath" + "strings" "time" "github.com/google/uuid" "github.com/yan1h/agent-coding-workflow/internal/acp" + "github.com/yan1h/agent-coding-workflow/internal/acp/sandbox" "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/brief" + "github.com/yan1h/agent-coding-workflow/internal/changerequest" "github.com/yan1h/agent-coding-workflow/internal/chat" + "github.com/yan1h/agent-coding-workflow/internal/codeindex" "github.com/yan1h/agent-coding-workflow/internal/config" + "github.com/yan1h/agent-coding-workflow/internal/docs" "github.com/yan1h/agent-coding-workflow/internal/infra/clock" "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" "github.com/yan1h/agent-coding-workflow/internal/infra/db" "github.com/yan1h/agent-coding-workflow/internal/infra/git" "github.com/yan1h/agent-coding-workflow/internal/infra/llm" "github.com/yan1h/agent-coding-workflow/internal/infra/logger" + "github.com/yan1h/agent-coding-workflow/internal/infra/metrics" "github.com/yan1h/agent-coding-workflow/internal/infra/notify" "github.com/yan1h/agent-coding-workflow/internal/infra/storage" + "github.com/yan1h/agent-coding-workflow/internal/infra/vcs" "github.com/yan1h/agent-coding-workflow/internal/jobs" "github.com/yan1h/agent-coding-workflow/internal/jobs/handlers" "github.com/yan1h/agent-coding-workflow/internal/jobs/runners" mcp "github.com/yan1h/agent-coding-workflow/internal/mcp" + "github.com/yan1h/agent-coding-workflow/internal/orchestrator" "github.com/yan1h/agent-coding-workflow/internal/project" + "github.com/yan1h/agent-coding-workflow/internal/projectmemory" runmod "github.com/yan1h/agent-coding-workflow/internal/run" + "github.com/yan1h/agent-coding-workflow/internal/security" "github.com/yan1h/agent-coding-workflow/internal/terminal" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" + httpmw "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" "github.com/yan1h/agent-coding-workflow/internal/user" "github.com/yan1h/agent-coding-workflow/internal/workspace" ) @@ -85,20 +97,101 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { auditRec := audit.NewPostgresRecorder(pool) wrappedAudit := mcp.AuditWrap(auditRec) // 给业务 service 用,自动注入 via_mcp metadata + auditReader := audit.NewPostgresReader(pool) + + // ===== observability:Prometheus 指标注册中心 ===== + // GaugeFunc 数据源用独立 pool 查询,避免与模块构造顺序耦合(acpRepo / chatRepo + // 此时尚未构造)。查询失败时回退 0,不阻断 /metrics scrape。 + var appMetrics *metrics.Metrics + if cfg.Metrics.Enabled { + activeSessionsFn := func() float64 { + qctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var n int64 + if err := pool.QueryRow(qctx, + `SELECT COUNT(*) FROM acp_sessions WHERE status IN ('starting','running')`).Scan(&n); err != nil { + return 0 + } + return float64(n) + } + llmCostTotalFn := func() float64 { + qctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + var cost float64 + if err := pool.QueryRow(qctx, + `SELECT COALESCE(SUM(cost_usd),0)::float8 FROM llm_usage`).Scan(&cost); err != nil { + return 0 + } + return cost + } + appMetrics = metrics.New(activeSessionsFn, llmCostTotalFn) + } + + // ===== secret 加密:versioned KeyedEncryptor + Postgres key store ===== + // provider 据 cfg.Crypto.Provider 选取(env|vault|kms);vault/kms 客户端尚未 + // 接入,选中时退回 env provider(version 1 = APP_MASTER_KEY),保持启动不破。 + cryptoProvider := buildCryptoProvider(cfg, log) + cryptoKeyStore := crypto.NewPostgresKeyStore(pool) + keyedEnc := crypto.NewKeyedEncryptor(cryptoProvider, cryptoKeyStore) + // enc 是 v1 shim,背靠同一个 KeyedEncryptor:~30 个既有调用点(workspace/chat/ + // acp/run 的 *crypto.Encryptor 参数)无需改动。需要按版本持久化新密文的少数 + // 调用点(re-encrypt job / rotate-key)直接用 keyedEnc。 + // 注意:shim 的写版本固定为 1(与历史密文格式一致);rotate 后由 re-encrypt job + // 把旧行迁到新版本,shim 仍能解 v1(DB key_version 列携带版本)。 + enc := crypto.NewEncryptorFromKeyed(keyedEnc, 1) userRepo := user.NewPostgresRepository(pool) userSvc := user.NewService(userRepo, auditRec) + // 登录暴力破解节流:装配期回填启用的实例(cfg-gated)。 + if setter, ok := userSvc.(user.LoginThrottleSetter); ok { + setter.SetLoginThrottle(user.NewLoginThrottle(user.LoginThrottleConfig{ + Enabled: cfg.User.LoginThrottle.Enabled, + MaxFailures: cfg.User.LoginThrottle.MaxFailures, + Window: cfg.User.LoginThrottle.Window, + }, nil)) + } + + // notifyDispatcher 先于 project 服务构造:phase-change 领域事件经其 fan-out。 + notifyRepo := notify.NewPostgresRepository(pool) + notifyPrefsRepo := notify.NewPostgresPrefsRepository(pool) + notifyDispatcher := notify.NewDispatcher(notifyRepo, log) + notifyDispatcher.Register(notify.NewDummyNotifier(log)) + // inbox 实时推送:per-user SSE 扇出中心。Dispatch 落库成功后 Publish。 + notifyStream := notify.NewStreamHub() + notifyDispatcher.SetStreamHub(notifyStream) + // email / im 外发通道:仅在配置启用时注册。二者均消费 notification_prefs + // 做 per-user 开关 + min_severity 过滤;in-app inbox 始终投递不受影响。 + if cfg.Notify.Email.Enabled { + notifyDispatcher.Register(notify.NewEmailNotifier(notify.EmailConfig{ + Enabled: cfg.Notify.Email.Enabled, + SMTPHost: cfg.Notify.Email.SMTPHost, + Port: cfg.Notify.Email.Port, + From: cfg.Notify.Email.From, + Username: cfg.Notify.Email.Username, + Password: cfg.Notify.Email.Password, + }, notifyPrefsRepo, emailLookupAdapter{svc: userSvc}, nil, log)) + } + if cfg.Notify.IM.Enabled { + notifyDispatcher.Register(notify.NewIMNotifier(notify.IMConfig{ + Enabled: cfg.Notify.IM.Enabled, + }, notifyPrefsRepo, enc, nil, log)) + } projectRepo := project.NewPostgresRepository(pool) projectSvc := project.NewProjectService(projectRepo, wrappedAudit) - requirementSvc := project.NewRequirementService(projectRepo, wrappedAudit) + // crRepo 在此提前构造(仅需 pool),供 Done 网关查询 workspace PR 合并状态; + // 同一实例在下方 change-request 装配处复用(无状态,复用安全)。 + crRepo := changerequest.NewPostgresRepository(pool) + // 阶段网关引擎:Repo 查阶段指针 / 产物 verdict;Workspace 查 PR 合并状态(Done 网关)。 + phaseGate := project.NewGateRunner(project.GateDeps{ + Repo: projectRepo, + Workspace: workspaceStateAdapter{crRepo: crRepo}, + }, nil) + phaseEmitter := phaseEventEmitterAdapter{d: notifyDispatcher} + requirementSvc := project.NewRequirementService(projectRepo, wrappedAudit, phaseEmitter, phaseGate) issueSvc := project.NewIssueService(projectRepo, wrappedAudit) artifactSvc := project.NewArtifactService(projectRepo, wrappedAudit) - notifyRepo := notify.NewPostgresRepository(pool) - notifyDispatcher := notify.NewDispatcher(notifyRepo, log) - notifyDispatcher.Register(notify.NewDummyNotifier(log)) - spaSub, err := fs.Sub(dist, "web/dist") if err != nil { return nil, fmt.Errorf("sub fs: %w", err) @@ -114,11 +207,36 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { }, SPAHandler: spaHandler, Logger: log, + Security: httpmw.SecurityHeadersConfig{ + Enabled: cfg.HTTP.Security.Enabled, + TLSEnabled: cfg.HTTP.TLS.Enabled, + HSTSMaxAgeSeconds: cfg.HTTP.Security.HSTSMaxAgeSeconds, + FrameOptions: cfg.HTTP.Security.FrameOptions, + ReferrerPolicy: cfg.HTTP.Security.ReferrerPolicy, + ContentSecurityPolicy: cfg.HTTP.Security.ContentSecurityPolicy, + }, + CORS: httpmw.CORSConfig{ + Enabled: cfg.HTTP.CORS.Enabled, + AllowedOrigins: cfg.HTTP.CORS.AllowedOrigins, + AllowedMethods: cfg.HTTP.CORS.AllowedMethods, + AllowedHeaders: cfg.HTTP.CORS.AllowedHeaders, + AllowCredentials: cfg.HTTP.CORS.AllowCredentials, + MaxAgeSeconds: cfg.HTTP.CORS.MaxAgeSeconds, + }, + MetricsHandler: func() http.Handler { + if appMetrics == nil { + return nil + } + return appMetrics.Handler() + }(), + MetricsAuthToken: cfg.Metrics.AuthToken, }) // userSvc 直接满足 middleware.SessionResolver(ResolveSession 同名同签名)。 user.NewHandler(userSvc).Mount(r) - notify.NewHandler(notifyRepo, userSvc).Mount(r) + notify.NewHandler(notifyRepo, userSvc).WithStreamHub(notifyStream).WithPrefs(notifyPrefsRepo, enc).Mount(r) + // admin-only 审计日志查看器(GET /api/v1/admin/audit-logs)。 + audit.NewHandler(auditReader, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) project.NewHandler(projectSvc, requirementSvc, issueSvc, artifactSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) // ===== workspace 模块装配 ===== @@ -143,11 +261,6 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { return nil, fmt.Errorf("mkdir workspace tmp: %w", err) } - enc, err := crypto.NewEncryptor(cfg.MasterKey) - if err != nil { - return nil, fmt.Errorf("crypto encryptor: %w", err) - } - gitr := git.NewDefaultRunner(git.Config{ Binary: cfg.Git.Binary, TmpDir: tmpDir, @@ -157,6 +270,13 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { OpsTimeout: cfg.Workspace.OpsTimeout, }) + // 服务端上下文组装器:把 (requirement|issue + 阶段产物 + 仓库定向 + 操作者文本) + // 组装成 token 受限的任务简报。注入 ACP session 初始 prompt 与 chat 的 FK 上下文。 + // orienter 走同一个 git runner(仅 ACP 场景且有 cwd 时才做仓库定向)。 + briefComposer := brief.NewComposer(brief.NewRepoOrienter(gitr), brief.Config{ + DefaultTokenBudget: cfg.Acp.BriefTokenBudget, + }) + wsRepo := workspace.NewPostgresRepository(pool) // 进程启动时把卡死的 cloning/syncing 复位为 error,避免行永远卡住; // 每个被复位的工作区写一条 workspace.sync_aborted_on_restart audit。 @@ -198,6 +318,21 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { gopSvc := workspace.NewGitOpsService(wsRepo, auditRec, gitr, pa, credLoader) workspace.NewHandler(wsSvc, wtSvc, gopSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) + // ===== change-request (PR) + VCS provider 装配 ===== + // vcs.Provider 是 git-host REST 客户端(v1 仅 Gitea);ParseRepoRef 用 Host() + // 做主机白名单校验。crSvc 复用 pa(owner+admin 写鉴权)、wsRepo(GitRemoteURL + // -> RepoRef);用 wrappedAudit 让经 MCP 调用时自动打 via_mcp 标记(与 PM + // service 一致)。合并网关:review_verdict 必须为 approved(审批仅 Web 暴露, + // agent 无法自批自合)。 + vcsProvider := vcs.NewGiteaProvider(cfg.VCS.Gitea.BaseURL, cfg.VCS.Gitea.Token, cfg.VCS.Gitea.Timeout) + // crRepo 已在上方 project 装配处提前构造(供 Done 网关复用),此处直接复用。 + crSvc := changerequest.NewService(crRepo, changeRequestWorkspaceAdapter{repo: wsRepo}, wrappedAudit, vcsProvider, pa, changerequest.Config{ + RequireCIPass: cfg.VCS.RequireCIPass, + MergeMethod: cfg.VCS.Gitea.MergeMethod, + Host: vcsProvider.Host(), + }) + changerequest.NewHandler(crSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) + // ===== chat 模块装配 ===== // 依赖图: // pgx.Pool ─ chat.Repository ─┐ @@ -244,7 +379,8 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { MaxFileBytes: cfg.Chat.Attachment.MaxFileSizeMB << 20, MaxMsgBytes: cfg.Chat.Attachment.MaxMessageSizeMB << 20, SafetyPct: safetyFraction, - }) + }, + chatContextReaderAdapter{projectRepo: projectRepo, composer: briefComposer}) chatUsageSvc := chat.NewUsageService(chatRepo) chatUploader := chat.NewUploader(chatRepo, attachStorage, cfg.Chat.Attachment.MimeWhitelist, @@ -350,30 +486,77 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { acpRepo, auditRec, notifyDispatcher, wtSvc, wsRepo, mcpTokenSvc, enc, acp.SupervisorConfig{ - SpawnTimeout: cfg.Acp.SpawnTimeout, - KillGrace: cfg.Acp.KillGrace, - StderrBufferLines: cfg.Acp.StderrBufferLines, - StderrTailBytes: cfg.Acp.StderrTailBytes, - EventMaxPayload: cfg.Acp.EventMaxPayload, - EventTruncateField: cfg.Acp.EventTruncateField, - ShutdownGrace: cfg.Acp.ShutdownGrace, - AgentHomesDir: filepath.Join(cfg.DataDir, "agent-homes"), + SpawnTimeout: cfg.Acp.SpawnTimeout, + KillGrace: cfg.Acp.KillGrace, + StderrBufferLines: cfg.Acp.StderrBufferLines, + StderrTailBytes: cfg.Acp.StderrTailBytes, + EventMaxPayload: cfg.Acp.EventMaxPayload, + EventTruncateField: cfg.Acp.EventTruncateField, + ShutdownGrace: cfg.Acp.ShutdownGrace, + AgentHomesDir: filepath.Join(cfg.DataDir, "agent-homes"), + DefaultProjectBudgetUSD: cfg.Acp.DefaultProjectBudgetUSD, + // per-session 沙箱:factory 据 mode 选实现(非 Linux / none 时为 no-op)。 + Sandbox: sandbox.New(sandbox.Mode(cfg.Acp.Sandbox.Mode)), + SandboxMode: sandbox.Mode(cfg.Acp.Sandbox.Mode), + SandboxBaseUID: cfg.Acp.Sandbox.BaseUID, + DataRoot: cfg.DataDir, + EgressProxyURL: cfg.Acp.Sandbox.ProxyURL, + SandboxRlimits: sandbox.Limits{ + AddressSpaceBytes: cfg.Acp.Sandbox.AddressSpaceBytes, + NProc: cfg.Acp.Sandbox.NProc, + CPUSeconds: cfg.Acp.Sandbox.CPUSeconds, + PIDs: cfg.Acp.Sandbox.PIDs, + DiskBytes: cfg.Acp.Sandbox.DiskBytes, + }, }, log, ) + // 成本核算:注入 model 价格查询(acp → chat.ComputeCost),并给 agent-kind + // 服务注入 model 校验器。两者均装配期回填,避免 acp 构造期依赖 chat。 + acpSup.SetModelPriceLookup(acpModelPriceAdapter{repo: chatRepo}) + if appMetrics != nil { + acpSup.SetMetrics(appMetrics) + } + if akSetter, ok := akSvc.(interface { + SetModelValidator(acp.ModelValidator) + }); ok { + akSetter.SetModelValidator(acpModelValidatorAdapter{repo: chatRepo}) + } + // 回合完成检测事件总线:进程内 pub-sub,供未来编排层订阅。注册一个日志订阅者 + // 把总线端到端跑通(编排层尚未实现)。 + turnBus := acp.NewTurnBus(log) + turnBus.Subscribe("log", func(_ context.Context, ev acp.TurnEvent) { + log.Info("acp.turn_event", + "kind", string(ev.Kind), + "session_id", ev.SessionID, + "turn_index", ev.TurnIndex, + "stop_reason", string(ev.StopReason), + "raw_stop_reason", ev.RawStopReason) + }) + acpSup.SetTurnBus(turnBus) + // 权限审批服务:与 supervisor 互相依赖,构造后回填(SetPermissionService / // SetRelayLocator),再注入 handler。 acpPermSvc := acp.NewPermissionService(acpRepo, auditRec, cfg.Acp.PermissionTimeout, log) + // HITL:待审权限通知(关闭页面的用户也能被提醒去 /approvals)+ 决策计数。 + acpPermSvc.SetNotifier(notifyDispatcher) + if appMetrics != nil { + acpPermSvc.SetMetrics(appMetrics) + } acpSup.SetPermissionService(acpPermSvc) acpPermSvc.SetRelayLocator(acpSup) + // HITL:允许任意 project maintainer(非仅会话 owner)处理待审权限请求。 + // 复用成员感知的 projectAccessAdapter(与 project_service ACL 一致)。 + acpPermSvc.SetProjectMaintainer(projectMaintainerAdapter{pa: pa}) sessSvc := acp.NewSessionService( acpRepo, wsSvc, wtSvc, wsRepo, - acpProjAccess, acpSup, auditRec, + acpProjAccess, acpArtifactAccessAdapter{projectRepo: projectRepo}, briefComposer, acpSup, auditRec, acp.SessionServiceConfig{ MaxActiveGlobal: cfg.Acp.MaxActiveGlobal, MaxActivePerUser: cfg.Acp.MaxActivePerUser, SystemTokenTTL: cfg.MCP.SystemTokenTTL, MCPPublicURL: cfg.MCP.PublicURL, + BriefTokenBudget: cfg.Acp.BriefTokenBudget, }, mcpTokenSvc, ) @@ -383,6 +566,208 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { SendBuffer: cfg.Acp.WSSendBuffer, }).Mount(r) + // ===== orchestrator/planner module wiring ===== + // jobsRepo 提前构造(无 ACP 依赖):编排器 StartRun/redrive 用它入队 orchestrator.step。 + jobsRepo := jobs.NewPostgresRepository(pool) + + // ===== code index (pgvector RAG) + project memory + auto-docs wiring ===== + // EmbeddingSelector 解析配置的 embedding endpoint+model(OpenAI/Gemini)。无 + // endpoint 配置时返回 ErrEmbeddingDisabled:search_code/reindex 报 typed 错误, + // memory_search 降级关键字回退。Anthropic-only 部署只有关键字能力。 + embedSel := &embeddingSelector{registry: llmRegistry, cfg: cfg.CodeIndex} + wsLocator := &workspaceLocatorAdapter{repo: wsRepo} + + codeIndexRepo := codeindex.NewPostgresRepository(pool) + projectMemoryRepo := projectmemory.NewPostgresRepository(pool) + docsRepo := docs.NewPostgresRepository(pool) + + chunker := codeindex.NewChunker() + if cfg.CodeIndex.ChunkWindowLines > 0 { + chunker.WindowLines = cfg.CodeIndex.ChunkWindowLines + } + if cfg.CodeIndex.ChunkOverlapLines > 0 { + chunker.OverlapLines = cfg.CodeIndex.ChunkOverlapLines + } + if cfg.CodeIndex.MaxFileBytes > 0 { + chunker.MaxFileBytes = cfg.CodeIndex.MaxFileBytes + } + + var codeIndexSvc codeindex.Service + var projectMemorySvc projectmemory.Service + if cfg.CodeIndex.Enabled { + codeIndexSvc = codeindex.NewService(codeIndexRepo, wsLocator, gitr, embedSel, jobsRepo, cfg.CodeIndex.SearchTopKCap) + } + // project memory 始终可用(embedder 缺失时只是无向量、走关键字回退)。 + projectMemorySvc = projectmemory.NewService(projectMemoryRepo, embedSel, log) + + // codeindex.build 处理器(失败时广播给 admin)。 + codeIndexFailureNotify := func(nctx context.Context, run *codeindex.Run, cause error) { + admins, lerr := userSvc.ListAdmins(nctx) + if lerr != nil { + return + } + for _, a := range admins { + _ = notifyDispatcher.Dispatch(nctx, notify.Message{ + UserID: a.ID, Topic: "codeindex.build_failed", Severity: notify.SeverityError, + Title: "Code index build failed", + Body: fmt.Sprintf("workspace=%s commit=%s: %v", run.WorkspaceID, run.CommitSHA, cause), + }) + } + } + codeIndexBuildRunner := codeindex.NewBuildRunner(codeIndexRepo, wsLocator, gitr, embedSel, chunker, codeIndexFailureNotify, log) + + // docs.commit_summary 处理器 + service。Summarizer 复用 chat 的 title-generator + // model 走 llm.Stream 收集 markdown。docs 默认关闭以省 LLM 成本。 + docsSummarizer := &commitSummarizer{registry: llmRegistry, chatRepo: chatRepo, log: log} + docsLocator := &docsLocatorAdapter{repo: wsRepo} + docsSummaryRunner := docs.NewSummaryRunner(docsRepo, docsLocator, gitr, docsSummarizer, log) + docsSvc := docs.NewService(jobsRepo, cfg.Docs.Enabled) + + // Hooks(functional setters,避免 workspace/acp → codeindex/docs 的 import 环)。 + if codeIndexSvc != nil { + indexHook := func(hctx context.Context, hwsID uuid.UUID, worktreeID *uuid.UUID, commitSHA string) error { + // Sync 路径(worktreeID==nil)先把旧 run 标 stale,再按新 HEAD 入队。 + if worktreeID == nil { + if serr := codeIndexSvc.MarkStale(hctx, hwsID); serr != nil { + log.Warn("codeindex.mark_stale_failed", "workspace_id", hwsID, "err", serr.Error()) + } + } + _, err := codeIndexSvc.EnqueueBuild(hctx, hwsID, worktreeID, commitSHA) + if errors.Is(err, codeindex.ErrEmbeddingDisabled) { + return nil // feature off; not an error + } + return err + } + if setter, ok := wtSvc.(interface { + SetIndexBuildHook(workspace.IndexBuildHook) + }); ok { + setter.SetIndexBuildHook(indexHook) + } + if setter, ok := wsSvc.(interface { + SetIndexBuildHook(workspace.IndexBuildHook) + }); ok { + setter.SetIndexBuildHook(indexHook) + } + } + if cfg.Docs.Enabled { + summaryHook := func(hctx context.Context, hwsID uuid.UUID, worktreeID *uuid.UUID, branch, commitSHA string) error { + return docsSvc.EnqueueCommitSummary(hctx, hwsID, worktreeID, branch, commitSHA) + } + if setter, ok := gopSvc.(interface { + SetCommitSummaryHook(workspace.CommitSummaryHook) + }); ok { + setter.SetCommitSummaryHook(summaryHook) + } + } + // AGENTS.md seeding:把 project memory 渲染进 worktree(acpSup 在下方构造,故此处 + // 仅保留 renderer 闭包,acpSup 构造后再注入)。 + agentsMDRenderer := func(rctx context.Context, projectID uuid.UUID, wsID *uuid.UUID) (string, error) { + return projectMemorySvc.RenderAgentsMD(rctx, projectID, wsID) + } + acpSup.SetAgentsMDRenderer(agentsMDRenderer) + + // ===== 安全:master-key 轮换 + re-encrypt job + admin 端点 ===== + // re-encrypt store 扫描每张加密列表、逐行重封到新版本;handler 注册进 jobs map。 + // rotate-key 端点 bump crypto_keys active 版本并入队 secret.reencrypt job。 + reencryptStore := handlers.NewPostgresReencryptStore(pool) + secretReencryptHandler := handlers.NewSecretReencryptHandler(reencryptStore, keyedEnc, log) + security.NewHandler(cryptoKeyStore, jobsRepo, reencryptStore, userSvc, + userAdminAdapter{svc: userSvc}, auditRec).Mount(r) + + orchRepo := orchestrator.NewPostgresRepository(pool) + // 阶段→默认 agent-kind:把 config 里的"阶段名→agent-kind 名"映射按名解析为 UUID。 + orchDefaults := map[project.Phase]uuid.UUID{} + for phaseName, kindName := range cfg.Orchestrator.PhaseAgentKinds { + if k, kerr := acpRepo.GetAgentKindByName(ctx, kindName); kerr == nil && k != nil { + orchDefaults[project.Phase(phaseName)] = k.ID + } else { + log.Warn("orchestrator.default_agent_kind_unresolved", + "phase", phaseName, "agent_kind", kindName) + } + } + // 不变量:编排器步骤跑在 jobs worker 上,单个 handler 超时 = lease_timeout*9/10。 + // turn_timeout 必须严格小于它,否则一次完整 agent 回合会被中途 reap 并重复执行。 + // 启动期快速失败,把配置错误暴露在部署时而非运行时。 + if cfg.Orchestrator.Enabled && cfg.Jobs.JobReaper.LeaseTimeout > 0 { + jobHandlerTimeout := cfg.Jobs.JobReaper.LeaseTimeout - cfg.Jobs.JobReaper.LeaseTimeout/10 + if cfg.Orchestrator.TurnTimeout >= jobHandlerTimeout { + return nil, fmt.Errorf("orchestrator.turn_timeout (%s) must be < jobs handler timeout (%s = job_reaper.lease_timeout*9/10); raise jobs.job_reaper.lease_timeout", + cfg.Orchestrator.TurnTimeout, jobHandlerTimeout) + } + } + orchCfg := orchestrator.Config{ + MaxAttemptsPerPhase: cfg.Orchestrator.MaxAttemptsPerPhase, + TurnTimeout: cfg.Orchestrator.TurnTimeout, + MaxDepth: cfg.Orchestrator.MaxDepth, + FanOutLimit: cfg.Orchestrator.FanOutLimit, + } + orchPM := orchPMAdapter{repo: projectRepo, reqs: requirementSvc, adminLookup: userAdminAdapter{svc: userSvc}} + orchSvc := orchestrator.NewService(orchestrator.ServiceDeps{ + Repo: orchRepo, + PM: orchPM, + ACL: orchACLAdapter{pa: projectAccessAdapter{p: projectSvc}}, + Jobs: jobsRepo, + Sessions: orchSessionTermAdapter{sess: sessSvc}, + Defaults: orchDefaults, + Config: orchCfg, + Audit: orchAuditAdapter{rec: auditRec}, + Log: log, + }) + // sessSvc 同时实现 acp.TurnRunner(SendPromptAndWait/ResumeSession)。 + orchTurnRunner, _ := sessSvc.(acp.TurnRunner) + orchStepHandler := orchestrator.NewStepHandler(orchestrator.StepHandlerDeps{ + Repo: orchRepo, + Runner: orchestrator.NewRunner(sessSvc, orchTurnRunner, orchACPLookupAdapter{repo: acpRepo}, cfg.Orchestrator.TurnTimeout), + Gate: orchestrator.NewV1Gate(orchGateArtifactAdapter{repo: projectRepo}, orchGateIssueAdapter{repo: projectRepo}), + PM: orchPM, + Enqueue: orchSvc.(orchestrator.StepEnqueuer), + DefaultAgentKind: orchDefaults, + Config: orchCfg, + Audit: orchAuditAdapter{rec: auditRec}, + Log: log, + }) + // 崩溃的编排 session → 重新入队对应 step(带短延迟)。注入为 func 避免 acp→orchestrator 循环。 + acpSup.SetOrchestratorRedrive(func(rctx context.Context, stepID uuid.UUID, reason string) { + orchSvc.EnqueueStepRedrive(rctx, stepID, reason) + }) + // 启动恢复:把活跃 run 下卡住的 step 重置为 pending 并重新入队(须在 ACP reaper 之后, + // 使 resume 看到已 crashed 的 session 与已释放的 worktree)。 + if stuckSteps, serr := orchRepo.ResetStuckStepsOnRestart(ctx); serr != nil { + log.Warn("orchestrator.startup_reaper.reset_failed", "err", serr.Error()) + } else { + for _, st := range stuckSteps { + if eerr := orchSvc.(orchestrator.StepEnqueuer).EnqueueStep(ctx, st.RunID, st.ID, time.Now().UTC()); eerr != nil { + log.Warn("orchestrator.startup_reaper.enqueue_failed", "step_id", st.ID, "err", eerr.Error()) + } + } + } + if cfg.Orchestrator.Enabled { + orchestrator.NewHandler(orchSvc, userSvc, userAdminAdapter{svc: userSvc}).Mount(r) + } + + // ===== task-decomposition parallel scheduler(autonomy roadmap §10)===== + // 服务端调度器:拓扑选取就绪(未阻塞/open/叶子/无活跃 session)子任务,扇出到独立 + // worktree 上的并行 ACP session。以项目 owner 特权身份调用 SessionService.Create, + // 故不受 checkNotSystemToken 限制。注册为 jobs 周期 RunnerFn(单实例、单 tick,跨重启存活)。 + orchScheduler := orchestrator.NewScheduler(orchestrator.SchedulerDeps{ + Projects: schedulerReadySourceAdapter{repo: projectRepo}, + Sessions: schedulerSessionStarterAdapter{ + sess: sessSvc, + projectRepo: projectRepo, + wsRepo: wsRepo, + defaultAgentKind: func() (uuid.UUID, bool) { + if id, ok := orchDefaults[project.PhaseImplementing]; ok && id != uuid.Nil { + return id, true + } + return uuid.Nil, false + }, + }, + Notify: schedulerNotifyAdapter{dispatcher: notifyDispatcher, projectRepo: projectRepo}, + Log: log, + MaxFanoutPerTick: cfg.Orchestrator.Scheduler.MaxFanoutPerTick, + MaxConcurrentPerProject: cfg.Orchestrator.Scheduler.MaxConcurrentPerProject, + }) + // ===== run profiles module wiring ===== // 进程托管:每个 workspace 的 run profile 作为长驻进程在 main_path 拉起。 // 复用 workspace.ProjectAccess(pa) 做 owner+admin 写鉴权。WS 心跳沿用 acp 的 @@ -400,6 +785,16 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { KillGrace: cfg.Run.KillGrace, OutTailBytes: cfg.Run.OutTailBytes, }, log) + // 一次性 exec(run_command / run_tests):agent 自验证闭环。无状态同步,无需 + // ShutdownAll 注册——RunOnce 受请求 ctx + 自身超时约束。 + execSvc := runmod.NewExecService(wsRepo, wtSvc, pa, auditRec, enc, runmod.ExecConfig{ + EnvWhitelist: cfg.Run.EnvWhitelist, + DefaultTimeout: cfg.Run.Exec.DefaultTimeout, + MaxTimeout: cfg.Run.Exec.MaxTimeout, + MaxOutputBytes: cfg.Run.Exec.MaxOutputBytes, + KillGrace: cfg.Run.KillGrace, + AllowedCommands: cfg.Run.Exec.AllowedCommands, + }, log) if cfg.Run.Enabled { runmod.NewHandler(runSvc, userSvc, userAdminAdapter{svc: userSvc}, runmod.WSConfig{ PingInterval: cfg.Acp.WSPingInterval, @@ -435,12 +830,17 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { Workspaces: wsSvc, Worktrees: wtSvc, GitOps: gopSvc, + ChangeReqs: crSvc, Templates: chatTplSvc, Messages: chatMsgSvc, Conversations: chatConvSvc, Endpoints: chatEpSvc, - ACP: acp.NewMCPBridge(akSvc, sessSvc, acpPermSvc), + ACP: acp.NewMCPBridge(akSvc, sessSvc, acpPermSvc, acpRepo), Runs: runSvc, + Execs: execSvc, + Orchestrator: orchestrator.NewMCPBridge(orchSvc), + CodeIndex: codeIndexSvc, // 可为 nil(code_index.enabled=false) + Memory: projectMemorySvc, // 始终非 nil(无 embedder 时关键字回退) } mcpServer := mcp.NewMCPServer(mcpDeps) mcp.MountTransports(r, mcpServer, mcpTokenSvc, mcpRateLimiter) @@ -450,7 +850,7 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { }) // ===== jobs module wiring ===== - jobsRepo := jobs.NewPostgresRepository(pool) + // jobsRepo 已在 orchestrator 装配块提前构造(见上)。 webhookBackoff := []time.Duration{ 30 * time.Second, 2 * time.Minute, 8 * time.Minute, 30 * time.Minute, 2 * time.Hour, @@ -491,10 +891,23 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { jobPurgeRunner := runners.NewJobPurge(jobsRepo, cfg.Jobs.JobPurge.CompletedRetention, log) acpEventsPurgeRunner := runners.NewAcpEventsPurge( acpEventsPurgeAdapter{repo: acpRepo}, cfg.Jobs.AcpEventsPurge.Retention, log) + acpTurnsPurgeRunner := runners.NewAcpTurnsPurge( + acpTurnsPurgeAdapter{repo: acpRepo}, cfg.Jobs.AcpEventsPurge.Retention, log) mcpTokensPurgeRunner := runners.NewMCPTokensPurge( mcpTokensPurgeAdapter{repo: mcpRepo}, cfg.Jobs.MCPTokensPurge.Retention, log) + acpSessionReaperRunner := runners.NewAcpSessionReaper( + acpSessionReaperAdapter{repo: acpRepo}, acpSup, notifyDispatcher, + runners.AcpSessionReaperConfig{ + WallClockTimeout: cfg.Jobs.AcpSessionReaper.WallClockTimeout, + IdleTimeout: cfg.Jobs.AcpSessionReaper.IdleTimeout, + KillGrace: cfg.Acp.KillGrace, + }, log) + auditRetentionRunner := runners.NewAuditRetention(auditReader, cfg.Jobs.AuditRetention.Retention, log) deadLetterFn := func(ctx context.Context, job jobs.Job, herr error) { + if appMetrics != nil { + appMetrics.RecordDeadLetter(string(job.Type)) + } errMsg := herr.Error() if len(errMsg) > 500 { errMsg = errMsg[:500] @@ -573,19 +986,41 @@ func New(ctx context.Context, cfg *config.Config, dist embed.FS) (*App, error) { Interval: cfg.Jobs.MCPTokensPurge.Interval, Retention: cfg.Jobs.MCPTokensPurge.Retention, }, + AcpSessionReaper: jobs.AcpSessionReaperConfig{ + Enabled: cfg.Jobs.AcpSessionReaper.Enabled, + Interval: cfg.Jobs.AcpSessionReaper.Interval, + }, + // 任务分解并行调度器的周期 tick(autonomy roadmap §10)。 + OrchestratorSchedule: jobs.RunnerConfig{ + Enabled: cfg.Orchestrator.Scheduler.Enabled, + Interval: cfg.Orchestrator.Scheduler.Interval, + }, + // 审计日志保留期清理(autonomy roadmap §11)。 + AuditRetention: jobs.RunnerConfig{ + Enabled: cfg.Jobs.AuditRetention.Enabled, + Interval: cfg.Jobs.AuditRetention.Interval, + }, }, jobs.ModuleDeps{ Repo: jobsRepo, Handlers: map[jobs.JobType]jobs.Handler{ - jobs.TypeWebhookDeliver: webhookHandler, + jobs.TypeWebhookDeliver: webhookHandler, + orchestrator.JobTypeOrchestratorStep: orchStepHandler, + jobs.TypeSecretReencrypt: secretReencryptHandler, + codeindex.JobTypeBuild: codeIndexBuildRunner, + docs.JobTypeCommitSummary: docsSummaryRunner, }, RunnerFns: map[string]jobs.RunnerFunc{ - "workspace_fetch": wsFetchRunner.Run, - "worktree_prune": wtPruneRunner.Run, - "attachment_cleanup": attCleanupRunner.Run, - "job_reaper": jobReaperRunner.Run, - "job_purge": jobPurgeRunner.Run, - "acp_events_purge": acpEventsPurgeRunner.Run, - "mcp_tokens_purge": mcpTokensPurgeRunner.Run, + "workspace_fetch": wsFetchRunner.Run, + "worktree_prune": wtPruneRunner.Run, + "attachment_cleanup": attCleanupRunner.Run, + "job_reaper": jobReaperRunner.Run, + "job_purge": jobPurgeRunner.Run, + "acp_events_purge": acpEventsPurgeRunner.Run, + "acp_turns_purge": acpTurnsPurgeRunner.Run, + "mcp_tokens_purge": mcpTokensPurgeRunner.Run, + "acp_session_reaper": acpSessionReaperRunner.Run, + "orchestrator_schedule": orchScheduler.Run, + "audit_retention": auditRetentionRunner.Run, }, Clock: clock.Real(), Log: log, @@ -642,8 +1077,15 @@ func (a *App) Run(ctx context.Context) error { errCh := make(chan error, 1) go func() { - if err := a.server.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) { - errCh <- err + var serveErr error + if a.cfg.HTTP.TLS.Enabled { + a.log.Info("server serving https", "cert", a.cfg.HTTP.TLS.CertFile) + serveErr = a.server.ListenAndServeTLS(a.cfg.HTTP.TLS.CertFile, a.cfg.HTTP.TLS.KeyFile) + } else { + serveErr = a.server.ListenAndServe() + } + if serveErr != nil && !errors.Is(serveErr, http.ErrServerClosed) { + errCh <- serveErr } }() @@ -716,6 +1158,21 @@ func (a *App) Run(ctx context.Context) error { // 复用 user.Service.Get:返回的 *User 直接读 IsAdmin。 // workspace.AdminLookup 与 project.AdminLookup 同形(IsAdmin 同名同签名), // 因此本类型同时满足两者,可在两个 handler 复用。 +// buildCryptoProvider 据 cfg.Crypto.Provider 选取 secret key provider。 +// env:APP_MASTER_KEY 为 version 1(轮换时 APP_MASTER_KEY_V 暂存新版本)。 +// vault/kms:客户端尚未接入,退回 env provider 并 warn,保持启动不破(接入真实 +// 客户端时把 DelegatingProvider 替换为对应实现即可)。 +func buildCryptoProvider(cfg *config.Config, log *slog.Logger) crypto.Provider { + env := crypto.NewEnvProvider(cfg.MasterKey) + switch cfg.Crypto.Provider { + case "vault", "kms": + log.Warn("crypto.provider_not_implemented_fallback_env", "provider", cfg.Crypto.Provider) + return crypto.NewDelegatingProvider(env) + default: + return env + } +} + type userAdminAdapter struct{ svc user.Service } func (a userAdminAdapter) IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) { @@ -726,6 +1183,25 @@ func (a userAdminAdapter) IsAdmin(ctx context.Context, id uuid.UUID) (bool, erro return u.IsAdmin, nil } +// changeRequestWorkspaceAdapter 把 workspace.Repository 桥接成 +// changerequest.WorkspaceLookup(窄接口),让 change-request service 不依赖 +// workspace service 全量,只取 project_id / remote_url / default_branch。 +type changeRequestWorkspaceAdapter struct { + repo workspace.Repository +} + +func (a changeRequestWorkspaceAdapter) GetWorkspaceMeta(ctx context.Context, wsID uuid.UUID) (changerequest.WorkspaceMeta, error) { + ws, err := a.repo.GetWorkspaceByID(ctx, wsID) + if err != nil { + return changerequest.WorkspaceMeta{}, err + } + return changerequest.WorkspaceMeta{ + ProjectID: ws.ProjectID, + GitRemoteURL: ws.GitRemoteURL, + DefaultBranch: ws.DefaultBranch, + }, nil +} + // projectAccessAdapter 把 project.ProjectService.Get / GetByID 桥接成 workspace.ProjectAccess。 // - Resolve = by slug:用于 POST /projects/{slug}/workspaces 等路径 // - ResolveByID = by uuid:用于 wsID 拿到 ws 后再校验 project 权限 @@ -747,7 +1223,8 @@ func (a projectAccessAdapter) Resolve(ctx context.Context, callerID uuid.UUID, i if err != nil { return uuid.Nil, false, false, err } - canRead, canWrite := projectACL(pj, callerID, isAdmin) + role := a.roleFor(ctx, pj, callerID, isAdmin) + canRead, canWrite := projectACL(pj, callerID, isAdmin, role) return pj.ID, canRead, canWrite, nil } @@ -756,24 +1233,57 @@ func (a projectAccessAdapter) ResolveByID(ctx context.Context, callerID uuid.UUI if err != nil { return false, false, err } - canRead, canWrite := projectACL(pj, callerID, isAdmin) + role := a.roleFor(ctx, pj, callerID, isAdmin) + canRead, canWrite := projectACL(pj, callerID, isAdmin, role) return canRead, canWrite, nil } -// projectACL 复制 project 包内部 assertReadable/assertWritable 的语义: -// - admin 永远可读可写 -// - owner 可读可写 +// roleFor 查 caller 在 pj 的成员角色,供 projectACL 叠加授权。owner/global-admin +// 隐式 RoleAdmin(与 project_service.memberRoleFor 保持一致);查询失败回退 RoleNone, +// 既有 owner/admin/internal 规则仍兜底。 +func (a projectAccessAdapter) roleFor(ctx context.Context, pj *project.Project, callerID uuid.UUID, isAdmin bool) project.Role { + if isAdmin || pj.OwnerID == callerID { + return project.RoleAdmin + } + role, err := a.p.MemberRole(ctx, pj.ID, callerID) + if err != nil { + return project.RoleNone + } + return role +} + +// projectACL 复刻 project 包内部 assertReadable/assertWritable 的语义,并叠加成员/ +// 角色 ACL(autonomy roadmap §11)——两侧必须一致,否则 workspace/ACP/chat 作用域 +// 会与 project service 鉴权分叉: +// - global-admin 永远可读可写 +// - owner 可读可写(经迁移回填为 project-admin 行) +// - project 角色 admin/member:可写(叠加);viewer:仅可读(叠加) // - internal 可见性:所有登录用户可读 // // 注:写操作还需考虑 archived 状态;workspace 的写场景会在 service 层用 ProjectAccess // 拿到 canWrite 后再下沉,archived 检查由 project service 在 GetByID 已可见。 -func projectACL(pj *project.Project, callerID uuid.UUID, isAdmin bool) (canRead, canWrite bool) { +func projectACL(pj *project.Project, callerID uuid.UUID, isAdmin bool, role project.Role) (canRead, canWrite bool) { owned := pj.OwnerID == callerID - canWrite = isAdmin || owned - canRead = canWrite || pj.Visibility == project.VisibilityInternal + canWrite = isAdmin || owned || role.CanWrite() + canRead = canWrite || pj.Visibility == project.VisibilityInternal || role.CanRead() return canRead, canWrite } +// projectMaintainerAdapter satisfies acp.ProjectMaintainer. It reuses the +// member-aware projectAccessAdapter so "who can resolve a permission request" +// stays consistent with project_service write ACL (owner/admin/member roles). +type projectMaintainerAdapter struct { + pa projectAccessAdapter +} + +func (a projectMaintainerAdapter) CanWriteProject(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) { + _, canWrite, err := a.pa.ResolveByID(ctx, callerID, isAdmin, projectID) + if err != nil { + return false, err + } + return canWrite, nil +} + // acpProjectAccessAdapter satisfies acp.ProjectAccess. It uses workspace.Repository // to resolve workspace → project, then project.Repository for issue/requirement. type acpProjectAccessAdapter struct { @@ -797,6 +1307,103 @@ func (a acpProjectAccessAdapter) GetRequirementByNumber(ctx context.Context, pro return a.projectRepo.GetRequirementByNumber(ctx, projectID, number) } +// acpArtifactAccessAdapter satisfies acp.ArtifactAccess. It loads all artifacts of a +// requirement and reduces to the latest version per phase (max version), so the +// brief composer receives at most one artifact per phase. +type acpArtifactAccessAdapter struct { + projectRepo project.Repository +} + +func (a acpArtifactAccessAdapter) ListLatestArtifacts(ctx context.Context, requirementID uuid.UUID) ([]*project.Artifact, error) { + all, err := a.projectRepo.ListArtifactsByRequirement(ctx, requirementID, "") + if err != nil { + return nil, err + } + return latestArtifactsPerPhase(all), nil +} + +// latestArtifactsPerPhase keeps the max-version artifact per phase. Deterministic: +// later versions strictly override earlier ones regardless of input order. +func latestArtifactsPerPhase(all []*project.Artifact) []*project.Artifact { + byPhase := make(map[project.Phase]*project.Artifact, len(all)) + for _, art := range all { + if art == nil { + continue + } + cur, ok := byPhase[art.Phase] + if !ok || art.Version > cur.Version { + byPhase[art.Phase] = art + } + } + out := make([]*project.Artifact, 0, len(byPhase)) + for _, art := range byPhase { + out = append(out, art) + } + return out +} + +// chatContextReaderAdapter satisfies chat.ContextReader. It resolves a conversation's +// FK mounts (requirement / issue / project) into domain objects + latest artifacts, +// then delegates to brief.Composer. Repo orientation is intentionally skipped for chat +// (RepoCwd left empty) to keep the request-path latency bounded — git shell-outs only +// run for ACP sessions. +type chatContextReaderAdapter struct { + projectRepo project.Repository + composer brief.Composer +} + +func (a chatContextReaderAdapter) BriefForConversation(ctx context.Context, conv *chat.Conversation) (string, error) { + if conv == nil || a.composer == nil { + return "", nil + } + + var ( + req *project.Requirement + iss *project.Issue + pj *project.Project + artifacts []*project.Artifact + kind = brief.KindChatRequirement + ) + + if conv.RequirementID != nil { + if r, err := a.projectRepo.GetRequirementByID(ctx, *conv.RequirementID); err == nil { + req = r + if arts, aerr := a.projectRepo.ListArtifactsByRequirement(ctx, r.ID, ""); aerr == nil { + artifacts = latestArtifactsPerPhase(arts) + } + } + } + if conv.IssueID != nil { + if i, err := a.projectRepo.GetIssueByID(ctx, *conv.IssueID); err == nil { + iss = i + kind = brief.KindChatIssue + } + } + if conv.ProjectID != nil { + if p, err := a.projectRepo.GetProjectByID(ctx, *conv.ProjectID); err == nil { + pj = p + } + } + + // No PM context resolved → no FK brief. + if req == nil && iss == nil { + return "", nil + } + + res, err := a.composer.ComposeTaskBrief(ctx, brief.BriefInput{ + Project: pj, + Requirement: req, + Issue: iss, + Artifacts: artifacts, + UserPrompt: "", // chat 的用户输入走消息历史,不进 FK 简报 + Kind: kind, + }) + if err != nil { + return "", err + } + return res.Text, nil +} + // ===== Adapters: bridge cross-module types into the narrow interfaces the // jobs/runners package expects, so the runners package never imports // workspace/chat/notify. @@ -966,6 +1573,75 @@ func (a dispatcherAdapter) Dispatch(ctx context.Context, msg notify.Message) err return a.d.Dispatch(ctx, msg) } +// emailLookupAdapter implements notify.EmailLookup over user.Service so the +// EmailNotifier can resolve a recipient address from a userID without notify +// importing the user package. +type emailLookupAdapter struct{ svc user.Service } + +func (a emailLookupAdapter) EmailForUser(ctx context.Context, userID uuid.UUID) (string, error) { + u, err := a.svc.Get(ctx, userID) + if err != nil { + return "", err + } + return u.Email, nil +} + +// phaseEventEmitterAdapter implements project.PhaseEventEmitter by translating a +// PhaseChangeEvent into a notify.Message (Topic="requirement.phase_change") and +// dispatching it. Fire-and-forget: dispatch errors are logged inside Dispatch, +// never surfaced, so a notify outage never blocks a phase change. +type phaseEventEmitterAdapter struct{ d *notify.Dispatcher } + +func (a phaseEventEmitterAdapter) EmitPhaseChange(ctx context.Context, e project.PhaseChangeEvent) { + meta := map[string]any{ + "project_id": e.ProjectID.String(), + "project_slug": e.ProjectSlug, + "requirement_id": e.RequirementID.String(), + "requirement_number": e.RequirementNumber, + "from": string(e.From), + "to": string(e.To), + "actor_id": e.ActorID.String(), + } + // 主要接收人是需求 owner(关心进度的人),而非发起变更的 actor(可能是编排器 + // 或其它协作者)。owner 与 actor 不同则两者都通知;相同则只发一条避免重复。 + recipients := map[uuid.UUID]struct{}{} + if e.OwnerID != uuid.Nil { + recipients[e.OwnerID] = struct{}{} + } + if e.ActorID != uuid.Nil { + recipients[e.ActorID] = struct{}{} + } + for uid := range recipients { + _ = a.d.Dispatch(ctx, notify.Message{ + UserID: uid, + Topic: "requirement.phase_change", + Severity: notify.SeverityInfo, + Title: "Requirement phase changed", + Metadata: meta, + }) + } +} + +// workspaceStateAdapter implements project.WorkspaceStateLookup over the +// change-request repository: the Done gate passes when THIS requirement has a +// change request in state merged (requirement-scoped, not "any PR in the +// workspace"). Backed by changerequest.Repository so the gate runs in a system +// context without per-caller auth. +type workspaceStateAdapter struct{ crRepo changerequest.Repository } + +func (a workspaceStateAdapter) PullRequestMerged(ctx context.Context, workspaceID, requirementID uuid.UUID) (bool, error) { + crs, err := a.crRepo.ListByWorkspace(ctx, workspaceID) + if err != nil { + return false, err + } + for _, cr := range crs { + if cr.State == changerequest.StateMerged && cr.RequirementID != nil && *cr.RequirementID == requirementID { + return true, nil + } + } + return false, nil +} + // acpEventsPurgeAdapter exposes acp.Repository.PurgeEventsBefore as the narrow // interface required by runners.AcpEventsPurge. type acpEventsPurgeAdapter struct{ repo acp.Repository } @@ -974,6 +1650,14 @@ func (a acpEventsPurgeAdapter) PurgeEventsBefore(ctx context.Context, before tim return a.repo.PurgeEventsBefore(ctx, before) } +// acpTurnsPurgeAdapter exposes acp.Repository.PurgeTurnsBefore as the narrow +// interface required by runners.AcpTurnsPurge. +type acpTurnsPurgeAdapter struct{ repo acp.Repository } + +func (a acpTurnsPurgeAdapter) PurgeTurnsBefore(ctx context.Context, before time.Time) (int64, error) { + return a.repo.PurgeTurnsBefore(ctx, before) +} + // mcpTokensPurgeAdapter exposes mcp.Repository.PurgeExpired as the narrow // interface required by runners.MCPTokensPurge. type mcpTokensPurgeAdapter struct{ repo mcp.Repository } @@ -981,3 +1665,192 @@ type mcpTokensPurgeAdapter struct{ repo mcp.Repository } func (a mcpTokensPurgeAdapter) PurgeExpired(ctx context.Context, before time.Time) (int64, error) { return a.repo.PurgeExpired(ctx, before) } + +// acpModelPriceAdapter resolves an LLM model_id to its prices for ACP cost +// accounting, bridging acp.ModelPriceLookup to chat.Repository.GetLLMModel. +// Reuses the single pricing source (chat) without an acp→chat compile-time dep. +type acpModelPriceAdapter struct{ repo chat.Repository } + +func (a acpModelPriceAdapter) PriceForModel(ctx context.Context, modelID uuid.UUID) (acp.ModelPrice, error) { + m, err := a.repo.GetLLMModel(ctx, modelID) + if err != nil || m == nil { + return acp.ModelPrice{Found: false}, nil + } + return acp.ModelPrice{ + PromptPerM: m.PromptPricePerMillionUSD, + CompletionPerM: m.CompletionPricePerMillionUSD, + ThinkingPerM: m.ThinkingPricePerMillionUSD, + Found: true, + }, nil +} + +// acpModelValidatorAdapter validates that a model_id references an enabled +// llm_model when an admin assigns one to an agent kind. +type acpModelValidatorAdapter struct{ repo chat.Repository } + +func (a acpModelValidatorAdapter) IsEnabledModel(ctx context.Context, modelID uuid.UUID) (bool, error) { + m, err := a.repo.GetLLMModel(ctx, modelID) + if err != nil || m == nil { + return false, nil + } + return m.Enabled, nil +} + +// acpSessionReaperAdapter exposes the ACP repo's reaper surface as the narrow +// interface required by runners.AcpSessionReaper. +type acpSessionReaperAdapter struct{ repo acp.Repository } + +func (a acpSessionReaperAdapter) ListSessionsForReaper(ctx context.Context, wallClockStartedBefore, idleSince time.Time) ([]runners.ReaperSession, error) { + rows, err := a.repo.ListSessionsForReaper(ctx, wallClockStartedBefore, idleSince) + if err != nil { + return nil, err + } + out := make([]runners.ReaperSession, 0, len(rows)) + for _, r := range rows { + out = append(out, runners.ReaperSession{ + ID: r.ID, + UserID: r.UserID, + StartedAt: r.StartedAt, + LastActivityAt: r.LastActivityAt, + MaxWallClockSeconds: r.MaxWallClockSeconds, + }) + } + return out, nil +} + +func (a acpSessionReaperAdapter) MarkSessionTerminatedReason(ctx context.Context, id uuid.UUID, reason string) error { + return a.repo.MarkSessionTerminatedReason(ctx, id, reason) +} + +// ===== code index / project memory / docs adapters ===== + +// embeddingSelector resolves the configured platform embedder from cfg.CodeIndex. +// When no embedding endpoint is configured it returns codeindex.ErrEmbeddingDisabled +// so callers degrade to keyword fallback (memory) or a typed disabled error (search). +type embeddingSelector struct { + registry *llm.Registry + cfg config.CodeIndexConfig +} + +func (s *embeddingSelector) Embedder(ctx context.Context) (llm.Embedder, uuid.UUID, string, int, error) { + if s.cfg.EmbeddingEndpointID == "" { + return nil, uuid.Nil, "", 0, codeindex.ErrEmbeddingDisabled + } + endpointID, err := uuid.Parse(s.cfg.EmbeddingEndpointID) + if err != nil { + return nil, uuid.Nil, "", 0, fmt.Errorf("code_index.embedding_endpoint_id parse: %w", err) + } + emb, err := s.registry.GetEmbedder(ctx, endpointID) + if err != nil { + return nil, uuid.Nil, "", 0, err + } + return emb, endpointID, s.cfg.EmbeddingModel, llm.PlatformEmbeddingDims, nil +} + +// workspaceLocatorAdapter resolves workspace/worktree on-disk paths for codeindex +// without exposing the full workspace.Repository (and without an import cycle: +// codeindex defines the narrow interface, app implements it over wsRepo). +type workspaceLocatorAdapter struct{ repo workspace.Repository } + +func (a *workspaceLocatorAdapter) LocateWorkspace(ctx context.Context, wsID uuid.UUID) (codeindex.WorkspaceInfo, error) { + w, err := a.repo.GetWorkspaceByID(ctx, wsID) + if err != nil { + return codeindex.WorkspaceInfo{}, err + } + return codeindex.WorkspaceInfo{ID: w.ID, MainPath: w.MainPath, DefaultBranch: w.DefaultBranch}, nil +} + +func (a *workspaceLocatorAdapter) LocateWorktree(ctx context.Context, worktreeID uuid.UUID) (string, string, uuid.UUID, error) { + wt, err := a.repo.GetWorktreeByID(ctx, worktreeID) + if err != nil { + return "", "", uuid.Nil, err + } + return wt.Path, wt.Branch, wt.WorkspaceID, nil +} + +// docsLocatorAdapter resolves on-disk directories for docs (commit summaries). +type docsLocatorAdapter struct{ repo workspace.Repository } + +func (a *docsLocatorAdapter) LocateWorkspace(ctx context.Context, wsID uuid.UUID) (string, error) { + w, err := a.repo.GetWorkspaceByID(ctx, wsID) + if err != nil { + return "", err + } + return w.MainPath, nil +} + +func (a *docsLocatorAdapter) LocateWorktree(ctx context.Context, worktreeID uuid.UUID) (string, error) { + wt, err := a.repo.GetWorktreeByID(ctx, worktreeID) + if err != nil { + return "", err + } + return wt.Path, nil +} + +// commitSummarizer implements docs.Summarizer using the chat title-generator model +// via the llm registry. It collects text deltas from a single Stream into markdown. +type commitSummarizer struct { + registry *llm.Registry + chatRepo chat.Repository + log *slog.Logger +} + +func (s *commitSummarizer) Summarize(ctx context.Context, branch, commitSHA, diff string) (string, string, string, error) { + model, err := s.chatRepo.GetTitleGeneratorModel(ctx) + if err != nil { + return "", "", "", fmt.Errorf("resolve summary model: %w", err) + } + if model == nil { + return "", "", "", fmt.Errorf("no title-generator model configured for commit summaries") + } + client, err := s.registry.GetClient(ctx, model.EndpointID) + if err != nil { + return "", "", "", fmt.Errorf("resolve summary client: %w", err) + } + const sys = "You are a senior engineer writing concise, accurate commit summaries. " + + "Given a git commit's metadata and --stat, produce a short markdown summary: a one-line title, " + + "then a brief bullet list of what changed and why. Do not invent changes not implied by the stat." + prompt := fmt.Sprintf("Branch: %s\nCommit: %s\n\n```\n%s\n```", branch, commitSHA, diff) + stream, err := client.Stream(ctx, model.ModelID, llm.Request{ + SystemPrompt: sys, + Messages: []llm.Message{{Role: llm.RoleUser, Blocks: []llm.ContentBlock{{Type: "text", Text: prompt}}}}, + MaxOutputTokens: 800, + }) + if err != nil { + return "", "", "", fmt.Errorf("summary stream: %w", err) + } + var b strings.Builder + for ev := range stream { + switch ev.Type { + case llm.EventTextDelta: + b.WriteString(ev.Text) + case llm.EventError: + if ev.Err != nil { + return "", "", "", fmt.Errorf("summary stream: %w", ev.Err) + } + } + } + body := strings.TrimSpace(b.String()) + if body == "" { + return "", "", "", fmt.Errorf("summary stream produced no text") + } + // Derive a title from the first markdown line. + title := firstLine(body) + return title, body, model.ModelID, nil +} + +// firstLine returns the first non-empty line, stripped of leading markdown +// heading / bullet markers, capped to a reasonable title length. +func firstLine(s string) string { + for _, ln := range strings.Split(s, "\n") { + ln = strings.TrimSpace(ln) + ln = strings.TrimLeft(ln, "#-*> ") + if ln != "" { + if len(ln) > 200 { + ln = ln[:200] + } + return ln + } + } + return "" +} diff --git a/internal/app/integration_test.go b/internal/app/integration_test.go index b868d50..429bc42 100644 --- a/internal/app/integration_test.go +++ b/internal/app/integration_test.go @@ -156,7 +156,7 @@ func TestEndToEnd_ProjectClosedLoop(t *testing.T) { projectRepo := project.NewPostgresRepository(pool) projectSvc := project.NewProjectService(projectRepo, auditRec) - requirementSvc := project.NewRequirementService(projectRepo, auditRec) + requirementSvc := project.NewRequirementService(projectRepo, auditRec, nil, nil) issueSvc := project.NewIssueService(projectRepo, auditRec) artifactSvc := project.NewArtifactService(projectRepo, auditRec) @@ -324,7 +324,7 @@ func TestPlan3_WorkspaceClosedLoop(t *testing.T) { projectRepo := project.NewPostgresRepository(pool) projectSvc := project.NewProjectService(projectRepo, auditRec) - requirementSvc := project.NewRequirementService(projectRepo, auditRec) + requirementSvc := project.NewRequirementService(projectRepo, auditRec, nil, nil) issueSvc := project.NewIssueService(projectRepo, auditRec) artifactSvc := project.NewArtifactService(projectRepo, auditRec) diff --git a/internal/app/mcp_integration_test.go b/internal/app/mcp_integration_test.go index 0cfd7be..1edbdea 100644 --- a/internal/app/mcp_integration_test.go +++ b/internal/app/mcp_integration_test.go @@ -148,7 +148,7 @@ func newMCPIntegrationSuite(t *testing.T) *mcpIntegrationSuite { projectRepo := project.NewPostgresRepository(pool) projectSvc := project.NewProjectService(projectRepo, auditWrapped) - requirementSvc := project.NewRequirementService(projectRepo, auditWrapped) + requirementSvc := project.NewRequirementService(projectRepo, auditWrapped, nil, nil) issueSvc := project.NewIssueService(projectRepo, auditWrapped) // --- Bootstrap users --- @@ -268,7 +268,7 @@ func (s *mcpIntegrationSuite) callToolViaMemory( deps := mcp.ServerDeps{ Caller: callerResolver, Projects: s.projectSvc, - Reqs: project.NewRequirementService(project.NewPostgresRepository(s.pool), mcp.AuditWrap(s.auditRec)), + Reqs: project.NewRequirementService(project.NewPostgresRepository(s.pool), mcp.AuditWrap(s.auditRec), nil, nil), Issues: s.issueSvc, // Workspaces/Templates/Messages/Conversations left nil — K3 tests don't call those tools. } @@ -928,7 +928,7 @@ func (s *mcpIntegrationSuite) testPromptsListFromTemplates(t *testing.T) { deps := mcp.ServerDeps{ Caller: callerResolver, Projects: s.projectSvc, - Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec)), + Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec), nil, nil), Issues: s.issueSvc, Templates: tplSvc, } @@ -970,7 +970,7 @@ func (s *mcpIntegrationSuite) testResourceReadNotFound(t *testing.T) { deps := mcp.ServerDeps{ Caller: callerResolver, Projects: s.projectSvc, - Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec)), + Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec), nil, nil), Issues: s.issueSvc, } srv := mcp.NewMCPServer(deps) @@ -1004,7 +1004,7 @@ func (s *mcpIntegrationSuite) testResourceListScopeFilter(t *testing.T) { deps := mcp.ServerDeps{ Caller: callerResolver, Projects: s.projectSvc, - Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec)), + Reqs: project.NewRequirementService(projectRepo, mcp.AuditWrap(s.auditRec), nil, nil), Issues: s.issueSvc, } srv := mcp.NewMCPServer(deps) diff --git a/internal/app/orchestrator_adapters.go b/internal/app/orchestrator_adapters.go new file mode 100644 index 0000000..2fc7656 --- /dev/null +++ b/internal/app/orchestrator_adapters.go @@ -0,0 +1,285 @@ +package app + +import ( + "context" + "strconv" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/acp" + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/infra/notify" + "github.com/yan1h/agent-coding-workflow/internal/orchestrator" + "github.com/yan1h/agent-coding-workflow/internal/project" + "github.com/yan1h/agent-coding-workflow/internal/workspace" +) + +// orchPMAdapter 把 project 的 Repository + RequirementService 适配为 orchestrator.PMAccess。 +// 读走 Repository(无需 Caller 鉴权——编排器内部以 run.owner 为权威身份);ChangePhase +// 走 RequirementService 以复用阶段网关/审计。 +type orchPMAdapter struct { + repo project.Repository + reqs project.RequirementService + adminLookup userAdminAdapter +} + +func (a orchPMAdapter) GetProjectByID(ctx context.Context, id uuid.UUID) (*project.Project, error) { + return a.repo.GetProjectByID(ctx, id) +} + +func (a orchPMAdapter) GetProjectBySlug(ctx context.Context, slug string) (*project.Project, error) { + return a.repo.GetProjectBySlug(ctx, slug) +} + +func (a orchPMAdapter) GetRequirementByID(ctx context.Context, id uuid.UUID) (*project.Requirement, error) { + return a.repo.GetRequirementByID(ctx, id) +} + +func (a orchPMAdapter) GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Requirement, error) { + return a.repo.GetRequirementByNumber(ctx, projectID, number) +} + +// ListLatestArtifacts 取某需求各阶段最新产物(每阶段最大版本),供 BuildPrompt 注入。 +func (a orchPMAdapter) ListLatestArtifacts(ctx context.Context, requirementID uuid.UUID) ([]*project.Artifact, error) { + all, err := a.repo.ListArtifactsByRequirement(ctx, requirementID, "") + if err != nil { + return nil, err + } + latest := map[project.Phase]*project.Artifact{} + for _, art := range all { + if art == nil { + continue + } + if cur, ok := latest[art.Phase]; !ok || art.Version > cur.Version { + latest[art.Phase] = art + } + } + out := make([]*project.Artifact, 0, len(latest)) + for _, ph := range project.AllPhases { + if art, ok := latest[ph]; ok { + out = append(out, art) + } + } + return out, nil +} + +// ChangeRequirementPhase 以 run.owner 身份推进需求阶段(经 RequirementService 复用网关/审计)。 +func (a orchPMAdapter) ChangeRequirementPhase(ctx context.Context, ownerID uuid.UUID, isAdmin bool, projectSlug string, number int, to project.Phase) error { + _, err := a.reqs.ChangePhase(ctx, project.Caller{UserID: ownerID, IsAdmin: isAdmin}, projectSlug, number, to) + return err +} + +// orchACLAdapter 把 project ACL 适配为 orchestrator.ProjectACL(owner+admin 可写)。 +type orchACLAdapter struct { + pa projectAccessAdapter +} + +func (a orchACLAdapter) CanRead(ctx context.Context, userID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) { + canRead, _, err := a.pa.ResolveByID(ctx, userID, isAdmin, projectID) + if err != nil { + return false, err + } + return canRead, nil +} + +func (a orchACLAdapter) CanWrite(ctx context.Context, userID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) { + _, canWrite, err := a.pa.ResolveByID(ctx, userID, isAdmin, projectID) + if err != nil { + return false, err + } + return canWrite, nil +} + +// orchAuditAdapter 把 audit.Recorder 适配为 orchestrator.auditRecorder(窄 record 方法)。 +// 注:orchestrator.auditRecorder 是非导出接口,但 app 在同模块外通过结构方法匹配满足。 +type orchAuditAdapter struct { + rec audit.Recorder +} + +func (a orchAuditAdapter) Record(ctx context.Context, userID uuid.UUID, action, targetID string, meta map[string]any) { + if a.rec == nil { + return + } + uid := userID + _ = a.rec.Record(ctx, audit.Entry{ + UserID: &uid, Action: action, TargetType: "orchestrator_run", TargetID: targetID, Metadata: meta, + }) +} + +// orchSessionTermAdapter 把 acp.SessionService 适配为 orchestrator.sessionTerminator。 +type orchSessionTermAdapter struct { + sess acp.SessionService +} + +func (a orchSessionTermAdapter) TerminateSession(ctx context.Context, ownerID uuid.UUID, isAdmin bool, sessionID uuid.UUID) error { + return a.sess.Terminate(ctx, acp.Caller{UserID: ownerID, IsAdmin: isAdmin}, sessionID) +} + +// orchGateArtifactAdapter 把 project.Repository 适配为 orchestrator.GateArtifactAccess。 +type orchGateArtifactAdapter struct { + repo project.Repository +} + +func (a orchGateArtifactAdapter) ListArtifacts(ctx context.Context, projectID, requirementID uuid.UUID, phase project.Phase) ([]*project.Artifact, error) { + return a.repo.ListArtifactsByRequirement(ctx, requirementID, phase) +} + +// orchGateIssueAdapter 把 project.Repository 适配为 orchestrator.GateIssueAccess。 +type orchGateIssueAdapter struct { + repo project.Repository +} + +func (a orchGateIssueAdapter) CountOpenIssues(ctx context.Context, requirementID uuid.UUID) (int, error) { + issues, err := a.repo.ListIssuesByRequirement(ctx, requirementID) + if err != nil { + return 0, err + } + n := 0 + for _, iss := range issues { + if iss != nil && iss.Status == project.StatusOpen { + n++ + } + } + return n, nil +} + +// orchACPLookupAdapter 把 acp.Repository 适配为 orchestrator.ACPSessionLookup。 +type orchACPLookupAdapter struct { + repo acp.Repository +} + +func (a orchACPLookupAdapter) GetSessionByStepID(ctx context.Context, stepID uuid.UUID) (*acp.Session, error) { + return a.repo.GetSessionByStepID(ctx, stepID) +} + +func (a orchACPLookupAdapter) GetCrashedSessionForResume(ctx context.Context, stepID uuid.UUID) (*acp.Session, error) { + return a.repo.GetCrashedSessionForResume(ctx, stepID) +} + +// 确保 acp.SessionService 同时满足 orchestrator.ACPSessions(Create)。 +var _ orchestrator.ACPSessions = (acp.SessionService)(nil) + +// ===== 任务分解并行调度器适配器(autonomy roadmap §10)===== + +// schedulerReadySourceAdapter 把 project.Repository 适配为 orchestrator.ReadyTaskSource。 +type schedulerReadySourceAdapter struct { + repo project.Repository +} + +func (a schedulerReadySourceAdapter) ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error) { + return a.repo.ListSchedulableProjects(ctx) +} + +func (a schedulerReadySourceAdapter) ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*project.Issue, error) { + return a.repo.ListReadyLeafSubtasks(ctx, projectID, limit) +} + +func (a schedulerReadySourceAdapter) CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error) { + return a.repo.CountActiveSessionsForProject(ctx, projectID) +} + +var _ orchestrator.ReadyTaskSource = schedulerReadySourceAdapter{} + +// schedulerSessionStarterAdapter 为就绪子任务以特权身份(项目 owner)创建 ACP session。 +// 解析 workspace(issue.WorkspaceID 优先,否则取项目首个 workspace)与 agent-kind +// (implementing 阶段默认),把 IssueNumber 注入使 SessionService 绑定 issue/-N worktree。 +type schedulerSessionStarterAdapter struct { + sess acp.SessionService + projectRepo project.Repository + wsRepo workspace.Repository + defaultAgentKind func() (uuid.UUID, bool) // 解析 implementing 阶段默认 agent-kind +} + +func (a schedulerSessionStarterAdapter) StartForIssue(ctx context.Context, iss *project.Issue) (uuid.UUID, error) { + proj, err := a.projectRepo.GetProjectByID(ctx, iss.ProjectID) + if err != nil { + return uuid.Nil, err + } + // 解析 workspace:issue 显式关联优先,否则取项目首个 workspace。 + var wsID uuid.UUID + if iss.WorkspaceID != nil { + wsID = *iss.WorkspaceID + } else { + list, lerr := a.wsRepo.ListWorkspacesByProject(ctx, iss.ProjectID) + if lerr != nil { + return uuid.Nil, lerr + } + if len(list) == 0 { + return uuid.Nil, errs.New(errs.CodeInvalidInput, "project 无可用 workspace,无法为子任务派发 session") + } + wsID = list[0].ID + } + akID, ok := a.defaultAgentKind() + if !ok { + return uuid.Nil, errs.New(errs.CodeInvalidInput, "未配置 implementing 阶段默认 agent-kind,无法派发子任务 session") + } + num := iss.Number + owner := acp.Caller{UserID: proj.OwnerID, IsAdmin: false} + created, err := a.sess.Create(ctx, owner, acp.CreateSessionInput{ + WorkspaceID: wsID, + AgentKindID: akID, + IssueNumber: &num, + }) + if err != nil { + return uuid.Nil, err + } + return created.ID, nil +} + +var _ orchestrator.SessionStarter = schedulerSessionStarterAdapter{} + +// schedulerNotifyAdapter 把 notify.Dispatcher 适配为 orchestrator.SchedulerNotifier。 +// 通知发给项目 owner(best-effort,失败不阻塞调度)。 +type schedulerNotifyAdapter struct { + dispatcher *notify.Dispatcher + projectRepo project.Repository +} + +func (a schedulerNotifyAdapter) ownerOf(ctx context.Context, projectID uuid.UUID) (uuid.UUID, bool) { + proj, err := a.projectRepo.GetProjectByID(ctx, projectID) + if err != nil { + return uuid.Nil, false + } + return proj.OwnerID, true +} + +func (a schedulerNotifyAdapter) NotifyDispatched(ctx context.Context, iss *project.Issue, sessionID uuid.UUID) { + if a.dispatcher == nil { + return + } + owner, ok := a.ownerOf(ctx, iss.ProjectID) + if !ok { + return + } + _ = a.dispatcher.Dispatch(ctx, notify.Message{ + UserID: owner, + Topic: "orchestrator.subtask_dispatched", + Severity: notify.SeverityInfo, + Title: "子任务已派发", + Body: "子任务 #" + itoa(iss.Number) + " 已派发到并行 ACP session", + Metadata: map[string]any{"issue_number": iss.Number, "session_id": sessionID.String()}, + }) +} + +func (a schedulerNotifyAdapter) NotifyQuotaBlocked(ctx context.Context, iss *project.Issue) { + if a.dispatcher == nil { + return + } + owner, ok := a.ownerOf(ctx, iss.ProjectID) + if !ok { + return + } + _ = a.dispatcher.Dispatch(ctx, notify.Message{ + UserID: owner, + Topic: "orchestrator.subtask_quota_blocked", + Severity: notify.SeverityWarning, + Title: "子任务派发受配额限制", + Body: "子任务 #" + itoa(iss.Number) + " 因会话配额暂缓派发,下一轮重试", + Metadata: map[string]any{"issue_number": iss.Number}, + }) +} + +var _ orchestrator.SchedulerNotifier = schedulerNotifyAdapter{} + +func itoa(n int) string { return strconv.Itoa(n) } diff --git a/internal/audit/handler.go b/internal/audit/handler.go new file mode 100644 index 0000000..5a5b199 --- /dev/null +++ b/internal/audit/handler.go @@ -0,0 +1,158 @@ +// handler.go 暴露 admin-only 的审计日志查看接口(spec §11 步骤4)。 +// +// GET /api/v1/admin/audit-logs?action=&action_prefix=&target_type=&target_id= +// &user_id=&from=&to=&limit=&offset= +// +// 鉴权:复刻 chat.adminGuard —— Auth 中间件解出 uid,再经 AdminLookup.IsAdmin +// 门禁;非 admin 返回 403。from/to 接受 RFC3339 时间字符串。 +package audit + +import ( + "context" + "net/http" + "strconv" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" +) + +// AdminLookup resolves whether a user is an admin (narrow interface; mirrors +// chat/acp handler pattern). user.Service satisfies it. +type AdminLookup interface { + IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) +} + +// Handler exposes the admin audit-log read endpoint. +type Handler struct { + reader Reader + resolver middleware.SessionResolver + admin AdminLookup +} + +// NewHandler constructs the audit read handler. +func NewHandler(reader Reader, resolver middleware.SessionResolver, admin AdminLookup) *Handler { + return &Handler{reader: reader, resolver: resolver, admin: admin} +} + +// Mount registers the admin audit routes under /api/v1/admin. +func (h *Handler) Mount(r chi.Router) { + r.Route("/api/v1/admin/audit-logs", func(r chi.Router) { + r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) + r.Use(h.adminGuard) + r.Get("/", h.list) + }) +} + +func (h *Handler) adminGuard(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + h.writeErr(w, r, errs.New(errs.CodeUnauthorized, "unauthorized")) + return + } + isAdmin, err := h.admin.IsAdmin(r.Context(), uid) + if err != nil { + h.writeErr(w, r, err) + return + } + if !isAdmin { + h.writeErr(w, r, errs.New(errs.CodeForbidden, "admin only")) + return + } + next.ServeHTTP(w, r) + }) +} + +type auditLogDTO struct { + ID int64 `json:"id"` + UserID *string `json:"user_id,omitempty"` + Action string `json:"action"` + TargetType string `json:"target_type,omitempty"` + TargetID string `json:"target_id,omitempty"` + IP string `json:"ip,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` + CreatedAt string `json:"created_at"` +} + +func (h *Handler) list(w http.ResponseWriter, r *http.Request) { + q := r.URL.Query() + f := AuditFilter{ + Action: q.Get("action"), + ActionPrefix: q.Get("action_prefix"), + TargetType: q.Get("target_type"), + TargetID: q.Get("target_id"), + } + if v := q.Get("user_id"); v != "" { + uid, err := uuid.Parse(v) + if err != nil { + h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 格式错误")) + return + } + f.UserID = &uid + } + if v := q.Get("from"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "from 须为 RFC3339")) + return + } + f.From = &t + } + if v := q.Get("to"); v != "" { + t, err := time.Parse(time.RFC3339, v) + if err != nil { + h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "to 须为 RFC3339")) + return + } + f.To = &t + } + if v := q.Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "limit 须为非负整数")) + return + } + f.Limit = n + } + if v := q.Get("offset"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 0 { + h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "offset 须为非负整数")) + return + } + f.Offset = n + } + + entries, total, err := h.reader.List(r.Context(), f) + if err != nil { + h.writeErr(w, r, err) + return + } + out := make([]auditLogDTO, 0, len(entries)) + for _, e := range entries { + dto := auditLogDTO{ + ID: e.ID, + Action: e.Action, + TargetType: e.TargetType, + TargetID: e.TargetID, + IP: e.IP, + Metadata: e.Metadata, + CreatedAt: e.CreatedAt.UTC().Format(time.RFC3339), + } + if e.UserID != nil { + s := e.UserID.String() + dto.UserID = &s + } + out = append(out, dto) + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": out, "total": total}) +} + +func (h *Handler) writeErr(w http.ResponseWriter, r *http.Request, err error) { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) +} diff --git a/internal/audit/handler_test.go b/internal/audit/handler_test.go new file mode 100644 index 0000000..6486295 --- /dev/null +++ b/internal/audit/handler_test.go @@ -0,0 +1,136 @@ +package audit + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" +) + +type fakeResolver struct{ uid uuid.UUID } + +func (f *fakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) { + if token == "" { + return uuid.Nil, false, nil + } + return f.uid, true, nil +} + +type fakeAdmin struct{ admins map[uuid.UUID]bool } + +func (f *fakeAdmin) IsAdmin(_ context.Context, id uuid.UUID) (bool, error) { + return f.admins[id], nil +} + +type fakeReader struct { + lastFilter AuditFilter + entries []LogEntry + total int +} + +func (f *fakeReader) List(_ context.Context, flt AuditFilter) ([]LogEntry, int, error) { + f.lastFilter = flt + return f.entries, f.total, nil +} +func (f *fakeReader) PurgeBefore(_ context.Context, _ time.Time) (int64, error) { return 0, nil } + +func mount(reader Reader, uid uuid.UUID, admins map[uuid.UUID]bool) http.Handler { + r := chi.NewRouter() + NewHandler(reader, &fakeResolver{uid: uid}, &fakeAdmin{admins: admins}).Mount(r) + return r +} + +func TestAuditHandlerNonAdminForbidden(t *testing.T) { + uid := uuid.New() + h := mount(&fakeReader{}, uid, map[uuid.UUID]bool{}) // not admin + req := httptest.NewRequest("GET", "/api/v1/admin/audit-logs/", nil) + req.Header.Set("Authorization", "Bearer tok") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d, want 403", rec.Code) + } +} + +func TestAuditHandlerUnauthenticated(t *testing.T) { + uid := uuid.New() + h := mount(&fakeReader{}, uid, map[uuid.UUID]bool{uid: true}) + req := httptest.NewRequest("GET", "/api/v1/admin/audit-logs/", nil) // no token + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", rec.Code) + } +} + +func TestAuditHandlerAdminListWithFilters(t *testing.T) { + uid := uuid.New() + filterUID := uuid.New() + now := time.Now().UTC().Truncate(time.Second) + reader := &fakeReader{ + entries: []LogEntry{{ + ID: 7, + UserID: &filterUID, + Action: "user.login", + CreatedAt: now, + }}, + total: 1, + } + h := mount(reader, uid, map[uuid.UUID]bool{uid: true}) + + from := now.Add(-time.Hour).Format(time.RFC3339) + url := "/api/v1/admin/audit-logs/?action=user.login&target_type=user&user_id=" + + filterUID.String() + "&from=" + from + "&limit=10&offset=5" + req := httptest.NewRequest("GET", url, nil) + req.Header.Set("Authorization", "Bearer tok") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200; body=%s", rec.Code, rec.Body.String()) + } + // Filter parsed and forwarded to reader. + if reader.lastFilter.Action != "user.login" { + t.Errorf("action filter = %q, want user.login", reader.lastFilter.Action) + } + if reader.lastFilter.TargetType != "user" { + t.Errorf("target_type filter = %q", reader.lastFilter.TargetType) + } + if reader.lastFilter.UserID == nil || *reader.lastFilter.UserID != filterUID { + t.Errorf("user_id filter not parsed: %v", reader.lastFilter.UserID) + } + if reader.lastFilter.From == nil { + t.Errorf("from filter not parsed") + } + if reader.lastFilter.Limit != 10 || reader.lastFilter.Offset != 5 { + t.Errorf("limit/offset = %d/%d, want 10/5", reader.lastFilter.Limit, reader.lastFilter.Offset) + } + + var resp struct { + Items []auditLogDTO `json:"items"` + Total int `json:"total"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Total != 1 || len(resp.Items) != 1 || resp.Items[0].ID != 7 { + t.Errorf("unexpected response: %+v", resp) + } +} + +func TestAuditHandlerBadQueryParam(t *testing.T) { + uid := uuid.New() + h := mount(&fakeReader{}, uid, map[uuid.UUID]bool{uid: true}) + req := httptest.NewRequest("GET", "/api/v1/admin/audit-logs/?from=not-a-date", nil) + req.Header.Set("Authorization", "Bearer tok") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d, want 400; body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/internal/audit/queries/audit.sql b/internal/audit/queries/audit.sql index 285a0d1..83bee42 100644 --- a/internal/audit/queries/audit.sql +++ b/internal/audit/queries/audit.sql @@ -1,3 +1,32 @@ -- name: InsertAuditLog :exec INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata) VALUES ($1, $2, $3, $4, $5, $6); + +-- name: ListAuditLog :many +-- Paginated read with optional filters. NULL filter args match everything +-- (sqlc.narg renders as a nullable parameter; COALESCE/IS NULL short-circuits). +SELECT id, user_id, action, target_type, target_id, ip, metadata, created_at +FROM audit_logs +WHERE (sqlc.narg('user_id')::uuid IS NULL OR user_id = sqlc.narg('user_id')) + AND (sqlc.narg('action')::text IS NULL OR action = sqlc.narg('action')) + AND (sqlc.narg('action_prefix')::text IS NULL OR action LIKE sqlc.narg('action_prefix') || '%') + AND (sqlc.narg('target_type')::text IS NULL OR target_type = sqlc.narg('target_type')) + AND (sqlc.narg('target_id')::text IS NULL OR target_id = sqlc.narg('target_id')) + AND (sqlc.narg('from_ts')::timestamptz IS NULL OR created_at >= sqlc.narg('from_ts')) + AND (sqlc.narg('to_ts')::timestamptz IS NULL OR created_at < sqlc.narg('to_ts')) +ORDER BY created_at DESC, id DESC +LIMIT $1 OFFSET $2; + +-- name: CountAuditLog :one +SELECT COUNT(*) +FROM audit_logs +WHERE (sqlc.narg('user_id')::uuid IS NULL OR user_id = sqlc.narg('user_id')) + AND (sqlc.narg('action')::text IS NULL OR action = sqlc.narg('action')) + AND (sqlc.narg('action_prefix')::text IS NULL OR action LIKE sqlc.narg('action_prefix') || '%') + AND (sqlc.narg('target_type')::text IS NULL OR target_type = sqlc.narg('target_type')) + AND (sqlc.narg('target_id')::text IS NULL OR target_id = sqlc.narg('target_id')) + AND (sqlc.narg('from_ts')::timestamptz IS NULL OR created_at >= sqlc.narg('from_ts')) + AND (sqlc.narg('to_ts')::timestamptz IS NULL OR created_at < sqlc.narg('to_ts')); + +-- name: DeleteAuditLogsBefore :execrows +DELETE FROM audit_logs WHERE created_at < $1; diff --git a/internal/audit/reader.go b/internal/audit/reader.go new file mode 100644 index 0000000..42f8a8a --- /dev/null +++ b/internal/audit/reader.go @@ -0,0 +1,164 @@ +// reader.go 提供审计日志的只读查询路径(spec §11 步骤4)。 +// +// audit.Recorder 是 write-only 门面;Reader 是与之对称的 read 门面,供 admin +// 审计查看器(GET /api/v1/admin/audit-logs)与保留期清理 runner 使用。pgReader +// 复用 auditsqlc 生成的 List/Count/Delete 查询,并在 pgtype <-> 领域类型间转换。 +package audit + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + auditsqlc "github.com/yan1h/agent-coding-workflow/internal/audit/sqlc" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// LogEntry 是一条已落库审计记录的只读领域视图。区别于写入用的 Entry:含 ID/ +// 时间戳,IP 以字符串形式返回。 +type LogEntry struct { + ID int64 + UserID *uuid.UUID + Action string + TargetType string + TargetID string + IP string + Metadata map[string]any + CreatedAt time.Time +} + +// AuditFilter 是 List/Count 的可选过滤条件。零值字段表示不过滤。Limit<=0 时由 +// Reader 兜底为默认页大小;Limit 上限由 Reader 钳制。 +type AuditFilter struct { + UserID *uuid.UUID + Action string // 精确匹配 + ActionPrefix string // 前缀匹配(搜索用) + TargetType string + TargetID string + From *time.Time + To *time.Time + Limit int + Offset int +} + +const ( + // DefaultAuditPageSize 是未指定 Limit 时的默认页大小。 + DefaultAuditPageSize = 50 + // MaxAuditPageSize 是单页返回的硬上限,避免一次拉全表。 + MaxAuditPageSize = 500 +) + +// Reader 是审计读取的对外契约:分页列表 + 总数 + 保留期清理。 +type Reader interface { + List(ctx context.Context, f AuditFilter) ([]LogEntry, int, error) + PurgeBefore(ctx context.Context, before time.Time) (int64, error) +} + +type pgReader struct { + q *auditsqlc.Queries +} + +// NewPostgresReader 用现有 pgxpool 构造 Reader 实现。 +func NewPostgresReader(pool *pgxpool.Pool) Reader { + return &pgReader{q: auditsqlc.New(pool)} +} + +// List 返回符合过滤条件的审计记录页 + 满足条件的总数(用于分页)。 +func (r *pgReader) List(ctx context.Context, f AuditFilter) ([]LogEntry, int, error) { + limit := f.Limit + if limit <= 0 { + limit = DefaultAuditPageSize + } + if limit > MaxAuditPageSize { + limit = MaxAuditPageSize + } + offset := f.Offset + if offset < 0 { + offset = 0 + } + + rows, err := r.q.ListAuditLog(ctx, auditsqlc.ListAuditLogParams{ + Limit: int32(limit), + Offset: int32(offset), + UserID: pgUUIDFromPtr(f.UserID), + Action: ptr(f.Action), + ActionPrefix: ptr(f.ActionPrefix), + TargetType: ptr(f.TargetType), + TargetID: ptr(f.TargetID), + FromTs: tsFromPtr(f.From), + ToTs: tsFromPtr(f.To), + }) + if err != nil { + return nil, 0, errs.Wrap(err, errs.CodeInternal, "list audit logs") + } + + total, err := r.q.CountAuditLog(ctx, auditsqlc.CountAuditLogParams{ + UserID: pgUUIDFromPtr(f.UserID), + Action: ptr(f.Action), + ActionPrefix: ptr(f.ActionPrefix), + TargetType: ptr(f.TargetType), + TargetID: ptr(f.TargetID), + FromTs: tsFromPtr(f.From), + ToTs: tsFromPtr(f.To), + }) + if err != nil { + return nil, 0, errs.Wrap(err, errs.CodeInternal, "count audit logs") + } + + out := make([]LogEntry, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToEntry(row)) + } + return out, int(total), nil +} + +// PurgeBefore 删除 created_at < before 的全部审计记录,返回删除行数。保留期 runner 调用。 +func (r *pgReader) PurgeBefore(ctx context.Context, before time.Time) (int64, error) { + n, err := r.q.DeleteAuditLogsBefore(ctx, toPgTimestamptz(before)) + if err != nil { + return 0, errs.Wrap(err, errs.CodeInternal, "purge audit logs") + } + return n, nil +} + +func rowToEntry(row auditsqlc.AuditLog) LogEntry { + e := LogEntry{ + ID: row.ID, + Action: row.Action, + CreatedAt: row.CreatedAt.Time, + } + if row.UserID.Valid { + u := uuid.UUID(row.UserID.Bytes) + e.UserID = &u + } + if row.TargetType != nil { + e.TargetType = *row.TargetType + } + if row.TargetID != nil { + e.TargetID = *row.TargetID + } + if row.Ip != nil { + e.IP = row.Ip.String() + } + if len(row.Metadata) > 0 { + _ = json.Unmarshal(row.Metadata, &e.Metadata) + } + return e +} + +// tsFromPtr 把 *time.Time 转为 pgtype.Timestamptz;nil 映射为 NULL(不过滤)。 +func tsFromPtr(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{Valid: false} + } + return pgtype.Timestamptz{Time: *t, Valid: true} +} + +// toPgTimestamptz 把非空 time.Time 转为有效 Timestamptz。 +func toPgTimestamptz(t time.Time) pgtype.Timestamptz { + return pgtype.Timestamptz{Time: t, Valid: true} +} diff --git a/internal/audit/sqlc/audit.sql.go b/internal/audit/sqlc/audit.sql.go index a75fe48..057a4ed 100644 --- a/internal/audit/sqlc/audit.sql.go +++ b/internal/audit/sqlc/audit.sql.go @@ -12,6 +12,55 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const countAuditLog = `-- name: CountAuditLog :one +SELECT COUNT(*) +FROM audit_logs +WHERE ($1::uuid IS NULL OR user_id = $1) + AND ($2::text IS NULL OR action = $2) + AND ($3::text IS NULL OR action LIKE $3 || '%') + AND ($4::text IS NULL OR target_type = $4) + AND ($5::text IS NULL OR target_id = $5) + AND ($6::timestamptz IS NULL OR created_at >= $6) + AND ($7::timestamptz IS NULL OR created_at < $7) +` + +type CountAuditLogParams struct { + UserID pgtype.UUID `json:"user_id"` + Action *string `json:"action"` + ActionPrefix *string `json:"action_prefix"` + TargetType *string `json:"target_type"` + TargetID *string `json:"target_id"` + FromTs pgtype.Timestamptz `json:"from_ts"` + ToTs pgtype.Timestamptz `json:"to_ts"` +} + +func (q *Queries) CountAuditLog(ctx context.Context, arg CountAuditLogParams) (int64, error) { + row := q.db.QueryRow(ctx, countAuditLog, + arg.UserID, + arg.Action, + arg.ActionPrefix, + arg.TargetType, + arg.TargetID, + arg.FromTs, + arg.ToTs, + ) + var count int64 + err := row.Scan(&count) + return count, err +} + +const deleteAuditLogsBefore = `-- name: DeleteAuditLogsBefore :execrows +DELETE FROM audit_logs WHERE created_at < $1 +` + +func (q *Queries) DeleteAuditLogsBefore(ctx context.Context, createdAt pgtype.Timestamptz) (int64, error) { + result, err := q.db.Exec(ctx, deleteAuditLogsBefore, createdAt) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + const insertAuditLog = `-- name: InsertAuditLog :exec INSERT INTO audit_logs (user_id, action, target_type, target_id, ip, metadata) VALUES ($1, $2, $3, $4, $5, $6) @@ -37,3 +86,70 @@ func (q *Queries) InsertAuditLog(ctx context.Context, arg InsertAuditLogParams) ) return err } + +const listAuditLog = `-- name: ListAuditLog :many +SELECT id, user_id, action, target_type, target_id, ip, metadata, created_at +FROM audit_logs +WHERE ($3::uuid IS NULL OR user_id = $3) + AND ($4::text IS NULL OR action = $4) + AND ($5::text IS NULL OR action LIKE $5 || '%') + AND ($6::text IS NULL OR target_type = $6) + AND ($7::text IS NULL OR target_id = $7) + AND ($8::timestamptz IS NULL OR created_at >= $8) + AND ($9::timestamptz IS NULL OR created_at < $9) +ORDER BY created_at DESC, id DESC +LIMIT $1 OFFSET $2 +` + +type ListAuditLogParams struct { + Limit int32 `json:"limit"` + Offset int32 `json:"offset"` + UserID pgtype.UUID `json:"user_id"` + Action *string `json:"action"` + ActionPrefix *string `json:"action_prefix"` + TargetType *string `json:"target_type"` + TargetID *string `json:"target_id"` + FromTs pgtype.Timestamptz `json:"from_ts"` + ToTs pgtype.Timestamptz `json:"to_ts"` +} + +// Paginated read with optional filters. NULL filter args match everything +// (sqlc.narg renders as a nullable parameter; COALESCE/IS NULL short-circuits). +func (q *Queries) ListAuditLog(ctx context.Context, arg ListAuditLogParams) ([]AuditLog, error) { + rows, err := q.db.Query(ctx, listAuditLog, + arg.Limit, + arg.Offset, + arg.UserID, + arg.Action, + arg.ActionPrefix, + arg.TargetType, + arg.TargetID, + arg.FromTs, + arg.ToTs, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []AuditLog + for rows.Next() { + var i AuditLog + if err := rows.Scan( + &i.ID, + &i.UserID, + &i.Action, + &i.TargetType, + &i.TargetID, + &i.Ip, + &i.Metadata, + &i.CreatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/audit/sqlc/models.go b/internal/audit/sqlc/models.go index ec31e15..b0c6cce 100644 --- a/internal/audit/sqlc/models.go +++ b/internal/audit/sqlc/models.go @@ -8,6 +8,7 @@ import ( "net/netip" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type AcpAgentKind struct { @@ -25,6 +26,11 @@ type AcpAgentKind struct { ToolAllowlist []string `json:"tool_allowlist"` ClientType string `json:"client_type"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` } type AcpAgentKindConfigFile struct { @@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct { UpdatedBy pgtype.UUID `json:"updated_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type AcpEvent struct { @@ -64,23 +71,64 @@ type AcpPermissionRequest struct { } type AcpSession struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ProjectID pgtype.UUID `json:"project_id"` - AgentKindID pgtype.UUID `json:"agent_kind_id"` - UserID pgtype.UUID `json:"user_id"` - IssueID pgtype.UUID `json:"issue_id"` - RequirementID pgtype.UUID `json:"requirement_id"` - AgentSessionID *string `json:"agent_session_id"` - Branch string `json:"branch"` - CwdPath string `json:"cwd_path"` - IsMainWorktree bool `json:"is_main_worktree"` - Status string `json:"status"` - Pid *int32 `json:"pid"` - ExitCode *int32 `json:"exit_code"` - LastError *string `json:"last_error"` - StartedAt pgtype.Timestamptz `json:"started_at"` - EndedAt pgtype.Timestamptz `json:"ended_at"` + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` } type AuditLog struct { @@ -94,6 +142,81 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Conversation struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` @@ -110,6 +233,14 @@ type Conversation struct { RequirementID pgtype.UUID `json:"requirement_id"` } +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type GitCredential struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -119,6 +250,7 @@ type GitCredential struct { Fingerprint *string `json:"fingerprint"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type Issue struct { @@ -135,6 +267,17 @@ type Issue struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Job struct { @@ -162,6 +305,7 @@ type LlmEndpoint struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type LlmModel struct { @@ -252,6 +396,50 @@ type Notification struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + type Project struct { ID pgtype.UUID `json:"id"` Slug string `json:"slug"` @@ -264,6 +452,30 @@ type Project struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type PromptTemplate struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` @@ -299,6 +511,16 @@ type RequirementArtifact struct { SourceMessageID *int64 `json:"source_message_id"` CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type User struct { @@ -354,6 +576,7 @@ type WorkspaceRunProfile struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type WorkspaceWorktree struct { diff --git a/internal/brief/composer.go b/internal/brief/composer.go new file mode 100644 index 0000000..e26755e --- /dev/null +++ b/internal/brief/composer.go @@ -0,0 +1,432 @@ +package brief + +import ( + "context" + "fmt" + "sort" + "strings" + + "github.com/yan1h/agent-coding-workflow/internal/infra/llm" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// BriefKind 标识简报的使用场景,影响默认角色提示与是否做仓库定向。 +type BriefKind string + +const ( + // KindACPSession 用于 ACP 执行型 session 的初始 prompt(含仓库定向)。 + KindACPSession BriefKind = "acp_session" + // KindChatRequirement 用于挂载到 requirement 的 chat 会话 FK 注入。 + KindChatRequirement BriefKind = "chat_requirement" + // KindChatIssue 用于挂载到 issue 的 chat 会话 FK 注入。 + KindChatIssue BriefKind = "chat_issue" +) + +// 默认 token 预算与有界参数。可被 Config 覆盖。 +const ( + DefaultTokenBudget = 6000 + defaultMaxCommits = 10 + defaultMaxDirty = 20 + // minSectionTokens 是一个 section 至少要能容纳的 token 数;不足以放下哪怕 + // 这么多时直接跳过该 section(避免输出只剩一个截断标记的无意义碎片)。 + minSectionTokens = 16 + truncateMarker = "\n\n……(已截断)" +) + +// truncatedSectionTokens 是 truncateMarker 的 token 预算占用估算,预留给截断标记。 +var truncatedSectionTokens = llm.EstimateTokens(truncateMarker) + +// BriefInput 是组装一份简报所需的全部输入。所有数据由调用方(app.go 适配器) +// 预先解析好传入,brief 不主动访问仓储,从而不产生跨模块依赖。 +type BriefInput struct { + Project *project.Project + Requirement *project.Requirement // nil 表示 issue-only 或自由对话 + Issue *project.Issue // nil 表示 requirement-only + Artifacts []*project.Artifact // 各阶段产物(可含多版本,composer 自行取每阶段最新) + RepoCwd string // session CwdPath / workspace main path,用于 git 定向 + Branch string // 当前分支(session.Branch),填入 RepoOrientation.Branch + UserPrompt string // 操作者的自由文本初始 prompt,永远追加在最后 + TokenBudget int // 硬上限;<=0 时用 Config.DefaultTokenBudget + Kind BriefKind +} + +// BriefResult 是组装结果。 +type BriefResult struct { + Text string // 组装好的、token 受限的简报 + Tokens int // llm.EstimateTokens(Text) + Truncated bool // 是否有任意 section 被裁剪以适配预算 + Sections []string // 实际纳入的 section 名(用于 audit/debug) +} + +// Composer 是 brief 包的唯一入口。 +type Composer interface { + ComposeTaskBrief(ctx context.Context, in BriefInput) (BriefResult, error) +} + +// Config 控制 Composer 的默认预算与定向参数。 +type Config struct { + DefaultTokenBudget int + MaxCommits int + MaxDirtyFiles int +} + +func (c Config) withDefaults() Config { + if c.DefaultTokenBudget <= 0 { + c.DefaultTokenBudget = DefaultTokenBudget + } + if c.MaxCommits <= 0 { + c.MaxCommits = defaultMaxCommits + } + if c.MaxDirtyFiles <= 0 { + c.MaxDirtyFiles = defaultMaxDirty + } + return c +} + +type composer struct { + orienter RepoOrienter + cfg Config +} + +// NewComposer 构造 Composer。orienter 可为 nil(chat 场景通常不做 git 定向以避免 +// 请求路径阻塞);nil 时跳过仓库定向 section。 +func NewComposer(orienter RepoOrienter, cfg Config) Composer { + return &composer{orienter: orienter, cfg: cfg.withDefaults()} +} + +// section 是简报的一段,按 priority 升序加入(数字越小优先级越高、越先放入预算)。 +type section struct { + name string + body string + truncatable bool // true 时预算不足可截断;false 时只能整段纳入或整段丢弃 +} + +// ComposeTaskBrief 按优先级顺序组装 section 并做 section 粒度的 token 预算控制。 +// +// 优先级(高→低): +// +// role/instructions > requirement/issue core > acceptance criteria > +// latest auditing artifact > prototyping > planning > repo orientation > user prompt +// +// user prompt 永远最后追加且不被截断(即使它单独就超预算也保留——它是操作者的 +// 真实意图,宁可超 budget 也不能丢)。 +func (c *composer) ComposeTaskBrief(ctx context.Context, in BriefInput) (BriefResult, error) { + budget := in.TokenBudget + if budget <= 0 { + budget = c.cfg.DefaultTokenBudget + } + + secs := c.buildSections(ctx, in) + + var ( + included []string + bodies []string + used int + trunc bool + ) + + for _, s := range secs { + if s.body == "" { + continue + } + // user prompt 特殊:永远纳入、永不截断。 + if !s.truncatable { + bodies = append(bodies, s.body) + included = append(included, s.name) + used += llm.EstimateTokens(s.body) + continue + } + + remaining := budget - used + if remaining < minSectionTokens { + trunc = true + continue + } + + cost := llm.EstimateTokens(s.body) + if cost <= remaining { + bodies = append(bodies, s.body) + included = append(included, s.name) + used += cost + continue + } + + // 预算不足:截断当前 section 以塞进剩余预算。 + cut := truncateToTokens(s.body, remaining-truncatedSectionTokens) + if cut == "" { + trunc = true + continue + } + body := cut + truncateMarker + bodies = append(bodies, body) + included = append(included, s.name) + used += llm.EstimateTokens(body) + trunc = true + } + + text := strings.Join(bodies, "\n\n") + return BriefResult{ + Text: text, + Tokens: llm.EstimateTokens(text), + Truncated: trunc, + Sections: included, + }, nil +} + +// buildSections 把 BriefInput 解析成有序的 section 列表(已按优先级排好)。 +func (c *composer) buildSections(ctx context.Context, in BriefInput) []section { + var secs []section + + // 1. 角色 / 指令 + if role := c.rolePrompt(in); role != "" { + secs = append(secs, section{name: "role", body: role, truncatable: false}) + } + if in.Kind == KindACPSession || in.Requirement != nil || in.Issue != nil { + secs = append(secs, section{name: "interactive_protocol", body: InteractiveQuestionProtocol, truncatable: false}) + } + + // 2. requirement / issue 核心 + if core := requirementCore(in.Requirement); core != "" { + secs = append(secs, section{name: "requirement_core", body: core, truncatable: true}) + } + if core := issueCore(in.Issue); core != "" { + secs = append(secs, section{name: "issue_core", body: core, truncatable: true}) + } + + latest := latestArtifactsByPhase(in.Artifacts) + + // 3. 验收标准:优先取 requirement 的最新 planning 产物中的"验收标准"段落。 + if ac := acceptanceCriteria(in.Requirement, latest); ac != "" { + secs = append(secs, section{name: "acceptance_criteria", body: ac, truncatable: true}) + } + + // 4-6. 阶段产物(auditing > prototyping > planning) + if a := latest[project.PhaseAuditing]; a != nil { + secs = append(secs, section{name: "artifact_auditing", body: artifactBody(a), truncatable: true}) + } + if a := latest[project.PhasePrototyping]; a != nil { + secs = append(secs, section{name: "artifact_prototyping", body: artifactBody(a), truncatable: true}) + } + if a := latest[project.PhasePlanning]; a != nil { + secs = append(secs, section{name: "artifact_planning", body: artifactBody(a), truncatable: true}) + } + + // 7. 仓库定向(仅 ACP 场景且有 orienter + cwd) + if c.orienter != nil && in.RepoCwd != "" { + ori := c.orienter.Orient(ctx, in.RepoCwd, in.Branch, c.cfg.MaxCommits, c.cfg.MaxDirtyFiles) + if body := orientationBody(ori); body != "" { + secs = append(secs, section{name: "repo_orientation", body: body, truncatable: true}) + } + } + + // 8. 用户自由文本(最后,永不截断) + if up := strings.TrimSpace(in.UserPrompt); up != "" { + secs = append(secs, section{name: "user_prompt", body: "## 操作者指令\n" + up, truncatable: false}) + } + + return secs +} + +func (c *composer) rolePrompt(in BriefInput) string { + // requirement 有阶段时优先用阶段角色(与原前端 PHASE_ROLE_PROMPTS 行为一致)。 + if in.Requirement != nil { + if rp, ok := PhaseRolePrompts[in.Requirement.Phase]; ok && rp != "" { + return rp + } + } + if in.Kind == KindACPSession { + return ACPRolePrompt + } + return "" +} + +func requirementCore(req *project.Requirement) string { + if req == nil { + return "" + } + var b strings.Builder + fmt.Fprintf(&b, "## 当前需求\n需求 #%d:%s", req.Number, req.Title) + if d := strings.TrimSpace(req.Description); d != "" { + fmt.Fprintf(&b, "\n### 需求描述\n%s", d) + } + return b.String() +} + +func issueCore(iss *project.Issue) string { + if iss == nil { + return "" + } + var b strings.Builder + fmt.Fprintf(&b, "## 当前 Issue\nIssue #%d:%s", iss.Number, iss.Title) + if d := strings.TrimSpace(iss.Description); d != "" { + fmt.Fprintf(&b, "\n### Issue 描述\n%s", d) + } + return b.String() +} + +func artifactBody(a *project.Artifact) string { + if a == nil { + return "" + } + content := strings.TrimSpace(a.Content) + if content == "" { + return "" + } + return fmt.Sprintf("## %s 阶段产物 v%d\n%s", a.Phase, a.Version, content) +} + +func orientationBody(o RepoOrientation) string { + if o.IsEmpty() { + return "" + } + var b strings.Builder + b.WriteString("## 仓库现状") + if o.Branch != "" { + fmt.Fprintf(&b, "\n当前分支:%s", o.Branch) + } + if len(o.RecentCommits) > 0 { + b.WriteString("\n最近提交:") + for _, c := range o.RecentCommits { + fmt.Fprintf(&b, "\n- %s", c) + } + } + if len(o.DirtyFiles) > 0 { + b.WriteString("\n未提交改动:") + for _, f := range o.DirtyFiles { + fmt.Fprintf(&b, "\n- %s", f) + } + } + return b.String() +} + +// latestArtifactsByPhase 按 (phase) 取 version 最大的产物。输入可包含同阶段多版本。 +func latestArtifactsByPhase(arts []*project.Artifact) map[project.Phase]*project.Artifact { + out := make(map[project.Phase]*project.Artifact, len(arts)) + for _, a := range arts { + if a == nil { + continue + } + cur, ok := out[a.Phase] + if !ok || a.Version > cur.Version { + out[a.Phase] = a + } + } + return out +} + +// acceptanceCriteriaHeadings 是从 planning 产物中提取"验收标准"段落时识别的标题关键字。 +var acceptanceCriteriaHeadings = []string{"验收标准", "acceptance criteria", "acceptance"} + +// acceptanceCriteria 解析验收标准。当前 requirement 无一级字段,回退为从最新 +// planning 产物的 Markdown 中提取"验收标准 / Acceptance Criteria"小节。 +// 保守策略:找不到明确小节时返回空串(宁可省略也不误纳入)。 +func acceptanceCriteria(_ *project.Requirement, latest map[project.Phase]*project.Artifact) string { + planning := latest[project.PhasePlanning] + if planning == nil { + return "" + } + body := extractSection(planning.Content, acceptanceCriteriaHeadings) + if body == "" { + return "" + } + return "## 验收标准\n" + body +} + +// extractSection 从 Markdown 文本中提取标题匹配 headings 之一的小节正文(到下一个 +// 同级或更高级标题前为止)。匹配大小写不敏感、忽略标题中的标点与空白。 +func extractSection(md string, headings []string) string { + lines := strings.Split(md, "\n") + startIdx := -1 + startLevel := 0 + for i, line := range lines { + level, title, ok := parseHeading(line) + if !ok { + continue + } + if matchesHeading(title, headings) { + startIdx = i + startLevel = level + break + } + } + if startIdx < 0 { + return "" + } + var bodyLines []string + for i := startIdx + 1; i < len(lines); i++ { + level, _, ok := parseHeading(lines[i]) + if ok && level <= startLevel { + break + } + bodyLines = append(bodyLines, lines[i]) + } + return strings.TrimSpace(strings.Join(bodyLines, "\n")) +} + +// parseHeading 解析一行是否为 ATX Markdown 标题(# / ## / ...)。返回级别与标题文本。 +func parseHeading(line string) (level int, title string, ok bool) { + trimmed := strings.TrimLeft(line, " ") + n := 0 + for n < len(trimmed) && trimmed[n] == '#' { + n++ + } + if n == 0 || n > 6 { + return 0, "", false + } + if n < len(trimmed) && trimmed[n] != ' ' { + return 0, "", false + } + return n, strings.TrimSpace(trimmed[n:]), true +} + +func matchesHeading(title string, headings []string) bool { + norm := normalizeHeading(title) + for _, h := range headings { + if norm == normalizeHeading(h) { + return true + } + } + return false +} + +// normalizeHeading 归一化标题:小写、去掉首尾标点与空白、去掉常见编号前缀。 +func normalizeHeading(s string) string { + s = strings.ToLower(strings.TrimSpace(s)) + s = strings.Trim(s, "  ::.。、") + // 去掉形如 "6. " / "6、" 的编号前缀 + for i := 0; i < len(s); i++ { + if s[i] >= '0' && s[i] <= '9' { + continue + } + if s[i] == '.' || s[i] == ' ' || s[i] == '\t' { + s = strings.TrimLeft(s[i:], ". \t") + } + break + } + return strings.TrimSpace(s) +} + +// truncateToTokens 把 s 裁剪到不超过 maxTokens 的最长前缀(按 rune 与 EstimateTokens +// 的 rune/4 估算反推,再二分微调,保证不超预算)。maxTokens<=0 返回空串。 +func truncateToTokens(s string, maxTokens int) string { + if maxTokens <= 0 { + return "" + } + runes := []rune(s) + if llm.EstimateTokens(s) <= maxTokens { + return s + } + // EstimateTokens(x) = ceil(len(runes)/4),故 maxRunes ≈ maxTokens*4。 + hi := maxTokens * 4 + if hi > len(runes) { + hi = len(runes) + } + // 用 sort.Search 找最大的 n(runes 前缀)使 EstimateTokens 仍 <= maxTokens。 + n := sort.Search(hi+1, func(k int) bool { + return llm.EstimateTokens(string(runes[:k])) > maxTokens + }) + if n > 0 { + n-- + } + return strings.TrimRight(string(runes[:n]), " \n\t") +} diff --git a/internal/brief/composer_test.go b/internal/brief/composer_test.go new file mode 100644 index 0000000..d5315a1 --- /dev/null +++ b/internal/brief/composer_test.go @@ -0,0 +1,282 @@ +package brief + +import ( + "context" + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +func newReq(phase project.Phase) *project.Requirement { + return &project.Requirement{ + ID: uuid.New(), + Number: 7, + Title: "导出报表", + Description: "支持导出 Excel", + Phase: phase, + } +} + +func newIssue() *project.Issue { + return &project.Issue{ + ID: uuid.New(), + Number: 12, + Title: "修复导出乱码", + Description: "UTF-8 BOM 缺失", + } +} + +func art(phase project.Phase, version int, content string) *project.Artifact { + return &project.Artifact{ + ID: uuid.New(), + RequirementID: uuid.New(), + Phase: phase, + Version: version, + Content: content, + } +} + +func TestComposeTaskBrief_FullBriefAllSectionsInPriorityOrder(t *testing.T) { + t.Parallel() + c := NewComposer(nil, Config{DefaultTokenBudget: 100000}) + + res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ + Requirement: newReq(project.PhaseAuditing), + Artifacts: []*project.Artifact{ + art(project.PhasePlanning, 1, "# 规划\n## 验收标准\n- 必须支持 Excel"), + art(project.PhasePrototyping, 1, "# 原型内容"), + art(project.PhaseAuditing, 1, "# 评审内容"), + }, + UserPrompt: "请开始实现", + Kind: KindACPSession, + }) + require.NoError(t, err) + assert.False(t, res.Truncated) + + // 角色(auditing 阶段用阶段角色)+ 交互协议 + 需求核心 + 验收标准 + 三个产物 + 用户指令。 + wantOrder := []string{ + "role", "interactive_protocol", "requirement_core", "acceptance_criteria", + "artifact_auditing", "artifact_prototyping", "artifact_planning", "user_prompt", + } + assert.Equal(t, wantOrder, res.Sections) + + // 验收标准必须出现在 auditing 产物之前(优先级)。 + idxAC := strings.Index(res.Text, "验收标准") + idxAudit := strings.Index(res.Text, "评审内容") + require.GreaterOrEqual(t, idxAC, 0) + require.GreaterOrEqual(t, idxAudit, 0) + assert.Less(t, idxAC, idxAudit) + + // 用户指令永远在最后。 + assert.True(t, strings.HasSuffix(strings.TrimSpace(res.Text), "请开始实现")) + // 角色提示与需求标题都在。 + assert.Contains(t, res.Text, PhaseRolePrompts[project.PhaseAuditing]) + assert.Contains(t, res.Text, "需求 #7:导出报表") +} + +func TestComposeTaskBrief_TightBudgetDropsLowPrioritySections(t *testing.T) { + t.Parallel() + c := NewComposer(nil, Config{}) + + bigPlanning := strings.Repeat("规", 4000) // ~1000 tokens + res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ + Requirement: newReq(project.PhasePlanning), + Artifacts: []*project.Artifact{ + art(project.PhasePlanning, 1, bigPlanning), + art(project.PhaseAuditing, 1, strings.Repeat("评", 4000)), + }, + UserPrompt: "做", + Kind: KindACPSession, + TokenBudget: 300, // 紧预算 + }) + require.NoError(t, err) + assert.True(t, res.Truncated) + // 高优先级(角色/需求核心)保留,用户指令永远保留。 + assert.Contains(t, res.Sections, "role") + assert.Contains(t, res.Sections, "requirement_core") + assert.Contains(t, res.Sections, "user_prompt") + // 用户指令永不丢。 + assert.Contains(t, res.Text, "## 操作者指令\n做") +} + +func TestComposeTaskBrief_RequirementOnly_IssueOnly_Both(t *testing.T) { + t.Parallel() + c := NewComposer(nil, Config{DefaultTokenBudget: 100000}) + + // requirement-only + res1, err := c.ComposeTaskBrief(context.Background(), BriefInput{ + Requirement: newReq(project.PhasePlanning), Kind: KindChatRequirement, + }) + require.NoError(t, err) + assert.Contains(t, res1.Sections, "requirement_core") + assert.NotContains(t, res1.Sections, "issue_core") + + // issue-only + res2, err := c.ComposeTaskBrief(context.Background(), BriefInput{ + Issue: newIssue(), Kind: KindChatIssue, + }) + require.NoError(t, err) + assert.Contains(t, res2.Sections, "issue_core") + assert.NotContains(t, res2.Sections, "requirement_core") + assert.Contains(t, res2.Text, "Issue #12:修复导出乱码") + + // both + res3, err := c.ComposeTaskBrief(context.Background(), BriefInput{ + Requirement: newReq(project.PhasePlanning), Issue: newIssue(), Kind: KindACPSession, + }) + require.NoError(t, err) + assert.Contains(t, res3.Sections, "requirement_core") + assert.Contains(t, res3.Sections, "issue_core") +} + +func TestComposeTaskBrief_MissingDataYieldsNoErrorAndOmitsSections(t *testing.T) { + t.Parallel() + c := NewComposer(nil, Config{DefaultTokenBudget: 100000}) + + // 空描述 + 无产物。 + req := newReq(project.PhasePlanning) + req.Description = "" + res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ + Requirement: req, Kind: KindChatRequirement, + }) + require.NoError(t, err) + assert.NotContains(t, res.Text, "需求描述") + assert.NotContains(t, res.Sections, "artifact_planning") + assert.NotContains(t, res.Sections, "acceptance_criteria") + + // 完全空输入(无 PM 上下文,仅 ACP 角色)。 + resEmpty, err := c.ComposeTaskBrief(context.Background(), BriefInput{Kind: KindACPSession}) + require.NoError(t, err) + // ACP 场景仍给角色 + 交互协议,但无 PM section。 + assert.Contains(t, resEmpty.Sections, "role") + assert.NotContains(t, resEmpty.Sections, "requirement_core") +} + +func TestComposeTaskBrief_AcceptanceCriteriaExtraction(t *testing.T) { + t.Parallel() + c := NewComposer(nil, Config{DefaultTokenBudget: 100000}) + + planning := art(project.PhasePlanning, 3, `# 规划文档 +## 背景与目标 +做导出。 +## 验收标准 +- 能导出 Excel +- 中文不乱码 +## 风险 +无`) + res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ + Requirement: newReq(project.PhasePlanning), + Artifacts: []*project.Artifact{planning}, + Kind: KindChatRequirement, + }) + require.NoError(t, err) + assert.Contains(t, res.Sections, "acceptance_criteria") + assert.Contains(t, res.Text, "能导出 Excel") + assert.Contains(t, res.Text, "中文不乱码") + // 验收标准段落不应吞掉后面的"风险"小节。 + acStart := strings.Index(res.Text, "## 验收标准") + require.GreaterOrEqual(t, acStart, 0) +} + +func TestComposeTaskBrief_AcceptanceCriteriaFallbackOmitsWhenAbsent(t *testing.T) { + t.Parallel() + c := NewComposer(nil, Config{DefaultTokenBudget: 100000}) + + planning := art(project.PhasePlanning, 1, "# 规划\n## 背景\n仅有背景,没有验收标准小节") + res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ + Requirement: newReq(project.PhasePlanning), + Artifacts: []*project.Artifact{planning}, + Kind: KindChatRequirement, + }) + require.NoError(t, err) + assert.NotContains(t, res.Sections, "acceptance_criteria") +} + +func TestComposeTaskBrief_UserPromptNeverTruncatedEvenWhenExceedsBudget(t *testing.T) { + t.Parallel() + c := NewComposer(nil, Config{}) + + huge := strings.Repeat("做", 5000) // 远超预算 + res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ + Requirement: newReq(project.PhasePlanning), + UserPrompt: huge, + Kind: KindACPSession, + TokenBudget: 50, + }) + require.NoError(t, err) + assert.Contains(t, res.Sections, "user_prompt") + // 用户指令整段保留(不含截断标记)。 + assert.Contains(t, res.Text, huge) + assert.NotContains(t, res.Text, "操作者指令\n"+huge+truncateMarker) +} + +func TestComposeTaskBrief_LatestArtifactPerPhase(t *testing.T) { + t.Parallel() + c := NewComposer(nil, Config{DefaultTokenBudget: 100000}) + + // 同阶段多版本,应取 version 最大的。 + res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ + Requirement: newReq(project.PhasePrototyping), + Artifacts: []*project.Artifact{ + art(project.PhasePrototyping, 1, "旧原型 v1"), + art(project.PhasePrototyping, 3, "新原型 v3"), + art(project.PhasePrototyping, 2, "中原型 v2"), + }, + Kind: KindChatRequirement, + }) + require.NoError(t, err) + assert.Contains(t, res.Text, "新原型 v3") + assert.NotContains(t, res.Text, "旧原型 v1") + assert.Contains(t, res.Text, "prototyping 阶段产物 v3") +} + +// fakeOrienter 用于验证 ACP 场景纳入仓库定向 section。 +type fakeOrienter struct{ ori RepoOrientation } + +func (f fakeOrienter) Orient(_ context.Context, _, _ string, _, _ int) RepoOrientation { + return f.ori +} + +func TestComposeTaskBrief_RepoOrientationIncludedForACP(t *testing.T) { + t.Parallel() + c := NewComposer(fakeOrienter{ori: RepoOrientation{ + Branch: "feat/x", + RecentCommits: []string{"abc1234 init"}, + DirtyFiles: []string{"main.go"}, + }}, Config{DefaultTokenBudget: 100000}) + + res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ + Requirement: newReq(project.PhasePlanning), + RepoCwd: "/tmp/repo", + Branch: "feat/x", + Kind: KindACPSession, + }) + require.NoError(t, err) + assert.Contains(t, res.Sections, "repo_orientation") + assert.Contains(t, res.Text, "当前分支:feat/x") + assert.Contains(t, res.Text, "abc1234 init") + assert.Contains(t, res.Text, "main.go") +} + +func TestComposeTaskBrief_NoOrientationWhenCwdEmpty(t *testing.T) { + t.Parallel() + c := NewComposer(fakeOrienter{ori: RepoOrientation{Branch: "x"}}, Config{DefaultTokenBudget: 100000}) + res, err := c.ComposeTaskBrief(context.Background(), BriefInput{ + Requirement: newReq(project.PhasePlanning), + Kind: KindACPSession, // RepoCwd 为空 → 跳过定向 + }) + require.NoError(t, err) + assert.NotContains(t, res.Sections, "repo_orientation") +} + +func TestExtractSection_NumberedHeadingAndCaseInsensitive(t *testing.T) { + t.Parallel() + md := "## 6. Acceptance Criteria\n- foo\n## next\nbar" + got := extractSection(md, acceptanceCriteriaHeadings) + assert.Equal(t, "- foo", got) +} diff --git a/internal/brief/orient.go b/internal/brief/orient.go new file mode 100644 index 0000000..d13a042 --- /dev/null +++ b/internal/brief/orient.go @@ -0,0 +1,69 @@ +package brief + +import ( + "context" + + "github.com/yan1h/agent-coding-workflow/internal/infra/git" +) + +// RepoOrientation 是仓库的轻量定向信息:当前分支、最近若干提交摘要、脏文件路径。 +// 用于给 agent 一个"我现在在哪个分支、最近发生了什么、工作树是否干净"的速览。 +type RepoOrientation struct { + Branch string + RecentCommits []string + DirtyFiles []string +} + +// IsEmpty 报告该定向信息是否完全为空(无分支、无提交、无脏文件)。 +func (o RepoOrientation) IsEmpty() bool { + return o.Branch == "" && len(o.RecentCommits) == 0 && len(o.DirtyFiles) == 0 +} + +// RepoOrienter 是 brief 对 git 的窄接口:给定目录返回有界的仓库定向信息。 +// 让 brief 可在没有真实仓库的情况下被单测(注入 fake orienter)。 +type RepoOrienter interface { + Orient(ctx context.Context, dir, branch string, maxCommits, maxDirty int) RepoOrientation +} + +// gitClient 是 orienter 对 git.Runner 的最小子集。*git.DefaultRunner 直接满足。 +type gitClient interface { + Log(ctx context.Context, dir string, n int) ([]git.Commit, error) + Status(ctx context.Context, dir string) ([]git.FileStatus, error) +} + +// gitOrienter 是 RepoOrienter 的生产实现,基于 infra/git 的 Log + Status。 +// 设计为"尽力而为且永不致命":任何 git 调用失败只产生部分/空的定向信息, +// 不返回 error,从而不会拖垮整份简报(git 失败不应阻塞 prompt 注入)。 +type gitOrienter struct{ g gitClient } + +// NewRepoOrienter 用给定 git runner 构造 RepoOrienter。 +func NewRepoOrienter(g gitClient) RepoOrienter { return &gitOrienter{g: g} } + +func (o *gitOrienter) Orient(ctx context.Context, dir, branch string, maxCommits, maxDirty int) RepoOrientation { + out := RepoOrientation{Branch: branch} + if dir == "" || o.g == nil { + return out + } + if maxCommits > 0 { + if commits, err := o.g.Log(ctx, dir, maxCommits); err == nil { + for _, c := range commits { + short := c.Hash + if len(short) > 8 { + short = short[:8] + } + out.RecentCommits = append(out.RecentCommits, short+" "+c.Subject) + } + } + } + if maxDirty > 0 { + if files, err := o.g.Status(ctx, dir); err == nil { + for i, f := range files { + if i >= maxDirty { + break + } + out.DirtyFiles = append(out.DirtyFiles, f.Path) + } + } + } + return out +} diff --git a/internal/brief/orient_test.go b/internal/brief/orient_test.go new file mode 100644 index 0000000..423c319 --- /dev/null +++ b/internal/brief/orient_test.go @@ -0,0 +1,107 @@ +package brief + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/git" +) + +func gitAvailable(t *testing.T) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not found in PATH") + } +} + +func mustGit(t *testing.T, dir string, args ...string) { + t.Helper() + c := exec.Command("git", args...) + c.Dir = dir + out, err := c.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) +} + +// makeWorkRepo 在临时目录里建一个有 N 次提交、可选脏文件的工作仓库,返回路径。 +func makeWorkRepo(t *testing.T, commits int, dirty bool) string { + t.Helper() + gitAvailable(t) + dir := t.TempDir() + mustGit(t, dir, "init", "-b", "main") + mustGit(t, dir, "config", "user.email", "test@example.com") + mustGit(t, dir, "config", "user.name", "Test") + for i := 0; i < commits; i++ { + name := filepath.Join(dir, "f"+string(rune('a'+i))+".txt") + require.NoError(t, os.WriteFile(name, []byte("v"), 0o644)) + mustGit(t, dir, "add", ".") + mustGit(t, dir, "commit", "-m", "commit "+string(rune('a'+i))) + } + if dirty { + require.NoError(t, os.WriteFile(filepath.Join(dir, "dirty.txt"), []byte("x"), 0o644)) + } + return dir +} + +func TestRepoOrienter_RealRepo(t *testing.T) { + t.Parallel() + dir := makeWorkRepo(t, 3, true) + o := NewRepoOrienter(git.NewDefaultRunner(git.Config{Binary: "git", TmpDir: t.TempDir()})) + + got := o.Orient(context.Background(), dir, "main", 10, 10) + assert.Equal(t, "main", got.Branch) + assert.Len(t, got.RecentCommits, 3) + // 脏文件含未跟踪的 dirty.txt。 + require.NotEmpty(t, got.DirtyFiles) + assert.Contains(t, got.DirtyFiles, "dirty.txt") +} + +func TestRepoOrienter_BoundsCommits(t *testing.T) { + t.Parallel() + dir := makeWorkRepo(t, 5, false) + o := NewRepoOrienter(git.NewDefaultRunner(git.Config{Binary: "git", TmpDir: t.TempDir()})) + + got := o.Orient(context.Background(), dir, "main", 2, 10) + assert.LessOrEqual(t, len(got.RecentCommits), 2) +} + +func TestRepoOrienter_NonexistentDirReturnsPartialNoFailure(t *testing.T) { + t.Parallel() + gitAvailable(t) + o := NewRepoOrienter(git.NewDefaultRunner(git.Config{Binary: "git", TmpDir: t.TempDir()})) + + // 不存在的目录:git 调用失败,但 orienter 不 panic、不返回错误,分支仍带入。 + got := o.Orient(context.Background(), filepath.Join(t.TempDir(), "nope"), "wip", 10, 10) + assert.Equal(t, "wip", got.Branch) + assert.Empty(t, got.RecentCommits) + assert.Empty(t, got.DirtyFiles) +} + +func TestRepoOrienter_EmptyDirSkips(t *testing.T) { + t.Parallel() + o := NewRepoOrienter(git.NewDefaultRunner(git.Config{Binary: "git", TmpDir: t.TempDir()})) + got := o.Orient(context.Background(), "", "main", 10, 10) + assert.Equal(t, "main", got.Branch) + assert.Empty(t, got.RecentCommits) + assert.Empty(t, got.DirtyFiles) +} + +func TestRepoOrienter_NilGitClient(t *testing.T) { + t.Parallel() + o := NewRepoOrienter(nil) + got := o.Orient(context.Background(), "/tmp/x", "main", 10, 10) + assert.Equal(t, "main", got.Branch) + assert.Empty(t, got.RecentCommits) +} + +func TestRepoOrientation_IsEmpty(t *testing.T) { + t.Parallel() + assert.True(t, RepoOrientation{}.IsEmpty()) + assert.False(t, RepoOrientation{Branch: "x"}.IsEmpty()) + assert.False(t, RepoOrientation{RecentCommits: []string{"a"}}.IsEmpty()) +} diff --git a/internal/brief/prompts.go b/internal/brief/prompts.go new file mode 100644 index 0000000..0278101 --- /dev/null +++ b/internal/brief/prompts.go @@ -0,0 +1,53 @@ +// Package brief 是服务端"上下文组装器":把 (project, requirement|issue, artifacts, +// repo orientation, 用户自由文本) 组装成一份 token 受限的结构化任务简报,供 ACP +// session 初始 prompt 与 chat composeHistory 的 FK 上下文注入复用。 +// +// 依赖纪律:brief 只依赖 project 领域类型 + infra/git + infra/llm,绝不导入 +// acp/chat(避免 import cycle)。acp/chat 通过 app.go 中的窄适配器反向调用 brief。 +package brief + +import "github.com/yan1h/agent-coding-workflow/internal/project" + +// PhaseRolePrompts 是三个 AI 对话阶段的角色模板,从前端 +// web/src/constants/requirementPhasePrompts.ts 的 PHASE_ROLE_PROMPTS 逐字迁移。 +// 服务端拥有这套提示词后,ACP session 与 chat 都能获得一致的阶段角色注入。 +var PhaseRolePrompts = map[project.Phase]string{ + project.PhasePlanning: `你是一名资深产品规划助手。你的任务是与用户深入讨论需求,澄清目标、范围、用户场景与约束,最终形成一份结构化的规划文档(Markdown)。 +讨论时主动提问补全缺失信息;输出规划文档时包含:背景与目标、范围(含不做什么)、用户场景、功能拆解、风险与依赖、验收标准。`, + project.PhasePrototyping: `你是一名资深原型设计助手。基于已确认的规划,与用户讨论并产出原型设计文档(Markdown)。 +内容应包含:页面/界面结构、交互流程、状态与边界情况、数据展示与输入约束;可用文字线框、列表与表格描述布局。`, + project.PhaseAuditing: `你是一名严格的方案评审助手。基于规划与原型,对方案进行评审并产出评审报告(Markdown)。 +评审维度:完整性(是否覆盖规划目标)、一致性(原型与规划是否矛盾)、可行性(技术与排期风险)、边界情况遗漏、验收标准可测性。明确给出问题清单与修改建议,并给出评审结论(通过 / 有条件通过 / 不通过)。`, +} + +// InteractiveQuestionProtocol 是结构化澄清问题协议,从前端 +// web/src/constants/requirementPhasePrompts.ts 的 INTERACTIVE_QUESTION_PROMPT 逐字迁移。 +const InteractiveQuestionProtocol = `## 交互式澄清问题协议 +当信息不足且用户直接输入大段文字的成本较高时,优先提出一个结构化澄清问题,方便用户点选。 +每次最多输出 1 个结构化问题;问题必须有 2-5 个选项;只有选项允许多选时 mode 才使用 multiple。 +结构化问题必须放在 acw-question 代码块中,代码块内只能是合法 JSON,不能输出 HTML。 +字段格式如下: +` + "```acw-question" + ` +{ + "version": 1, + "id": "stable_question_id", + "title": "需要用户确认的问题", + "description": "选择这个问题的原因,可省略", + "mode": "single", + "options": [ + { + "id": "stable_option_id", + "label": "给用户看的短选项", + "summary": "一句话解释,可省略", + "details": "更完整的影响说明,可省略" + } + ], + "allowCustom": true +} +` + "```" + ` +用户回答结构化问题后,继续基于回答推进本阶段讨论或产物输出。` + +// ACPRolePrompt 是 ACP session(执行型 agent,非阶段对话)默认的角色说明。 +// 与 PhaseRolePrompts(规划/原型/评审三阶段讨论助手)不同,ACP session 通常是 +// 直接对代码仓库动手的执行型 agent,因此给出一段中性的工程执行角色。 +const ACPRolePrompt = `你是一名在真实代码仓库中工作的资深工程实现助手。请基于下面提供的需求/Issue 背景、阶段产物与仓库现状,谨慎地推进实现:先理解上下文,必要时澄清,再动手修改;保持改动最小且可验证。` diff --git a/internal/changerequest/domain.go b/internal/changerequest/domain.go new file mode 100644 index 0000000..d05a47e --- /dev/null +++ b/internal/changerequest/domain.go @@ -0,0 +1,119 @@ +// Package changerequest models the change_requests entity and the server-side +// merge gate. A change request links a project/workspace/(requirement|issue) +// and a source->target branch to an external (Gitea) pull request, carrying a +// review verdict + CI state. The crux is Service.Merge, which hard-blocks the +// host-side merge unless review_verdict=='approved' (and optionally +// ci_state=='success'): an agent can open and merge PRs via MCP, but cannot +// self-approve — approval is HTTP/web-only by policy. +// +// Layout mirrors internal/workspace (single package, service + repository + +// handler + sqlc) to keep the aggregate cohesive. +package changerequest + +import ( + "context" + "time" + + "github.com/google/uuid" +) + +// State mirrors the change_requests.state CHECK constraint. +type State string + +// State enum values. +const ( + StateOpen State = "open" + StateMerged State = "merged" + StateClosed State = "closed" +) + +// Verdict mirrors the change_requests.review_verdict CHECK constraint. +type Verdict string + +// Verdict enum values. +const ( + VerdictPending Verdict = "pending" + VerdictApproved Verdict = "approved" + VerdictChangesRequested Verdict = "changes_requested" + VerdictRejected Verdict = "rejected" +) + +// IsValid reports whether v is in the enum set. +func (v Verdict) IsValid() bool { + switch v { + case VerdictPending, VerdictApproved, VerdictChangesRequested, VerdictRejected: + return true + } + return false +} + +// CIState mirrors the change_requests.ci_state CHECK constraint. +type CIState string + +// CIState enum values. +const ( + CIUnknown CIState = "unknown" + CIPending CIState = "pending" + CISuccess CIState = "success" + CIFailure CIState = "failure" +) + +// ChangeRequest is the change_requests row projection. +type ChangeRequest struct { + ID uuid.UUID + ProjectID uuid.UUID + WorkspaceID uuid.UUID + RequirementID *uuid.UUID + IssueID *uuid.UUID + Number int + Title string + Description string + SourceBranch string + TargetBranch string + State State + ReviewVerdict Verdict + CIState CIState + Provider string + ExternalID *int64 + ExternalURL string + MergeCommitSHA string + ReviewedBy *uuid.UUID + ReviewedAt *time.Time + CreatedBy uuid.UUID + MergedAt *time.Time + CreatedAt time.Time + UpdatedAt time.Time +} + +// Caller mirrors workspace.Caller: the user context driving auth + audit. +type Caller struct { + UserID uuid.UUID + IsAdmin bool + SessionID *uuid.UUID +} + +// CreateInput carries the fields to open a change request (and host PR). +type CreateInput struct { + SourceBranch string + TargetBranch string // empty => workspace default branch + Title string + Description string + RequirementID *uuid.UUID + IssueID *uuid.UUID +} + +// Service is the change-request application service. +type Service interface { + Create(ctx context.Context, c Caller, wsID uuid.UUID, in CreateInput) (*ChangeRequest, error) + Get(ctx context.Context, c Caller, id uuid.UUID) (*ChangeRequest, error) + GetByNumber(ctx context.Context, c Caller, projectSlug string, number int) (*ChangeRequest, error) + ListByWorkspace(ctx context.Context, c Caller, wsID uuid.UUID) ([]*ChangeRequest, error) + // Review records a human verdict (owner/admin only). It is intentionally NOT + // exposed as an MCP tool so agents cannot self-approve their own PRs. + Review(ctx context.Context, c Caller, id uuid.UUID, verdict Verdict, note string) (*ChangeRequest, error) + // Merge is the gate: requires review_verdict=='approved' (+ ci success when + // RequireCIPass), then merges the host PR and persists merged state. + Merge(ctx context.Context, c Caller, id uuid.UUID, method string) (*ChangeRequest, error) + // SyncStatus refreshes ci_state/state from the host (provider.GetPR). + SyncStatus(ctx context.Context, c Caller, id uuid.UUID) (*ChangeRequest, error) +} diff --git a/internal/changerequest/dto.go b/internal/changerequest/dto.go new file mode 100644 index 0000000..e995f82 --- /dev/null +++ b/internal/changerequest/dto.go @@ -0,0 +1,70 @@ +// dto.go defines the HTTP request/response shapes for the change-request module. +package changerequest + +import ( + "time" + + "github.com/google/uuid" +) + +// ===== request DTO ===== + +type createReq struct { + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + Title string `json:"title"` + Description string `json:"description"` + RequirementID *uuid.UUID `json:"requirement_id"` + IssueID *uuid.UUID `json:"issue_id"` +} + +type reviewReq struct { + Verdict string `json:"verdict"` + Note string `json:"note"` +} + +type mergeReq struct { + Method string `json:"method"` +} + +// ===== response DTO ===== + +type changeRequestResp struct { + ID uuid.UUID `json:"id"` + ProjectID uuid.UUID `json:"project_id"` + WorkspaceID uuid.UUID `json:"workspace_id"` + RequirementID *uuid.UUID `json:"requirement_id"` + IssueID *uuid.UUID `json:"issue_id"` + Number int `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CIState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalURL string `json:"external_url"` + MergeCommitSHA string `json:"merge_commit_sha"` + ReviewedBy *uuid.UUID `json:"reviewed_by"` + ReviewedAt *time.Time `json:"reviewed_at"` + CreatedBy uuid.UUID `json:"created_by"` + MergedAt *time.Time `json:"merged_at"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func toResp(cr *ChangeRequest) changeRequestResp { + return changeRequestResp{ + ID: cr.ID, ProjectID: cr.ProjectID, WorkspaceID: cr.WorkspaceID, + RequirementID: cr.RequirementID, IssueID: cr.IssueID, Number: cr.Number, + Title: cr.Title, Description: cr.Description, + SourceBranch: cr.SourceBranch, TargetBranch: cr.TargetBranch, + State: string(cr.State), ReviewVerdict: string(cr.ReviewVerdict), + CIState: string(cr.CIState), Provider: cr.Provider, + ExternalID: cr.ExternalID, ExternalURL: cr.ExternalURL, MergeCommitSHA: cr.MergeCommitSHA, + ReviewedBy: cr.ReviewedBy, ReviewedAt: cr.ReviewedAt, CreatedBy: cr.CreatedBy, + MergedAt: cr.MergedAt, CreatedAt: cr.CreatedAt, UpdatedAt: cr.UpdatedAt, + } +} diff --git a/internal/changerequest/handler.go b/internal/changerequest/handler.go new file mode 100644 index 0000000..268133c --- /dev/null +++ b/internal/changerequest/handler.go @@ -0,0 +1,218 @@ +// handler.go exposes the change-request HTTP endpoints under middleware.Auth, +// mirroring workspace/handler.go. Routes: +// +// POST /api/v1/workspaces/{wsID}/change-requests create +// GET /api/v1/workspaces/{wsID}/change-requests list +// GET /api/v1/change-requests/{id} get +// POST /api/v1/change-requests/{id}/review review (human approve/reject) +// POST /api/v1/change-requests/{id}/merge gated merge +// POST /api/v1/change-requests/{id}/sync refresh status from host +package changerequest + +import ( + "context" + "encoding/json" + "net/http" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" +) + +// AdminLookup resolves is_admin for a user (narrow interface, same as +// workspace.AdminLookup so the userAdminAdapter can be reused). +type AdminLookup interface { + IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) +} + +// Handler holds the change-request service + auth deps. +type Handler struct { + svc Service + resolver middleware.SessionResolver + users AdminLookup +} + +// NewHandler constructs the Handler. +func NewHandler(svc Service, resolver middleware.SessionResolver, users AdminLookup) *Handler { + return &Handler{svc: svc, resolver: resolver, users: users} +} + +// Mount registers the change-request routes on r under the Auth middleware. +func (h *Handler) Mount(r chi.Router) { + auth := middleware.Auth(h.resolver, middleware.AuthOptions{}) + + r.With(auth).Route("/api/v1/workspaces/{wsID}/change-requests", func(r chi.Router) { + r.Post("/", h.create) + r.Get("/", h.list) + }) + r.With(auth).Route("/api/v1/change-requests/{id}", func(r chi.Router) { + r.Get("/", h.get) + r.Post("/review", h.review) + r.Post("/merge", h.merge) + r.Post("/sync", h.sync) + }) +} + +func (h *Handler) caller(r *http.Request) (Caller, error) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + return Caller{}, errs.New(errs.CodeUnauthorized, "unauthenticated") + } + isAdmin, err := h.users.IsAdmin(r.Context(), uid) + if err != nil { + return Caller{}, err + } + return Caller{UserID: uid, IsAdmin: isAdmin}, nil +} + +func writeJSON(w http.ResponseWriter, status int, body any) { httpx.WriteJSON(w, status, body) } + +func writeErr(w http.ResponseWriter, r *http.Request, err error) { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) +} + +func parseUUID(s string) (uuid.UUID, error) { + id, err := uuid.Parse(s) + if err != nil { + return uuid.Nil, errs.Wrap(err, errs.CodeInvalidInput, "invalid uuid") + } + return id, nil +} + +func (h *Handler) create(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + wsID, err := parseUUID(chi.URLParam(r, "wsID")) + if err != nil { + writeErr(w, r, err) + return + } + var req createReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) + return + } + cr, err := h.svc.Create(r.Context(), c, wsID, CreateInput{ + SourceBranch: req.SourceBranch, TargetBranch: req.TargetBranch, + Title: req.Title, Description: req.Description, + RequirementID: req.RequirementID, IssueID: req.IssueID, + }) + if err != nil { + writeErr(w, r, err) + return + } + writeJSON(w, http.StatusCreated, toResp(cr)) +} + +func (h *Handler) list(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + wsID, err := parseUUID(chi.URLParam(r, "wsID")) + if err != nil { + writeErr(w, r, err) + return + } + out, err := h.svc.ListByWorkspace(r.Context(), c, wsID) + if err != nil { + writeErr(w, r, err) + return + } + resp := make([]changeRequestResp, 0, len(out)) + for _, cr := range out { + resp = append(resp, toResp(cr)) + } + writeJSON(w, http.StatusOK, resp) +} + +func (h *Handler) get(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + cr, err := h.svc.Get(r.Context(), c, id) + if err != nil { + writeErr(w, r, err) + return + } + writeJSON(w, http.StatusOK, toResp(cr)) +} + +func (h *Handler) review(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + var req reviewReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + writeErr(w, r, errs.Wrap(err, errs.CodeInvalidInput, "decode body")) + return + } + cr, err := h.svc.Review(r.Context(), c, id, Verdict(req.Verdict), req.Note) + if err != nil { + writeErr(w, r, err) + return + } + writeJSON(w, http.StatusOK, toResp(cr)) +} + +func (h *Handler) merge(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + var req mergeReq + // merge body is optional (method default) + _ = json.NewDecoder(r.Body).Decode(&req) + cr, err := h.svc.Merge(r.Context(), c, id, req.Method) + if err != nil { + writeErr(w, r, err) + return + } + writeJSON(w, http.StatusOK, toResp(cr)) +} + +func (h *Handler) sync(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + id, err := parseUUID(chi.URLParam(r, "id")) + if err != nil { + writeErr(w, r, err) + return + } + cr, err := h.svc.SyncStatus(r.Context(), c, id) + if err != nil { + writeErr(w, r, err) + return + } + writeJSON(w, http.StatusOK, toResp(cr)) +} diff --git a/internal/changerequest/handler_test.go b/internal/changerequest/handler_test.go new file mode 100644 index 0000000..33dae78 --- /dev/null +++ b/internal/changerequest/handler_test.go @@ -0,0 +1,140 @@ +package changerequest + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" +) + +// fakeSvc implements Service for handler tests. +type fakeSvc struct { + mergeErr error + cr *ChangeRequest +} + +func (f *fakeSvc) Create(_ context.Context, c Caller, wsID uuid.UUID, _ CreateInput) (*ChangeRequest, error) { + return &ChangeRequest{ID: uuid.New(), WorkspaceID: wsID, Number: 1, State: StateOpen, ReviewVerdict: VerdictPending, CreatedBy: c.UserID}, nil +} +func (f *fakeSvc) Get(_ context.Context, _ Caller, id uuid.UUID) (*ChangeRequest, error) { + return &ChangeRequest{ID: id, State: StateOpen}, nil +} +func (f *fakeSvc) GetByNumber(_ context.Context, _ Caller, _ string, n int) (*ChangeRequest, error) { + return &ChangeRequest{ID: uuid.New(), Number: n}, nil +} +func (f *fakeSvc) ListByWorkspace(_ context.Context, _ Caller, wsID uuid.UUID) ([]*ChangeRequest, error) { + return []*ChangeRequest{{ID: uuid.New(), WorkspaceID: wsID, Number: 1}}, nil +} +func (f *fakeSvc) Review(_ context.Context, _ Caller, id uuid.UUID, v Verdict, _ string) (*ChangeRequest, error) { + return &ChangeRequest{ID: id, ReviewVerdict: v}, nil +} +func (f *fakeSvc) Merge(_ context.Context, _ Caller, id uuid.UUID, _ string) (*ChangeRequest, error) { + if f.mergeErr != nil { + return nil, f.mergeErr + } + return &ChangeRequest{ID: id, State: StateMerged}, nil +} +func (f *fakeSvc) SyncStatus(_ context.Context, _ Caller, id uuid.UUID) (*ChangeRequest, error) { + return &ChangeRequest{ID: id, CIState: CISuccess}, nil +} + +type fakeResolver struct{ valid map[string]uuid.UUID } + +func (f *fakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) { + uid, ok := f.valid[token] + return uid, ok, nil +} + +type fakeAdmin struct{} + +func (fakeAdmin) IsAdmin(_ context.Context, _ uuid.UUID) (bool, error) { return false, nil } + +func newTestHandler(svc Service, tok string, uid uuid.UUID) chi.Router { + resolver := &fakeResolver{valid: map[string]uuid.UUID{tok: uid}} + h := NewHandler(svc, resolver, fakeAdmin{}) + r := chi.NewRouter() + r.Use(middleware.RequestID) + h.Mount(r) + return r +} + +func TestHandler_Unauthenticated(t *testing.T) { + t.Parallel() + r := newTestHandler(&fakeSvc{}, "tok", uuid.New()) + req := httptest.NewRequest("GET", "/api/v1/workspaces/"+uuid.NewString()+"/change-requests", nil) + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusUnauthorized, w.Code) +} + +func TestHandler_CreateAndList(t *testing.T) { + t.Parallel() + uid := uuid.New() + r := newTestHandler(&fakeSvc{}, "tok", uid) + wsID := uuid.New() + + body, _ := json.Marshal(createReq{SourceBranch: "feat", Title: "T"}) + req := httptest.NewRequest("POST", "/api/v1/workspaces/"+wsID.String()+"/change-requests", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer tok") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusCreated, w.Code) + + req = httptest.NewRequest("GET", "/api/v1/workspaces/"+wsID.String()+"/change-requests", nil) + req.Header.Set("Authorization", "Bearer tok") + w = httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) +} + +func TestHandler_MergeGateReturns412(t *testing.T) { + t.Parallel() + svc := &fakeSvc{mergeErr: errs.New(errs.CodeFailedPrecondition, "merge blocked: review not approved")} + r := newTestHandler(svc, "tok", uuid.New()) + + req := httptest.NewRequest("POST", "/api/v1/change-requests/"+uuid.NewString()+"/merge", nil) + req.Header.Set("Authorization", "Bearer tok") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusPreconditionFailed, w.Code) +} + +func TestHandler_MergeHappyPath(t *testing.T) { + t.Parallel() + r := newTestHandler(&fakeSvc{}, "tok", uuid.New()) + + req := httptest.NewRequest("POST", "/api/v1/change-requests/"+uuid.NewString()+"/merge", bytes.NewReader([]byte(`{"method":"squash"}`))) + req.Header.Set("Authorization", "Bearer tok") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp changeRequestResp + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.Equal(t, string(StateMerged), resp.State) +} + +func TestHandler_Review(t *testing.T) { + t.Parallel() + r := newTestHandler(&fakeSvc{}, "tok", uuid.New()) + + body, _ := json.Marshal(reviewReq{Verdict: "approved", Note: "lgtm"}) + req := httptest.NewRequest("POST", "/api/v1/change-requests/"+uuid.NewString()+"/review", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer tok") + w := httptest.NewRecorder() + r.ServeHTTP(w, req) + require.Equal(t, http.StatusOK, w.Code) + + var resp changeRequestResp + require.NoError(t, json.NewDecoder(w.Body).Decode(&resp)) + require.Equal(t, "approved", resp.ReviewVerdict) +} diff --git a/internal/changerequest/queries/change_requests.sql b/internal/changerequest/queries/change_requests.sql new file mode 100644 index 0000000..000374e --- /dev/null +++ b/internal/changerequest/queries/change_requests.sql @@ -0,0 +1,63 @@ +-- name: NextChangeRequestNumber :one +-- Per-project monotonic number; FOR UPDATE not needed because the +-- max(number)+1 INSERT runs inside the same tx, but we lock existing project +-- rows to serialize concurrent creates within a project. +SELECT COALESCE(MAX(number), 0) + 1 FROM change_requests WHERE project_id = $1 FOR UPDATE; + +-- name: CreateChangeRequest :one +INSERT INTO change_requests ( + id, project_id, workspace_id, requirement_id, issue_id, number, + title, description, source_branch, target_branch, + state, review_verdict, ci_state, provider, external_id, external_url, + merge_commit_sha, created_by +) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, + 'open', 'pending', $11, $12, $13, $14, + '', $15 +) +RETURNING *; + +-- name: GetChangeRequestByID :one +SELECT * FROM change_requests WHERE id = $1; + +-- name: GetChangeRequestByNumber :one +SELECT cr.* FROM change_requests cr +JOIN projects p ON p.id = cr.project_id +WHERE p.slug = $1 AND cr.number = $2; + +-- name: ListChangeRequestsByWorkspace :many +SELECT * FROM change_requests +WHERE workspace_id = $1 +ORDER BY number DESC; + +-- name: ListChangeRequestsByProject :many +SELECT * FROM change_requests +WHERE project_id = $1 +ORDER BY number DESC; + +-- name: UpdateChangeRequestReview :one +UPDATE change_requests +SET review_verdict = $2, + reviewed_by = $3, + reviewed_at = now(), + updated_at = now() +WHERE id = $1 +RETURNING *; + +-- name: UpdateChangeRequestState :one +UPDATE change_requests +SET state = $2, + merge_commit_sha = $3, + merged_at = $4, + updated_at = now() +WHERE id = $1 +RETURNING *; + +-- name: UpdateChangeRequestCI :one +UPDATE change_requests +SET ci_state = $2, + state = $3, + updated_at = now() +WHERE id = $1 +RETURNING *; diff --git a/internal/changerequest/repository.go b/internal/changerequest/repository.go new file mode 100644 index 0000000..87e5ece --- /dev/null +++ b/internal/changerequest/repository.go @@ -0,0 +1,264 @@ +// repository.go is a thin wrapper over the sqlc layer: pgtype<->domain +// conversion, pgx.ErrNoRows -> errs.NotFound translation, and unique-violation +// detection. Mirrors internal/workspace/repository.go. +package changerequest + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + changerequestsqlc "github.com/yan1h/agent-coding-workflow/internal/changerequest/sqlc" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// Tx is the transaction handle exposed to the service: NextNumber + Create run +// in one tx so per-project numbering is serialized. +type Tx interface { + NextNumber(ctx context.Context, projectID uuid.UUID) (int, error) + Create(ctx context.Context, cr *ChangeRequest) (*ChangeRequest, error) +} + +// Repository is the change-request module's PG dependency. +type Repository interface { + GetByID(ctx context.Context, id uuid.UUID) (*ChangeRequest, error) + GetByNumber(ctx context.Context, projectSlug string, number int) (*ChangeRequest, error) + ListByWorkspace(ctx context.Context, wsID uuid.UUID) ([]*ChangeRequest, error) + ListByProject(ctx context.Context, projectID uuid.UUID) ([]*ChangeRequest, error) + UpdateReview(ctx context.Context, id uuid.UUID, verdict Verdict, reviewedBy uuid.UUID) (*ChangeRequest, error) + UpdateState(ctx context.Context, id uuid.UUID, st State, mergeSHA string, mergedAt *time.Time) (*ChangeRequest, error) + UpdateCI(ctx context.Context, id uuid.UUID, ci CIState, st State) (*ChangeRequest, error) + InTx(ctx context.Context, fn func(Tx) error) error +} + +// IsUniqueViolation reports whether err is a PG unique constraint violation. +func IsUniqueViolation(err error) bool { + var pg *pgconn.PgError + return errors.As(err, &pg) && pg.Code == pgerrcode.UniqueViolation +} + +type pgRepo struct { + pool *pgxpool.Pool + q *changerequestsqlc.Queries +} + +// NewPostgresRepository builds a Repository from a pgxpool. +func NewPostgresRepository(pool *pgxpool.Pool) Repository { + return &pgRepo{pool: pool, q: changerequestsqlc.New(pool)} +} + +// ===== conversion helpers ===== + +func toPgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} } + +func toPgUUIDPtr(u *uuid.UUID) pgtype.UUID { + if u == nil { + return pgtype.UUID{Valid: false} + } + return pgtype.UUID{Bytes: *u, Valid: true} +} + +func fromPgUUID(p pgtype.UUID) uuid.UUID { + if !p.Valid { + return uuid.Nil + } + return uuid.UUID(p.Bytes) +} + +func fromPgUUIDPtr(p pgtype.UUID) *uuid.UUID { + if !p.Valid { + return nil + } + u := uuid.UUID(p.Bytes) + return &u +} + +func toPgTimePtr(t *time.Time) pgtype.Timestamptz { + if t == nil { + return pgtype.Timestamptz{Valid: false} + } + return pgtype.Timestamptz{Time: *t, Valid: true} +} + +func fromPgTimePtr(t pgtype.Timestamptz) *time.Time { + if !t.Valid { + return nil + } + v := t.Time + return &v +} + +func rowToCR(r changerequestsqlc.ChangeRequest) *ChangeRequest { + return &ChangeRequest{ + ID: fromPgUUID(r.ID), + ProjectID: fromPgUUID(r.ProjectID), + WorkspaceID: fromPgUUID(r.WorkspaceID), + RequirementID: fromPgUUIDPtr(r.RequirementID), + IssueID: fromPgUUIDPtr(r.IssueID), + Number: int(r.Number), + Title: r.Title, + Description: r.Description, + SourceBranch: r.SourceBranch, + TargetBranch: r.TargetBranch, + State: State(r.State), + ReviewVerdict: Verdict(r.ReviewVerdict), + CIState: CIState(r.CiState), + Provider: r.Provider, + ExternalID: r.ExternalID, + ExternalURL: r.ExternalUrl, + MergeCommitSHA: r.MergeCommitSha, + ReviewedBy: fromPgUUIDPtr(r.ReviewedBy), + ReviewedAt: fromPgTimePtr(r.ReviewedAt), + CreatedBy: fromPgUUID(r.CreatedBy), + MergedAt: fromPgTimePtr(r.MergedAt), + CreatedAt: r.CreatedAt.Time, + UpdatedAt: r.UpdatedAt.Time, + } +} + +// ===== queries ===== + +func (r *pgRepo) GetByID(ctx context.Context, id uuid.UUID) (*ChangeRequest, error) { + row, err := r.q.GetChangeRequestByID(ctx, toPgUUID(id)) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "change request not found") + } + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "get change request") + } + return rowToCR(row), nil +} + +func (r *pgRepo) GetByNumber(ctx context.Context, projectSlug string, number int) (*ChangeRequest, error) { + row, err := r.q.GetChangeRequestByNumber(ctx, changerequestsqlc.GetChangeRequestByNumberParams{ + Slug: projectSlug, + Number: int32(number), + }) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "change request not found") + } + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "get change request by number") + } + return rowToCR(row), nil +} + +func (r *pgRepo) ListByWorkspace(ctx context.Context, wsID uuid.UUID) ([]*ChangeRequest, error) { + rows, err := r.q.ListChangeRequestsByWorkspace(ctx, toPgUUID(wsID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list change requests by workspace") + } + out := make([]*ChangeRequest, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToCR(row)) + } + return out, nil +} + +func (r *pgRepo) ListByProject(ctx context.Context, projectID uuid.UUID) ([]*ChangeRequest, error) { + rows, err := r.q.ListChangeRequestsByProject(ctx, toPgUUID(projectID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list change requests by project") + } + out := make([]*ChangeRequest, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToCR(row)) + } + return out, nil +} + +func (r *pgRepo) UpdateReview(ctx context.Context, id uuid.UUID, verdict Verdict, reviewedBy uuid.UUID) (*ChangeRequest, error) { + row, err := r.q.UpdateChangeRequestReview(ctx, changerequestsqlc.UpdateChangeRequestReviewParams{ + ID: toPgUUID(id), + ReviewVerdict: string(verdict), + ReviewedBy: toPgUUID(reviewedBy), + }) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "change request not found") + } + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "update change request review") + } + return rowToCR(row), nil +} + +func (r *pgRepo) UpdateState(ctx context.Context, id uuid.UUID, st State, mergeSHA string, mergedAt *time.Time) (*ChangeRequest, error) { + row, err := r.q.UpdateChangeRequestState(ctx, changerequestsqlc.UpdateChangeRequestStateParams{ + ID: toPgUUID(id), + State: string(st), + MergeCommitSha: mergeSHA, + MergedAt: toPgTimePtr(mergedAt), + }) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "change request not found") + } + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "update change request state") + } + return rowToCR(row), nil +} + +func (r *pgRepo) UpdateCI(ctx context.Context, id uuid.UUID, ci CIState, st State) (*ChangeRequest, error) { + row, err := r.q.UpdateChangeRequestCI(ctx, changerequestsqlc.UpdateChangeRequestCIParams{ + ID: toPgUUID(id), + CiState: string(ci), + State: string(st), + }) + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "change request not found") + } + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "update change request ci") + } + return rowToCR(row), nil +} + +func (r *pgRepo) InTx(ctx context.Context, fn func(Tx) error) error { + return pgx.BeginFunc(ctx, r.pool, func(tx pgx.Tx) error { + return fn(&pgTx{q: r.q.WithTx(tx)}) + }) +} + +type pgTx struct{ q *changerequestsqlc.Queries } + +func (t *pgTx) NextNumber(ctx context.Context, projectID uuid.UUID) (int, error) { + n, err := t.q.NextChangeRequestNumber(ctx, toPgUUID(projectID)) + if err != nil { + return 0, errs.Wrap(err, errs.CodeInternal, "next change request number") + } + return int(n), nil +} + +func (t *pgTx) Create(ctx context.Context, cr *ChangeRequest) (*ChangeRequest, error) { + row, err := t.q.CreateChangeRequest(ctx, changerequestsqlc.CreateChangeRequestParams{ + ID: toPgUUID(cr.ID), + ProjectID: toPgUUID(cr.ProjectID), + WorkspaceID: toPgUUID(cr.WorkspaceID), + RequirementID: toPgUUIDPtr(cr.RequirementID), + IssueID: toPgUUIDPtr(cr.IssueID), + Number: int32(cr.Number), + Title: cr.Title, + Description: cr.Description, + SourceBranch: cr.SourceBranch, + TargetBranch: cr.TargetBranch, + CiState: string(cr.CIState), + Provider: cr.Provider, + ExternalID: cr.ExternalID, + ExternalUrl: cr.ExternalURL, + CreatedBy: toPgUUID(cr.CreatedBy), + }) + if err != nil { + if IsUniqueViolation(err) { + return nil, errs.Wrap(err, errs.CodeConflict, "change request number already used") + } + return nil, errs.Wrap(err, errs.CodeInternal, "create change request") + } + return rowToCR(row), nil +} diff --git a/internal/changerequest/service.go b/internal/changerequest/service.go new file mode 100644 index 0000000..e2b7746 --- /dev/null +++ b/internal/changerequest/service.go @@ -0,0 +1,338 @@ +// service.go implements the change-request application service, including the +// merge gate (Service.Merge). Auth delegates to ProjectAccess (owner+admin +// write), mirroring workspace's narrow-interface approach so this package does +// not depend on internal/project wholesale. +package changerequest + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/infra/vcs" +) + +// WorkspaceLookup resolves a workspace's project + remote URL + default branch +// for RepoRef derivation and target-branch defaulting. Narrow interface over +// workspace.Repository so we avoid importing the full workspace service. +type WorkspaceLookup interface { + GetWorkspaceMeta(ctx context.Context, wsID uuid.UUID) (WorkspaceMeta, error) +} + +// WorkspaceMeta is the workspace projection the change-request service needs. +type WorkspaceMeta struct { + ProjectID uuid.UUID + GitRemoteURL string + DefaultBranch string +} + +// ProjectAccess is the auth interface (same shape as workspace.ProjectAccess): +// ResolveByID returns (canRead, canWrite, err) for a project. +type ProjectAccess interface { + ResolveByID(ctx context.Context, callerID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, bool, error) +} + +// Config controls the merge gate policy. +type Config struct { + RequireCIPass bool // when true, merge also requires ci_state=='success' + MergeMethod string // default host merge method ("merge"|"squash"|"rebase"); "merge" when empty + Host string // bare host the provider serves, for RepoRef host check; empty => skip +} + +type service struct { + repo Repository + ws WorkspaceLookup + rec audit.Recorder + provider vcs.Provider + pa ProjectAccess + cfg Config +} + +// NewService constructs the change-request Service. +func NewService(repo Repository, ws WorkspaceLookup, rec audit.Recorder, provider vcs.Provider, pa ProjectAccess, cfg Config) Service { + if cfg.MergeMethod == "" { + cfg.MergeMethod = "merge" + } + return &service{repo: repo, ws: ws, rec: rec, provider: provider, pa: pa, cfg: cfg} +} + +// Create opens a host PR and persists the change_requests row. +func (s *service) Create(ctx context.Context, c Caller, wsID uuid.UUID, in CreateInput) (*ChangeRequest, error) { + meta, err := s.ws.GetWorkspaceMeta(ctx, wsID) + if err != nil { + return nil, err + } + if err := s.guardWrite(ctx, c, meta.ProjectID); err != nil { + return nil, err + } + if in.SourceBranch == "" { + return nil, errs.New(errs.CodeInvalidInput, "source_branch required") + } + if in.Title == "" { + return nil, errs.New(errs.CodeInvalidInput, "title required") + } + target := in.TargetBranch + if target == "" { + target = meta.DefaultBranch + } + if target == "" { + target = "main" + } + if in.SourceBranch == target { + return nil, errs.New(errs.CodeInvalidInput, "source_branch and target_branch must differ") + } + + repoRef, err := vcs.ParseRepoRef(meta.GitRemoteURL, s.cfg.Host) + if err != nil { + return nil, err + } + + pr, err := s.provider.CreatePR(ctx, vcs.CreatePRInput{ + Repo: repoRef, Head: in.SourceBranch, Base: target, + Title: in.Title, Body: in.Description, + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeUpstream, "open pull request") + } + + cr := &ChangeRequest{ + ID: uuid.New(), + ProjectID: meta.ProjectID, + WorkspaceID: wsID, + RequirementID: in.RequirementID, + IssueID: in.IssueID, + Title: in.Title, + Description: in.Description, + SourceBranch: in.SourceBranch, + TargetBranch: target, + CIState: CIState(pr.CIState), + Provider: "gitea", + ExternalURL: pr.HTMLURL, + CreatedBy: c.UserID, + } + if cr.CIState == "" { + cr.CIState = CIUnknown + } + extID := pr.Number + cr.ExternalID = &extID + + var created *ChangeRequest + err = s.repo.InTx(ctx, func(tx Tx) error { + n, nerr := tx.NextNumber(ctx, meta.ProjectID) + if nerr != nil { + return nerr + } + cr.Number = n + c2, cerr := tx.Create(ctx, cr) + if cerr != nil { + return cerr + } + created = c2 + return nil + }) + if err != nil { + return nil, err + } + + s.audit(ctx, &c.UserID, "change_request.created", created.ID.String(), map[string]any{ + "number": created.Number, "external_id": extID, "source": in.SourceBranch, "target": target, + }) + return created, nil +} + +func (s *service) Get(ctx context.Context, c Caller, id uuid.UUID) (*ChangeRequest, error) { + cr, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if err := s.guardRead(ctx, c, cr.ProjectID); err != nil { + return nil, err + } + return cr, nil +} + +func (s *service) GetByNumber(ctx context.Context, c Caller, projectSlug string, number int) (*ChangeRequest, error) { + cr, err := s.repo.GetByNumber(ctx, projectSlug, number) + if err != nil { + return nil, err + } + if err := s.guardRead(ctx, c, cr.ProjectID); err != nil { + return nil, err + } + return cr, nil +} + +func (s *service) ListByWorkspace(ctx context.Context, c Caller, wsID uuid.UUID) ([]*ChangeRequest, error) { + meta, err := s.ws.GetWorkspaceMeta(ctx, wsID) + if err != nil { + return nil, err + } + if err := s.guardRead(ctx, c, meta.ProjectID); err != nil { + return nil, err + } + return s.repo.ListByWorkspace(ctx, wsID) +} + +// Review records a human verdict. Owner/admin only. Not an MCP tool. +func (s *service) Review(ctx context.Context, c Caller, id uuid.UUID, verdict Verdict, note string) (*ChangeRequest, error) { + if !verdict.IsValid() || verdict == VerdictPending { + return nil, errs.New(errs.CodeInvalidInput, "invalid review verdict") + } + cr, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if err := s.guardWrite(ctx, c, cr.ProjectID); err != nil { + return nil, err + } + if cr.State != StateOpen { + return nil, errs.New(errs.CodeFailedPrecondition, "cannot review a non-open change request") + } + updated, err := s.repo.UpdateReview(ctx, id, verdict, c.UserID) + if err != nil { + return nil, err + } + if note != "" && updated.ExternalID != nil { + if repoRef, perr := s.repoRefFor(ctx, updated.WorkspaceID); perr == nil { + _ = s.provider.Comment(ctx, repoRef, *updated.ExternalID, "Review ("+string(verdict)+"): "+note) + } + } + s.audit(ctx, &c.UserID, "change_request.reviewed", id.String(), map[string]any{ + "verdict": string(verdict), + }) + return updated, nil +} + +// Merge is the gate. It requires review_verdict=='approved' (and ci success +// when RequireCIPass and not admin), then merges the host PR. +func (s *service) Merge(ctx context.Context, c Caller, id uuid.UUID, method string) (*ChangeRequest, error) { + cr, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if err := s.guardWrite(ctx, c, cr.ProjectID); err != nil { + return nil, err + } + if cr.State != StateOpen { + return nil, errs.New(errs.CodeFailedPrecondition, "change request is not open") + } + // THE GATE: review must be approved. + if cr.ReviewVerdict != VerdictApproved { + return nil, errs.New(errs.CodeFailedPrecondition, "merge blocked: review not approved") + } + // Optional CI gate; admins may override. + if s.cfg.RequireCIPass && !c.IsAdmin && cr.CIState != CISuccess { + return nil, errs.New(errs.CodeFailedPrecondition, "merge blocked: CI not passing") + } + if cr.ExternalID == nil { + return nil, errs.New(errs.CodeFailedPrecondition, "change request has no external PR") + } + + repoRef, err := s.repoRefFor(ctx, cr.WorkspaceID) + if err != nil { + return nil, err + } + mm := method + if mm == "" { + mm = s.cfg.MergeMethod + } + mergeSHA, err := s.provider.Merge(ctx, repoRef, *cr.ExternalID, vcs.MergeInput{Method: mm}) + if err != nil { + return nil, errs.Wrap(err, errs.CodeFailedPrecondition, "host merge failed") + } + + now := time.Now() + updated, err := s.repo.UpdateState(ctx, id, StateMerged, mergeSHA, &now) + if err != nil { + return nil, err + } + s.audit(ctx, &c.UserID, "change_request.merged", id.String(), map[string]any{ + "method": mm, "merge_sha": mergeSHA, + }) + return updated, nil +} + +// SyncStatus refreshes ci_state/state from the host PR. +func (s *service) SyncStatus(ctx context.Context, c Caller, id uuid.UUID) (*ChangeRequest, error) { + cr, err := s.repo.GetByID(ctx, id) + if err != nil { + return nil, err + } + if err := s.guardRead(ctx, c, cr.ProjectID); err != nil { + return nil, err + } + if cr.ExternalID == nil { + return cr, nil + } + repoRef, err := s.repoRefFor(ctx, cr.WorkspaceID) + if err != nil { + return nil, err + } + pr, err := s.provider.GetPR(ctx, repoRef, *cr.ExternalID) + if err != nil { + return nil, errs.Wrap(err, errs.CodeUpstream, "fetch pull request status") + } + newState := cr.State + switch { + case pr.Merged: + newState = StateMerged + case pr.State == "closed": + newState = StateClosed + default: + newState = StateOpen + } + ci := CIState(pr.CIState) + if !validCI(ci) { + ci = CIUnknown + } + return s.repo.UpdateCI(ctx, id, ci, newState) +} + +// ===== helpers ===== + +func (s *service) repoRefFor(ctx context.Context, wsID uuid.UUID) (vcs.RepoRef, error) { + meta, err := s.ws.GetWorkspaceMeta(ctx, wsID) + if err != nil { + return vcs.RepoRef{}, err + } + return vcs.ParseRepoRef(meta.GitRemoteURL, s.cfg.Host) +} + +func (s *service) guardRead(ctx context.Context, c Caller, projectID uuid.UUID) error { + canRead, _, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, projectID) + if err != nil { + return err + } + if !canRead { + return errs.New(errs.CodeForbidden, "no read access") + } + return nil +} + +func (s *service) guardWrite(ctx context.Context, c Caller, projectID uuid.UUID) error { + _, canWrite, err := s.pa.ResolveByID(ctx, c.UserID, c.IsAdmin, projectID) + if err != nil { + return err + } + if !canWrite { + return errs.New(errs.CodeForbidden, "no write access") + } + return nil +} + +func (s *service) audit(ctx context.Context, uid *uuid.UUID, action, id string, meta map[string]any) { + _ = s.rec.Record(ctx, audit.Entry{ + UserID: uid, Action: action, TargetType: "change_request", TargetID: id, Metadata: meta, + }) +} + +func validCI(ci CIState) bool { + switch ci { + case CIUnknown, CIPending, CISuccess, CIFailure: + return true + } + return false +} diff --git a/internal/changerequest/service_test.go b/internal/changerequest/service_test.go new file mode 100644 index 0000000..ad3afff --- /dev/null +++ b/internal/changerequest/service_test.go @@ -0,0 +1,312 @@ +package changerequest + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/infra/vcs" +) + +// ===== fakes ===== + +type fakeRepo struct { + items map[uuid.UUID]*ChangeRequest + nextNum int +} + +func newFakeRepo() *fakeRepo { return &fakeRepo{items: map[uuid.UUID]*ChangeRequest{}, nextNum: 1} } + +func (f *fakeRepo) GetByID(_ context.Context, id uuid.UUID) (*ChangeRequest, error) { + cr, ok := f.items[id] + if !ok { + return nil, errs.New(errs.CodeNotFound, "not found") + } + cp := *cr + return &cp, nil +} +func (f *fakeRepo) GetByNumber(_ context.Context, _ string, number int) (*ChangeRequest, error) { + for _, cr := range f.items { + if cr.Number == number { + cp := *cr + return &cp, nil + } + } + return nil, errs.New(errs.CodeNotFound, "not found") +} +func (f *fakeRepo) ListByWorkspace(_ context.Context, wsID uuid.UUID) ([]*ChangeRequest, error) { + var out []*ChangeRequest + for _, cr := range f.items { + if cr.WorkspaceID == wsID { + cp := *cr + out = append(out, &cp) + } + } + return out, nil +} +func (f *fakeRepo) ListByProject(_ context.Context, pid uuid.UUID) ([]*ChangeRequest, error) { + var out []*ChangeRequest + for _, cr := range f.items { + if cr.ProjectID == pid { + cp := *cr + out = append(out, &cp) + } + } + return out, nil +} +func (f *fakeRepo) UpdateReview(_ context.Context, id uuid.UUID, v Verdict, by uuid.UUID) (*ChangeRequest, error) { + cr := f.items[id] + cr.ReviewVerdict = v + cr.ReviewedBy = &by + now := time.Now() + cr.ReviewedAt = &now + cp := *cr + return &cp, nil +} +func (f *fakeRepo) UpdateState(_ context.Context, id uuid.UUID, st State, sha string, at *time.Time) (*ChangeRequest, error) { + cr := f.items[id] + cr.State = st + cr.MergeCommitSHA = sha + cr.MergedAt = at + cp := *cr + return &cp, nil +} +func (f *fakeRepo) UpdateCI(_ context.Context, id uuid.UUID, ci CIState, st State) (*ChangeRequest, error) { + cr := f.items[id] + cr.CIState = ci + cr.State = st + cp := *cr + return &cp, nil +} +func (f *fakeRepo) InTx(ctx context.Context, fn func(Tx) error) error { + return fn(&fakeTx{r: f}) +} + +type fakeTx struct{ r *fakeRepo } + +func (t *fakeTx) NextNumber(_ context.Context, _ uuid.UUID) (int, error) { + n := t.r.nextNum + t.r.nextNum++ + return n, nil +} +func (t *fakeTx) Create(_ context.Context, cr *ChangeRequest) (*ChangeRequest, error) { + cp := *cr + // mirror the DB INSERT defaults baked into the query (state/verdict). + cp.State = StateOpen + cp.ReviewVerdict = VerdictPending + if cp.Provider == "" { + cp.Provider = "gitea" + } + t.r.items[cr.ID] = &cp + out := cp + return &out, nil +} + +type fakeProvider struct { + createPR *vcs.PullRequest + getPR *vcs.PullRequest + mergeSHA string + mergeErr error + mergeCalled bool +} + +func (p *fakeProvider) CreatePR(_ context.Context, _ vcs.CreatePRInput) (*vcs.PullRequest, error) { + if p.createPR != nil { + return p.createPR, nil + } + return &vcs.PullRequest{Number: 1, State: "open", HTMLURL: "http://h/pr/1", CIState: "unknown"}, nil +} +func (p *fakeProvider) GetPR(_ context.Context, _ vcs.RepoRef, _ int64) (*vcs.PullRequest, error) { + if p.getPR != nil { + return p.getPR, nil + } + return &vcs.PullRequest{Number: 1, State: "open", CIState: "success"}, nil +} +func (p *fakeProvider) Comment(_ context.Context, _ vcs.RepoRef, _ int64, _ string) error { return nil } +func (p *fakeProvider) Merge(_ context.Context, _ vcs.RepoRef, _ int64, _ vcs.MergeInput) (string, error) { + p.mergeCalled = true + if p.mergeErr != nil { + return "", p.mergeErr + } + if p.mergeSHA == "" { + return "merged-sha", nil + } + return p.mergeSHA, nil +} +func (p *fakeProvider) ListIssues(_ context.Context, _ vcs.RepoRef, _ vcs.IssueListOptions) ([]vcs.Issue, error) { + return nil, nil +} + +type fakeWS struct { + meta WorkspaceMeta + err error +} + +func (f *fakeWS) GetWorkspaceMeta(_ context.Context, _ uuid.UUID) (WorkspaceMeta, error) { + return f.meta, f.err +} + +type fakePA struct { + canRead, canWrite bool +} + +func (f *fakePA) ResolveByID(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, bool, error) { + return f.canRead, f.canWrite, nil +} + +type nopRec struct{} + +func (nopRec) Record(_ context.Context, _ audit.Entry) error { return nil } + +var ( + tProj = uuid.New() + tWS = uuid.New() + tUser = uuid.New() +) + +func newSvc(t *testing.T, prov *fakeProvider, pa *fakePA, cfg Config) (*service, *fakeRepo) { + t.Helper() + repo := newFakeRepo() + ws := &fakeWS{meta: WorkspaceMeta{ProjectID: tProj, GitRemoteURL: "https://git.jerryyan.net/owner/repo.git", DefaultBranch: "main"}} + svc := NewService(repo, ws, nopRec{}, prov, pa, cfg).(*service) + return svc, repo +} + +func caller() Caller { return Caller{UserID: tUser} } + +// ===== tests ===== + +func TestCreate_PersistsPending(t *testing.T) { + prov := &fakeProvider{createPR: &vcs.PullRequest{Number: 7, State: "open", HTMLURL: "http://h/7", CIState: "pending"}} + svc, _ := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"}) + + cr, err := svc.Create(context.Background(), caller(), tWS, CreateInput{ + SourceBranch: "feature", Title: "T", + }) + require.NoError(t, err) + assert.Equal(t, VerdictPending, cr.ReviewVerdict) + assert.Equal(t, StateOpen, cr.State) + assert.Equal(t, "main", cr.TargetBranch) + require.NotNil(t, cr.ExternalID) + assert.Equal(t, int64(7), *cr.ExternalID) + assert.Equal(t, 1, cr.Number) + assert.Equal(t, CIPending, cr.CIState) +} + +func TestCreate_RequiresWrite(t *testing.T) { + svc, _ := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: false}, Config{Host: "git.jerryyan.net"}) + _, err := svc.Create(context.Background(), caller(), tWS, CreateInput{SourceBranch: "f", Title: "T"}) + require.Error(t, err) + ae, _ := errs.As(err) + assert.Equal(t, errs.CodeForbidden, ae.Code) +} + +func TestCreate_SameBranchRejected(t *testing.T) { + svc, _ := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"}) + _, err := svc.Create(context.Background(), caller(), tWS, CreateInput{SourceBranch: "main", Title: "T"}) + require.Error(t, err) + ae, _ := errs.As(err) + assert.Equal(t, errs.CodeInvalidInput, ae.Code) +} + +// seed inserts an open, approved-able CR into the repo for merge tests. +func seedCR(repo *fakeRepo, verdict Verdict, ci CIState) *ChangeRequest { + id := uuid.New() + ext := int64(7) + cr := &ChangeRequest{ + ID: id, ProjectID: tProj, WorkspaceID: tWS, Number: 1, + Title: "T", SourceBranch: "feature", TargetBranch: "main", + State: StateOpen, ReviewVerdict: verdict, CIState: ci, + Provider: "gitea", ExternalID: &ext, CreatedBy: tUser, + } + repo.items[id] = cr + return cr +} + +func TestMerge_DeniedWhenNotApproved(t *testing.T) { + for _, v := range []Verdict{VerdictPending, VerdictChangesRequested, VerdictRejected} { + prov := &fakeProvider{} + svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"}) + cr := seedCR(repo, v, CISuccess) + + _, err := svc.Merge(context.Background(), caller(), cr.ID, "") + require.Error(t, err, v) + ae, _ := errs.As(err) + assert.Equal(t, errs.CodeFailedPrecondition, ae.Code, v) + assert.False(t, prov.mergeCalled, "provider.Merge must NOT be called when verdict=%s", v) + } +} + +func TestMerge_DeniedWhenCINotPassing(t *testing.T) { + prov := &fakeProvider{} + svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true}) + cr := seedCR(repo, VerdictApproved, CIFailure) + + _, err := svc.Merge(context.Background(), caller(), cr.ID, "") + require.Error(t, err) + ae, _ := errs.As(err) + assert.Equal(t, errs.CodeFailedPrecondition, ae.Code) + assert.False(t, prov.mergeCalled) +} + +func TestMerge_AdminBypassesCI(t *testing.T) { + prov := &fakeProvider{mergeSHA: "sha-x"} + svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true}) + cr := seedCR(repo, VerdictApproved, CIFailure) + + out, err := svc.Merge(context.Background(), Caller{UserID: tUser, IsAdmin: true}, cr.ID, "") + require.NoError(t, err) + assert.True(t, prov.mergeCalled) + assert.Equal(t, StateMerged, out.State) + assert.Equal(t, "sha-x", out.MergeCommitSHA) +} + +func TestMerge_AllowedWhenApproved(t *testing.T) { + prov := &fakeProvider{mergeSHA: "sha-y"} + svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net", RequireCIPass: true}) + cr := seedCR(repo, VerdictApproved, CISuccess) + + out, err := svc.Merge(context.Background(), caller(), cr.ID, "squash") + require.NoError(t, err) + assert.True(t, prov.mergeCalled) + assert.Equal(t, StateMerged, out.State) + assert.NotNil(t, out.MergedAt) + assert.Equal(t, "sha-y", out.MergeCommitSHA) +} + +func TestReview_SetsReviewer(t *testing.T) { + svc, repo := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"}) + cr := seedCR(repo, VerdictPending, CIUnknown) + + out, err := svc.Review(context.Background(), caller(), cr.ID, VerdictApproved, "lgtm") + require.NoError(t, err) + assert.Equal(t, VerdictApproved, out.ReviewVerdict) + require.NotNil(t, out.ReviewedBy) + assert.Equal(t, tUser, *out.ReviewedBy) +} + +func TestReview_RequiresWrite(t *testing.T) { + svc, repo := newSvc(t, &fakeProvider{}, &fakePA{canRead: true, canWrite: false}, Config{Host: "git.jerryyan.net"}) + cr := seedCR(repo, VerdictPending, CIUnknown) + _, err := svc.Review(context.Background(), caller(), cr.ID, VerdictApproved, "") + require.Error(t, err) + ae, _ := errs.As(err) + assert.Equal(t, errs.CodeForbidden, ae.Code) +} + +func TestSyncStatus_RefreshesCIAndState(t *testing.T) { + prov := &fakeProvider{getPR: &vcs.PullRequest{Number: 7, State: "closed", Merged: true, CIState: "success", MergeCommitSHA: "z"}} + svc, repo := newSvc(t, prov, &fakePA{canRead: true, canWrite: true}, Config{Host: "git.jerryyan.net"}) + cr := seedCR(repo, VerdictApproved, CIUnknown) + + out, err := svc.SyncStatus(context.Background(), caller(), cr.ID) + require.NoError(t, err) + assert.Equal(t, CISuccess, out.CIState) + assert.Equal(t, StateMerged, out.State) +} diff --git a/internal/changerequest/sqlc/change_requests.sql.go b/internal/changerequest/sqlc/change_requests.sql.go new file mode 100644 index 0000000..3630bd1 --- /dev/null +++ b/internal/changerequest/sqlc/change_requests.sql.go @@ -0,0 +1,429 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: change_requests.sql + +package changerequestsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const createChangeRequest = `-- name: CreateChangeRequest :one +INSERT INTO change_requests ( + id, project_id, workspace_id, requirement_id, issue_id, number, + title, description, source_branch, target_branch, + state, review_verdict, ci_state, provider, external_id, external_url, + merge_commit_sha, created_by +) VALUES ( + $1, $2, $3, $4, $5, $6, + $7, $8, $9, $10, + 'open', 'pending', $11, $12, $13, $14, + '', $15 +) +RETURNING id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at +` + +type CreateChangeRequestParams struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + CreatedBy pgtype.UUID `json:"created_by"` +} + +func (q *Queries) CreateChangeRequest(ctx context.Context, arg CreateChangeRequestParams) (ChangeRequest, error) { + row := q.db.QueryRow(ctx, createChangeRequest, + arg.ID, + arg.ProjectID, + arg.WorkspaceID, + arg.RequirementID, + arg.IssueID, + arg.Number, + arg.Title, + arg.Description, + arg.SourceBranch, + arg.TargetBranch, + arg.CiState, + arg.Provider, + arg.ExternalID, + arg.ExternalUrl, + arg.CreatedBy, + ) + var i ChangeRequest + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.RequirementID, + &i.IssueID, + &i.Number, + &i.Title, + &i.Description, + &i.SourceBranch, + &i.TargetBranch, + &i.State, + &i.ReviewVerdict, + &i.CiState, + &i.Provider, + &i.ExternalID, + &i.ExternalUrl, + &i.MergeCommitSha, + &i.ReviewedBy, + &i.ReviewedAt, + &i.CreatedBy, + &i.MergedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getChangeRequestByID = `-- name: GetChangeRequestByID :one +SELECT id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at FROM change_requests WHERE id = $1 +` + +func (q *Queries) GetChangeRequestByID(ctx context.Context, id pgtype.UUID) (ChangeRequest, error) { + row := q.db.QueryRow(ctx, getChangeRequestByID, id) + var i ChangeRequest + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.RequirementID, + &i.IssueID, + &i.Number, + &i.Title, + &i.Description, + &i.SourceBranch, + &i.TargetBranch, + &i.State, + &i.ReviewVerdict, + &i.CiState, + &i.Provider, + &i.ExternalID, + &i.ExternalUrl, + &i.MergeCommitSha, + &i.ReviewedBy, + &i.ReviewedAt, + &i.CreatedBy, + &i.MergedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const getChangeRequestByNumber = `-- name: GetChangeRequestByNumber :one +SELECT cr.id, cr.project_id, cr.workspace_id, cr.requirement_id, cr.issue_id, cr.number, cr.title, cr.description, cr.source_branch, cr.target_branch, cr.state, cr.review_verdict, cr.ci_state, cr.provider, cr.external_id, cr.external_url, cr.merge_commit_sha, cr.reviewed_by, cr.reviewed_at, cr.created_by, cr.merged_at, cr.created_at, cr.updated_at FROM change_requests cr +JOIN projects p ON p.id = cr.project_id +WHERE p.slug = $1 AND cr.number = $2 +` + +type GetChangeRequestByNumberParams struct { + Slug string `json:"slug"` + Number int32 `json:"number"` +} + +func (q *Queries) GetChangeRequestByNumber(ctx context.Context, arg GetChangeRequestByNumberParams) (ChangeRequest, error) { + row := q.db.QueryRow(ctx, getChangeRequestByNumber, arg.Slug, arg.Number) + var i ChangeRequest + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.RequirementID, + &i.IssueID, + &i.Number, + &i.Title, + &i.Description, + &i.SourceBranch, + &i.TargetBranch, + &i.State, + &i.ReviewVerdict, + &i.CiState, + &i.Provider, + &i.ExternalID, + &i.ExternalUrl, + &i.MergeCommitSha, + &i.ReviewedBy, + &i.ReviewedAt, + &i.CreatedBy, + &i.MergedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listChangeRequestsByProject = `-- name: ListChangeRequestsByProject :many +SELECT id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at FROM change_requests +WHERE project_id = $1 +ORDER BY number DESC +` + +func (q *Queries) ListChangeRequestsByProject(ctx context.Context, projectID pgtype.UUID) ([]ChangeRequest, error) { + rows, err := q.db.Query(ctx, listChangeRequestsByProject, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChangeRequest + for rows.Next() { + var i ChangeRequest + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.RequirementID, + &i.IssueID, + &i.Number, + &i.Title, + &i.Description, + &i.SourceBranch, + &i.TargetBranch, + &i.State, + &i.ReviewVerdict, + &i.CiState, + &i.Provider, + &i.ExternalID, + &i.ExternalUrl, + &i.MergeCommitSha, + &i.ReviewedBy, + &i.ReviewedAt, + &i.CreatedBy, + &i.MergedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listChangeRequestsByWorkspace = `-- name: ListChangeRequestsByWorkspace :many +SELECT id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at FROM change_requests +WHERE workspace_id = $1 +ORDER BY number DESC +` + +func (q *Queries) ListChangeRequestsByWorkspace(ctx context.Context, workspaceID pgtype.UUID) ([]ChangeRequest, error) { + rows, err := q.db.Query(ctx, listChangeRequestsByWorkspace, workspaceID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ChangeRequest + for rows.Next() { + var i ChangeRequest + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.RequirementID, + &i.IssueID, + &i.Number, + &i.Title, + &i.Description, + &i.SourceBranch, + &i.TargetBranch, + &i.State, + &i.ReviewVerdict, + &i.CiState, + &i.Provider, + &i.ExternalID, + &i.ExternalUrl, + &i.MergeCommitSha, + &i.ReviewedBy, + &i.ReviewedAt, + &i.CreatedBy, + &i.MergedAt, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const nextChangeRequestNumber = `-- name: NextChangeRequestNumber :one +SELECT COALESCE(MAX(number), 0) + 1 FROM change_requests WHERE project_id = $1 FOR UPDATE +` + +// Per-project monotonic number; FOR UPDATE not needed because the +// max(number)+1 INSERT runs inside the same tx, but we lock existing project +// rows to serialize concurrent creates within a project. +func (q *Queries) NextChangeRequestNumber(ctx context.Context, projectID pgtype.UUID) (int32, error) { + row := q.db.QueryRow(ctx, nextChangeRequestNumber, projectID) + var column_1 int32 + err := row.Scan(&column_1) + return column_1, err +} + +const updateChangeRequestCI = `-- name: UpdateChangeRequestCI :one +UPDATE change_requests +SET ci_state = $2, + state = $3, + updated_at = now() +WHERE id = $1 +RETURNING id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at +` + +type UpdateChangeRequestCIParams struct { + ID pgtype.UUID `json:"id"` + CiState string `json:"ci_state"` + State string `json:"state"` +} + +func (q *Queries) UpdateChangeRequestCI(ctx context.Context, arg UpdateChangeRequestCIParams) (ChangeRequest, error) { + row := q.db.QueryRow(ctx, updateChangeRequestCI, arg.ID, arg.CiState, arg.State) + var i ChangeRequest + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.RequirementID, + &i.IssueID, + &i.Number, + &i.Title, + &i.Description, + &i.SourceBranch, + &i.TargetBranch, + &i.State, + &i.ReviewVerdict, + &i.CiState, + &i.Provider, + &i.ExternalID, + &i.ExternalUrl, + &i.MergeCommitSha, + &i.ReviewedBy, + &i.ReviewedAt, + &i.CreatedBy, + &i.MergedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateChangeRequestReview = `-- name: UpdateChangeRequestReview :one +UPDATE change_requests +SET review_verdict = $2, + reviewed_by = $3, + reviewed_at = now(), + updated_at = now() +WHERE id = $1 +RETURNING id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at +` + +type UpdateChangeRequestReviewParams struct { + ID pgtype.UUID `json:"id"` + ReviewVerdict string `json:"review_verdict"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` +} + +func (q *Queries) UpdateChangeRequestReview(ctx context.Context, arg UpdateChangeRequestReviewParams) (ChangeRequest, error) { + row := q.db.QueryRow(ctx, updateChangeRequestReview, arg.ID, arg.ReviewVerdict, arg.ReviewedBy) + var i ChangeRequest + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.RequirementID, + &i.IssueID, + &i.Number, + &i.Title, + &i.Description, + &i.SourceBranch, + &i.TargetBranch, + &i.State, + &i.ReviewVerdict, + &i.CiState, + &i.Provider, + &i.ExternalID, + &i.ExternalUrl, + &i.MergeCommitSha, + &i.ReviewedBy, + &i.ReviewedAt, + &i.CreatedBy, + &i.MergedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const updateChangeRequestState = `-- name: UpdateChangeRequestState :one +UPDATE change_requests +SET state = $2, + merge_commit_sha = $3, + merged_at = $4, + updated_at = now() +WHERE id = $1 +RETURNING id, project_id, workspace_id, requirement_id, issue_id, number, title, description, source_branch, target_branch, state, review_verdict, ci_state, provider, external_id, external_url, merge_commit_sha, reviewed_by, reviewed_at, created_by, merged_at, created_at, updated_at +` + +type UpdateChangeRequestStateParams struct { + ID pgtype.UUID `json:"id"` + State string `json:"state"` + MergeCommitSha string `json:"merge_commit_sha"` + MergedAt pgtype.Timestamptz `json:"merged_at"` +} + +func (q *Queries) UpdateChangeRequestState(ctx context.Context, arg UpdateChangeRequestStateParams) (ChangeRequest, error) { + row := q.db.QueryRow(ctx, updateChangeRequestState, + arg.ID, + arg.State, + arg.MergeCommitSha, + arg.MergedAt, + ) + var i ChangeRequest + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.RequirementID, + &i.IssueID, + &i.Number, + &i.Title, + &i.Description, + &i.SourceBranch, + &i.TargetBranch, + &i.State, + &i.ReviewVerdict, + &i.CiState, + &i.Provider, + &i.ExternalID, + &i.ExternalUrl, + &i.MergeCommitSha, + &i.ReviewedBy, + &i.ReviewedAt, + &i.CreatedBy, + &i.MergedAt, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/changerequest/sqlc/db.go b/internal/changerequest/sqlc/db.go new file mode 100644 index 0000000..81ffbd9 --- /dev/null +++ b/internal/changerequest/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package changerequestsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/changerequest/sqlc/models.go b/internal/changerequest/sqlc/models.go new file mode 100644 index 0000000..4df3d0f --- /dev/null +++ b/internal/changerequest/sqlc/models.go @@ -0,0 +1,593 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package changerequestsqlc + +import ( + "net/netip" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" +) + +type AcpAgentKind struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` + ClientType string `json:"client_type"` + EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` +} + +type AcpAgentKindConfigFile struct { + ID pgtype.UUID `json:"id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + RelPath string `json:"rel_path"` + EncryptedContent []byte `json:"encrypted_content"` + UpdatedBy pgtype.UUID `json:"updated_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type AcpEvent struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + Direction string `json:"direction"` + RpcKind string `json:"rpc_kind"` + Method *string `json:"method"` + Payload []byte `json:"payload"` + PayloadSize int32 `json:"payload_size"` + Truncated bool `json:"truncated"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpSession struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` +} + +type AuditLog struct { + ID int64 `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Action string `json:"action"` + TargetType *string `json:"target_type"` + TargetID *string `json:"target_id"` + Ip *netip.Addr `json:"ip"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Conversation struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + IssueID pgtype.UUID `json:"issue_id"` + ModelID pgtype.UUID `json:"model_id"` + SystemPrompt string `json:"system_prompt"` + Title *string `json:"title"` + TitleGeneratedAt pgtype.Timestamptz `json:"title_generated_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` +} + +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type GitCredential struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Username *string `json:"username"` + EncryptedSecret []byte `json:"encrypted_secret"` + Fingerprint *string `json:"fingerprint"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type Issue struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + AssigneeID pgtype.UUID `json:"assignee_id"` + CreatedBy pgtype.UUID `json:"created_by"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Job struct { + ID pgtype.UUID `json:"id"` + Type string `json:"type"` + Payload []byte `json:"payload"` + Status string `json:"status"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + Attempts int32 `json:"attempts"` + MaxAttempts int32 `json:"max_attempts"` + LeasedAt pgtype.Timestamptz `json:"leased_at"` + LeasedBy *string `json:"leased_by"` + LastError *string `json:"last_error"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmEndpoint struct { + ID pgtype.UUID `json:"id"` + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + BaseUrl string `json:"base_url"` + ApiKeyEncrypted []byte `json:"api_key_encrypted"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type LlmModel struct { + ID pgtype.UUID `json:"id"` + EndpointID pgtype.UUID `json:"endpoint_id"` + ModelID string `json:"model_id"` + DisplayName string `json:"display_name"` + Capabilities []byte `json:"capabilities"` + ContextWindow int32 `json:"context_window"` + MaxOutputTokens int32 `json:"max_output_tokens"` + PromptPricePerMillionUsd pgtype.Numeric `json:"prompt_price_per_million_usd"` + CompletionPricePerMillionUsd pgtype.Numeric `json:"completion_price_per_million_usd"` + ThinkingPricePerMillionUsd pgtype.Numeric `json:"thinking_price_per_million_usd"` + IsTitleGenerator bool `json:"is_title_generator"` + Enabled bool `json:"enabled"` + SortOrder int32 `json:"sort_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmUsage struct { + ID int64 `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + MessageID int64 `json:"message_id"` + UserID pgtype.UUID `json:"user_id"` + EndpointID pgtype.UUID `json:"endpoint_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int32 `json:"prompt_tokens"` + CompletionTokens int32 `json:"completion_tokens"` + ThinkingTokens int32 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type McpToken struct { + ID pgtype.UUID `json:"id"` + TokenHash []byte `json:"token_hash"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + Issuer string `json:"issuer"` + Scope []byte `json:"scope"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Message struct { + ID int64 `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + Role string `json:"role"` + Content string `json:"content"` + Thinking *string `json:"thinking"` + ToolCalls []byte `json:"tool_calls"` + Status string `json:"status"` + ErrorMessage *string `json:"error_message"` + PromptTokens *int32 `json:"prompt_tokens"` + CompletionTokens *int32 `json:"completion_tokens"` + ThinkingTokens *int32 `json:"thinking_tokens"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type MessageAttachment struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + MessageID *int64 `json:"message_id"` + Filename string `json:"filename"` + MimeType string `json:"mime_type"` + SizeBytes int64 `json:"size_bytes"` + Sha256 string `json:"sha256"` + StoragePath string `json:"storage_path"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Notification struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Topic string `json:"topic"` + Severity string `json:"severity"` + Title string `json:"title"` + Body string `json:"body"` + Link *string `json:"link"` + Metadata []byte `json:"metadata"` + ReadAt pgtype.Timestamptz `json:"read_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type Project struct { + ID pgtype.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + OwnerID pgtype.UUID `json:"owner_id"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type PromptTemplate struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Content string `json:"content"` + Scope string `json:"scope"` + OwnerID pgtype.UUID `json:"owner_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Requirement struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Phase string `json:"phase"` + Status string `json:"status"` + OwnerID pgtype.UUID `json:"owner_id"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type User struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + DisplayName string `json:"display_name"` + IsAdmin bool `json:"is_admin"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` +} + +type UserSession struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + TokenHash []byte `json:"token_hash"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Workspace struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + GitRemoteUrl string `json:"git_remote_url"` + DefaultBranch string `json:"default_branch"` + MainPath string `json:"main_path"` + SyncStatus string `json:"sync_status"` + LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"` + LastSyncError *string `json:"last_sync_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"` +} + +type WorkspaceRunProfile struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Command string `json:"command"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + LastStartedAt pgtype.Timestamptz `json:"last_started_at"` + LastExitCode *int32 `json:"last_exit_code"` + LastError string `json:"last_error"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type WorkspaceWorktree struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Branch string `json:"branch"` + Path string `json:"path"` + Status string `json:"status"` + ActiveHolder *string `json:"active_holder"` + AcquiredAt pgtype.Timestamptz `json:"acquired_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + PruneWarningAt pgtype.Timestamptz `json:"prune_warning_at"` +} diff --git a/internal/chat/domain.go b/internal/chat/domain.go index a7d5e16..ef98010 100644 --- a/internal/chat/domain.go +++ b/internal/chat/domain.go @@ -112,6 +112,13 @@ type Conversation struct { DeletedAt *time.Time } +// HasFKMount 报告会话是否挂载了任一 PM/工作区 FK(project/workspace/issue/requirement)。 +// composeHistory 据此决定是否调用 ContextReader 组装 FK 上下文简报。 +func (c *Conversation) HasFKMount() bool { + return c.ProjectID != nil || c.WorkspaceID != nil || + c.IssueID != nil || c.RequirementID != nil +} + // Attachment 是 message 的附件(user_id 必填,message_id 上传后未关联时为 nil)。 type Attachment struct { ID uuid.UUID @@ -169,6 +176,14 @@ type ConversationReader interface { ListConversationsByUser(ctx context.Context, userID uuid.UUID, limit int) ([]Conversation, error) } +// ContextReader 是 chat 模块对 FK 上下文组装器的窄接口(adapter 在 app.go 实现, +// 内部委托 internal/brief.Composer)。给定一个挂载了 project/workspace/issue/requirement +// 的会话,返回组装好的 FK 上下文简报;无任何挂载时返回空串。messageService 在 +// composeHistory 注入它的输出到有效 system prompt。chat 不直接依赖 project/brief。 +type ContextReader interface { + BriefForConversation(ctx context.Context, conv *Conversation) (string, error) +} + // ===== Service 接口(在 service_*.go 实现) ===== // ConversationService — 会话 CRUD + 标题生成 hook。 diff --git a/internal/chat/pricing.go b/internal/chat/pricing.go index 3923eb9..9574284 100644 --- a/internal/chat/pricing.go +++ b/internal/chat/pricing.go @@ -2,14 +2,22 @@ package chat import "strconv" -// computeCost returns the cost in USD for a single LLM call. -// Prices on LLMModel are expressed in USD per million tokens. -func computeCost(m LLMModel, ptok, ctok, ttok int) float64 { +// ComputeCost returns the cost in USD for a single LLM call. +// Prices on LLMModel are expressed in USD per million tokens. Exported so other +// modules (e.g. internal/acp session cost accounting) reuse the single pricing +// formula instead of replicating it. +func ComputeCost(m LLMModel, ptok, ctok, ttok int) float64 { return (float64(ptok)*m.PromptPricePerMillionUSD + float64(ctok)*m.CompletionPricePerMillionUSD + float64(ttok)*m.ThinkingPricePerMillionUSD) / 1_000_000 } +// computeCost is a thin alias kept for the existing internal caller (streamer.go) +// so that change stays untouched while ComputeCost is the public entry point. +func computeCost(m LLMModel, ptok, ctok, ttok int) float64 { + return ComputeCost(m, ptok, ctok, ttok) +} + // int64ToString converts an int64 to its decimal string representation. // Uses strconv.FormatInt rather than fmt.Sprintf for efficiency. func int64ToString(v int64) string { return strconv.FormatInt(v, 10) } diff --git a/internal/chat/pricing_test.go b/internal/chat/pricing_test.go index ed69a7f..5164486 100644 --- a/internal/chat/pricing_test.go +++ b/internal/chat/pricing_test.go @@ -30,6 +30,19 @@ func TestComputeCost_BasicMath(t *testing.T) { require.InDelta(t, 0.021, got, 1e-9) } +// TestExportedComputeCost_EqualsUnexported guards that the exported ComputeCost +// (reused by internal/acp) returns identical results to the unexported alias. +func TestExportedComputeCost_EqualsUnexported(t *testing.T) { + m := LLMModel{ + PromptPricePerMillionUSD: 3.0, + CompletionPricePerMillionUSD: 15.0, + ThinkingPricePerMillionUSD: 3.0, + } + for _, c := range [][3]int{{1000, 0, 0}, {0, 1000, 0}, {0, 0, 1000}, {1234, 5678, 90}} { + require.Equal(t, computeCost(m, c[0], c[1], c[2]), ComputeCost(m, c[0], c[1], c[2])) + } +} + func TestComputeCost_ZeroTokens(t *testing.T) { m := LLMModel{ PromptPricePerMillionUSD: 3.0, diff --git a/internal/chat/service_message.go b/internal/chat/service_message.go index 8d96791..5706193 100644 --- a/internal/chat/service_message.go +++ b/internal/chat/service_message.go @@ -26,18 +26,20 @@ type MessageServiceConfig struct { } type messageService struct { - repo Repository - storage storage.Storage - hub StreamerHub - auditor audit.Recorder - log *slog.Logger - mimeWhite map[string]bool - maxFile int64 - maxMsg int64 - safetyPct float64 + repo Repository + storage storage.Storage + hub StreamerHub + auditor audit.Recorder + log *slog.Logger + mimeWhite map[string]bool + maxFile int64 + maxMsg int64 + safetyPct float64 + briefReader ContextReader // 可为 nil;非 nil 时 composeHistory 注入 FK 上下文 } -// NewMessageService constructs a MessageService. +// NewMessageService constructs a MessageService. briefReader 可为 nil:nil 时 +// composeHistory 完全保留旧行为(仅用 conv.SystemPrompt),不做 FK 上下文注入。 func NewMessageService( repo Repository, st storage.Storage, @@ -45,21 +47,23 @@ func NewMessageService( auditor audit.Recorder, log *slog.Logger, cfg MessageServiceConfig, + briefReader ContextReader, ) MessageService { mimeWhite := make(map[string]bool, len(cfg.MimeWhitelist)) for _, m := range cfg.MimeWhitelist { mimeWhite[m] = true } return &messageService{ - repo: repo, - storage: st, - hub: hub, - auditor: auditor, - log: log, - mimeWhite: mimeWhite, - maxFile: cfg.MaxFileBytes, - maxMsg: cfg.MaxMsgBytes, - safetyPct: cfg.SafetyPct, + repo: repo, + storage: st, + hub: hub, + auditor: auditor, + log: log, + mimeWhite: mimeWhite, + maxFile: cfg.MaxFileBytes, + maxMsg: cfg.MaxMsgBytes, + safetyPct: cfg.SafetyPct, + briefReader: briefReader, } } @@ -483,20 +487,52 @@ func (s *messageService) composeHistory(ctx context.Context, conv *Conversation, } } + // Resolve the effective system prompt by merging conv.SystemPrompt with the + // FK-derived context brief (requirement/issue/project mounts). The brief is + // pre-bounded by the composer, so it cannot starve message history to zero + // (truncateByTokens still reserves the remaining budget for messages). + effectiveSystemPrompt := s.effectiveSystemPrompt(ctx, conv) + // Compute token budget: context window minus max output tokens, then apply safety margin. budget := model.ContextWindow - model.MaxOutputTokens budget = int(float64(budget) * (1 - s.safetyPct)) - msgs = truncateByTokens(msgs, conv.SystemPrompt, budget) + msgs = truncateByTokens(msgs, effectiveSystemPrompt, budget) return llm.Request{ - SystemPrompt: conv.SystemPrompt, + SystemPrompt: effectiveSystemPrompt, Messages: msgs, MaxOutputTokens: model.MaxOutputTokens, Reasoning: model.Capabilities.Reasoning, }, nil } +// effectiveSystemPrompt 把会话静态 system prompt 与 FK 派生上下文简报合并。 +// briefReader 为 nil、会话无任何 FK 挂载、或简报为空时,直接返回 conv.SystemPrompt +// (与旧行为完全一致)。合并是追加式的(FK 简报置于静态 prompt 之后),便于 +// 让旧会话(曾把角色 prompt 烘焙进 SystemPrompt)与新会话都能正常工作。 +func (s *messageService) effectiveSystemPrompt(ctx context.Context, conv *Conversation) string { + if s.briefReader == nil || !conv.HasFKMount() { + return conv.SystemPrompt + } + fkBrief, err := s.briefReader.BriefForConversation(ctx, conv) + if err != nil { + if s.log != nil { + s.log.Warn("chat.compose_history.brief_failed", + "conversation_id", conv.ID, "err", err.Error()) + } + return conv.SystemPrompt + } + fkBrief = strings.TrimSpace(fkBrief) + if fkBrief == "" { + return conv.SystemPrompt + } + if strings.TrimSpace(conv.SystemPrompt) == "" { + return fkBrief + } + return conv.SystemPrompt + "\n\n" + fkBrief +} + // truncateByTokens walks messages backwards and drops oldest messages until the // estimated token count (system prompt + messages) fits within budget. func truncateByTokens(msgs []llm.Message, systemPrompt string, budget int) []llm.Message { diff --git a/internal/chat/service_message_test.go b/internal/chat/service_message_test.go index b0e6aba..b6b80ff 100644 --- a/internal/chat/service_message_test.go +++ b/internal/chat/service_message_test.go @@ -9,6 +9,7 @@ import ( "context" "errors" "io" + "strings" "sync" "testing" @@ -282,7 +283,7 @@ func (f *fakeStorage) Delete(_ context.Context, _ string) error { // ===== test builder ===== func buildMessageService(repo *msgFakeRepo, hub *fakeHub, cfg MessageServiceConfig) MessageService { - return NewMessageService(repo, newFakeStorage(), hub, noopAuditor{}, discardLogger(), cfg) + return NewMessageService(repo, newFakeStorage(), hub, noopAuditor{}, discardLogger(), cfg, nil) } func defaultMsgConfig() MessageServiceConfig { @@ -457,7 +458,7 @@ func TestMessageService_Send_CapabilityMismatch(t *testing.T) { a := &Attachment{ID: uuid.New(), UserID: userID, MimeType: "image/png", SizeBytes: 100} specialRepo.addAttachment(a) - svc := NewMessageService(specialRepo, newFakeStorage(), hub, noopAuditor{}, discardLogger(), defaultMsgConfig()) + svc := NewMessageService(specialRepo, newFakeStorage(), hub, noopAuditor{}, discardLogger(), defaultMsgConfig(), nil) _, _, err := svc.Send(context.Background(), userID, conv.ID, "hi", []uuid.UUID{a.ID}) require.Error(t, err) @@ -990,6 +991,130 @@ func TestTruncateByTokens_PreservesNewest(t *testing.T) { require.Equal(t, "hi", result[0].Blocks[0].Text) } +// ===== composeHistory FK context injection tests ===== + +// fakeContextReader 是 ContextReader 的测试替身。 +type fakeContextReader struct { + brief string + err error + called bool +} + +func (f *fakeContextReader) BriefForConversation(_ context.Context, _ *Conversation) (string, error) { + f.called = true + return f.brief, f.err +} + +func newMsgSvcWithReader(repo *msgFakeRepo, reader ContextReader) *messageService { + return &messageService{ + repo: repo, + storage: newFakeStorage(), + auditor: noopAuditor{}, + log: discardLogger(), + mimeWhite: map[string]bool{}, + safetyPct: 0.0, + briefReader: reader, + } +} + +func TestComposeHistory_FKBriefMergedIntoSystemPrompt(t *testing.T) { + repo := newMsgFakeRepo() + reqID := uuid.New() + conv := newConv(uuid.New(), uuid.New()) + conv.SystemPrompt = "STATIC PROMPT" + conv.RequirementID = &reqID + repo.addConversation(conv) + repo.addMessage(&Message{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hi", Status: StatusOK}) + + reader := &fakeContextReader{brief: "## 当前需求\n需求 #7:导出报表"} + svc := newMsgSvcWithReader(repo, reader) + + model := &LLMModel{ContextWindow: 4096, MaxOutputTokens: 1024} + req, err := svc.composeHistory(context.Background(), conv, model) + require.NoError(t, err) + require.True(t, reader.called) + require.Contains(t, req.SystemPrompt, "STATIC PROMPT") + require.Contains(t, req.SystemPrompt, "需求 #7:导出报表") +} + +func TestComposeHistory_FKBriefCountsAgainstTokenBudget(t *testing.T) { + repo := newMsgFakeRepo() + reqID := uuid.New() + conv := newConv(uuid.New(), uuid.New()) + conv.RequirementID = &reqID + repo.addConversation(conv) + // 多条历史消息,预算很小,FK 简报应挤占消息预算导致历史被截断。 + for i := int64(1); i <= 5; i++ { + repo.addMessage(&Message{ + ID: i, ConversationID: conv.ID, Role: RoleUser, + Content: "message content that consumes tokens in the budget", Status: StatusOK, + }) + } + + hugeBrief := strings.Repeat("需求上下文 ", 300) + reader := &fakeContextReader{brief: hugeBrief} + svc := newMsgSvcWithReader(repo, reader) + + // ContextWindow 紧到只够装下简报 + 少量消息: + // budget = (500-100)*(1-0) = 400 tokens;简报约 450 tokens(已超 budget), + // 剩余预算 < 5 条消息所需 → 消息被截断。 + model := &LLMModel{ContextWindow: 500, MaxOutputTokens: 100} + req, err := svc.composeHistory(context.Background(), conv, model) + require.NoError(t, err) + require.Contains(t, req.SystemPrompt, "需求上下文") + // 简报占了预算 → 消息被截断(少于 5 条)。 + require.Less(t, len(req.Messages), 5) +} + +func TestComposeHistory_NilReaderPreservesBehavior(t *testing.T) { + repo := newMsgFakeRepo() + reqID := uuid.New() + conv := newConv(uuid.New(), uuid.New()) + conv.SystemPrompt = "ONLY STATIC" + conv.RequirementID = &reqID + repo.addConversation(conv) + repo.addMessage(&Message{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hi", Status: StatusOK}) + + svc := newMsgSvcWithReader(repo, nil) // 无 reader + model := &LLMModel{ContextWindow: 4096, MaxOutputTokens: 1024} + req, err := svc.composeHistory(context.Background(), conv, model) + require.NoError(t, err) + require.Equal(t, "ONLY STATIC", req.SystemPrompt) +} + +func TestComposeHistory_NoFKMountSkipsReader(t *testing.T) { + repo := newMsgFakeRepo() + conv := newConv(uuid.New(), uuid.New()) // 无任何 FK 挂载 + conv.SystemPrompt = "STATIC" + repo.addConversation(conv) + repo.addMessage(&Message{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hi", Status: StatusOK}) + + reader := &fakeContextReader{brief: "SHOULD NOT APPEAR"} + svc := newMsgSvcWithReader(repo, reader) + model := &LLMModel{ContextWindow: 4096, MaxOutputTokens: 1024} + req, err := svc.composeHistory(context.Background(), conv, model) + require.NoError(t, err) + require.False(t, reader.called, "reader must not be called without FK mount") + require.Equal(t, "STATIC", req.SystemPrompt) +} + +func TestComposeHistory_ReaderErrorFallsBackToStatic(t *testing.T) { + repo := newMsgFakeRepo() + reqID := uuid.New() + conv := newConv(uuid.New(), uuid.New()) + conv.SystemPrompt = "STATIC FALLBACK" + conv.RequirementID = &reqID + repo.addConversation(conv) + repo.addMessage(&Message{ID: 1, ConversationID: conv.ID, Role: RoleUser, Content: "hi", Status: StatusOK}) + + reader := &fakeContextReader{err: errs.New(errs.CodeInternal, "boom")} + svc := newMsgSvcWithReader(repo, reader) + model := &LLMModel{ContextWindow: 4096, MaxOutputTokens: 1024} + req, err := svc.composeHistory(context.Background(), conv, model) + require.NoError(t, err) + require.Equal(t, "STATIC FALLBACK", req.SystemPrompt) +} + // ===== mimeAllowed / capabilityAllows helpers ===== func TestMimeAllowed(t *testing.T) { diff --git a/internal/chat/sqlc/endpoints.sql.go b/internal/chat/sqlc/endpoints.sql.go index 3603add..5df8e6e 100644 --- a/internal/chat/sqlc/endpoints.sql.go +++ b/internal/chat/sqlc/endpoints.sql.go @@ -21,7 +21,7 @@ func (q *Queries) DeleteLLMEndpoint(ctx context.Context, id pgtype.UUID) error { } const getLLMEndpoint = `-- name: GetLLMEndpoint :one -SELECT id, provider, display_name, base_url, api_key_encrypted, created_by, created_at, updated_at FROM llm_endpoints WHERE id = $1 +SELECT id, provider, display_name, base_url, api_key_encrypted, created_by, created_at, updated_at, key_version FROM llm_endpoints WHERE id = $1 ` func (q *Queries) GetLLMEndpoint(ctx context.Context, id pgtype.UUID) (LlmEndpoint, error) { @@ -36,6 +36,7 @@ func (q *Queries) GetLLMEndpoint(ctx context.Context, id pgtype.UUID) (LlmEndpoi &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.KeyVersion, ) return i, err } @@ -43,7 +44,7 @@ func (q *Queries) GetLLMEndpoint(ctx context.Context, id pgtype.UUID) (LlmEndpoi const insertLLMEndpoint = `-- name: InsertLLMEndpoint :one INSERT INTO llm_endpoints (id, provider, display_name, base_url, api_key_encrypted, created_by) VALUES ($1, $2, $3, $4, $5, $6) -RETURNING id, provider, display_name, base_url, api_key_encrypted, created_by, created_at, updated_at +RETURNING id, provider, display_name, base_url, api_key_encrypted, created_by, created_at, updated_at, key_version ` type InsertLLMEndpointParams struct { @@ -74,12 +75,13 @@ func (q *Queries) InsertLLMEndpoint(ctx context.Context, arg InsertLLMEndpointPa &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.KeyVersion, ) return i, err } const listLLMEndpoints = `-- name: ListLLMEndpoints :many -SELECT id, provider, display_name, base_url, api_key_encrypted, created_by, created_at, updated_at FROM llm_endpoints ORDER BY created_at DESC +SELECT id, provider, display_name, base_url, api_key_encrypted, created_by, created_at, updated_at, key_version FROM llm_endpoints ORDER BY created_at DESC ` func (q *Queries) ListLLMEndpoints(ctx context.Context) ([]LlmEndpoint, error) { @@ -100,6 +102,7 @@ func (q *Queries) ListLLMEndpoints(ctx context.Context) ([]LlmEndpoint, error) { &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.KeyVersion, ); err != nil { return nil, err } diff --git a/internal/chat/sqlc/models.go b/internal/chat/sqlc/models.go index d4ee0e3..23a1fa9 100644 --- a/internal/chat/sqlc/models.go +++ b/internal/chat/sqlc/models.go @@ -8,6 +8,7 @@ import ( "net/netip" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type AcpAgentKind struct { @@ -25,6 +26,11 @@ type AcpAgentKind struct { ToolAllowlist []string `json:"tool_allowlist"` ClientType string `json:"client_type"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` } type AcpAgentKindConfigFile struct { @@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct { UpdatedBy pgtype.UUID `json:"updated_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type AcpEvent struct { @@ -64,23 +71,64 @@ type AcpPermissionRequest struct { } type AcpSession struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ProjectID pgtype.UUID `json:"project_id"` - AgentKindID pgtype.UUID `json:"agent_kind_id"` - UserID pgtype.UUID `json:"user_id"` - IssueID pgtype.UUID `json:"issue_id"` - RequirementID pgtype.UUID `json:"requirement_id"` - AgentSessionID *string `json:"agent_session_id"` - Branch string `json:"branch"` - CwdPath string `json:"cwd_path"` - IsMainWorktree bool `json:"is_main_worktree"` - Status string `json:"status"` - Pid *int32 `json:"pid"` - ExitCode *int32 `json:"exit_code"` - LastError *string `json:"last_error"` - StartedAt pgtype.Timestamptz `json:"started_at"` - EndedAt pgtype.Timestamptz `json:"ended_at"` + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` } type AuditLog struct { @@ -94,6 +142,81 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Conversation struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` @@ -110,6 +233,14 @@ type Conversation struct { RequirementID pgtype.UUID `json:"requirement_id"` } +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type GitCredential struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -119,6 +250,7 @@ type GitCredential struct { Fingerprint *string `json:"fingerprint"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type Issue struct { @@ -135,6 +267,17 @@ type Issue struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Job struct { @@ -162,6 +305,7 @@ type LlmEndpoint struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type LlmModel struct { @@ -252,6 +396,50 @@ type Notification struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + type Project struct { ID pgtype.UUID `json:"id"` Slug string `json:"slug"` @@ -264,6 +452,30 @@ type Project struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type PromptTemplate struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` @@ -299,6 +511,16 @@ type RequirementArtifact struct { SourceMessageID *int64 `json:"source_message_id"` CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type User struct { @@ -354,6 +576,7 @@ type WorkspaceRunProfile struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type WorkspaceWorktree struct { diff --git a/internal/codeindex/build_runner.go b/internal/codeindex/build_runner.go new file mode 100644 index 0000000..2869636 --- /dev/null +++ b/internal/codeindex/build_runner.go @@ -0,0 +1,191 @@ +package codeindex + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "os" + "path/filepath" + + "github.com/yan1h/agent-coding-workflow/internal/infra/git" + "github.com/yan1h/agent-coding-workflow/internal/infra/llm" + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +// EmbedBatchSize bounds how many chunks are sent to the embedder per request. +const EmbedBatchSize = 64 + +// FailureNotifier is an optional best-effort callback invoked when a build run +// fails, so app.go can broadcast to admins without the runner importing user/notify. +type FailureNotifier func(ctx context.Context, run *Run, cause error) + +// BuildRunner is the jobs.Handler that materializes a code_index_runs row into +// embedded code_chunks. It is event-driven (enqueued by EnqueueBuild), never a +// periodic ticker. Idempotent: re-running for the same run replaces its chunks. +type BuildRunner struct { + repo Repository + locator WorkspaceLocator + gitr git.Runner + embedSel EmbedderSelector + chunker *Chunker + notify FailureNotifier + log *slog.Logger +} + +// NewBuildRunner constructs a BuildRunner. notify and log may be nil. +func NewBuildRunner(repo Repository, locator WorkspaceLocator, gitr git.Runner, embedSel EmbedderSelector, chunker *Chunker, notify FailureNotifier, log *slog.Logger) *BuildRunner { + if chunker == nil { + chunker = NewChunker() + } + if log == nil { + log = slog.Default() + } + return &BuildRunner{repo: repo, locator: locator, gitr: gitr, embedSel: embedSel, chunker: chunker, notify: notify, log: log} +} + +var _ jobs.Handler = (*BuildRunner)(nil) + +// Type returns the job type this handler processes. +func (h *BuildRunner) Type() jobs.JobType { return JobTypeBuild } + +// Handle builds the index for the run referenced in the payload. On any error it +// marks the run failed and notifies admins; the returned error drives jobs retry. +func (h *BuildRunner) Handle(ctx context.Context, job jobs.Job) error { + var p buildPayload + if err := json.Unmarshal(job.Payload, &p); err != nil { + return jobs.Permanent(fmt.Errorf("codeindex.build: bad payload: %w", err)) + } + run, err := h.repo.GetRun(ctx, p.RunID) + if err != nil { + return jobs.Permanent(fmt.Errorf("codeindex.build: load run %s: %w", p.RunID, err)) + } + // Skip runs superseded by a newer one (stale) — no-op success. + if run.Status == StatusStale || run.Status == StatusCompleted { + return nil + } + + if err := h.build(ctx, run); err != nil { + if markErr := h.repo.MarkFailed(ctx, run.ID, truncErr(err)); markErr != nil { + h.log.Error("codeindex.build.mark_failed", "run_id", run.ID, "err", markErr.Error()) + } + if h.notify != nil { + h.notify(ctx, run, err) + } + return err // retryable unless wrapped Permanent inside build + } + return nil +} + +func (h *BuildRunner) build(ctx context.Context, run *Run) error { + emb, _, model, dims, err := h.embedSel.Embedder(ctx) + if err != nil { + return jobs.Permanent(fmt.Errorf("codeindex.build: embedder unavailable: %w", err)) + } + // Validate dims match the fixed pgvector column to avoid corrupt inserts. + if d := emb.EmbedDims(model); d != 0 && d != llm.PlatformEmbeddingDims { + return jobs.Permanent(fmt.Errorf("codeindex.build: embedder dims %d != platform %d", d, llm.PlatformEmbeddingDims)) + } + if dims != llm.PlatformEmbeddingDims { + return jobs.Permanent(fmt.Errorf("codeindex.build: run dims %d != platform %d", dims, llm.PlatformEmbeddingDims)) + } + + // Resolve the worktree/main directory holding the commit's files. + dir := "" + if run.WorktreeID != nil { + path, _, _, lerr := h.locator.LocateWorktree(ctx, *run.WorktreeID) + if lerr != nil { + return fmt.Errorf("locate worktree: %w", lerr) + } + dir = path + } else { + info, lerr := h.locator.LocateWorkspace(ctx, run.WorkspaceID) + if lerr != nil { + return fmt.Errorf("locate workspace: %w", lerr) + } + dir = info.MainPath + } + + if err := h.repo.MarkRunning(ctx, run.ID); err != nil { + return fmt.Errorf("mark running: %w", err) + } + + files, err := h.gitr.ListTrackedFiles(ctx, dir) + if err != nil { + return fmt.Errorf("git ls-files: %w", err) + } + + var rows []ChunkRow + filesIndexed := 0 + for _, rel := range files { + abs := filepath.Join(dir, filepath.FromSlash(rel)) + fi, serr := os.Stat(abs) + if serr != nil || fi.IsDir() { + continue + } + if int(fi.Size()) > h.chunker.maxFileBytes() || isVendored(rel) { + continue + } + content, rerr := os.ReadFile(abs) + if rerr != nil { + continue + } + chunks := h.chunker.Chunk(rel, content) + if len(chunks) == 0 { + continue + } + filesIndexed++ + for _, c := range chunks { + rows = append(rows, ChunkRow{ + FilePath: rel, + Lang: c.Lang, + StartLine: c.StartLine, + EndLine: c.EndLine, + Content: c.Content, + ContentSHA: c.ContentSHA, + TokenCount: llm.EstimateTokens(c.Content), + }) + } + } + + // Batch-embed chunk contents (input order preserved per batch). + for start := 0; start < len(rows); start += EmbedBatchSize { + end := start + EmbedBatchSize + if end > len(rows) { + end = len(rows) + } + inputs := make([]string, 0, end-start) + for i := start; i < end; i++ { + inputs = append(inputs, rows[i].Content) + } + vecs, eerr := emb.Embed(ctx, model, inputs) + if eerr != nil { + return fmt.Errorf("embed batch [%d:%d]: %w", start, end, eerr) + } + if len(vecs) != end-start { + return fmt.Errorf("embed batch [%d:%d]: got %d vectors", start, end, len(vecs)) + } + for i := start; i < end; i++ { + rows[i].Embedding = vecs[i-start] + } + } + + if err := h.repo.ReplaceChunks(ctx, run.ID, run.WorkspaceID, run.CommitSHA, rows); err != nil { + return fmt.Errorf("insert chunks: %w", err) + } + if err := h.repo.MarkCompleted(ctx, run.ID, filesIndexed, len(rows)); err != nil { + return fmt.Errorf("mark completed: %w", err) + } + h.log.Info("codeindex.build.completed", + "run_id", run.ID, "workspace_id", run.WorkspaceID, "commit", run.CommitSHA, + "files", filesIndexed, "chunks", len(rows)) + return nil +} + +func truncErr(err error) string { + s := err.Error() + if len(s) > 1000 { + return s[:1000] + } + return s +} diff --git a/internal/codeindex/build_runner_test.go b/internal/codeindex/build_runner_test.go new file mode 100644 index 0000000..ac8235d --- /dev/null +++ b/internal/codeindex/build_runner_test.go @@ -0,0 +1,213 @@ +package codeindex + +import ( + "context" + "encoding/json" + "errors" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/git" + "github.com/yan1h/agent-coding-workflow/internal/infra/llm" + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +// ===== fakes ===== + +type fakeRepo struct { + runs map[uuid.UUID]*Run + replaced map[uuid.UUID][]ChunkRow + completed map[uuid.UUID]bool + failed map[uuid.UUID]string + running map[uuid.UUID]bool +} + +func newFakeRepo() *fakeRepo { + return &fakeRepo{ + runs: map[uuid.UUID]*Run{}, replaced: map[uuid.UUID][]ChunkRow{}, + completed: map[uuid.UUID]bool{}, failed: map[uuid.UUID]string{}, running: map[uuid.UUID]bool{}, + } +} + +func (r *fakeRepo) InsertRun(_ context.Context, run *Run) (*Run, error) { + cp := *run + r.runs[run.ID] = &cp + return &cp, nil +} +func (r *fakeRepo) GetRun(_ context.Context, id uuid.UUID) (*Run, error) { + run, ok := r.runs[id] + if !ok { + return nil, errors.New("not found") + } + cp := *run + return &cp, nil +} +func (r *fakeRepo) GetRunByCommit(_ context.Context, wsID uuid.UUID, commit, model string) (*Run, error) { + for _, run := range r.runs { + if run.WorkspaceID == wsID && run.CommitSHA == commit && run.EmbeddingModel == model { + cp := *run + return &cp, nil + } + } + return nil, nil +} +func (r *fakeRepo) GetLatestCompletedRun(_ context.Context, wsID uuid.UUID) (*Run, error) { + for _, run := range r.runs { + if run.WorkspaceID == wsID && run.Status == StatusCompleted { + cp := *run + return &cp, nil + } + } + return nil, nil +} +func (r *fakeRepo) MarkRunning(_ context.Context, id uuid.UUID) error { + r.running[id] = true + return nil +} +func (r *fakeRepo) MarkCompleted(_ context.Context, id uuid.UUID, files, chunks int) error { + r.completed[id] = true + if run := r.runs[id]; run != nil { + run.Status = StatusCompleted + run.FilesIndexed = files + run.ChunksIndexed = chunks + } + return nil +} +func (r *fakeRepo) MarkFailed(_ context.Context, id uuid.UUID, msg string) error { + r.failed[id] = msg + if run := r.runs[id]; run != nil { + run.Status = StatusFailed + } + return nil +} +func (r *fakeRepo) MarkStale(_ context.Context, _ uuid.UUID) error { return nil } +func (r *fakeRepo) MarkStuckStale(_ context.Context, _ time.Time) ([]uuid.UUID, error) { + return nil, nil +} +func (r *fakeRepo) ReplaceChunks(_ context.Context, runID, _ uuid.UUID, _ string, rows []ChunkRow) error { + r.replaced[runID] = rows + return nil +} +func (r *fakeRepo) SearchKNN(context.Context, uuid.UUID, []float32, int, string, string) ([]Snippet, error) { + return nil, nil +} + +type fakeLocator struct { + dir string + wsID uuid.UUID +} + +func (l fakeLocator) LocateWorkspace(context.Context, uuid.UUID) (WorkspaceInfo, error) { + return WorkspaceInfo{ID: l.wsID, MainPath: l.dir, DefaultBranch: "main"}, nil +} +func (l fakeLocator) LocateWorktree(context.Context, uuid.UUID) (string, string, uuid.UUID, error) { + return l.dir, "main", l.wsID, nil +} + +type fakeEmbedSel struct { + emb llm.Embedder + err error +} + +func (s fakeEmbedSel) Embedder(context.Context) (llm.Embedder, uuid.UUID, string, int, error) { + if s.err != nil { + return nil, uuid.Nil, "", 0, s.err + } + return s.emb, uuid.New(), "fake-model", llm.PlatformEmbeddingDims, nil +} + +func mustGit(t *testing.T, dir string, args ...string) { + t.Helper() + c := exec.Command("git", args...) + c.Dir = dir + out, err := c.CombinedOutput() + require.NoError(t, err, "git %v: %s", args, string(out)) +} + +func tempGitRepo(t *testing.T) (dir, sha string) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not in PATH") + } + dir = t.TempDir() + mustGit(t, dir, "init", "-b", "main") + mustGit(t, dir, "config", "user.email", "t@e.com") + mustGit(t, dir, "config", "user.name", "T") + require.NoError(t, os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "util.go"), []byte("package main\n\nfunc add(a, b int) int { return a + b }\n"), 0o644)) + mustGit(t, dir, "add", ".") + mustGit(t, dir, "commit", "-m", "init") + out, err := exec.Command("git", "-C", dir, "rev-parse", "HEAD").Output() + require.NoError(t, err) + sha = string(out[:len(out)-1]) + return dir, sha +} + +func TestBuildRunner_Success(t *testing.T) { + dir, sha := tempGitRepo(t) + wsID := uuid.New() + repo := newFakeRepo() + run := &Run{ + ID: uuid.New(), WorkspaceID: wsID, Branch: "main", CommitSHA: sha, + Status: StatusPending, EmbeddingModel: "fake-model", Dims: llm.PlatformEmbeddingDims, + } + repo.runs[run.ID] = run + + gitr := git.NewDefaultRunner(git.DefaultConfig()) + br := NewBuildRunner(repo, fakeLocator{dir: dir, wsID: wsID}, gitr, + fakeEmbedSel{emb: llm.NewFakeEmbedder()}, NewChunker(), nil, nil) + + payload, _ := json.Marshal(buildPayload{RunID: run.ID}) + err := br.Handle(context.Background(), jobs.Job{Payload: payload}) + require.NoError(t, err) + require.True(t, repo.completed[run.ID]) + require.True(t, repo.running[run.ID]) + require.NotEmpty(t, repo.replaced[run.ID], "chunks must be inserted") + // Each inserted row must carry an embedding of platform dims. + for _, row := range repo.replaced[run.ID] { + require.Len(t, row.Embedding, llm.PlatformEmbeddingDims) + } +} + +func TestBuildRunner_FailureMarksFailedAndNotifies(t *testing.T) { + dir, sha := tempGitRepo(t) + wsID := uuid.New() + repo := newFakeRepo() + run := &Run{ + ID: uuid.New(), WorkspaceID: wsID, Branch: "main", CommitSHA: sha, + Status: StatusPending, EmbeddingModel: "fake-model", Dims: llm.PlatformEmbeddingDims, + } + repo.runs[run.ID] = run + + embErr := errors.New("embed boom") + notified := false + gitr := git.NewDefaultRunner(git.DefaultConfig()) + br := NewBuildRunner(repo, fakeLocator{dir: dir, wsID: wsID}, gitr, + fakeEmbedSel{emb: &llm.FakeEmbedder{Dims: llm.PlatformEmbeddingDims, Err: embErr}}, + NewChunker(), + func(context.Context, *Run, error) { notified = true }, nil) + + payload, _ := json.Marshal(buildPayload{RunID: run.ID}) + err := br.Handle(context.Background(), jobs.Job{Payload: payload}) + require.Error(t, err) + require.Contains(t, repo.failed[run.ID], "embed boom") + require.True(t, notified) +} + +func TestBuildRunner_SkipsAlreadyCompleted(t *testing.T) { + repo := newFakeRepo() + run := &Run{ID: uuid.New(), Status: StatusCompleted, EmbeddingModel: "fake-model", Dims: llm.PlatformEmbeddingDims} + repo.runs[run.ID] = run + br := NewBuildRunner(repo, fakeLocator{}, nil, fakeEmbedSel{emb: llm.NewFakeEmbedder()}, NewChunker(), nil, nil) + payload, _ := json.Marshal(buildPayload{RunID: run.ID}) + require.NoError(t, br.Handle(context.Background(), jobs.Job{Payload: payload})) + require.Empty(t, repo.replaced[run.ID]) +} + +var _ Repository = (*fakeRepo)(nil) diff --git a/internal/codeindex/chunker.go b/internal/codeindex/chunker.go new file mode 100644 index 0000000..80a2289 --- /dev/null +++ b/internal/codeindex/chunker.go @@ -0,0 +1,216 @@ +package codeindex + +import ( + "bytes" + "crypto/sha256" + "path/filepath" + "strings" + + "github.com/yan1h/agent-coding-workflow/internal/infra/llm" +) + +// Chunker config defaults. Windows are line-based; the token cap (estimated via +// llm.EstimateTokens) hard-bounds embedding cost on pathological long lines. +const ( + DefaultWindowLines = 60 + DefaultOverlapLines = 10 + DefaultMaxFileBytes = 512 * 1024 // skip files larger than this + DefaultMaxChunkTokens = 1024 // estimated; oversize windows are truncated +) + +// Chunker splits file content into overlapping line windows. +type Chunker struct { + WindowLines int + OverlapLines int + MaxFileBytes int + MaxChunkTokens int +} + +// NewChunker returns a Chunker with platform defaults. +func NewChunker() *Chunker { + return &Chunker{ + WindowLines: DefaultWindowLines, + OverlapLines: DefaultOverlapLines, + MaxFileBytes: DefaultMaxFileBytes, + MaxChunkTokens: DefaultMaxChunkTokens, + } +} + +func (c *Chunker) window() int { + if c.WindowLines > 0 { + return c.WindowLines + } + return DefaultWindowLines +} + +func (c *Chunker) overlap() int { + if c.OverlapLines >= 0 && c.OverlapLines < c.window() { + return c.OverlapLines + } + return DefaultOverlapLines +} + +func (c *Chunker) maxFileBytes() int { + if c.MaxFileBytes > 0 { + return c.MaxFileBytes + } + return DefaultMaxFileBytes +} + +func (c *Chunker) maxChunkTokens() int { + if c.MaxChunkTokens > 0 { + return c.MaxChunkTokens + } + return DefaultMaxChunkTokens +} + +// Skippable reports whether a file should not be indexed (binary, oversize, or +// vendored). Callers should check this before reading large files. +func (c *Chunker) Skippable(filePath string, size int, content []byte) bool { + if size > c.maxFileBytes() { + return true + } + if isVendored(filePath) { + return true + } + if isBinary(content) { + return true + } + return false +} + +// Chunk splits content into overlapping line windows. Returns nil for skippable +// or empty input. ContentSHA is the sha256 of the chunk's exact content so the +// build runner can reuse already-embedded chunks across commits. +func (c *Chunker) Chunk(filePath string, content []byte) []Chunk { + if len(content) == 0 { + return nil + } + if c.Skippable(filePath, len(content), content) { + return nil + } + lines := strings.Split(string(content), "\n") + // Drop a trailing empty element from a final newline so end_line is accurate. + if n := len(lines); n > 0 && lines[n-1] == "" { + lines = lines[:n-1] + } + if len(lines) == 0 { + return nil + } + lang := langForPath(filePath) + window := c.window() + step := window - c.overlap() + if step <= 0 { + step = window + } + maxTok := c.maxChunkTokens() + + var out []Chunk + for start := 0; start < len(lines); start += step { + end := start + window + if end > len(lines) { + end = len(lines) + } + body := strings.Join(lines[start:end], "\n") + // Hard token cap: truncate runaway windows (minified/one-line files). + if llm.EstimateTokens(body) > maxTok { + body = truncateToTokens(body, maxTok) + } + sum := sha256.Sum256([]byte(body)) + out = append(out, Chunk{ + StartLine: start + 1, // 1-based, inclusive + EndLine: end, // 1-based, inclusive + Lang: lang, + Content: body, + ContentSHA: sum[:], + }) + if end == len(lines) { + break + } + } + return out +} + +// truncateToTokens cuts s down to roughly maxTok estimated tokens (4 chars/token). +func truncateToTokens(s string, maxTok int) string { + maxBytes := maxTok * 4 + if len(s) <= maxBytes { + return s + } + // Cut on a UTF-8 boundary. + for maxBytes > 0 && (s[maxBytes]&0xC0) == 0x80 { + maxBytes-- + } + return s[:maxBytes] +} + +// isBinary reports whether content looks binary (NUL byte in the first 8KB). +func isBinary(content []byte) bool { + n := len(content) + if n > 8192 { + n = 8192 + } + return bytes.IndexByte(content[:n], 0) >= 0 +} + +// vendoredDirs are path segments whose contents are excluded from the index. +var vendoredDirs = []string{ + "vendor/", "node_modules/", ".git/", "dist/", "build/", "third_party/", + "testdata/", ".venv/", "__pycache__/", +} + +func isVendored(p string) bool { + norm := filepath.ToSlash(p) + for _, d := range vendoredDirs { + if strings.HasPrefix(norm, d) || strings.Contains(norm, "/"+d) { + return true + } + } + return false +} + +// langForPath maps a file extension to a coarse language label (best-effort). +func langForPath(p string) string { + switch strings.ToLower(filepath.Ext(p)) { + case ".go": + return "go" + case ".ts", ".tsx": + return "typescript" + case ".js", ".jsx", ".mjs", ".cjs": + return "javascript" + case ".vue": + return "vue" + case ".py": + return "python" + case ".rs": + return "rust" + case ".java": + return "java" + case ".c", ".h": + return "c" + case ".cpp", ".cc", ".hpp": + return "cpp" + case ".rb": + return "ruby" + case ".php": + return "php" + case ".sql": + return "sql" + case ".sh", ".bash": + return "shell" + case ".md", ".markdown": + return "markdown" + case ".json": + return "json" + case ".yaml", ".yml": + return "yaml" + case ".toml": + return "toml" + case ".html", ".htm": + return "html" + case ".css", ".scss": + return "css" + default: + return "" + } +} diff --git a/internal/codeindex/chunker_test.go b/internal/codeindex/chunker_test.go new file mode 100644 index 0000000..5f1d072 --- /dev/null +++ b/internal/codeindex/chunker_test.go @@ -0,0 +1,91 @@ +package codeindex + +import ( + "crypto/sha256" + "strings" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestChunker_WindowsAndLineNumbers(t *testing.T) { + c := &Chunker{WindowLines: 10, OverlapLines: 2, MaxFileBytes: 1 << 20, MaxChunkTokens: 10000} + // 25 numbered lines. + var sb strings.Builder + for i := 1; i <= 25; i++ { + sb.WriteString("line") + sb.WriteByte(byte('0' + i%10)) + sb.WriteByte('\n') + } + chunks := c.Chunk("a.go", []byte(sb.String())) + require.NotEmpty(t, chunks) + + // First window: lines 1..10. + require.Equal(t, 1, chunks[0].StartLine) + require.Equal(t, 10, chunks[0].EndLine) + require.Equal(t, "go", chunks[0].Lang) + + // step = window - overlap = 8 → next window starts at line 9. + require.Equal(t, 9, chunks[1].StartLine) + require.Equal(t, 18, chunks[1].EndLine) + + // Last chunk must end exactly at line 25. + last := chunks[len(chunks)-1] + require.Equal(t, 25, last.EndLine) +} + +func TestChunker_ContentSHAStable(t *testing.T) { + c := NewChunker() + content := []byte("package x\n\nfunc Y() {}\n") + a := c.Chunk("x.go", content) + b := c.Chunk("x.go", content) + require.Equal(t, len(a), len(b)) + require.Equal(t, a[0].ContentSHA, b[0].ContentSHA) + // SHA must be sha256 of the chunk content. + sum := sha256.Sum256([]byte(a[0].Content)) + require.Equal(t, sum[:], a[0].ContentSHA) +} + +func TestChunker_SkipsBinary(t *testing.T) { + c := NewChunker() + bin := []byte("text\x00more\x00binary") + require.Nil(t, c.Chunk("data.bin", bin)) + require.True(t, c.Skippable("data.bin", len(bin), bin)) +} + +func TestChunker_SkipsOversize(t *testing.T) { + c := &Chunker{WindowLines: 10, MaxFileBytes: 8} + big := []byte("aaaaaaaaaaaaaaaaaaaa\n") // > 8 bytes + require.Nil(t, c.Chunk("big.txt", big)) +} + +func TestChunker_SkipsVendored(t *testing.T) { + c := NewChunker() + content := []byte("package vendored\n") + require.Nil(t, c.Chunk("vendor/foo/bar.go", content)) + require.Nil(t, c.Chunk("node_modules/pkg/index.js", content)) + require.True(t, isVendored("a/b/node_modules/x.js")) + require.False(t, isVendored("internal/codeindex/chunker.go")) +} + +func TestChunker_EmptyInput(t *testing.T) { + c := NewChunker() + require.Nil(t, c.Chunk("e.go", nil)) + require.Nil(t, c.Chunk("e.go", []byte(""))) +} + +func TestChunker_TokenCapTruncates(t *testing.T) { + // One very long line that exceeds the token cap is truncated. + c := &Chunker{WindowLines: 60, OverlapLines: 10, MaxFileBytes: 1 << 20, MaxChunkTokens: 5} + long := strings.Repeat("x", 1000) + chunks := c.Chunk("min.js", []byte(long+"\n")) + require.Len(t, chunks, 1) + require.LessOrEqual(t, len(chunks[0].Content), 5*4) // ~maxTok*4 bytes +} + +func TestLangForPath(t *testing.T) { + require.Equal(t, "go", langForPath("a/b.go")) + require.Equal(t, "typescript", langForPath("c.tsx")) + require.Equal(t, "python", langForPath("x.py")) + require.Equal(t, "", langForPath("Makefile")) +} diff --git a/internal/codeindex/domain.go b/internal/codeindex/domain.go new file mode 100644 index 0000000..f07a79f --- /dev/null +++ b/internal/codeindex/domain.go @@ -0,0 +1,75 @@ +// Package codeindex builds and queries a pgvector-backed code index keyed by +// (workspace, commit). A background job (BuildRunner) embeds git-tracked file +// chunks; the Search path runs cosine-KNN scoped to the latest completed run. +// When no embedding endpoint is configured the whole feature is disabled with a +// typed error (callers degrade to keyword discovery via fs/grep). +package codeindex + +import ( + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +// JobTypeBuild is the jobs.JobType for an event-driven index build. +const JobTypeBuild jobs.JobType = "codeindex.build" + +// Run status values mirror the code_index_runs.status CHECK constraint. +const ( + StatusPending = "pending" + StatusRunning = "running" + StatusCompleted = "completed" + StatusFailed = "failed" + StatusStale = "stale" +) + +// Run mirrors a code_index_runs row. +type Run struct { + ID uuid.UUID + WorkspaceID uuid.UUID + WorktreeID *uuid.UUID + Branch string + CommitSHA string + Status string + EmbeddingEndpointID *uuid.UUID + EmbeddingModel string + Dims int + FilesIndexed int + ChunksIndexed int + Error *string + CreatedAt time.Time +} + +// Chunk is one embeddable line window produced by the Chunker. +type Chunk struct { + StartLine int + EndLine int + Lang string + Content string + ContentSHA []byte // sha256 of Content; enables incremental re-embed +} + +// Snippet is a ranked search result returned to callers (MCP search_code). +type Snippet struct { + FilePath string + StartLine int + EndLine int + Content string + Score float32 // cosine similarity in [0,1]; higher is closer + CommitSHA string +} + +// SearchQuery parameterizes a code search. +type SearchQuery struct { + Text string + TopK int + PathPrefix string + Lang string +} + +// buildPayload is the jobs payload for JobTypeBuild. +type buildPayload struct { + RunID uuid.UUID `json:"run_id"` +} diff --git a/internal/codeindex/queries/chunks.sql b/internal/codeindex/queries/chunks.sql new file mode 100644 index 0000000..f2fd43b --- /dev/null +++ b/internal/codeindex/queries/chunks.sql @@ -0,0 +1,9 @@ +-- The embedding (vector) column is intentionally excluded from every sqlc query +-- here: sqlc has no native pgvector type. Vector INSERT (bulk) and cosine-KNN +-- SELECT are hand-written in repository.go using the pgvector text literal format. + +-- name: DeleteChunksByRun :exec +DELETE FROM code_chunks WHERE run_id = $1; + +-- name: CountChunksByRun :one +SELECT count(*) FROM code_chunks WHERE run_id = $1; diff --git a/internal/codeindex/queries/runs.sql b/internal/codeindex/queries/runs.sql new file mode 100644 index 0000000..aab0a7b --- /dev/null +++ b/internal/codeindex/queries/runs.sql @@ -0,0 +1,58 @@ +-- name: InsertRun :one +INSERT INTO code_index_runs ( + id, workspace_id, worktree_id, branch, commit_sha, status, + embedding_endpoint_id, embedding_model, dims +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING id, workspace_id, worktree_id, branch, commit_sha, status, + embedding_endpoint_id, embedding_model, dims, + files_indexed, chunks_indexed, error, started_at, finished_at, created_at; + +-- name: GetRun :one +SELECT id, workspace_id, worktree_id, branch, commit_sha, status, + embedding_endpoint_id, embedding_model, dims, + files_indexed, chunks_indexed, error, started_at, finished_at, created_at +FROM code_index_runs +WHERE id = $1; + +-- name: GetRunByCommit :one +SELECT id, workspace_id, worktree_id, branch, commit_sha, status, + embedding_endpoint_id, embedding_model, dims, + files_indexed, chunks_indexed, error, started_at, finished_at, created_at +FROM code_index_runs +WHERE workspace_id = $1 AND commit_sha = $2 AND embedding_model = $3; + +-- name: GetLatestCompletedRun :one +SELECT id, workspace_id, worktree_id, branch, commit_sha, status, + embedding_endpoint_id, embedding_model, dims, + files_indexed, chunks_indexed, error, started_at, finished_at, created_at +FROM code_index_runs +WHERE workspace_id = $1 AND status = 'completed' +ORDER BY created_at DESC +LIMIT 1; + +-- name: MarkRunRunning :exec +UPDATE code_index_runs +SET status = 'running', started_at = now() +WHERE id = $1; + +-- name: MarkRunCompleted :exec +UPDATE code_index_runs +SET status = 'completed', files_indexed = $2, chunks_indexed = $3, + error = NULL, finished_at = now() +WHERE id = $1; + +-- name: MarkRunFailed :exec +UPDATE code_index_runs +SET status = 'failed', error = $2, finished_at = now() +WHERE id = $1; + +-- name: MarkRunsStale :exec +UPDATE code_index_runs +SET status = 'stale' +WHERE workspace_id = $1 AND status IN ('pending','running','completed'); + +-- name: MarkStuckRunsStale :many +UPDATE code_index_runs +SET status = 'stale' +WHERE status = 'running' AND started_at < $1 +RETURNING id; diff --git a/internal/codeindex/repository.go b/internal/codeindex/repository.go new file mode 100644 index 0000000..ba6d968 --- /dev/null +++ b/internal/codeindex/repository.go @@ -0,0 +1,294 @@ +package codeindex + +import ( + "context" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/pgvector/pgvector-go" + + codeindexsqlc "github.com/yan1h/agent-coding-workflow/internal/codeindex/sqlc" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// ChunkRow is a fully-populated chunk ready for bulk insert (embedding included). +type ChunkRow struct { + FilePath string + Lang string + StartLine int + EndLine int + Content string + ContentSHA []byte + TokenCount int + Embedding []float32 +} + +// Repository is the codeindex persistence contract. +type Repository interface { + InsertRun(ctx context.Context, r *Run) (*Run, error) + GetRun(ctx context.Context, id uuid.UUID) (*Run, error) + GetRunByCommit(ctx context.Context, wsID uuid.UUID, commitSHA, model string) (*Run, error) + GetLatestCompletedRun(ctx context.Context, wsID uuid.UUID) (*Run, error) + MarkRunning(ctx context.Context, id uuid.UUID) error + MarkCompleted(ctx context.Context, id uuid.UUID, files, chunks int) error + MarkFailed(ctx context.Context, id uuid.UUID, errMsg string) error + MarkStale(ctx context.Context, wsID uuid.UUID) error + MarkStuckStale(ctx context.Context, runningBefore time.Time) ([]uuid.UUID, error) + // ReplaceChunks deletes any existing chunks for run then bulk-inserts rows. + ReplaceChunks(ctx context.Context, runID, wsID uuid.UUID, commitSHA string, rows []ChunkRow) error + // SearchKNN returns the top-k chunks of the latest completed run for wsID + // ordered by cosine similarity to queryVec, with optional path/lang filters. + SearchKNN(ctx context.Context, wsID uuid.UUID, queryVec []float32, topK int, pathPrefix, lang string) ([]Snippet, error) +} + +// PgRepository is the production Repository on a pgxpool.Pool. +type PgRepository struct { + pool *pgxpool.Pool + q *codeindexsqlc.Queries +} + +// NewPostgresRepository builds a PgRepository. +func NewPostgresRepository(pool *pgxpool.Pool) *PgRepository { + return &PgRepository{pool: pool, q: codeindexsqlc.New(pool)} +} + +var _ Repository = (*PgRepository)(nil) + +func pgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} } + +func pgUUIDPtr(u *uuid.UUID) pgtype.UUID { + if u == nil { + return pgtype.UUID{} + } + return pgtype.UUID{Bytes: *u, Valid: true} +} + +func uuidPtr(p pgtype.UUID) *uuid.UUID { + if !p.Valid { + return nil + } + id := uuid.UUID(p.Bytes) + return &id +} + +func runFromRow(r codeindexsqlc.CodeIndexRun) *Run { + out := &Run{ + ID: uuid.UUID(r.ID.Bytes), + WorkspaceID: uuid.UUID(r.WorkspaceID.Bytes), + WorktreeID: uuidPtr(r.WorktreeID), + Branch: r.Branch, + CommitSHA: r.CommitSha, + Status: r.Status, + EmbeddingEndpointID: uuidPtr(r.EmbeddingEndpointID), + EmbeddingModel: r.EmbeddingModel, + Dims: int(r.Dims), + FilesIndexed: int(r.FilesIndexed), + ChunksIndexed: int(r.ChunksIndexed), + Error: r.Error, + } + if r.CreatedAt.Valid { + out.CreatedAt = r.CreatedAt.Time + } + return out +} + +func (r *PgRepository) InsertRun(ctx context.Context, run *Run) (*Run, error) { + row, err := r.q.InsertRun(ctx, codeindexsqlc.InsertRunParams{ + ID: pgUUID(run.ID), + WorkspaceID: pgUUID(run.WorkspaceID), + WorktreeID: pgUUIDPtr(run.WorktreeID), + Branch: run.Branch, + CommitSha: run.CommitSHA, + Status: StatusPending, + EmbeddingEndpointID: pgUUIDPtr(run.EmbeddingEndpointID), + EmbeddingModel: run.EmbeddingModel, + Dims: int32(run.Dims), + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "insert code index run") + } + return runFromRow(row), nil +} + +func (r *PgRepository) GetRun(ctx context.Context, id uuid.UUID) (*Run, error) { + row, err := r.q.GetRun(ctx, pgUUID(id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "code index run not found") + } + return nil, errs.Wrap(err, errs.CodeInternal, "get code index run") + } + return runFromRow(row), nil +} + +func (r *PgRepository) GetRunByCommit(ctx context.Context, wsID uuid.UUID, commitSHA, model string) (*Run, error) { + row, err := r.q.GetRunByCommit(ctx, codeindexsqlc.GetRunByCommitParams{ + WorkspaceID: pgUUID(wsID), + CommitSha: commitSHA, + EmbeddingModel: model, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, errs.Wrap(err, errs.CodeInternal, "get run by commit") + } + return runFromRow(row), nil +} + +func (r *PgRepository) GetLatestCompletedRun(ctx context.Context, wsID uuid.UUID) (*Run, error) { + row, err := r.q.GetLatestCompletedRun(ctx, pgUUID(wsID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, errs.Wrap(err, errs.CodeInternal, "get latest completed run") + } + return runFromRow(row), nil +} + +func (r *PgRepository) MarkRunning(ctx context.Context, id uuid.UUID) error { + if err := r.q.MarkRunRunning(ctx, pgUUID(id)); err != nil { + return errs.Wrap(err, errs.CodeInternal, "mark run running") + } + return nil +} + +func (r *PgRepository) MarkCompleted(ctx context.Context, id uuid.UUID, files, chunks int) error { + if err := r.q.MarkRunCompleted(ctx, codeindexsqlc.MarkRunCompletedParams{ + ID: pgUUID(id), + FilesIndexed: int32(files), + ChunksIndexed: int32(chunks), + }); err != nil { + return errs.Wrap(err, errs.CodeInternal, "mark run completed") + } + return nil +} + +func (r *PgRepository) MarkFailed(ctx context.Context, id uuid.UUID, errMsg string) error { + msg := errMsg + if err := r.q.MarkRunFailed(ctx, codeindexsqlc.MarkRunFailedParams{ + ID: pgUUID(id), + Error: &msg, + }); err != nil { + return errs.Wrap(err, errs.CodeInternal, "mark run failed") + } + return nil +} + +func (r *PgRepository) MarkStale(ctx context.Context, wsID uuid.UUID) error { + if err := r.q.MarkRunsStale(ctx, pgUUID(wsID)); err != nil { + return errs.Wrap(err, errs.CodeInternal, "mark runs stale") + } + return nil +} + +func (r *PgRepository) MarkStuckStale(ctx context.Context, runningBefore time.Time) ([]uuid.UUID, error) { + rows, err := r.q.MarkStuckRunsStale(ctx, pgtype.Timestamptz{Time: runningBefore, Valid: true}) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "mark stuck runs stale") + } + out := make([]uuid.UUID, 0, len(rows)) + for _, p := range rows { + out = append(out, uuid.UUID(p.Bytes)) + } + return out, nil +} + +// ReplaceChunks deletes prior chunks for the run and bulk-inserts the given rows +// in a single transaction. The embedding column uses the pgvector text literal. +func (r *PgRepository) ReplaceChunks(ctx context.Context, runID, wsID uuid.UUID, commitSHA string, rows []ChunkRow) error { + tx, err := r.pool.Begin(ctx) + if err != nil { + return errs.Wrap(err, errs.CodeInternal, "begin chunk tx") + } + defer func() { _ = tx.Rollback(ctx) }() + + if _, err := tx.Exec(ctx, `DELETE FROM code_chunks WHERE run_id = $1`, pgUUID(runID)); err != nil { + return errs.Wrap(err, errs.CodeInternal, "delete prior chunks") + } + + const insertSQL = `INSERT INTO code_chunks ( + id, run_id, workspace_id, commit_sha, file_path, lang, + start_line, end_line, content, content_sha, token_count, embedding + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12::vector)` + + batch := &pgx.Batch{} + for _, row := range rows { + var lang *string + if row.Lang != "" { + l := row.Lang + lang = &l + } + var tok *int32 + if row.TokenCount > 0 { + t := int32(row.TokenCount) + tok = &t + } + batch.Queue(insertSQL, + pgUUID(uuid.New()), pgUUID(runID), pgUUID(wsID), commitSHA, + row.FilePath, lang, int32(row.StartLine), int32(row.EndLine), + row.Content, row.ContentSHA, tok, pgvector.NewVector(row.Embedding), + ) + } + br := tx.SendBatch(ctx, batch) + for range rows { + if _, err := br.Exec(); err != nil { + _ = br.Close() + return errs.Wrap(err, errs.CodeInternal, "insert chunk") + } + } + if err := br.Close(); err != nil { + return errs.Wrap(err, errs.CodeInternal, "close chunk batch") + } + if err := tx.Commit(ctx); err != nil { + return errs.Wrap(err, errs.CodeInternal, "commit chunk tx") + } + return nil +} + +// SearchKNN runs cosine-distance KNN scoped to the latest completed run for the +// workspace. Score is 1 - cosine_distance (so higher is more similar). Optional +// path_prefix / lang filters narrow the candidate set. +func (r *PgRepository) SearchKNN(ctx context.Context, wsID uuid.UUID, queryVec []float32, topK int, pathPrefix, lang string) ([]Snippet, error) { + if topK <= 0 { + topK = 10 + } + q := ` +WITH latest AS ( + SELECT id FROM code_index_runs + WHERE workspace_id = $1 AND status = 'completed' + ORDER BY created_at DESC LIMIT 1 +) +SELECT c.file_path, c.start_line, c.end_line, c.content, c.commit_sha, + 1 - (c.embedding <=> $2::vector) AS score +FROM code_chunks c +JOIN latest ON c.run_id = latest.id +WHERE ($3::text = '' OR c.file_path LIKE $3 || '%') + AND ($4::text = '' OR c.lang = $4) +ORDER BY c.embedding <=> $2::vector +LIMIT $5` + rows, err := r.pool.Query(ctx, q, pgUUID(wsID), pgvector.NewVector(queryVec), pathPrefix, lang, int32(topK)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "knn search") + } + defer rows.Close() + var out []Snippet + for rows.Next() { + var s Snippet + var start, end int32 + if err := rows.Scan(&s.FilePath, &start, &end, &s.Content, &s.CommitSHA, &s.Score); err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "scan knn row") + } + s.StartLine, s.EndLine = int(start), int(end) + out = append(out, s) + } + if err := rows.Err(); err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "knn rows") + } + return out, nil +} diff --git a/internal/codeindex/service.go b/internal/codeindex/service.go new file mode 100644 index 0000000..19e4253 --- /dev/null +++ b/internal/codeindex/service.go @@ -0,0 +1,195 @@ +package codeindex + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/infra/git" + "github.com/yan1h/agent-coding-workflow/internal/infra/llm" + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +// ErrEmbeddingDisabled is returned by Search/EnqueueBuild when no embedding +// endpoint is configured. Callers degrade to keyword discovery (fs/grep) and +// expose this as a typed, non-fatal "feature off" condition. +var ErrEmbeddingDisabled = errors.New("codeindex: no embedding endpoint configured") + +// WorkspaceInfo is the minimal workspace data the indexer needs. Provided by a +// locator implemented in app.go over the workspace repository (no import cycle). +type WorkspaceInfo struct { + ID uuid.UUID + MainPath string + DefaultBranch string +} + +// WorkspaceLocator resolves a workspace's on-disk path and default branch. +type WorkspaceLocator interface { + LocateWorkspace(ctx context.Context, wsID uuid.UUID) (WorkspaceInfo, error) + // LocateWorktree resolves a worktree's on-disk path + branch (for branch builds). + LocateWorktree(ctx context.Context, worktreeID uuid.UUID) (path, branch string, wsID uuid.UUID, err error) +} + +// EmbedderSelector resolves the configured platform embedder, or nil + a typed +// error when embeddings are disabled (no endpoint configured / unsupported). +type EmbedderSelector interface { + // Embedder returns the embedder, the embedding endpoint id, the model id and + // the platform dims. Returns ErrEmbeddingDisabled when not configured. + Embedder(ctx context.Context) (emb llm.Embedder, endpointID uuid.UUID, model string, dims int, err error) +} + +// Enqueuer is the subset of jobs.Repository the service needs. +type Enqueuer interface { + Enqueue(ctx context.Context, typ jobs.JobType, payload json.RawMessage, scheduledAt time.Time, maxAttempts int32) (*jobs.Job, error) +} + +// Service is the codeindex application service. +type Service interface { + // EnqueueBuild resolves the build commit (HEAD of the worktree/main) and + // enqueues a codeindex.build job. Idempotent on (workspace, commit, model): + // a re-enqueue at the same commit returns the existing run without rebuild. + EnqueueBuild(ctx context.Context, wsID uuid.UUID, worktreeID *uuid.UUID, commitSHA string) (*Run, error) + Search(ctx context.Context, wsID uuid.UUID, q SearchQuery) ([]Snippet, error) + MarkStale(ctx context.Context, wsID uuid.UUID) error +} + +type service struct { + repo Repository + locator WorkspaceLocator + gitr git.Runner + embedSel EmbedderSelector + jobs Enqueuer + maxTopK int +} + +// NewService constructs the codeindex Service. maxTopK caps Search top_k. +func NewService(repo Repository, locator WorkspaceLocator, gitr git.Runner, embedSel EmbedderSelector, jobsRepo Enqueuer, maxTopK int) Service { + if maxTopK <= 0 { + maxTopK = 50 + } + return &service{repo: repo, locator: locator, gitr: gitr, embedSel: embedSel, jobs: jobsRepo, maxTopK: maxTopK} +} + +func (s *service) EnqueueBuild(ctx context.Context, wsID uuid.UUID, worktreeID *uuid.UUID, commitSHA string) (*Run, error) { + emb, endpointID, model, dims, err := s.embedSel.Embedder(ctx) + if err != nil { + return nil, err // ErrEmbeddingDisabled or config error + } + _ = emb + + branch := "" + dir := "" + if worktreeID != nil { + path, b, locWS, lerr := s.locator.LocateWorktree(ctx, *worktreeID) + if lerr != nil { + return nil, lerr + } + dir, branch, wsID = path, b, locWS + } else { + info, lerr := s.locator.LocateWorkspace(ctx, wsID) + if lerr != nil { + return nil, lerr + } + dir, branch = info.MainPath, info.DefaultBranch + } + + // Resolve HEAD commit when not supplied. + if commitSHA == "" { + commits, lerr := s.gitr.Log(ctx, dir, 1) + if lerr != nil { + return nil, errs.Wrap(lerr, errs.CodeGitCmdFailed, "resolve HEAD") + } + if len(commits) == 0 { + return nil, errs.New(errs.CodeInvalidInput, "empty repository: no commit to index") + } + commitSHA = commits[0].Hash + } + + // Idempotency: an existing run at this (ws, commit, model) is reused unless it + // previously failed/stale (in which case we re-enqueue against the same row). + existing, lerr := s.repo.GetRunByCommit(ctx, wsID, commitSHA, model) + if lerr != nil { + return nil, lerr + } + if existing != nil { + switch existing.Status { + case StatusPending, StatusRunning, StatusCompleted: + return existing, nil + } + // failed/stale: fall through and create a fresh run is blocked by UNIQUE; + // re-enqueue the existing run id instead. + if err := s.enqueueJob(ctx, existing.ID); err != nil { + return nil, err + } + return existing, nil + } + + run := &Run{ + ID: uuid.New(), + WorkspaceID: wsID, + WorktreeID: worktreeID, + Branch: branch, + CommitSHA: commitSHA, + Status: StatusPending, + EmbeddingEndpointID: &endpointID, + EmbeddingModel: model, + Dims: dims, + } + saved, err := s.repo.InsertRun(ctx, run) + if err != nil { + return nil, err + } + if err := s.enqueueJob(ctx, saved.ID); err != nil { + return nil, err + } + return saved, nil +} + +func (s *service) enqueueJob(ctx context.Context, runID uuid.UUID) error { + payload, _ := json.Marshal(buildPayload{RunID: runID}) + if _, err := s.jobs.Enqueue(ctx, JobTypeBuild, payload, time.Now(), 3); err != nil { + return errs.Wrap(err, errs.CodeInternal, "enqueue codeindex.build") + } + return nil +} + +func (s *service) Search(ctx context.Context, wsID uuid.UUID, q SearchQuery) ([]Snippet, error) { + emb, _, model, _, err := s.embedSel.Embedder(ctx) + if err != nil { + return nil, err + } + if q.Text == "" { + return nil, errs.New(errs.CodeInvalidInput, "query text required") + } + topK := q.TopK + if topK <= 0 { + topK = 10 + } + if topK > s.maxTopK { + topK = s.maxTopK + } + // Ensure there is a completed run before paying for an embedding call. + latest, err := s.repo.GetLatestCompletedRun(ctx, wsID) + if err != nil { + return nil, err + } + if latest == nil { + return nil, errs.New(errs.CodeNotFound, "no completed code index for workspace; reindex first") + } + vecs, err := emb.Embed(ctx, model, []string{q.Text}) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "embed query") + } + if len(vecs) != 1 { + return nil, errs.New(errs.CodeInternal, "embed query: unexpected vector count") + } + return s.repo.SearchKNN(ctx, wsID, vecs[0], topK, q.PathPrefix, q.Lang) +} + +func (s *service) MarkStale(ctx context.Context, wsID uuid.UUID) error { + return s.repo.MarkStale(ctx, wsID) +} diff --git a/internal/codeindex/service_test.go b/internal/codeindex/service_test.go new file mode 100644 index 0000000..50e7c26 --- /dev/null +++ b/internal/codeindex/service_test.go @@ -0,0 +1,68 @@ +package codeindex + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/git" + "github.com/yan1h/agent-coding-workflow/internal/infra/llm" + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +type fakeEnqueuer struct{ count int } + +func (f *fakeEnqueuer) Enqueue(_ context.Context, typ jobs.JobType, _ json.RawMessage, _ time.Time, _ int32) (*jobs.Job, error) { + f.count++ + return &jobs.Job{ID: uuid.New(), Type: typ}, nil +} + +func TestService_EnqueueBuild_DisabledEmbedder(t *testing.T) { + repo := newFakeRepo() + enq := &fakeEnqueuer{} + svc := NewService(repo, fakeLocator{}, nil, fakeEmbedSel{err: ErrEmbeddingDisabled}, enq, 50) + _, err := svc.EnqueueBuild(context.Background(), uuid.New(), nil, "abc123") + require.ErrorIs(t, err, ErrEmbeddingDisabled) + require.Equal(t, 0, enq.count) +} + +func TestService_EnqueueBuild_CreatesRunAndEnqueuesOnce(t *testing.T) { + dir, sha := tempGitRepo(t) + wsID := uuid.New() + repo := newFakeRepo() + enq := &fakeEnqueuer{} + gitr := git.NewDefaultRunner(git.DefaultConfig()) + svc := NewService(repo, fakeLocator{dir: dir, wsID: wsID}, gitr, + fakeEmbedSel{emb: llm.NewFakeEmbedder()}, enq, 50) + + run, err := svc.EnqueueBuild(context.Background(), wsID, nil, sha) + require.NoError(t, err) + require.Equal(t, sha, run.CommitSHA) + require.Equal(t, 1, enq.count) + require.Len(t, repo.runs, 1) + + // Re-enqueue at the same commit while pending → no new job, no new run. + run2, err := svc.EnqueueBuild(context.Background(), wsID, nil, sha) + require.NoError(t, err) + require.Equal(t, run.ID, run2.ID) + require.Equal(t, 1, enq.count, "idempotent: pending run is reused") + require.Len(t, repo.runs, 1) +} + +func TestService_Search_NoCompletedRun(t *testing.T) { + repo := newFakeRepo() + svc := NewService(repo, fakeLocator{}, nil, fakeEmbedSel{emb: llm.NewFakeEmbedder()}, &fakeEnqueuer{}, 50) + _, err := svc.Search(context.Background(), uuid.New(), SearchQuery{Text: "foo"}) + require.Error(t, err) // no completed index yet +} + +func TestService_Search_EmptyQuery(t *testing.T) { + repo := newFakeRepo() + svc := NewService(repo, fakeLocator{}, nil, fakeEmbedSel{emb: llm.NewFakeEmbedder()}, &fakeEnqueuer{}, 50) + _, err := svc.Search(context.Background(), uuid.New(), SearchQuery{Text: ""}) + require.Error(t, err) +} diff --git a/internal/codeindex/sqlc/chunks.sql.go b/internal/codeindex/sqlc/chunks.sql.go new file mode 100644 index 0000000..e09006f --- /dev/null +++ b/internal/codeindex/sqlc/chunks.sql.go @@ -0,0 +1,36 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: chunks.sql + +package codeindexsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countChunksByRun = `-- name: CountChunksByRun :one +SELECT count(*) FROM code_chunks WHERE run_id = $1 +` + +func (q *Queries) CountChunksByRun(ctx context.Context, runID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countChunksByRun, runID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const deleteChunksByRun = `-- name: DeleteChunksByRun :exec + +DELETE FROM code_chunks WHERE run_id = $1 +` + +// The embedding (vector) column is intentionally excluded from every sqlc query +// here: sqlc has no native pgvector type. Vector INSERT (bulk) and cosine-KNN +// SELECT are hand-written in repository.go using the pgvector text literal format. +func (q *Queries) DeleteChunksByRun(ctx context.Context, runID pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteChunksByRun, runID) + return err +} diff --git a/internal/codeindex/sqlc/db.go b/internal/codeindex/sqlc/db.go new file mode 100644 index 0000000..3aead1f --- /dev/null +++ b/internal/codeindex/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package codeindexsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/codeindex/sqlc/models.go b/internal/codeindex/sqlc/models.go new file mode 100644 index 0000000..ed52043 --- /dev/null +++ b/internal/codeindex/sqlc/models.go @@ -0,0 +1,593 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package codeindexsqlc + +import ( + "net/netip" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" +) + +type AcpAgentKind struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` + ClientType string `json:"client_type"` + EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` +} + +type AcpAgentKindConfigFile struct { + ID pgtype.UUID `json:"id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + RelPath string `json:"rel_path"` + EncryptedContent []byte `json:"encrypted_content"` + UpdatedBy pgtype.UUID `json:"updated_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type AcpEvent struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + Direction string `json:"direction"` + RpcKind string `json:"rpc_kind"` + Method *string `json:"method"` + Payload []byte `json:"payload"` + PayloadSize int32 `json:"payload_size"` + Truncated bool `json:"truncated"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpSession struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` +} + +type AuditLog struct { + ID int64 `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Action string `json:"action"` + TargetType *string `json:"target_type"` + TargetID *string `json:"target_id"` + Ip *netip.Addr `json:"ip"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Conversation struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + IssueID pgtype.UUID `json:"issue_id"` + ModelID pgtype.UUID `json:"model_id"` + SystemPrompt string `json:"system_prompt"` + Title *string `json:"title"` + TitleGeneratedAt pgtype.Timestamptz `json:"title_generated_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` +} + +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type GitCredential struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Username *string `json:"username"` + EncryptedSecret []byte `json:"encrypted_secret"` + Fingerprint *string `json:"fingerprint"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type Issue struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + AssigneeID pgtype.UUID `json:"assignee_id"` + CreatedBy pgtype.UUID `json:"created_by"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Job struct { + ID pgtype.UUID `json:"id"` + Type string `json:"type"` + Payload []byte `json:"payload"` + Status string `json:"status"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + Attempts int32 `json:"attempts"` + MaxAttempts int32 `json:"max_attempts"` + LeasedAt pgtype.Timestamptz `json:"leased_at"` + LeasedBy *string `json:"leased_by"` + LastError *string `json:"last_error"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmEndpoint struct { + ID pgtype.UUID `json:"id"` + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + BaseUrl string `json:"base_url"` + ApiKeyEncrypted []byte `json:"api_key_encrypted"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type LlmModel struct { + ID pgtype.UUID `json:"id"` + EndpointID pgtype.UUID `json:"endpoint_id"` + ModelID string `json:"model_id"` + DisplayName string `json:"display_name"` + Capabilities []byte `json:"capabilities"` + ContextWindow int32 `json:"context_window"` + MaxOutputTokens int32 `json:"max_output_tokens"` + PromptPricePerMillionUsd pgtype.Numeric `json:"prompt_price_per_million_usd"` + CompletionPricePerMillionUsd pgtype.Numeric `json:"completion_price_per_million_usd"` + ThinkingPricePerMillionUsd pgtype.Numeric `json:"thinking_price_per_million_usd"` + IsTitleGenerator bool `json:"is_title_generator"` + Enabled bool `json:"enabled"` + SortOrder int32 `json:"sort_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmUsage struct { + ID int64 `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + MessageID int64 `json:"message_id"` + UserID pgtype.UUID `json:"user_id"` + EndpointID pgtype.UUID `json:"endpoint_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int32 `json:"prompt_tokens"` + CompletionTokens int32 `json:"completion_tokens"` + ThinkingTokens int32 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type McpToken struct { + ID pgtype.UUID `json:"id"` + TokenHash []byte `json:"token_hash"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + Issuer string `json:"issuer"` + Scope []byte `json:"scope"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Message struct { + ID int64 `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + Role string `json:"role"` + Content string `json:"content"` + Thinking *string `json:"thinking"` + ToolCalls []byte `json:"tool_calls"` + Status string `json:"status"` + ErrorMessage *string `json:"error_message"` + PromptTokens *int32 `json:"prompt_tokens"` + CompletionTokens *int32 `json:"completion_tokens"` + ThinkingTokens *int32 `json:"thinking_tokens"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type MessageAttachment struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + MessageID *int64 `json:"message_id"` + Filename string `json:"filename"` + MimeType string `json:"mime_type"` + SizeBytes int64 `json:"size_bytes"` + Sha256 string `json:"sha256"` + StoragePath string `json:"storage_path"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Notification struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Topic string `json:"topic"` + Severity string `json:"severity"` + Title string `json:"title"` + Body string `json:"body"` + Link *string `json:"link"` + Metadata []byte `json:"metadata"` + ReadAt pgtype.Timestamptz `json:"read_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type Project struct { + ID pgtype.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + OwnerID pgtype.UUID `json:"owner_id"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type PromptTemplate struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Content string `json:"content"` + Scope string `json:"scope"` + OwnerID pgtype.UUID `json:"owner_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Requirement struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Phase string `json:"phase"` + Status string `json:"status"` + OwnerID pgtype.UUID `json:"owner_id"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type User struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + DisplayName string `json:"display_name"` + IsAdmin bool `json:"is_admin"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` +} + +type UserSession struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + TokenHash []byte `json:"token_hash"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Workspace struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + GitRemoteUrl string `json:"git_remote_url"` + DefaultBranch string `json:"default_branch"` + MainPath string `json:"main_path"` + SyncStatus string `json:"sync_status"` + LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"` + LastSyncError *string `json:"last_sync_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"` +} + +type WorkspaceRunProfile struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Command string `json:"command"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + LastStartedAt pgtype.Timestamptz `json:"last_started_at"` + LastExitCode *int32 `json:"last_exit_code"` + LastError string `json:"last_error"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type WorkspaceWorktree struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Branch string `json:"branch"` + Path string `json:"path"` + Status string `json:"status"` + ActiveHolder *string `json:"active_holder"` + AcquiredAt pgtype.Timestamptz `json:"acquired_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + PruneWarningAt pgtype.Timestamptz `json:"prune_warning_at"` +} diff --git a/internal/codeindex/sqlc/runs.sql.go b/internal/codeindex/sqlc/runs.sql.go new file mode 100644 index 0000000..3a55760 --- /dev/null +++ b/internal/codeindex/sqlc/runs.sql.go @@ -0,0 +1,251 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: runs.sql + +package codeindexsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getLatestCompletedRun = `-- name: GetLatestCompletedRun :one +SELECT id, workspace_id, worktree_id, branch, commit_sha, status, + embedding_endpoint_id, embedding_model, dims, + files_indexed, chunks_indexed, error, started_at, finished_at, created_at +FROM code_index_runs +WHERE workspace_id = $1 AND status = 'completed' +ORDER BY created_at DESC +LIMIT 1 +` + +func (q *Queries) GetLatestCompletedRun(ctx context.Context, workspaceID pgtype.UUID) (CodeIndexRun, error) { + row := q.db.QueryRow(ctx, getLatestCompletedRun, workspaceID) + var i CodeIndexRun + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.WorktreeID, + &i.Branch, + &i.CommitSha, + &i.Status, + &i.EmbeddingEndpointID, + &i.EmbeddingModel, + &i.Dims, + &i.FilesIndexed, + &i.ChunksIndexed, + &i.Error, + &i.StartedAt, + &i.FinishedAt, + &i.CreatedAt, + ) + return i, err +} + +const getRun = `-- name: GetRun :one +SELECT id, workspace_id, worktree_id, branch, commit_sha, status, + embedding_endpoint_id, embedding_model, dims, + files_indexed, chunks_indexed, error, started_at, finished_at, created_at +FROM code_index_runs +WHERE id = $1 +` + +func (q *Queries) GetRun(ctx context.Context, id pgtype.UUID) (CodeIndexRun, error) { + row := q.db.QueryRow(ctx, getRun, id) + var i CodeIndexRun + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.WorktreeID, + &i.Branch, + &i.CommitSha, + &i.Status, + &i.EmbeddingEndpointID, + &i.EmbeddingModel, + &i.Dims, + &i.FilesIndexed, + &i.ChunksIndexed, + &i.Error, + &i.StartedAt, + &i.FinishedAt, + &i.CreatedAt, + ) + return i, err +} + +const getRunByCommit = `-- name: GetRunByCommit :one +SELECT id, workspace_id, worktree_id, branch, commit_sha, status, + embedding_endpoint_id, embedding_model, dims, + files_indexed, chunks_indexed, error, started_at, finished_at, created_at +FROM code_index_runs +WHERE workspace_id = $1 AND commit_sha = $2 AND embedding_model = $3 +` + +type GetRunByCommitParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + EmbeddingModel string `json:"embedding_model"` +} + +func (q *Queries) GetRunByCommit(ctx context.Context, arg GetRunByCommitParams) (CodeIndexRun, error) { + row := q.db.QueryRow(ctx, getRunByCommit, arg.WorkspaceID, arg.CommitSha, arg.EmbeddingModel) + var i CodeIndexRun + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.WorktreeID, + &i.Branch, + &i.CommitSha, + &i.Status, + &i.EmbeddingEndpointID, + &i.EmbeddingModel, + &i.Dims, + &i.FilesIndexed, + &i.ChunksIndexed, + &i.Error, + &i.StartedAt, + &i.FinishedAt, + &i.CreatedAt, + ) + return i, err +} + +const insertRun = `-- name: InsertRun :one +INSERT INTO code_index_runs ( + id, workspace_id, worktree_id, branch, commit_sha, status, + embedding_endpoint_id, embedding_model, dims +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING id, workspace_id, worktree_id, branch, commit_sha, status, + embedding_endpoint_id, embedding_model, dims, + files_indexed, chunks_indexed, error, started_at, finished_at, created_at +` + +type InsertRunParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` +} + +func (q *Queries) InsertRun(ctx context.Context, arg InsertRunParams) (CodeIndexRun, error) { + row := q.db.QueryRow(ctx, insertRun, + arg.ID, + arg.WorkspaceID, + arg.WorktreeID, + arg.Branch, + arg.CommitSha, + arg.Status, + arg.EmbeddingEndpointID, + arg.EmbeddingModel, + arg.Dims, + ) + var i CodeIndexRun + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.WorktreeID, + &i.Branch, + &i.CommitSha, + &i.Status, + &i.EmbeddingEndpointID, + &i.EmbeddingModel, + &i.Dims, + &i.FilesIndexed, + &i.ChunksIndexed, + &i.Error, + &i.StartedAt, + &i.FinishedAt, + &i.CreatedAt, + ) + return i, err +} + +const markRunCompleted = `-- name: MarkRunCompleted :exec +UPDATE code_index_runs +SET status = 'completed', files_indexed = $2, chunks_indexed = $3, + error = NULL, finished_at = now() +WHERE id = $1 +` + +type MarkRunCompletedParams struct { + ID pgtype.UUID `json:"id"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` +} + +func (q *Queries) MarkRunCompleted(ctx context.Context, arg MarkRunCompletedParams) error { + _, err := q.db.Exec(ctx, markRunCompleted, arg.ID, arg.FilesIndexed, arg.ChunksIndexed) + return err +} + +const markRunFailed = `-- name: MarkRunFailed :exec +UPDATE code_index_runs +SET status = 'failed', error = $2, finished_at = now() +WHERE id = $1 +` + +type MarkRunFailedParams struct { + ID pgtype.UUID `json:"id"` + Error *string `json:"error"` +} + +func (q *Queries) MarkRunFailed(ctx context.Context, arg MarkRunFailedParams) error { + _, err := q.db.Exec(ctx, markRunFailed, arg.ID, arg.Error) + return err +} + +const markRunRunning = `-- name: MarkRunRunning :exec +UPDATE code_index_runs +SET status = 'running', started_at = now() +WHERE id = $1 +` + +func (q *Queries) MarkRunRunning(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, markRunRunning, id) + return err +} + +const markRunsStale = `-- name: MarkRunsStale :exec +UPDATE code_index_runs +SET status = 'stale' +WHERE workspace_id = $1 AND status IN ('pending','running','completed') +` + +func (q *Queries) MarkRunsStale(ctx context.Context, workspaceID pgtype.UUID) error { + _, err := q.db.Exec(ctx, markRunsStale, workspaceID) + return err +} + +const markStuckRunsStale = `-- name: MarkStuckRunsStale :many +UPDATE code_index_runs +SET status = 'stale' +WHERE status = 'running' AND started_at < $1 +RETURNING id +` + +func (q *Queries) MarkStuckRunsStale(ctx context.Context, startedAt pgtype.Timestamptz) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, markStuckRunsStale, startedAt) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var id pgtype.UUID + if err := rows.Scan(&id); err != nil { + return nil, err + } + items = append(items, id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/config/config.go b/internal/config/config.go index 83c6b20..743a981 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -29,27 +29,156 @@ func (k MasterKey) LogValue() slog.Value { return slog.StringValue("[REDACTED 32 // Config 是应用全局配置的根结构。 type Config struct { - Env string `mapstructure:"env"` - DataDir string `mapstructure:"data_dir"` - MasterKey MasterKey `mapstructure:"-"` // 解码后的 32 字节,单独从 master_key 解析 - HTTP HTTPConfig `mapstructure:"http"` - DB DBConfig `mapstructure:"db"` - Bootstrap BootstrapConfig `mapstructure:"bootstrap"` - Workspace Workspace `mapstructure:"workspace"` - Git Git `mapstructure:"git"` - Chat Chat `mapstructure:"chat"` - Storage Storage `mapstructure:"storage"` - Jobs JobsConfig `mapstructure:"jobs"` - Notify NotifyConfig `mapstructure:"notify"` - Acp AcpConfig `mapstructure:"acp"` - MCP MCPConfig `mapstructure:"mcp"` - Run RunConfig `mapstructure:"run"` - Terminal TerminalConfig `mapstructure:"terminal"` + Env string `mapstructure:"env"` + DataDir string `mapstructure:"data_dir"` + MasterKey MasterKey `mapstructure:"-"` // 解码后的 32 字节,单独从 master_key 解析 + HTTP HTTPConfig `mapstructure:"http"` + DB DBConfig `mapstructure:"db"` + Bootstrap BootstrapConfig `mapstructure:"bootstrap"` + Workspace Workspace `mapstructure:"workspace"` + Git Git `mapstructure:"git"` + Chat Chat `mapstructure:"chat"` + Storage Storage `mapstructure:"storage"` + Jobs JobsConfig `mapstructure:"jobs"` + Notify NotifyConfig `mapstructure:"notify"` + Acp AcpConfig `mapstructure:"acp"` + MCP MCPConfig `mapstructure:"mcp"` + Run RunConfig `mapstructure:"run"` + Terminal TerminalConfig `mapstructure:"terminal"` + VCS VCSConfig `mapstructure:"vcs"` + Orchestrator OrchestratorConfig `mapstructure:"orchestrator"` + Crypto CryptoConfig `mapstructure:"crypto"` + User UserConfig `mapstructure:"user"` + CodeIndex CodeIndexConfig `mapstructure:"code_index"` + Docs DocsConfig `mapstructure:"docs"` + Metrics MetricsConfig `mapstructure:"metrics"` } -// HTTPConfig 描述 HTTP 服务监听参数。 +// MetricsConfig 控制 Prometheus /metrics 端点。Enabled=false 时不挂载该路由。 +// AuthToken 非空时要求 Bearer/?token 匹配,避免成本/会话数对未授权方泄露。 +type MetricsConfig struct { + Enabled bool `mapstructure:"enabled"` + AuthToken string `mapstructure:"auth_token"` +} + +// CodeIndexConfig controls the pgvector code index + project memory embeddings. +// EmbeddingEndpointID/EmbeddingModel select an OpenAI-compatible or Gemini +// endpoint for embeddings (Anthropic has no embeddings API). When the endpoint +// is empty, the feature degrades: search_code is disabled and memory_search +// falls back to keyword/tag matching. +type CodeIndexConfig struct { + Enabled bool `mapstructure:"enabled"` + EmbeddingEndpointID string `mapstructure:"embedding_endpoint_id"` // llm_endpoints.id (UUID); empty disables embeddings + EmbeddingModel string `mapstructure:"embedding_model"` // model id producing PlatformEmbeddingDims (1536) vectors + ChunkWindowLines int `mapstructure:"chunk_window_lines"` + ChunkOverlapLines int `mapstructure:"chunk_overlap_lines"` + MaxFileBytes int `mapstructure:"max_file_bytes"` + SearchTopKCap int `mapstructure:"search_top_k_cap"` + GrepMaxMatches int `mapstructure:"grep_max_matches"` + GrepMaxFileBytes int `mapstructure:"grep_max_file_bytes"` +} + +// DocsConfig controls auto doc / PR-summary generation on commit. +type DocsConfig struct { + Enabled bool `mapstructure:"enabled"` +} + +// CryptoConfig 选择 secret 加密的 key provider(env|vault|kms)。key 版本元数据 +// 落在 crypto_keys 表;key 材料留在 env/KMS。vault/kms 的客户端尚未接入,选中时 +// 退回 env provider(见 app.go),以保持启动不破。 +type CryptoConfig struct { + Provider string `mapstructure:"provider"` // env(默认)| vault | kms + // Vault/KMS 的连接参数预留位(接入真实客户端时填充)。 + Vault VaultCfg `mapstructure:"vault"` + KMS KMSCfg `mapstructure:"kms"` +} + +// VaultCfg 是 HashiCorp Vault transit 的预留配置。 +type VaultCfg struct { + Address string `mapstructure:"address"` + Token string `mapstructure:"token"` + KeyName string `mapstructure:"key_name"` +} + +// KMSCfg 是云 KMS 的预留配置。 +type KMSCfg struct { + KeyID string `mapstructure:"key_id"` + Region string `mapstructure:"region"` +} + +// UserConfig 控制 user 模块:当前仅登录暴力破解节流。 +type UserConfig struct { + LoginThrottle LoginThrottleCfg `mapstructure:"login_throttle"` +} + +// LoginThrottleCfg 控制登录失败计数窗口。Enabled=false 时不节流(开发/测试)。 +type LoginThrottleCfg struct { + Enabled bool `mapstructure:"enabled"` + MaxFailures int `mapstructure:"max_failures"` + Window time.Duration `mapstructure:"window"` +} + +// OrchestratorConfig 控制编排器模块:开关、每阶段最大尝试、回合超时、子任务深度与 +// fan-out 上限。每阶段默认 agent-kind 由 PhaseAgentKinds(agent-kind 名→阶段)映射, +// app 层按名解析为 UUID 注入。 +type OrchestratorConfig struct { + Enabled bool `mapstructure:"enabled"` + MaxAttemptsPerPhase int `mapstructure:"max_attempts_per_phase"` + TurnTimeout time.Duration `mapstructure:"turn_timeout"` + MaxDepth int `mapstructure:"max_depth"` + FanOutLimit int `mapstructure:"fan_out_limit"` + // PhaseAgentKinds 把阶段名映射到 agent-kind 名(如 planning→"claude")。app 层在 + // 装配时按名解析为 agent_kind_id,作为 run.config 未覆盖时的默认值。空 map 表示无 + // 默认:每个 run 必须在 config.PhaseAgentKinds 显式提供。 + PhaseAgentKinds map[string]string `mapstructure:"phase_agent_kinds"` + // Scheduler 控制任务分解的并行调度器(autonomy roadmap §10)。 + Scheduler SchedulerConfig `mapstructure:"scheduler"` +} + +// SchedulerConfig 控制任务分解并行调度器:拓扑选取就绪子任务、扇出到并行 ACP session。 +type SchedulerConfig struct { + Enabled bool `mapstructure:"enabled"` + Interval time.Duration `mapstructure:"interval"` + // MaxFanoutPerTick 每个 project 每 tick 最多派发的 session 数(<=0 默认 8)。 + MaxFanoutPerTick int `mapstructure:"max_fanout_per_tick"` + // MaxConcurrentPerProject project 内活跃 session 软上限(<=0 不预检,仅靠 acp 层硬上限)。 + MaxConcurrentPerProject int `mapstructure:"max_concurrent_per_project"` +} + +// HTTPConfig 描述 HTTP 服务监听参数及可选的 TLS / CORS / 安全头中间件。 type HTTPConfig struct { - Addr string `mapstructure:"addr"` + Addr string `mapstructure:"addr"` + TLS TLSConfig `mapstructure:"tls"` + CORS CORSCfg `mapstructure:"cors"` + Security SecurityHdrCfg `mapstructure:"security"` +} + +// TLSConfig 控制是否以 HTTPS 提供服务。Enabled=true 时 cmd/server 用 +// ListenAndServeTLS,读取 CertFile/KeyFile。 +type TLSConfig struct { + Enabled bool `mapstructure:"enabled"` + CertFile string `mapstructure:"cert_file"` + KeyFile string `mapstructure:"key_file"` +} + +// CORSCfg 控制跨域中间件。默认 off,避免误配破坏 SPA + WS 握手。 +type CORSCfg struct { + Enabled bool `mapstructure:"enabled"` + AllowedOrigins []string `mapstructure:"allowed_origins"` + AllowedMethods []string `mapstructure:"allowed_methods"` + AllowedHeaders []string `mapstructure:"allowed_headers"` + AllowCredentials bool `mapstructure:"allow_credentials"` + MaxAgeSeconds int `mapstructure:"max_age_seconds"` +} + +// SecurityHdrCfg 控制安全响应头中间件。HSTS 仅在 TLS 启用时下发(由 app 层 +// 据 TLS.Enabled 传入)。 +type SecurityHdrCfg struct { + Enabled bool `mapstructure:"enabled"` + HSTSMaxAgeSeconds int `mapstructure:"hsts_max_age_seconds"` + FrameOptions string `mapstructure:"frame_options"` + ReferrerPolicy string `mapstructure:"referrer_policy"` + ContentSecurityPolicy string `mapstructure:"content_security_policy"` } // DBConfig 描述数据库连接参数。 @@ -78,6 +207,22 @@ type Git struct { Binary string `mapstructure:"binary"` } +// VCSConfig 配置 git-host(VCS)REST 客户端与合并网关策略。v1 仅支持 Gitea。 +// RequireCIPass 默认 false:未接 CI 的仓库不会因 ci_state=unknown 永久卡住合并。 +type VCSConfig struct { + Gitea GiteaConfig `mapstructure:"gitea"` + RequireCIPass bool `mapstructure:"require_ci_pass"` +} + +// GiteaConfig 是 Gitea provider 的参数。Token 是平台级 PAT(建议组织/管理员 +// token),BaseURL 默认指向自托管实例 git.jerryyan.net。 +type GiteaConfig struct { + BaseURL string `mapstructure:"base_url"` + Token string `mapstructure:"token"` + Timeout time.Duration `mapstructure:"timeout"` + MergeMethod string `mapstructure:"merge_method"` +} + // Chat 配置 chat 模块的附件、流式、历史与定时清理策略。 type Chat struct { Attachment ChatAttachment `mapstructure:"attachment"` @@ -125,6 +270,15 @@ type JobsConfig struct { JobPurge JobPurgeCfg `mapstructure:"job_purge"` AcpEventsPurge AcpEventsPurgeCfg `mapstructure:"acp_events_purge"` MCPTokensPurge MCPTokensPurgeCfg `mapstructure:"mcp_tokens_purge"` + AcpSessionReaper AcpSessionReaperCfg `mapstructure:"acp_session_reaper"` + AuditRetention AuditRetentionCfg `mapstructure:"audit_retention"` +} + +// AuditRetentionCfg 控制审计日志保留期清理:删除早于 Retention 的 audit_logs 行。 +type AuditRetentionCfg struct { + Enabled bool `mapstructure:"enabled"` + Interval time.Duration `mapstructure:"interval"` + Retention time.Duration `mapstructure:"retention"` } type RunnerCfg struct { @@ -169,9 +323,21 @@ type MCPTokensPurgeCfg struct { Retention time.Duration `mapstructure:"retention"` } -// NotifyConfig 是 notify 模块的可选外发通道配置。当前仅 webhook。 +// AcpSessionReaperCfg 控制 ACP 会话回收器:墙钟超时 + 空闲超时终止。 +type AcpSessionReaperCfg struct { + Enabled bool `mapstructure:"enabled"` + Interval time.Duration `mapstructure:"interval"` + WallClockTimeout time.Duration `mapstructure:"wall_clock_timeout"` + IdleTimeout time.Duration `mapstructure:"idle_timeout"` +} + +// NotifyConfig 是 notify 模块的可选外发通道配置:webhook(jobs 异步投递)、 +// email(SMTP)、im(per-user 加密 webhook)。email/im 默认关闭,未配置时 +// 对应 Notifier 不注册到 Dispatcher,行为与历史一致。 type NotifyConfig struct { Webhook WebhookCfg `mapstructure:"webhook"` + Email EmailCfg `mapstructure:"email"` + IM IMCfg `mapstructure:"im"` } // WebhookCfg 是 webhook 通道的配置。enabled=false 时上层 dispatcher 仍会丢弃所有 webhook 消息。 @@ -183,6 +349,24 @@ type WebhookCfg struct { Topics []string `mapstructure:"topics"` } +// EmailCfg 是 SMTP email 通道的配置。Enabled=false(默认)时不注册 EmailNotifier, +// 不发任何邮件。Username/Password 为空表示无认证 SMTP(如本地中继)。 +type EmailCfg struct { + Enabled bool `mapstructure:"enabled"` + SMTPHost string `mapstructure:"smtp_host"` + Port int `mapstructure:"port"` + From string `mapstructure:"from"` + Username string `mapstructure:"username"` + Password string `mapstructure:"password"` +} + +// IMCfg 是 IM(通用 webhook,如 Slack incoming webhook)通道的配置。 +// Enabled=false(默认)时不注册 IMNotifier。投递地址是 per-user 的 +// im_webhook_url(加密落库于 notification_prefs),此处仅做全局开关。 +type IMCfg struct { + Enabled bool `mapstructure:"enabled"` +} + // AcpConfig 控制 ACP 模块的并发限流、子进程超时、WS 心跳与事件保留。 type AcpConfig struct { Enabled bool `mapstructure:"enabled"` @@ -200,6 +384,28 @@ type AcpConfig struct { EventRetentionDays int `mapstructure:"event_retention_days"` ShutdownGrace time.Duration `mapstructure:"shutdown_grace"` PermissionTimeout time.Duration `mapstructure:"permission_timeout"` + // BriefTokenBudget 是服务端组装 ACP session 初始 prompt 简报的 token 硬上限。 + BriefTokenBudget int `mapstructure:"brief_token_budget"` + // DefaultProjectBudgetUSD 是 per-project ACP 累计花费的全局软上限(0 = 不限)。 + // 当 session/agent-kind 均未设置成本上限时作为预算判定基线。 + DefaultProjectBudgetUSD float64 `mapstructure:"default_project_budget_usd"` + // Sandbox 控制 per-session 执行沙箱(none|uid|container)。默认 none: + // Windows 开发 / CI / 任何非 Linux 机器都跑 none,行为与历史一致。 + Sandbox SandboxCfg `mapstructure:"sandbox"` +} + +// SandboxCfg 控制 ACP 子进程的 per-session 隔离。mode=none 时全部字段被忽略。 +type SandboxCfg struct { + Mode string `mapstructure:"mode"` // none(默认)| uid | container + BaseUID int `mapstructure:"base_uid"` // 低权 UID 基址;per-session uid = base + offset + // ProxyURL 注入子进程 env 的 HTTP(S)_PROXY(egress 白名单代理);空 = 不限制。 + ProxyURL string `mapstructure:"proxy_url"` + // 资源上限(OS rlimit / cgroup)。0 = 不设该项。 + AddressSpaceBytes uint64 `mapstructure:"address_space_bytes"` + NProc uint64 `mapstructure:"nproc"` + CPUSeconds uint64 `mapstructure:"cpu_seconds"` + PIDs uint64 `mapstructure:"pids"` + DiskBytes uint64 `mapstructure:"disk_bytes"` } // RunConfig 控制 workspace run profiles 模块:进程托管的终止宽限、日志环形缓冲 @@ -214,6 +420,20 @@ type RunConfig struct { // EnvWhitelist 是允许从宿主进程透传给被托管命令的环境变量名白名单。 // APP_MASTER_KEY、DB DSN、git 凭据等敏感变量绝不应出现在此名单中。 EnvWhitelist []string `mapstructure:"env_whitelist"` + // Exec 控制一次性 exec 原语(run_command / run_tests MCP 工具)。 + Exec ExecConfig `mapstructure:"exec"` +} + +// ExecConfig 控制 agent 自验证用的一次性 exec(run_command / run_tests): +// 默认 / 最大超时、输出捕获上限,以及命令二进制白名单。AllowedCommands 为空表示 +// 显式不限制命令;默认配置保持非空,生产环境不应放空。 +type ExecConfig struct { + DefaultTimeout time.Duration `mapstructure:"default_timeout"` + MaxTimeout time.Duration `mapstructure:"max_timeout"` + MaxOutputBytes int `mapstructure:"max_output_bytes"` + // AllowedCommands 是允许执行的命令 base name 白名单(如 go / npm / pnpm)。 + // 为空时不做命令限制(仍受 env 白名单、worktree 路径、超时与树终止约束)。 + AllowedCommands []string `mapstructure:"allowed_commands"` } // TerminalConfig 控制仅限管理员的网页端终端模块:开关、启动的 shell、并发会话上限、 @@ -257,6 +477,10 @@ func Load(configFile string) (*Config, error) { v.SetDefault("workspace.push_timeout", "5m") v.SetDefault("workspace.ops_timeout", "60s") v.SetDefault("git.binary", "git") + v.SetDefault("vcs.gitea.base_url", "https://git.jerryyan.net") + v.SetDefault("vcs.gitea.timeout", "15s") + v.SetDefault("vcs.gitea.merge_method", "merge") + v.SetDefault("vcs.require_ci_pass", false) v.SetDefault("chat.attachment.max_file_size_mb", 20) v.SetDefault("chat.attachment.max_message_size_mb", 50) v.SetDefault("chat.attachment.mime_whitelist", []string{ @@ -281,7 +505,10 @@ func Load(configFile string) (*Config, error) { v.SetDefault("jobs.attachment_cleanup.retention", "720h") v.SetDefault("jobs.job_reaper.enabled", true) v.SetDefault("jobs.job_reaper.interval", "1m") - v.SetDefault("jobs.job_reaper.lease_timeout", "5m") + // 15m:必须大于 orchestrator.turn_timeout(10m) 才能容纳一次完整的 agent 回合—— + // jobs handler 超时 = lease_timeout*9/10 = 13.5m > 10m。否则编排器步骤会被中途 reap + // 并重复执行(见 app.go 启动期不变量校验)。 + v.SetDefault("jobs.job_reaper.lease_timeout", "15m") v.SetDefault("jobs.job_purge.enabled", true) v.SetDefault("jobs.job_purge.interval", "24h") v.SetDefault("jobs.job_purge.completed_retention", "168h") @@ -291,8 +518,25 @@ func Load(configFile string) (*Config, error) { v.SetDefault("jobs.mcp_tokens_purge.enabled", true) v.SetDefault("jobs.mcp_tokens_purge.interval", "24h") v.SetDefault("jobs.mcp_tokens_purge.retention", "168h") + v.SetDefault("jobs.acp_session_reaper.enabled", true) + v.SetDefault("jobs.acp_session_reaper.interval", "1m") + v.SetDefault("jobs.acp_session_reaper.wall_clock_timeout", "4h") + v.SetDefault("jobs.acp_session_reaper.idle_timeout", "30m") + // 审计日志保留期清理:默认 90 天,每日扫描一次。 + v.SetDefault("jobs.audit_retention.enabled", true) + v.SetDefault("jobs.audit_retention.interval", "24h") + v.SetDefault("jobs.audit_retention.retention", "2160h") + // Prometheus /metrics:默认开启(绑定内网 / 反代鉴权场景留空 token)。 + v.SetDefault("metrics.enabled", true) + v.SetDefault("metrics.auth_token", "") v.SetDefault("notify.webhook.enabled", false) v.SetDefault("notify.webhook.timeout", "10s") + // 默认转发的 webhook topic:phase 流转事件供下游自动化(如触发 ACP run)订阅。 + v.SetDefault("notify.webhook.topics", []string{"requirement.phase_change"}) + // email / im 通道默认关闭,未显式启用时不注册对应 Notifier。 + v.SetDefault("notify.email.enabled", false) + v.SetDefault("notify.email.port", 587) + v.SetDefault("notify.im.enabled", false) v.SetDefault("acp.enabled", true) v.SetDefault("acp.max_active_global", 20) v.SetDefault("acp.max_active_per_user", 3) @@ -308,6 +552,42 @@ func Load(configFile string) (*Config, error) { v.SetDefault("acp.event_retention_days", 7) v.SetDefault("acp.shutdown_grace", "30s") v.SetDefault("acp.permission_timeout", "5m") + v.SetDefault("acp.brief_token_budget", 6000) + v.SetDefault("acp.default_project_budget_usd", 0) + // 沙箱默认 none:Windows 开发 / CI / 非 Linux 机器均不隔离,行为与历史一致。 + v.SetDefault("acp.sandbox.mode", "none") + v.SetDefault("acp.sandbox.base_uid", 0) + v.SetDefault("acp.sandbox.proxy_url", "") + v.SetDefault("acp.sandbox.address_space_bytes", 0) + v.SetDefault("acp.sandbox.nproc", 0) + v.SetDefault("acp.sandbox.cpu_seconds", 0) + v.SetDefault("acp.sandbox.pids", 0) + v.SetDefault("acp.sandbox.disk_bytes", 0) + // secret key provider:默认 env(沿用 APP_MASTER_KEY 为 version 1)。 + v.SetDefault("crypto.provider", "env") + // 登录暴力破解节流:默认开启,15 分钟窗口内 10 次失败即 429。 + v.SetDefault("user.login_throttle.enabled", true) + v.SetDefault("user.login_throttle.max_failures", 10) + v.SetDefault("user.login_throttle.window", "15m") + // HTTP TLS / CORS / 安全头:默认 off(HTTP,无 CORS);安全头默认开启(透传安全)。 + v.SetDefault("http.tls.enabled", false) + v.SetDefault("http.cors.enabled", false) + v.SetDefault("http.cors.allow_credentials", true) + v.SetDefault("http.cors.max_age_seconds", 600) + v.SetDefault("http.security.enabled", true) + v.SetDefault("http.security.frame_options", "DENY") + v.SetDefault("http.security.referrer_policy", "no-referrer") + v.SetDefault("orchestrator.enabled", true) + v.SetDefault("orchestrator.max_attempts_per_phase", 3) + v.SetDefault("orchestrator.turn_timeout", "10m") + v.SetDefault("orchestrator.max_depth", 3) + v.SetDefault("orchestrator.fan_out_limit", 3) + v.SetDefault("orchestrator.phase_agent_kinds", map[string]string{}) + // 任务分解并行调度器:默认关闭(须显式开启,配合 jobs 周期 RunnerFn 驱动)。 + v.SetDefault("orchestrator.scheduler.enabled", false) + v.SetDefault("orchestrator.scheduler.interval", "30s") + v.SetDefault("orchestrator.scheduler.max_fanout_per_tick", 8) + v.SetDefault("orchestrator.scheduler.max_concurrent_per_project", 4) v.SetDefault("mcp.enabled", true) v.SetDefault("mcp.public_url", "http://localhost:8080/mcp") v.SetDefault("mcp.system_token_ttl", "24h") @@ -324,6 +604,15 @@ func Load(configFile string) (*Config, error) { "SystemRoot", "TEMP", "TMP", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PATHEXT", "ComSpec", "NUMBER_OF_PROCESSORS", }) + v.SetDefault("run.exec.default_timeout", "60s") + v.SetDefault("run.exec.max_timeout", "600s") + v.SetDefault("run.exec.max_output_bytes", 1<<20) + v.SetDefault("run.exec.allowed_commands", []string{ + "go", "npm", "pnpm", "yarn", "node", + "python", "python3", "pytest", "uv", + "cargo", "rustc", "make", "cmake", "ctest", + "git", + }) v.SetDefault("terminal.enabled", true) v.SetDefault("terminal.shell", "/bin/bash") // Windows 开发环境经 APP_TERMINAL_SHELL 覆盖 v.SetDefault("terminal.max_sessions", 5) @@ -336,6 +625,18 @@ func Load(configFile string) (*Config, error) { "SystemRoot", "TEMP", "TMP", "USERPROFILE", "APPDATA", "LOCALAPPDATA", "PATHEXT", "ComSpec", "NUMBER_OF_PROCESSORS", }) + // 代码索引 / 项目记忆:默认开启,但无 embedding endpoint 时自动降级为关键字回退。 + v.SetDefault("code_index.enabled", true) + v.SetDefault("code_index.embedding_endpoint_id", "") + v.SetDefault("code_index.embedding_model", "text-embedding-3-small") + v.SetDefault("code_index.chunk_window_lines", 60) + v.SetDefault("code_index.chunk_overlap_lines", 10) + v.SetDefault("code_index.max_file_bytes", 512*1024) + v.SetDefault("code_index.search_top_k_cap", 50) + v.SetDefault("code_index.grep_max_matches", 200) + v.SetDefault("code_index.grep_max_file_bytes", 1<<20) + // 自动文档/PR 摘要:默认关闭(避免每次提交都调用 LLM 产生成本)。 + v.SetDefault("docs.enabled", false) if configFile != "" { v.SetConfigFile(configFile) @@ -355,6 +656,7 @@ func Load(configFile string) (*Config, error) { "bootstrap.admin_email", "bootstrap.admin_password", "master_key", + "vcs.gitea.token", } { if err := v.BindEnv(key); err != nil { return nil, fmt.Errorf("bind env %s: %w", key, err) diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 7890dc0..fd440c8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -3,6 +3,7 @@ package config import ( "encoding/json" "fmt" + "path/filepath" "testing" "time" @@ -65,6 +66,31 @@ func TestLoad_WorkspaceDefaults(t *testing.T) { require.Equal(t, "git", cfg.Git.Binary) } +func TestLoad_RunExecAllowedCommandsDefaultIsRestricted(t *testing.T) { + t.Setenv("APP_DB_DSN", "postgres://x") + t.Setenv("APP_MASTER_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") + + cfg, err := Load("") + require.NoError(t, err) + require.NotEmpty(t, cfg.Run.Exec.AllowedCommands) + require.Contains(t, cfg.Run.Exec.AllowedCommands, "go") + require.NotContains(t, cfg.Run.Exec.AllowedCommands, "sh") + require.NotContains(t, cfg.Run.Exec.AllowedCommands, "powershell") +} + +func TestLoad_ConfigExample(t *testing.T) { + t.Setenv("APP_DB_DSN", "postgres://override") + t.Setenv("APP_MASTER_KEY", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") + + cfg, err := Load(filepath.Join("..", "..", "config.example.yaml")) + require.NoError(t, err) + require.Equal(t, "postgres://override", cfg.DB.DSN) + require.Equal(t, 15*time.Minute, cfg.Jobs.JobReaper.LeaseTimeout) + require.Equal(t, 10*time.Minute, cfg.Orchestrator.TurnTimeout) + require.NotEmpty(t, cfg.Run.Exec.AllowedCommands) + require.True(t, cfg.HTTP.Security.Enabled) +} + func TestMasterKey_Redaction(t *testing.T) { k := MasterKey([]byte{0x01, 0x02, 0x03}) require.Equal(t, "[REDACTED 32B]", k.String()) diff --git a/internal/docs/domain.go b/internal/docs/domain.go new file mode 100644 index 0000000..43f821a --- /dev/null +++ b/internal/docs/domain.go @@ -0,0 +1,53 @@ +// Package docs generates commit / PR markdown summaries on commit. A background +// job (SummaryRunner) gathers the diffstat + log for a commit, prompts an LLM to +// produce markdown, and upserts a commit_summaries row. Enqueued post-commit via +// EnqueueCommitSummary (injected as a callback into gitops to avoid import cycles). +package docs + +import ( + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +// JobTypeCommitSummary is the jobs.JobType for an event-driven commit summary. +const JobTypeCommitSummary jobs.JobType = "docs.commit_summary" + +// Summary kind values mirror the commit_summaries.kind CHECK constraint. +const ( + KindCommit = "commit" + KindPR = "pr" +) + +// Status values mirror the commit_summaries.status CHECK constraint. +const ( + StatusPending = "pending" + StatusCompleted = "completed" + StatusFailed = "failed" +) + +// Summary mirrors a commit_summaries row. +type Summary struct { + ID uuid.UUID + WorkspaceID uuid.UUID + WorktreeID *uuid.UUID + Branch string + CommitSHA string + Kind string + Title *string + BodyMD string + Model *string + Status string + Error *string + CreatedAt time.Time +} + +// summaryPayload is the jobs payload for JobTypeCommitSummary. +type summaryPayload struct { + WorkspaceID uuid.UUID `json:"workspace_id"` + WorktreeID *uuid.UUID `json:"worktree_id,omitempty"` + Branch string `json:"branch"` + CommitSHA string `json:"commit_sha"` +} diff --git a/internal/docs/queries/summaries.sql b/internal/docs/queries/summaries.sql new file mode 100644 index 0000000..35ec2d6 --- /dev/null +++ b/internal/docs/queries/summaries.sql @@ -0,0 +1,29 @@ +-- name: UpsertSummary :one +INSERT INTO commit_summaries ( + id, workspace_id, worktree_id, branch, commit_sha, kind, + title, body_md, model, status, error +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +ON CONFLICT (workspace_id, commit_sha, kind) DO UPDATE +SET title = EXCLUDED.title, + body_md = EXCLUDED.body_md, + model = EXCLUDED.model, + status = EXCLUDED.status, + error = EXCLUDED.error, + branch = EXCLUDED.branch, + worktree_id = EXCLUDED.worktree_id +RETURNING id, workspace_id, worktree_id, branch, commit_sha, kind, + title, body_md, model, status, error, created_at; + +-- name: GetSummary :one +SELECT id, workspace_id, worktree_id, branch, commit_sha, kind, + title, body_md, model, status, error, created_at +FROM commit_summaries +WHERE workspace_id = $1 AND commit_sha = $2 AND kind = $3; + +-- name: InsertPendingSummary :one +INSERT INTO commit_summaries ( + id, workspace_id, worktree_id, branch, commit_sha, kind, body_md, status +) VALUES ($1, $2, $3, $4, $5, $6, '', 'pending') +ON CONFLICT (workspace_id, commit_sha, kind) DO NOTHING +RETURNING id, workspace_id, worktree_id, branch, commit_sha, kind, + title, body_md, model, status, error, created_at; diff --git a/internal/docs/repository.go b/internal/docs/repository.go new file mode 100644 index 0000000..3c4209b --- /dev/null +++ b/internal/docs/repository.go @@ -0,0 +1,127 @@ +package docs + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + docssqlc "github.com/yan1h/agent-coding-workflow/internal/docs/sqlc" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// Repository is the docs persistence contract. +type Repository interface { + Get(ctx context.Context, wsID uuid.UUID, commitSHA, kind string) (*Summary, error) + Upsert(ctx context.Context, in UpsertInput) (*Summary, error) +} + +// UpsertInput carries the fields for a commit_summaries upsert. +type UpsertInput struct { + WorkspaceID uuid.UUID + WorktreeID *uuid.UUID + Branch string + CommitSHA string + Kind string + Title *string + BodyMD string + Model *string + Status string + Error *string +} + +// PgRepository is the production Repository on a pgxpool.Pool. +type PgRepository struct { + pool *pgxpool.Pool + q *docssqlc.Queries +} + +// NewPostgresRepository builds a PgRepository. +func NewPostgresRepository(pool *pgxpool.Pool) *PgRepository { + return &PgRepository{pool: pool, q: docssqlc.New(pool)} +} + +var _ Repository = (*PgRepository)(nil) + +func pgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} } + +func pgUUIDPtr(u *uuid.UUID) pgtype.UUID { + if u == nil { + return pgtype.UUID{} + } + return pgtype.UUID{Bytes: *u, Valid: true} +} + +func uuidPtr(p pgtype.UUID) *uuid.UUID { + if !p.Valid { + return nil + } + id := uuid.UUID(p.Bytes) + return &id +} + +func summaryFromRow(r docssqlc.CommitSummary) *Summary { + s := &Summary{ + ID: uuid.UUID(r.ID.Bytes), + WorkspaceID: uuid.UUID(r.WorkspaceID.Bytes), + WorktreeID: uuidPtr(r.WorktreeID), + Branch: r.Branch, + CommitSHA: r.CommitSha, + Kind: r.Kind, + Title: r.Title, + BodyMD: r.BodyMd, + Model: r.Model, + Status: r.Status, + Error: r.Error, + } + if r.CreatedAt.Valid { + s.CreatedAt = r.CreatedAt.Time + } + return s +} + +func (r *PgRepository) Get(ctx context.Context, wsID uuid.UUID, commitSHA, kind string) (*Summary, error) { + row, err := r.q.GetSummary(ctx, docssqlc.GetSummaryParams{ + WorkspaceID: pgUUID(wsID), + CommitSha: commitSHA, + Kind: kind, + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + return nil, errs.Wrap(err, errs.CodeInternal, "get commit summary") + } + return summaryFromRow(row), nil +} + +func (r *PgRepository) Upsert(ctx context.Context, in UpsertInput) (*Summary, error) { + kind := in.Kind + if kind == "" { + kind = KindCommit + } + status := in.Status + if status == "" { + status = StatusCompleted + } + row, err := r.q.UpsertSummary(ctx, docssqlc.UpsertSummaryParams{ + ID: pgUUID(uuid.New()), + WorkspaceID: pgUUID(in.WorkspaceID), + WorktreeID: pgUUIDPtr(in.WorktreeID), + Branch: in.Branch, + CommitSha: in.CommitSHA, + Kind: kind, + Title: in.Title, + BodyMd: in.BodyMD, + Model: in.Model, + Status: status, + Error: in.Error, + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "upsert commit summary") + } + return summaryFromRow(row), nil +} diff --git a/internal/docs/service.go b/internal/docs/service.go new file mode 100644 index 0000000..2a74e67 --- /dev/null +++ b/internal/docs/service.go @@ -0,0 +1,54 @@ +package docs + +import ( + "context" + "encoding/json" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +// Enqueuer is the subset of jobs.Repository the service needs. +type Enqueuer interface { + Enqueue(ctx context.Context, typ jobs.JobType, payload json.RawMessage, scheduledAt time.Time, maxAttempts int32) (*jobs.Job, error) +} + +// Service is the docs application service. +type Service interface { + // EnqueueCommitSummary enqueues a docs.commit_summary job for the given + // commit. Idempotent at the data layer via UNIQUE(workspace, commit, kind). + EnqueueCommitSummary(ctx context.Context, wsID uuid.UUID, worktreeID *uuid.UUID, branch, commitSHA string) error +} + +type service struct { + jobs Enqueuer + enabled bool +} + +// NewService constructs the docs Service. When enabled is false, EnqueueCommitSummary +// is a no-op so the commit path stays free of doc-gen overhead. +func NewService(jobsRepo Enqueuer, enabled bool) Service { + return &service{jobs: jobsRepo, enabled: enabled} +} + +func (s *service) EnqueueCommitSummary(ctx context.Context, wsID uuid.UUID, worktreeID *uuid.UUID, branch, commitSHA string) error { + if !s.enabled { + return nil + } + if commitSHA == "" { + return errs.New(errs.CodeInvalidInput, "commit_sha required") + } + payload, _ := json.Marshal(summaryPayload{ + WorkspaceID: wsID, + WorktreeID: worktreeID, + Branch: branch, + CommitSHA: commitSHA, + }) + if _, err := s.jobs.Enqueue(ctx, JobTypeCommitSummary, payload, time.Now(), 3); err != nil { + return errs.Wrap(err, errs.CodeInternal, "enqueue docs.commit_summary") + } + return nil +} diff --git a/internal/docs/service_test.go b/internal/docs/service_test.go new file mode 100644 index 0000000..72d381e --- /dev/null +++ b/internal/docs/service_test.go @@ -0,0 +1,44 @@ +package docs + +import ( + "context" + "encoding/json" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +type fakeEnqueuer struct { + count int + lastType jobs.JobType +} + +func (f *fakeEnqueuer) Enqueue(_ context.Context, typ jobs.JobType, _ json.RawMessage, _ time.Time, _ int32) (*jobs.Job, error) { + f.count++ + f.lastType = typ + return &jobs.Job{ID: uuid.New(), Type: typ}, nil +} + +func TestService_EnqueueCommitSummary_Enabled(t *testing.T) { + enq := &fakeEnqueuer{} + svc := NewService(enq, true) + require.NoError(t, svc.EnqueueCommitSummary(context.Background(), uuid.New(), nil, "main", "abc")) + require.Equal(t, 1, enq.count) + require.Equal(t, JobTypeCommitSummary, enq.lastType) +} + +func TestService_EnqueueCommitSummary_Disabled(t *testing.T) { + enq := &fakeEnqueuer{} + svc := NewService(enq, false) + require.NoError(t, svc.EnqueueCommitSummary(context.Background(), uuid.New(), nil, "main", "abc")) + require.Equal(t, 0, enq.count, "disabled: no enqueue") +} + +func TestService_EnqueueCommitSummary_RequiresSHA(t *testing.T) { + svc := NewService(&fakeEnqueuer{}, true) + require.Error(t, svc.EnqueueCommitSummary(context.Background(), uuid.New(), nil, "main", "")) +} diff --git a/internal/docs/sqlc/db.go b/internal/docs/sqlc/db.go new file mode 100644 index 0000000..8a590f7 --- /dev/null +++ b/internal/docs/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package docssqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/docs/sqlc/models.go b/internal/docs/sqlc/models.go new file mode 100644 index 0000000..958e10f --- /dev/null +++ b/internal/docs/sqlc/models.go @@ -0,0 +1,593 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package docssqlc + +import ( + "net/netip" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" +) + +type AcpAgentKind struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` + ClientType string `json:"client_type"` + EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` +} + +type AcpAgentKindConfigFile struct { + ID pgtype.UUID `json:"id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + RelPath string `json:"rel_path"` + EncryptedContent []byte `json:"encrypted_content"` + UpdatedBy pgtype.UUID `json:"updated_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type AcpEvent struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + Direction string `json:"direction"` + RpcKind string `json:"rpc_kind"` + Method *string `json:"method"` + Payload []byte `json:"payload"` + PayloadSize int32 `json:"payload_size"` + Truncated bool `json:"truncated"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpSession struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` +} + +type AuditLog struct { + ID int64 `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Action string `json:"action"` + TargetType *string `json:"target_type"` + TargetID *string `json:"target_id"` + Ip *netip.Addr `json:"ip"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Conversation struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + IssueID pgtype.UUID `json:"issue_id"` + ModelID pgtype.UUID `json:"model_id"` + SystemPrompt string `json:"system_prompt"` + Title *string `json:"title"` + TitleGeneratedAt pgtype.Timestamptz `json:"title_generated_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` +} + +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type GitCredential struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Username *string `json:"username"` + EncryptedSecret []byte `json:"encrypted_secret"` + Fingerprint *string `json:"fingerprint"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type Issue struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + AssigneeID pgtype.UUID `json:"assignee_id"` + CreatedBy pgtype.UUID `json:"created_by"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Job struct { + ID pgtype.UUID `json:"id"` + Type string `json:"type"` + Payload []byte `json:"payload"` + Status string `json:"status"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + Attempts int32 `json:"attempts"` + MaxAttempts int32 `json:"max_attempts"` + LeasedAt pgtype.Timestamptz `json:"leased_at"` + LeasedBy *string `json:"leased_by"` + LastError *string `json:"last_error"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmEndpoint struct { + ID pgtype.UUID `json:"id"` + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + BaseUrl string `json:"base_url"` + ApiKeyEncrypted []byte `json:"api_key_encrypted"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type LlmModel struct { + ID pgtype.UUID `json:"id"` + EndpointID pgtype.UUID `json:"endpoint_id"` + ModelID string `json:"model_id"` + DisplayName string `json:"display_name"` + Capabilities []byte `json:"capabilities"` + ContextWindow int32 `json:"context_window"` + MaxOutputTokens int32 `json:"max_output_tokens"` + PromptPricePerMillionUsd pgtype.Numeric `json:"prompt_price_per_million_usd"` + CompletionPricePerMillionUsd pgtype.Numeric `json:"completion_price_per_million_usd"` + ThinkingPricePerMillionUsd pgtype.Numeric `json:"thinking_price_per_million_usd"` + IsTitleGenerator bool `json:"is_title_generator"` + Enabled bool `json:"enabled"` + SortOrder int32 `json:"sort_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmUsage struct { + ID int64 `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + MessageID int64 `json:"message_id"` + UserID pgtype.UUID `json:"user_id"` + EndpointID pgtype.UUID `json:"endpoint_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int32 `json:"prompt_tokens"` + CompletionTokens int32 `json:"completion_tokens"` + ThinkingTokens int32 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type McpToken struct { + ID pgtype.UUID `json:"id"` + TokenHash []byte `json:"token_hash"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + Issuer string `json:"issuer"` + Scope []byte `json:"scope"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Message struct { + ID int64 `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + Role string `json:"role"` + Content string `json:"content"` + Thinking *string `json:"thinking"` + ToolCalls []byte `json:"tool_calls"` + Status string `json:"status"` + ErrorMessage *string `json:"error_message"` + PromptTokens *int32 `json:"prompt_tokens"` + CompletionTokens *int32 `json:"completion_tokens"` + ThinkingTokens *int32 `json:"thinking_tokens"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type MessageAttachment struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + MessageID *int64 `json:"message_id"` + Filename string `json:"filename"` + MimeType string `json:"mime_type"` + SizeBytes int64 `json:"size_bytes"` + Sha256 string `json:"sha256"` + StoragePath string `json:"storage_path"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Notification struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Topic string `json:"topic"` + Severity string `json:"severity"` + Title string `json:"title"` + Body string `json:"body"` + Link *string `json:"link"` + Metadata []byte `json:"metadata"` + ReadAt pgtype.Timestamptz `json:"read_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type Project struct { + ID pgtype.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + OwnerID pgtype.UUID `json:"owner_id"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type PromptTemplate struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Content string `json:"content"` + Scope string `json:"scope"` + OwnerID pgtype.UUID `json:"owner_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Requirement struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Phase string `json:"phase"` + Status string `json:"status"` + OwnerID pgtype.UUID `json:"owner_id"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type User struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + DisplayName string `json:"display_name"` + IsAdmin bool `json:"is_admin"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` +} + +type UserSession struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + TokenHash []byte `json:"token_hash"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Workspace struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + GitRemoteUrl string `json:"git_remote_url"` + DefaultBranch string `json:"default_branch"` + MainPath string `json:"main_path"` + SyncStatus string `json:"sync_status"` + LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"` + LastSyncError *string `json:"last_sync_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"` +} + +type WorkspaceRunProfile struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Command string `json:"command"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + LastStartedAt pgtype.Timestamptz `json:"last_started_at"` + LastExitCode *int32 `json:"last_exit_code"` + LastError string `json:"last_error"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type WorkspaceWorktree struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Branch string `json:"branch"` + Path string `json:"path"` + Status string `json:"status"` + ActiveHolder *string `json:"active_holder"` + AcquiredAt pgtype.Timestamptz `json:"acquired_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + PruneWarningAt pgtype.Timestamptz `json:"prune_warning_at"` +} diff --git a/internal/docs/sqlc/summaries.sql.go b/internal/docs/sqlc/summaries.sql.go new file mode 100644 index 0000000..a5e09c3 --- /dev/null +++ b/internal/docs/sqlc/summaries.sql.go @@ -0,0 +1,153 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: summaries.sql + +package docssqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getSummary = `-- name: GetSummary :one +SELECT id, workspace_id, worktree_id, branch, commit_sha, kind, + title, body_md, model, status, error, created_at +FROM commit_summaries +WHERE workspace_id = $1 AND commit_sha = $2 AND kind = $3 +` + +type GetSummaryParams struct { + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` +} + +func (q *Queries) GetSummary(ctx context.Context, arg GetSummaryParams) (CommitSummary, error) { + row := q.db.QueryRow(ctx, getSummary, arg.WorkspaceID, arg.CommitSha, arg.Kind) + var i CommitSummary + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.WorktreeID, + &i.Branch, + &i.CommitSha, + &i.Kind, + &i.Title, + &i.BodyMd, + &i.Model, + &i.Status, + &i.Error, + &i.CreatedAt, + ) + return i, err +} + +const insertPendingSummary = `-- name: InsertPendingSummary :one +INSERT INTO commit_summaries ( + id, workspace_id, worktree_id, branch, commit_sha, kind, body_md, status +) VALUES ($1, $2, $3, $4, $5, $6, '', 'pending') +ON CONFLICT (workspace_id, commit_sha, kind) DO NOTHING +RETURNING id, workspace_id, worktree_id, branch, commit_sha, kind, + title, body_md, model, status, error, created_at +` + +type InsertPendingSummaryParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` +} + +func (q *Queries) InsertPendingSummary(ctx context.Context, arg InsertPendingSummaryParams) (CommitSummary, error) { + row := q.db.QueryRow(ctx, insertPendingSummary, + arg.ID, + arg.WorkspaceID, + arg.WorktreeID, + arg.Branch, + arg.CommitSha, + arg.Kind, + ) + var i CommitSummary + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.WorktreeID, + &i.Branch, + &i.CommitSha, + &i.Kind, + &i.Title, + &i.BodyMd, + &i.Model, + &i.Status, + &i.Error, + &i.CreatedAt, + ) + return i, err +} + +const upsertSummary = `-- name: UpsertSummary :one +INSERT INTO commit_summaries ( + id, workspace_id, worktree_id, branch, commit_sha, kind, + title, body_md, model, status, error +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11) +ON CONFLICT (workspace_id, commit_sha, kind) DO UPDATE +SET title = EXCLUDED.title, + body_md = EXCLUDED.body_md, + model = EXCLUDED.model, + status = EXCLUDED.status, + error = EXCLUDED.error, + branch = EXCLUDED.branch, + worktree_id = EXCLUDED.worktree_id +RETURNING id, workspace_id, worktree_id, branch, commit_sha, kind, + title, body_md, model, status, error, created_at +` + +type UpsertSummaryParams struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` +} + +func (q *Queries) UpsertSummary(ctx context.Context, arg UpsertSummaryParams) (CommitSummary, error) { + row := q.db.QueryRow(ctx, upsertSummary, + arg.ID, + arg.WorkspaceID, + arg.WorktreeID, + arg.Branch, + arg.CommitSha, + arg.Kind, + arg.Title, + arg.BodyMd, + arg.Model, + arg.Status, + arg.Error, + ) + var i CommitSummary + err := row.Scan( + &i.ID, + &i.WorkspaceID, + &i.WorktreeID, + &i.Branch, + &i.CommitSha, + &i.Kind, + &i.Title, + &i.BodyMd, + &i.Model, + &i.Status, + &i.Error, + &i.CreatedAt, + ) + return i, err +} diff --git a/internal/docs/summary_runner.go b/internal/docs/summary_runner.go new file mode 100644 index 0000000..9fe4540 --- /dev/null +++ b/internal/docs/summary_runner.go @@ -0,0 +1,123 @@ +package docs + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "strings" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/git" + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +// maxDiffChars bounds the diffstat fed to the LLM so a huge commit can't blow the +// prompt budget. +const maxDiffChars = 12000 + +// WorkspaceLocator resolves the on-disk directory holding a commit's files. +type WorkspaceLocator interface { + LocateWorkspace(ctx context.Context, wsID uuid.UUID) (mainPath string, err error) + LocateWorktree(ctx context.Context, worktreeID uuid.UUID) (path string, err error) +} + +// Summarizer turns a diffstat into a markdown summary. Implemented in app.go over +// the llm registry (default chat endpoint); the SummaryRunner only sees this +// narrow contract so tests can inject a canned-output fake. +type Summarizer interface { + // Summarize returns (title, bodyMarkdown, modelID, error). + Summarize(ctx context.Context, branch, commitSHA, diff string) (title, bodyMD, model string, err error) +} + +// SummaryRunner is the jobs.Handler that generates a commit_summaries row. It is +// event-driven (enqueued post-commit), never a periodic ticker. +type SummaryRunner struct { + repo Repository + locator WorkspaceLocator + gitr git.Runner + summ Summarizer + log *slog.Logger +} + +// NewSummaryRunner constructs a SummaryRunner. log may be nil. +func NewSummaryRunner(repo Repository, locator WorkspaceLocator, gitr git.Runner, summ Summarizer, log *slog.Logger) *SummaryRunner { + if log == nil { + log = slog.Default() + } + return &SummaryRunner{repo: repo, locator: locator, gitr: gitr, summ: summ, log: log} +} + +var _ jobs.Handler = (*SummaryRunner)(nil) + +// Type returns the job type this handler processes. +func (h *SummaryRunner) Type() jobs.JobType { return JobTypeCommitSummary } + +// Handle generates and upserts a commit summary. Idempotent: a completed summary +// for the same (workspace, commit, kind) is left untouched. +func (h *SummaryRunner) Handle(ctx context.Context, job jobs.Job) error { + var p summaryPayload + if err := json.Unmarshal(job.Payload, &p); err != nil { + return jobs.Permanent(fmt.Errorf("docs.commit_summary: bad payload: %w", err)) + } + + // Skip if a completed summary already exists (idempotent re-enqueue). + if existing, err := h.repo.Get(ctx, p.WorkspaceID, p.CommitSHA, KindCommit); err == nil && existing != nil && existing.Status == StatusCompleted { + return nil + } + + dir := "" + if p.WorktreeID != nil { + path, lerr := h.locator.LocateWorktree(ctx, *p.WorktreeID) + if lerr != nil { + return fmt.Errorf("locate worktree: %w", lerr) + } + dir = path + } else { + path, lerr := h.locator.LocateWorkspace(ctx, p.WorkspaceID) + if lerr != nil { + return fmt.Errorf("locate workspace: %w", lerr) + } + dir = path + } + + diff, err := h.gitr.DiffStat(ctx, dir, p.CommitSHA) + if err != nil { + return fmt.Errorf("git show --stat: %w", err) + } + if len(diff) > maxDiffChars { + diff = diff[:maxDiffChars] + "\n...[truncated]" + } + + title, body, model, serr := h.summ.Summarize(ctx, p.Branch, p.CommitSHA, diff) + if serr != nil { + // Persist a failed row so the failure is visible, then return for retry. + msg := serr.Error() + if _, uerr := h.repo.Upsert(ctx, UpsertInput{ + WorkspaceID: p.WorkspaceID, WorktreeID: p.WorktreeID, Branch: p.Branch, + CommitSHA: p.CommitSHA, Kind: KindCommit, BodyMD: "", + Status: StatusFailed, Error: &msg, + }); uerr != nil { + h.log.Error("docs.summary.upsert_failed_row", "commit", p.CommitSHA, "err", uerr.Error()) + } + return fmt.Errorf("summarize: %w", serr) + } + + var titlePtr, modelPtr *string + if t := strings.TrimSpace(title); t != "" { + titlePtr = &t + } + if m := strings.TrimSpace(model); m != "" { + modelPtr = &m + } + if _, err := h.repo.Upsert(ctx, UpsertInput{ + WorkspaceID: p.WorkspaceID, WorktreeID: p.WorktreeID, Branch: p.Branch, + CommitSHA: p.CommitSHA, Kind: KindCommit, Title: titlePtr, + BodyMD: body, Model: modelPtr, Status: StatusCompleted, + }); err != nil { + return fmt.Errorf("upsert summary: %w", err) + } + h.log.Info("docs.summary.completed", "workspace_id", p.WorkspaceID, "commit", p.CommitSHA) + return nil +} diff --git a/internal/docs/summary_runner_test.go b/internal/docs/summary_runner_test.go new file mode 100644 index 0000000..4e6eb96 --- /dev/null +++ b/internal/docs/summary_runner_test.go @@ -0,0 +1,110 @@ +package docs + +import ( + "context" + "encoding/json" + "errors" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/git" + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +type fakeDocsRepo struct { + summaries map[string]*Summary // keyed by commitSHA|kind + upserts int +} + +func key(commit, kind string) string { return commit + "|" + kind } + +func newFakeDocsRepo() *fakeDocsRepo { + return &fakeDocsRepo{summaries: map[string]*Summary{}} +} + +func (r *fakeDocsRepo) Get(_ context.Context, _ uuid.UUID, commit, kind string) (*Summary, error) { + return r.summaries[key(commit, kind)], nil +} +func (r *fakeDocsRepo) Upsert(_ context.Context, in UpsertInput) (*Summary, error) { + r.upserts++ + s := &Summary{ + ID: uuid.New(), WorkspaceID: in.WorkspaceID, WorktreeID: in.WorktreeID, + Branch: in.Branch, CommitSHA: in.CommitSHA, Kind: in.Kind, Title: in.Title, + BodyMD: in.BodyMD, Model: in.Model, Status: in.Status, Error: in.Error, + } + r.summaries[key(in.CommitSHA, in.Kind)] = s + return s, nil +} + +type fakeLocator struct{} + +func (fakeLocator) LocateWorkspace(context.Context, uuid.UUID) (string, error) { return "/tmp/x", nil } +func (fakeLocator) LocateWorktree(context.Context, uuid.UUID) (string, error) { return "/tmp/x", nil } + +// gitShim embeds git.Runner (nil) and overrides only DiffStat, the single method +// the SummaryRunner invokes. Any other call would panic, which never happens here. +type gitShim struct { + git.Runner + diff string +} + +func (g gitShim) DiffStat(context.Context, string, string) (string, error) { return g.diff, nil } + +type fakeSummarizer struct { + title, body, model string + err error +} + +func (s fakeSummarizer) Summarize(context.Context, string, string, string) (string, string, string, error) { + return s.title, s.body, s.model, s.err +} + +func newRunner(repo Repository, summ Summarizer, diff string) *SummaryRunner { + return NewSummaryRunner(repo, fakeLocator{}, gitShim{diff: diff}, summ, nil) +} + +func TestSummaryRunner_Success(t *testing.T) { + repo := newFakeDocsRepo() + wsID := uuid.New() + r := newRunner(repo, fakeSummarizer{title: "Add feature X", body: "# Add feature X\n\n- did stuff", model: "m1"}, "f.go | 2 +-") + + payload, _ := json.Marshal(summaryPayload{WorkspaceID: wsID, Branch: "main", CommitSHA: "abc"}) + require.NoError(t, r.Handle(context.Background(), jobs.Job{Payload: payload})) + + s := repo.summaries[key("abc", KindCommit)] + require.NotNil(t, s) + require.Equal(t, StatusCompleted, s.Status) + require.Equal(t, "# Add feature X\n\n- did stuff", s.BodyMD) + require.NotNil(t, s.Title) + require.Equal(t, "Add feature X", *s.Title) + require.NotNil(t, s.Model) + require.Equal(t, "m1", *s.Model) +} + +func TestSummaryRunner_IdempotentWhenCompleted(t *testing.T) { + repo := newFakeDocsRepo() + wsID := uuid.New() + repo.summaries[key("abc", KindCommit)] = &Summary{CommitSHA: "abc", Kind: KindCommit, Status: StatusCompleted} + + r := newRunner(repo, fakeSummarizer{title: "x", body: "y", model: "m"}, "stat") + payload, _ := json.Marshal(summaryPayload{WorkspaceID: wsID, Branch: "main", CommitSHA: "abc"}) + require.NoError(t, r.Handle(context.Background(), jobs.Job{Payload: payload})) + require.Equal(t, 0, repo.upserts, "completed summary must not be regenerated") +} + +func TestSummaryRunner_FailurePersistsFailedRow(t *testing.T) { + repo := newFakeDocsRepo() + wsID := uuid.New() + r := newRunner(repo, fakeSummarizer{err: errors.New("llm down")}, "stat") + payload, _ := json.Marshal(summaryPayload{WorkspaceID: wsID, Branch: "main", CommitSHA: "abc"}) + err := r.Handle(context.Background(), jobs.Job{Payload: payload}) + require.Error(t, err) + s := repo.summaries[key("abc", KindCommit)] + require.NotNil(t, s) + require.Equal(t, StatusFailed, s.Status) + require.NotNil(t, s.Error) +} + +var _ Repository = (*fakeDocsRepo)(nil) diff --git a/internal/infra/crypto/keyed.go b/internal/infra/crypto/keyed.go new file mode 100644 index 0000000..19d0a84 --- /dev/null +++ b/internal/infra/crypto/keyed.go @@ -0,0 +1,163 @@ +package crypto + +import ( + "context" + "crypto/aes" + "crypto/cipher" + "crypto/rand" + "errors" + "fmt" + "io" + "sync" +) + +// KeyStore tracks which key versions exist and which one is the current write +// ("active") version. The key material itself is never stored here — only the +// version metadata (see crypto_keys table). It is consulted on Encrypt to pick +// the active version and by the re-encrypt job to discover the target version. +type KeyStore interface { + // ActiveVersion returns the version that new ciphertext must be sealed with. + ActiveVersion(ctx context.Context) (int, error) +} + +// staticKeyStore is a fixed-version store, used when no DB is wired (tests, +// degraded boot). It always reports the same active version. +type staticKeyStore struct{ v int } + +// NewStaticKeyStore returns a KeyStore that always reports version v as active. +func NewStaticKeyStore(v int) KeyStore { return staticKeyStore{v: v} } + +func (s staticKeyStore) ActiveVersion(context.Context) (int, error) { return s.v, nil } + +// KeyedEncryptor seals/opens secrets with a versioned key. The on-disk format +// is unchanged from the legacy Encryptor — nonce(12) || ciphertext || tag — +// because the key version travels in a separate DB column (key_version), not in +// the ciphertext. This keeps every existing blob forward-compatible: a v1 blob +// is still openable as long as version 1 is recorded for its row. +type KeyedEncryptor struct { + provider Provider + store KeyStore + + mu sync.Mutex + gcms map[int]cipher.AEAD // version -> AEAD, lazily built from provider keys +} + +// NewKeyedEncryptor builds a KeyedEncryptor over a key provider and key store. +// store may be nil → a static version-1 store is used (back-compat with the +// single-key world). +func NewKeyedEncryptor(provider Provider, store KeyStore) *KeyedEncryptor { + if store == nil { + store = NewStaticKeyStore(1) + } + return &KeyedEncryptor{provider: provider, store: store, gcms: map[int]cipher.AEAD{}} +} + +// gcmFor returns (and caches) the AEAD for a version, resolving the key bytes +// via the provider on first use. +func (e *KeyedEncryptor) gcmFor(ctx context.Context, version int) (cipher.AEAD, error) { + e.mu.Lock() + defer e.mu.Unlock() + if g, ok := e.gcms[version]; ok { + return g, nil + } + key, err := e.provider.KeyMaterial(ctx, version) + if err != nil { + return nil, fmt.Errorf("resolve key v%d: %w", version, err) + } + if len(key) != 32 { + return nil, fmt.Errorf("key v%d must be 32 bytes (got %d)", version, len(key)) + } + block, err := aes.NewCipher(key) + if err != nil { + return nil, fmt.Errorf("aes new cipher v%d: %w", version, err) + } + g, err := cipher.NewGCM(block) + if err != nil { + return nil, fmt.Errorf("new gcm v%d: %w", version, err) + } + e.gcms[version] = g + return g, nil +} + +// ActiveVersion reports the version new ciphertext is sealed with. +func (e *KeyedEncryptor) ActiveVersion(ctx context.Context) (int, error) { + return e.store.ActiveVersion(ctx) +} + +// EncryptVersion seals plaintext with the active version and returns the +// ciphertext together with the version it was sealed under (to persist into the +// row's key_version column). +func (e *KeyedEncryptor) EncryptVersion(ctx context.Context, plaintext []byte) ([]byte, int, error) { + version, err := e.store.ActiveVersion(ctx) + if err != nil { + return nil, 0, fmt.Errorf("active key version: %w", err) + } + ct, err := e.sealWith(ctx, version, plaintext) + if err != nil { + return nil, 0, err + } + return ct, version, nil +} + +// SealWithVersion encrypts plaintext under an explicit version. The re-encrypt +// job uses this to re-seal an old-version blob onto the new active version. +func (e *KeyedEncryptor) SealWithVersion(ctx context.Context, version int, plaintext []byte) ([]byte, error) { + return e.sealWith(ctx, version, plaintext) +} + +// sealWith encrypts with a specific version (used by EncryptVersion and the +// re-encrypt job which re-seals onto the new active version). +func (e *KeyedEncryptor) sealWith(ctx context.Context, version int, plaintext []byte) ([]byte, error) { + g, err := e.gcmFor(ctx, version) + if err != nil { + return nil, err + } + nonce := make([]byte, g.NonceSize()) + if _, err := io.ReadFull(rand.Reader, nonce); err != nil { + return nil, fmt.Errorf("rand nonce: %w", err) + } + return g.Seal(nonce, nonce, plaintext, nil), nil +} + +// DecryptVersion opens a ciphertext that was sealed under the given version. +func (e *KeyedEncryptor) DecryptVersion(ctx context.Context, ciphertext []byte, version int) ([]byte, error) { + g, err := e.gcmFor(ctx, version) + if err != nil { + return nil, err + } + if len(ciphertext) < g.NonceSize() { + return nil, errors.New("ciphertext too short") + } + nonce, ct := ciphertext[:g.NonceSize()], ciphertext[g.NonceSize():] + pt, err := g.Open(nil, nonce, ct, nil) + if err != nil { + return nil, fmt.Errorf("gcm open v%d: %w", version, err) + } + return pt, nil +} + +// DecryptAny opens a ciphertext without knowing its key version: it tries the +// active version first, then every lower version down to 1, returning the first +// that authenticates. AES-GCM's auth tag guarantees only the correct key opens a +// blob, so trying versions is safe and lets the read path stay version-agnostic +// (callers need not thread key_version through every SELECT). Version count is +// tiny (1-2 in practice), so the loop is cheap. A version whose key material is +// not configured simply fails to build its AEAD and is skipped. +func (e *KeyedEncryptor) DecryptAny(ctx context.Context, ciphertext []byte) ([]byte, error) { + active, err := e.store.ActiveVersion(ctx) + if err != nil || active < 1 { + active = 1 + } + var lastErr error + for v := active; v >= 1; v-- { + pt, derr := e.DecryptVersion(ctx, ciphertext, v) + if derr == nil { + return pt, nil + } + lastErr = derr + } + if lastErr == nil { + lastErr = errors.New("decrypt: no key version available") + } + return nil, lastErr +} diff --git a/internal/infra/crypto/keyed_test.go b/internal/infra/crypto/keyed_test.go new file mode 100644 index 0000000..dbd79d8 --- /dev/null +++ b/internal/infra/crypto/keyed_test.go @@ -0,0 +1,141 @@ +package crypto + +import ( + "context" + "crypto/rand" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +// mutableStore is a KeyStore whose active version can change at runtime, +// modeling a rotation. +type mutableStore struct { + mu sync.Mutex + v int +} + +func (m *mutableStore) ActiveVersion(context.Context) (int, error) { + m.mu.Lock() + defer m.mu.Unlock() + return m.v, nil +} + +func (m *mutableStore) set(v int) { + m.mu.Lock() + defer m.mu.Unlock() + m.v = v +} + +func rawKey(t *testing.T) []byte { + t.Helper() + k := make([]byte, 32) + _, err := rand.Read(k) + require.NoError(t, err) + return k +} + +func TestKeyedEncryptor_RoundTripV1AndV2(t *testing.T) { + ctx := context.Background() + prov := NewEnvProvider(nil) + prov.SetKey(1, rawKey(t)) + prov.SetKey(2, rawKey(t)) + store := &mutableStore{v: 1} + ke := NewKeyedEncryptor(prov, store) + + pt := []byte("super secret value") + + ctV1, v, err := ke.EncryptVersion(ctx, pt) + require.NoError(t, err) + require.Equal(t, 1, v) + got, err := ke.DecryptVersion(ctx, ctV1, 1) + require.NoError(t, err) + require.Equal(t, pt, got) + + store.set(2) + ctV2, v2, err := ke.EncryptVersion(ctx, pt) + require.NoError(t, err) + require.Equal(t, 2, v2) + require.NotEqual(t, ctV1, ctV2) + got2, err := ke.DecryptVersion(ctx, ctV2, 2) + require.NoError(t, err) + require.Equal(t, pt, got2) +} + +func TestKeyedEncryptor_V1BlobDecryptsAfterActiveBumpsToV2(t *testing.T) { + ctx := context.Background() + prov := NewEnvProvider(nil) + prov.SetKey(1, rawKey(t)) + prov.SetKey(2, rawKey(t)) + store := &mutableStore{v: 1} + ke := NewKeyedEncryptor(prov, store) + + pt := []byte("encrypted under v1") + ctV1, v, err := ke.EncryptVersion(ctx, pt) + require.NoError(t, err) + require.Equal(t, 1, v) + + // Rotate the active write version; the OLD blob must still decrypt under v1. + store.set(2) + got, err := ke.DecryptVersion(ctx, ctV1, 1) + require.NoError(t, err) + require.Equal(t, pt, got) +} + +func TestKeyedEncryptor_WrongVersionDecryptFailsCleanly(t *testing.T) { + ctx := context.Background() + prov := NewEnvProvider(nil) + prov.SetKey(1, rawKey(t)) + prov.SetKey(2, rawKey(t)) + ke := NewKeyedEncryptor(prov, NewStaticKeyStore(1)) + + ct, _, err := ke.EncryptVersion(ctx, []byte("x")) + require.NoError(t, err) + // Decrypting a v1 blob as v2 must fail authentication, not panic. + _, err = ke.DecryptVersion(ctx, ct, 2) + require.Error(t, err) +} + +func TestKeyedEncryptor_UnknownVersionErrors(t *testing.T) { + ctx := context.Background() + prov := NewEnvProvider(nil) + prov.SetKey(1, rawKey(t)) + ke := NewKeyedEncryptor(prov, NewStaticKeyStore(1)) + _, err := ke.DecryptVersion(ctx, make([]byte, 16), 9) + require.Error(t, err) +} + +func TestEncryptorShim_BacksOntoKeyedV1(t *testing.T) { + // The legacy Encryptor shim must remain byte-compatible: a blob it produces + // decrypts via the underlying KeyedEncryptor at version 1, and vice versa. + key := rawKey(t) + enc, err := NewEncryptor(key) + require.NoError(t, err) + + pt := []byte("legacy callers see no versions") + ct, err := enc.Encrypt(pt) + require.NoError(t, err) + + got, err := enc.Decrypt(ct) + require.NoError(t, err) + require.Equal(t, pt, got) + + // Same key via KeyedEncryptor v1 opens the shim's ciphertext. + prov := NewEnvProvider(key) + ke := NewKeyedEncryptor(prov, NewStaticKeyStore(1)) + got2, err := ke.DecryptVersion(context.Background(), ct, 1) + require.NoError(t, err) + require.Equal(t, pt, got2) +} + +func TestEnvProvider_ReadsVersionedEnv(t *testing.T) { + t.Setenv("APP_MASTER_KEY_V2", "00112233445566778899aabbccddeeff00112233445566778899aabbccddeeff") + prov := NewEnvProvider(nil) + k, err := prov.KeyMaterial(context.Background(), 2) + require.NoError(t, err) + require.Len(t, k, 32) + + _, err = prov.KeyMaterial(context.Background(), 3) + require.Error(t, err, "unstaged version must error, not silently use a default") +} diff --git a/internal/infra/crypto/keystore_postgres.go b/internal/infra/crypto/keystore_postgres.go new file mode 100644 index 0000000..00e8e07 --- /dev/null +++ b/internal/infra/crypto/keystore_postgres.go @@ -0,0 +1,106 @@ +package crypto + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +// PostgresKeyStore reads/writes the crypto_keys table that records which key +// versions exist and which is currently active (the write key). The key +// material is NOT stored here — only version metadata. +type PostgresKeyStore struct { + pool poolExec +} + +// poolExec is the subset of *pgxpool.Pool used by the store. Declared as an +// interface so tests can inject a fake without a live DB. +type poolExec interface { + QueryRow(ctx context.Context, sql string, args ...any) pgx.Row +} + +// poolFullExec additionally allows mutations (used by RotateActive/RetireVersion). +// Exec returns the CONCRETE pgconn.CommandTag so *pgxpool.Pool satisfies this +// interface — an interface-typed return would not match pgxpool's method +// signature, making the type assertion in RotateActive/RetireVersion silently +// fail and key rotation always error out. +type poolFullExec interface { + poolExec + Exec(ctx context.Context, sql string, args ...any) (pgconn.CommandTag, error) +} + +// NewPostgresKeyStore builds the store over a pgx pool (or any compatible +// querier). It satisfies KeyStore. +func NewPostgresKeyStore(pool poolExec) *PostgresKeyStore { + return &PostgresKeyStore{pool: pool} +} + +// ActiveVersion returns the single crypto_keys row whose status='active'. If +// none is found it falls back to version 1 (single-key bootstrap before the +// seed row exists). +func (s *PostgresKeyStore) ActiveVersion(ctx context.Context) (int, error) { + var v int + err := s.pool.QueryRow(ctx, + `SELECT version FROM crypto_keys WHERE status = 'active' ORDER BY version DESC LIMIT 1`, + ).Scan(&v) + if err != nil { + if err == pgx.ErrNoRows { + return 1, nil + } + return 0, fmt.Errorf("crypto key active version: %w", err) + } + return v, nil +} + +// RotateActive bumps the active write version: it retires the current active +// row and inserts/activates the next version (current+1). The new version's key +// material must already be loadable by the provider (e.g. APP_MASTER_KEY_V +// staged) before this is called. Returns the new active version. +// +// pool must support Exec (a *pgxpool.Pool does). Both rows are written in one +// transaction by the caller's pool if it is transactional; here we issue two +// statements and rely on the unique active invariant being eventually consistent +// within the rotation window (both versions remain loadable, so decrypts never +// break). +func (s *PostgresKeyStore) RotateActive(ctx context.Context, provider string, keyRef string) (int, error) { + fe, ok := s.pool.(poolFullExec) + if !ok { + return 0, fmt.Errorf("crypto key store: pool does not support writes") + } + cur, err := s.ActiveVersion(ctx) + if err != nil { + return 0, err + } + next := cur + 1 + if _, err := fe.Exec(ctx, + `UPDATE crypto_keys SET status = 'retiring' WHERE status = 'active'`); err != nil { + return 0, fmt.Errorf("retire current key: %w", err) + } + if _, err := fe.Exec(ctx, + `INSERT INTO crypto_keys (version, provider, key_ref, status) + VALUES ($1, $2, NULLIF($3, ''), 'active') + ON CONFLICT (version) DO UPDATE SET status = 'active', provider = EXCLUDED.provider, key_ref = EXCLUDED.key_ref`, + next, provider, keyRef); err != nil { + return 0, fmt.Errorf("insert next key: %w", err) + } + return next, nil +} + +// RetireVersion marks a fully-superseded version as retired (no longer needed +// after the re-encrypt job has moved every row off it). The provider may then +// safely drop that key material. +func (s *PostgresKeyStore) RetireVersion(ctx context.Context, version int) error { + fe, ok := s.pool.(poolFullExec) + if !ok { + return fmt.Errorf("crypto key store: pool does not support writes") + } + if _, err := fe.Exec(ctx, + `UPDATE crypto_keys SET status = 'retired' WHERE version = $1`, version); err != nil { + return fmt.Errorf("retire key v%d: %w", version, err) + } + return nil +} + +var _ KeyStore = (*PostgresKeyStore)(nil) diff --git a/internal/infra/crypto/provider.go b/internal/infra/crypto/provider.go new file mode 100644 index 0000000..9d54ec5 --- /dev/null +++ b/internal/infra/crypto/provider.go @@ -0,0 +1,103 @@ +package crypto + +import ( + "context" + "encoding/hex" + "fmt" + "os" + "sync" +) + +// Provider resolves the raw 32-byte AES-256 key material for a given key +// version. The key material itself never lives in the DB — only the version +// number (in crypto_keys + the per-row key_version column) is persisted; the +// provider maps that version back to actual bytes (env var, Vault, KMS, ...). +type Provider interface { + // KeyMaterial returns the 32-byte key for the given version, or an error if + // the version is unknown / unavailable. Implementations may cache. + KeyMaterial(ctx context.Context, version int) ([]byte, error) +} + +// EnvProvider maps key versions to env vars. Version 1 is APP_MASTER_KEY +// (the historical single key); higher versions read APP_MASTER_KEY_V so a +// rotation can stage the next key alongside the current one. The constructor +// also accepts an explicit version-1 key so callers that already decoded +// cfg.MasterKey do not re-read the env. +type EnvProvider struct { + mu sync.RWMutex + keys map[int][]byte +} + +// NewEnvProvider builds an EnvProvider seeded with the version-1 key (typically +// the already-decoded cfg.MasterKey). A nil/empty v1 key falls back to reading +// APP_MASTER_KEY lazily on first KeyMaterial(1). +func NewEnvProvider(v1 []byte) *EnvProvider { + p := &EnvProvider{keys: map[int][]byte{}} + if len(v1) > 0 { + p.keys[1] = append([]byte(nil), v1...) + } + return p +} + +// KeyMaterial returns the cached key for version, loading it from the matching +// env var on first use. Version 1 → APP_MASTER_KEY; version n>1 → +// APP_MASTER_KEY_V. The value must be 64 hex chars (32 bytes). +func (p *EnvProvider) KeyMaterial(_ context.Context, version int) ([]byte, error) { + if version < 1 { + return nil, fmt.Errorf("crypto: invalid key version %d", version) + } + p.mu.RLock() + k, ok := p.keys[version] + p.mu.RUnlock() + if ok { + return k, nil + } + + envName := "APP_MASTER_KEY" + if version > 1 { + envName = fmt.Sprintf("APP_MASTER_KEY_V%d", version) + } + raw := os.Getenv(envName) + if raw == "" { + return nil, fmt.Errorf("crypto: key version %d unavailable (%s unset)", version, envName) + } + key, err := hex.DecodeString(raw) + if err != nil { + return nil, fmt.Errorf("crypto: %s invalid hex: %w", envName, err) + } + if len(key) != 32 { + return nil, fmt.Errorf("crypto: %s must be 32 bytes (got %d)", envName, len(key)) + } + p.mu.Lock() + p.keys[version] = key + p.mu.Unlock() + return key, nil +} + +// SetKey installs key material for a version explicitly (used in tests and when +// a KMS-fetched key is handed to an EnvProvider-style cache). +func (p *EnvProvider) SetKey(version int, key []byte) { + p.mu.Lock() + defer p.mu.Unlock() + p.keys[version] = append([]byte(nil), key...) +} + +// VaultProvider / KMSProvider are deferred: their interface is identical to +// EnvProvider (KeyMaterial(ctx, version) -> []byte), so wiring a real +// HashiCorp Vault transit / cloud KMS client is a drop-in replacement. They are +// declared here so config.Provider can select "vault"/"kms" without a build +// break; until a real client is wired they delegate to a backing Provider. + +// DelegatingProvider wraps another Provider; it is the shape both VaultProvider +// and KMSProvider take until their remote clients are implemented. +type DelegatingProvider struct{ inner Provider } + +// NewDelegatingProvider returns a Provider that forwards to inner. +func NewDelegatingProvider(inner Provider) *DelegatingProvider { + return &DelegatingProvider{inner: inner} +} + +// KeyMaterial forwards to the wrapped provider. +func (d *DelegatingProvider) KeyMaterial(ctx context.Context, version int) ([]byte, error) { + return d.inner.KeyMaterial(ctx, version) +} diff --git a/internal/infra/crypto/secret.go b/internal/infra/crypto/secret.go index add7e2b..b337250 100644 --- a/internal/infra/crypto/secret.go +++ b/internal/infra/crypto/secret.go @@ -3,61 +3,70 @@ // // The master key is supplied by the caller (see config.MasterKey) and never // stored alongside the ciphertext. +// +// As of the secret-hardening epic, the underlying engine is KeyedEncryptor: +// every blob is sealed under a key *version* whose number is persisted in a +// per-row key_version column (the key material stays in env/KMS). The legacy +// Encryptor type below is a thin version-1 shim so the ~30 existing call sites +// — which neither know nor care about versions — compile and behave unchanged. package crypto import ( - "crypto/aes" - "crypto/cipher" - "crypto/rand" - "errors" + "context" "fmt" - "io" ) -// Encryptor 用 AES-256-GCM 加解密 secret。 -// 密文格式:nonce(12) || ciphertext || tag。 +// Encryptor 用 AES-256-GCM 加解密 secret(version-1 shim over KeyedEncryptor)。 +// 密文格式:nonce(12) || ciphertext || tag(与历史一致,未变)。 type Encryptor struct { - gcm cipher.AEAD + keyed *KeyedEncryptor + version int } // NewEncryptor constructs an Encryptor from a 32-byte (AES-256) key. // Any other key length yields an error rather than silently downgrading. +// It builds a KeyedEncryptor backed by an EnvProvider seeded with this key at +// version 1 and a static version-1 store, preserving the previous semantics. func NewEncryptor(key []byte) (*Encryptor, error) { if len(key) != 32 { return nil, fmt.Errorf("key must be 32 bytes (got %d)", len(key)) } - block, err := aes.NewCipher(key) - if err != nil { - return nil, fmt.Errorf("aes new cipher: %w", err) + keyed := NewKeyedEncryptor(NewEnvProvider(key), NewStaticKeyStore(1)) + // Eagerly validate the key by building the v1 AEAD once. + if _, err := keyed.gcmFor(context.Background(), 1); err != nil { + return nil, err } - gcm, err := cipher.NewGCM(block) - if err != nil { - return nil, fmt.Errorf("new gcm: %w", err) - } - return &Encryptor{gcm: gcm}, nil + return &Encryptor{keyed: keyed, version: 1}, nil } -// Encrypt seals plaintext with a fresh random nonce and returns -// nonce || ciphertext || tag. +// NewEncryptorFromKeyed wraps an existing KeyedEncryptor as a fixed-version +// Encryptor shim. version is the version this shim seals/opens with (the call +// sites that use the shim only ever round-trip a single logical version because +// the DB column travels separately). app.go uses this so workspace/chat/acp/run +// keep the *crypto.Encryptor argument while sharing one KeyedEncryptor. +func NewEncryptorFromKeyed(keyed *KeyedEncryptor, version int) *Encryptor { + if version < 1 { + version = 1 + } + return &Encryptor{keyed: keyed, version: version} +} + +// Keyed exposes the underlying KeyedEncryptor so version-aware callers (the +// re-encrypt job, rotate-key endpoint) can use EncryptVersion/DecryptVersion. +func (e *Encryptor) Keyed() *KeyedEncryptor { return e.keyed } + +// Encrypt seals plaintext under the shim's version and returns +// nonce || ciphertext || tag (version travels via the DB key_version column). func (e *Encryptor) Encrypt(plaintext []byte) ([]byte, error) { - nonce := make([]byte, e.gcm.NonceSize()) - if _, err := io.ReadFull(rand.Reader, nonce); err != nil { - return nil, fmt.Errorf("rand nonce: %w", err) - } - // Seal 把 nonce 作为 dst 前缀写入 - return e.gcm.Seal(nonce, nonce, plaintext, nil), nil + return e.keyed.sealWith(context.Background(), e.version, plaintext) } -// Decrypt verifies and opens a ciphertext produced by Encrypt. -// It returns an error if the input is too short or authentication fails. +// Decrypt verifies and opens a ciphertext produced by Encrypt. It is +// version-agnostic: it tries the active key version then falls back through +// older versions (GCM auth guarantees correctness), so blobs re-sealed onto a +// newer version after a key rotation still open without the call site needing to +// know the row's key_version. Returns an error if input is too short or no key +// authenticates. func (e *Encryptor) Decrypt(ciphertext []byte) ([]byte, error) { - if len(ciphertext) < e.gcm.NonceSize() { - return nil, errors.New("ciphertext too short") - } - nonce, ct := ciphertext[:e.gcm.NonceSize()], ciphertext[e.gcm.NonceSize():] - pt, err := e.gcm.Open(nil, nonce, ct, nil) - if err != nil { - return nil, fmt.Errorf("gcm open: %w", err) - } - return pt, nil + return e.keyed.DecryptAny(context.Background(), ciphertext) } diff --git a/internal/infra/errs/errs.go b/internal/infra/errs/errs.go index f37b2f1..54768e2 100644 --- a/internal/infra/errs/errs.go +++ b/internal/infra/errs/errs.go @@ -18,20 +18,22 @@ type Code string // Canonical error codes. Keep this list in sync with HTTPStatus and any // downstream consumers (e.g., HTTP transport, OpenAPI schemas). const ( - CodeInvalidInput Code = "invalid_input" - CodeUnauthorized Code = "unauthorized" - CodeForbidden Code = "forbidden" - CodeNotFound Code = "not_found" - CodeConflict Code = "conflict" - CodeRateLimited Code = "rate_limited" - CodeUpstream Code = "upstream_error" - CodeUnavailable Code = "unavailable" - CodeInternal Code = "internal" + CodeInvalidInput Code = "invalid_input" + CodeUnauthorized Code = "unauthorized" + CodeForbidden Code = "forbidden" + CodeNotFound Code = "not_found" + CodeConflict Code = "conflict" + CodeFailedPrecondition Code = "failed_precondition" + CodeRateLimited Code = "rate_limited" + CodeUpstream Code = "upstream_error" + CodeUnavailable Code = "unavailable" + CodeInternal Code = "internal" // Project 模块专用细粒度 code(HTTP 仍 409,但前端可据此区分提示)。 CodeProjectSlugTaken Code = "project_slug_taken" CodeProjectArchived Code = "project_archived" CodeRequirementClosed Code = "requirement_closed" + CodePhaseGateFailed Code = "phase_gate_failed" // Workspace 模块细粒度 code CodeWorkspaceSlugTaken Code = "workspace_slug_taken" @@ -108,6 +110,10 @@ const ( CodeRunProfileDisabled Code = "run.profile_disabled" CodeRunSpawnFailed Code = "run.spawn_failed" CodeRunCommandInvalid Code = "run.command_invalid" + // 一次性 exec(run_command / run_tests)专用错误。 + CodeRunExecTimeout Code = "run.exec_timeout" + CodeRunExecCommandNotAllowed Code = "run.exec_command_not_allowed" + CodeRunExecFailed Code = "run.exec_failed" ) // AppError is the application's structured error type. It carries a stable @@ -179,10 +185,12 @@ func HTTPStatus(code Code) int { case CodeNotFound: return http.StatusNotFound case CodeConflict, - CodeProjectSlugTaken, CodeProjectArchived, CodeRequirementClosed, + CodeProjectSlugTaken, CodeProjectArchived, CodeRequirementClosed, CodePhaseGateFailed, CodeWorkspaceSlugTaken, CodeWorkspaceSyncInProgress, CodeWorktreeBranchConflict, CodeWorktreeAlreadyActive: return http.StatusConflict + case CodeFailedPrecondition: + return http.StatusPreconditionFailed case CodeRateLimited: return http.StatusTooManyRequests case CodeUpstream: @@ -257,6 +265,12 @@ func HTTPStatus(code Code) int { return http.StatusBadRequest case CodeRunSpawnFailed: return http.StatusBadGateway + case CodeRunExecTimeout: + return http.StatusGatewayTimeout + case CodeRunExecCommandNotAllowed: + return http.StatusForbidden + case CodeRunExecFailed: + return http.StatusInternalServerError default: return http.StatusInternalServerError } diff --git a/internal/infra/errs/errs_test.go b/internal/infra/errs/errs_test.go index 22bd98d..f600621 100644 --- a/internal/infra/errs/errs_test.go +++ b/internal/infra/errs/errs_test.go @@ -37,6 +37,7 @@ func TestHTTPStatus(t *testing.T) { CodeProjectSlugTaken: http.StatusConflict, CodeProjectArchived: http.StatusConflict, CodeRequirementClosed: http.StatusConflict, + CodePhaseGateFailed: http.StatusConflict, CodeRateLimited: http.StatusTooManyRequests, CodeUpstream: http.StatusBadGateway, CodeUnavailable: http.StatusServiceUnavailable, diff --git a/internal/infra/git/diff.go b/internal/infra/git/diff.go new file mode 100644 index 0000000..5096cae --- /dev/null +++ b/internal/infra/git/diff.go @@ -0,0 +1,106 @@ +package git + +import ( + "context" + "fmt" + "strings" +) + +// diffMaxBytes caps the raw unified-diff stdout to keep MCP payloads / memory +// bounded. Larger diffs are truncated and flagged via DiffResult.Truncated. +const diffMaxBytes = 1 << 20 // 1 MiB + +// DiffOptions controls `git diff` argument building. +// +// - Ref/Against both empty => working-tree vs HEAD (the default). +// - Ref set, Against empty => `git diff ` (working-tree vs Ref). +// - Ref and Against both set => `git diff ..` (two refs). +// - Staged => add `--cached` (index vs HEAD); mutually composes with refs. +// - Paths => restrict to the given pathspecs (passed after `--`). +// - ContextLines => `-U` when > 0 (git default 3 otherwise). +type DiffOptions struct { + Ref string + Against string + Staged bool + Paths []string + ContextLines int +} + +// DiffResult carries the raw unified-diff text. The viewer parses it; the git +// layer never parses the hunks. Truncated reports whether stdout exceeded the +// 1 MiB cap and was cut. +type DiffResult struct { + Diff string + Truncated bool +} + +// Diff returns the unified diff of dir per opts. The output is the raw `git +// diff --no-color` text (capped at 1 MiB). On a clean tree the diff is empty. +func (r *DefaultRunner) Diff(ctx context.Context, dir string, opts DiffOptions) (DiffResult, error) { + // 安全护栏:ref/against 来自 MCP/HTTP 入参,可能未经分支校验。以 '-' 开头的值会被 + // git 当作选项解析(如 --output=、-O),故一律拒绝,杜绝参数注入。 + if strings.HasPrefix(opts.Ref, "-") || strings.HasPrefix(opts.Against, "-") { + return DiffResult{}, fmt.Errorf("git diff: ref/against must not begin with '-': %q %q", opts.Ref, opts.Against) + } + ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout) + defer cancel() + args := buildDiffArgs(opts) + out, _, err := r.exec(ctx, dir, nil, args...) + if err != nil { + return DiffResult{}, err + } + res := DiffResult{} + if len(out) > diffMaxBytes { + out = out[:diffMaxBytes] + res.Truncated = true + } + res.Diff = string(out) + return res, nil +} + +// buildDiffArgs assembles the `git diff` argument slice from opts. Refs are +// never user-supplied paths (workspace package validates branches), so they are +// passed positionally; pathspecs go after the `--` separator to disambiguate. +func buildDiffArgs(opts DiffOptions) []string { + args := []string{"diff", "--no-color"} + if opts.Staged { + args = append(args, "--cached") + } + if opts.ContextLines > 0 { + args = append(args, "-U"+itoa(opts.ContextLines)) + } + switch { + case opts.Ref != "" && opts.Against != "": + args = append(args, opts.Ref+".."+opts.Against) + case opts.Ref != "": + args = append(args, opts.Ref) + } + if len(opts.Paths) > 0 { + args = append(args, "--") + args = append(args, opts.Paths...) + } + return args +} + +// itoa is a tiny strconv.Itoa avoiding an import just for one call site. +func itoa(n int) string { + if n == 0 { + return "0" + } + neg := n < 0 + if neg { + n = -n + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + if neg { + i-- + buf[i] = '-' + } + return string(buf[i:]) +} diff --git a/internal/infra/git/diff_test.go b/internal/infra/git/diff_test.go new file mode 100644 index 0000000..fa71c02 --- /dev/null +++ b/internal/infra/git/diff_test.go @@ -0,0 +1,125 @@ +package git + +import ( + "context" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestBuildDiffArgs(t *testing.T) { + t.Parallel() + cases := []struct { + name string + opts DiffOptions + want []string + }{ + { + name: "working tree vs HEAD", + opts: DiffOptions{}, + want: []string{"diff", "--no-color"}, + }, + { + name: "staged", + opts: DiffOptions{Staged: true}, + want: []string{"diff", "--no-color", "--cached"}, + }, + { + name: "single ref", + opts: DiffOptions{Ref: "HEAD~1"}, + want: []string{"diff", "--no-color", "HEAD~1"}, + }, + { + name: "ref range", + opts: DiffOptions{Ref: "main", Against: "feature"}, + want: []string{"diff", "--no-color", "main..feature"}, + }, + { + name: "context lines + paths", + opts: DiffOptions{ContextLines: 5, Paths: []string{"a.txt", "dir/b.go"}}, + want: []string{"diff", "--no-color", "-U5", "--", "a.txt", "dir/b.go"}, + }, + { + name: "staged with ref range and paths", + opts: DiffOptions{Staged: true, Ref: "main", Against: "dev", Paths: []string{"x"}}, + want: []string{"diff", "--no-color", "--cached", "main..dev", "--", "x"}, + }, + } + for _, tc := range cases { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + require.Equal(t, tc.want, buildDiffArgs(tc.opts)) + }) + } +} + +func TestDiff_WorkingTreeAndStaged(t *testing.T) { + t.Parallel() + gitAvailable(t) + bare := makeBareRepo(t) + r := newTestRunner(t) + dst := filepath.Join(t.TempDir(), "wc") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + require.NoError(t, r.Clone(ctx, dst, bare, "main", nil)) + + // clean tree => empty diff + res, err := r.Diff(ctx, dst, DiffOptions{}) + require.NoError(t, err) + require.Empty(t, res.Diff) + require.False(t, res.Truncated) + + // modify a tracked file => unified diff against working tree + require.NoError(t, os.WriteFile(filepath.Join(dst, "README.md"), []byte("hello\nworld\n"), 0o644)) + res, err = r.Diff(ctx, dst, DiffOptions{}) + require.NoError(t, err) + require.Contains(t, res.Diff, "diff --git") + require.Contains(t, res.Diff, "+world") + + // stage it; working-tree diff now empty, --cached shows the change + mustGit(t, dst, "add", "README.md") + res, err = r.Diff(ctx, dst, DiffOptions{}) + require.NoError(t, err) + require.Empty(t, res.Diff) + + res, err = r.Diff(ctx, dst, DiffOptions{Staged: true}) + require.NoError(t, err) + require.Contains(t, res.Diff, "+world") +} + +func TestDiff_PathsFilter(t *testing.T) { + t.Parallel() + gitAvailable(t) + bare := makeBareRepo(t) + r := newTestRunner(t) + dst := filepath.Join(t.TempDir(), "wc") + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + require.NoError(t, r.Clone(ctx, dst, bare, "main", nil)) + + require.NoError(t, os.WriteFile(filepath.Join(dst, "README.md"), []byte("changed\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dst, "other.txt"), []byte("new\n"), 0o644)) + mustGit(t, dst, "add", "other.txt") + + // restrict to README only => other.txt not in output + res, err := r.Diff(ctx, dst, DiffOptions{Paths: []string{"README.md"}}) + require.NoError(t, err) + require.Contains(t, res.Diff, "README.md") + require.False(t, strings.Contains(res.Diff, "other.txt")) +} + +func TestDiff_ErrorPropagation(t *testing.T) { + t.Parallel() + gitAvailable(t) + r := newTestRunner(t) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + // not a git repo => git diff fails, error propagates + _, err := r.Diff(ctx, t.TempDir(), DiffOptions{}) + require.Error(t, err) +} diff --git a/internal/infra/git/diffstat.go b/internal/infra/git/diffstat.go new file mode 100644 index 0000000..68e92ae --- /dev/null +++ b/internal/infra/git/diffstat.go @@ -0,0 +1,17 @@ +package git + +import "context" + +// DiffStat 返回单个 commit 的变更摘要(git show --stat),供自动文档生成喂给 +// LLM 一个有界的改动概览。commitSHA 由上层从可信来源(git log / commit 返回值) +// 取得,不接受用户任意输入。--no-color 保证输出稳定,-M 检测重命名。 +func (r *DefaultRunner) DiffStat(ctx context.Context, dir, commitSHA string) (string, error) { + ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout) + defer cancel() + out, _, err := r.exec(ctx, dir, nil, + "show", "--no-color", "--stat", "--format=%H%n%an%n%s%n%n%b", "-M", commitSHA) + if err != nil { + return "", err + } + return string(out), nil +} diff --git a/internal/infra/git/lsfiles.go b/internal/infra/git/lsfiles.go new file mode 100644 index 0000000..17e2f9d --- /dev/null +++ b/internal/infra/git/lsfiles.go @@ -0,0 +1,34 @@ +package git + +import ( + "bytes" + "context" +) + +// ListTrackedFiles 返回 dir 仓库中被 git 跟踪的文件(git ls-files -z), +// 路径相对 worktree 根,NUL 分隔以正确处理含空格/特殊字符的文件名。 +// 仅跟踪文件 —— 忽略 .gitignore / 未跟踪 / vendored,避免索引器遍历海量无关文件。 +func (r *DefaultRunner) ListTrackedFiles(ctx context.Context, dir string) ([]string, error) { + ctx, cancel := withCtx(ctx, r.cfg.OpsTimeout) + defer cancel() + out, _, err := r.exec(ctx, dir, nil, "ls-files", "-z") + if err != nil { + return nil, err + } + return parseNULList(out), nil +} + +// parseNULList 解析 NUL 分隔的文件列表(git ls-files -z)。空输入返回 nil。 +func parseNULList(buf []byte) []string { + if len(buf) == 0 { + return nil + } + var out []string + for _, rec := range bytes.Split(buf, []byte{0}) { + if len(rec) == 0 { + continue + } + out = append(out, string(rec)) + } + return out +} diff --git a/internal/infra/git/lsfiles_test.go b/internal/infra/git/lsfiles_test.go new file mode 100644 index 0000000..f82a3b4 --- /dev/null +++ b/internal/infra/git/lsfiles_test.go @@ -0,0 +1,56 @@ +package git + +import ( + "context" + "os" + "path/filepath" + "sort" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestListTrackedFiles(t *testing.T) { + gitAvailable(t) + dir := t.TempDir() + mustGit(t, dir, "init", "-b", "main") + mustGit(t, dir, "config", "user.email", "test@example.com") + mustGit(t, dir, "config", "user.name", "Test") + require.NoError(t, os.WriteFile(filepath.Join(dir, "a.go"), []byte("package a\n"), 0o644)) + require.NoError(t, os.MkdirAll(filepath.Join(dir, "sub"), 0o755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "sub", "b.txt"), []byte("b\n"), 0o644)) + // Untracked + ignored files must NOT appear. + require.NoError(t, os.WriteFile(filepath.Join(dir, ".gitignore"), []byte("ignored.txt\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "ignored.txt"), []byte("x\n"), 0o644)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "untracked.txt"), []byte("y\n"), 0o644)) + mustGit(t, dir, "add", "a.go", "sub/b.txt", ".gitignore") + mustGit(t, dir, "commit", "-m", "init") + + r := newTestRunner(t) + files, err := r.ListTrackedFiles(context.Background(), dir) + require.NoError(t, err) + sort.Strings(files) + require.Equal(t, []string{".gitignore", "a.go", "sub/b.txt"}, files) +} + +func TestDiffStat(t *testing.T) { + gitAvailable(t) + dir := t.TempDir() + mustGit(t, dir, "init", "-b", "main") + mustGit(t, dir, "config", "user.email", "test@example.com") + mustGit(t, dir, "config", "user.name", "Test") + require.NoError(t, os.WriteFile(filepath.Join(dir, "f.go"), []byte("package f\n\nfunc F() {}\n"), 0o644)) + mustGit(t, dir, "add", "f.go") + mustGit(t, dir, "commit", "-m", "add F") + + r := newTestRunner(t) + // HEAD resolves the just-made commit. + commits, err := r.Log(context.Background(), dir, 1) + require.NoError(t, err) + require.Len(t, commits, 1) + + stat, err := r.DiffStat(context.Background(), dir, commits[0].Hash) + require.NoError(t, err) + require.Contains(t, stat, "f.go") + require.Contains(t, stat, "add F") // subject from --format +} diff --git a/internal/infra/git/runner.go b/internal/infra/git/runner.go index f351789..5841e5b 100644 --- a/internal/infra/git/runner.go +++ b/internal/infra/git/runner.go @@ -34,6 +34,13 @@ type Runner interface { ListRemoteBranches(ctx context.Context, dir string, cred *Credential) ([]string, error) Checkout(ctx context.Context, dir, branch string) error Log(ctx context.Context, dir string, n int) ([]Commit, error) + Diff(ctx context.Context, dir string, opts DiffOptions) (DiffResult, error) + // ListTrackedFiles returns the git-tracked files at HEAD (git ls-files), + // worktree-relative, so the indexer never walks ignored/vendored/untracked paths. + ListTrackedFiles(ctx context.Context, dir string) ([]string, error) + // DiffStat returns the `--stat` summary for a single commit (git show --stat), + // used by auto-doc generation to feed the LLM a bounded change overview. + DiffStat(ctx context.Context, dir, commitSHA string) (string, error) } // FileStatus 表示 `git status --porcelain=v1` 的一行。 diff --git a/internal/infra/llm/anthropic.go b/internal/infra/llm/anthropic.go index 2cf4e71..976cd4d 100644 --- a/internal/infra/llm/anthropic.go +++ b/internal/infra/llm/anthropic.go @@ -9,8 +9,12 @@ import ( "github.com/anthropics/anthropic-sdk-go/option" ) -// Compile-time assertion: AnthropicClient must satisfy LLMClient. -var _ LLMClient = (*AnthropicClient)(nil) +// Compile-time assertion: AnthropicClient must satisfy LLMClient + Embedder +// (Embed returns ErrEmbeddingsUnsupported). +var ( + _ LLMClient = (*AnthropicClient)(nil) + _ Embedder = (*AnthropicClient)(nil) +) // AnthropicClient 通过官方 SDK 调用 Anthropic Messages API。 // baseURL 可改写以走 LiteLLM/OpenRouter 等代理。 @@ -152,3 +156,12 @@ func (c *AnthropicClient) CountTokens(ctx context.Context, modelID string, req R } return int(res.InputTokens), nil } + +// Embed is unsupported: Anthropic has no embeddings API. Embeddings must target +// an OpenAI-compatible or Gemini endpoint; callers degrade to keyword fallback. +func (c *AnthropicClient) Embed(context.Context, string, []string) ([][]float32, error) { + return nil, ErrEmbeddingsUnsupported +} + +// EmbedDims is 0 since Anthropic produces no embeddings. +func (c *AnthropicClient) EmbedDims(string) int { return 0 } diff --git a/internal/infra/llm/embedder.go b/internal/infra/llm/embedder.go new file mode 100644 index 0000000..94f0132 --- /dev/null +++ b/internal/infra/llm/embedder.go @@ -0,0 +1,25 @@ +package llm + +import ( + "context" + "errors" +) + +// ErrEmbeddingsUnsupported is returned by providers that have no embeddings API +// (notably Anthropic). Callers degrade to keyword fallback when they see it. +var ErrEmbeddingsUnsupported = errors.New("llm: embeddings not supported by provider") + +// PlatformEmbeddingDims is the single vector dimension baked into the pgvector +// columns at migration time (vector(1536)). Embedding endpoints MUST be +// configured with a model that produces this dimension; the build runner rejects +// mismatched dims rather than corrupting the index. +const PlatformEmbeddingDims = 1536 + +// Embedder is implemented by providers that expose a text-embeddings endpoint. +// Embed returns one vector per input, in input order. EmbedDims returns the +// vector length the given model produces (used to validate against the fixed +// pgvector column dimension before inserting). +type Embedder interface { + Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error) + EmbedDims(modelID string) int +} diff --git a/internal/infra/llm/embedder_test.go b/internal/infra/llm/embedder_test.go new file mode 100644 index 0000000..b57a546 --- /dev/null +++ b/internal/infra/llm/embedder_test.go @@ -0,0 +1,129 @@ +package llm + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestOpenAI_Embed_ReturnsVectors(t *testing.T) { + var gotInputs []string + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var body struct { + Input []string `json:"input"` + Model string `json:"model"` + Dimensions int `json:"dimensions"` + } + _ = json.NewDecoder(r.Body).Decode(&body) + gotInputs = body.Input + require.Equal(t, PlatformEmbeddingDims, body.Dimensions) + // Echo two 3-dim vectors (dimension count here is irrelevant to the test). + resp := map[string]any{ + "object": "list", + "data": []map[string]any{ + {"object": "embedding", "index": 0, "embedding": []float64{0.1, 0.2, 0.3}}, + {"object": "embedding", "index": 1, "embedding": []float64{0.4, 0.5, 0.6}}, + }, + "model": body.Model, + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + c, _ := NewOpenAI("k", srv.URL) + vecs, err := c.Embed(context.Background(), "text-embedding-3-small", []string{"alpha", "beta"}) + require.NoError(t, err) + require.Len(t, vecs, 2) + require.Equal(t, []float32{0.1, 0.2, 0.3}, vecs[0]) + require.Equal(t, []float32{0.4, 0.5, 0.6}, vecs[1]) + require.Equal(t, []string{"alpha", "beta"}, gotInputs) + require.Equal(t, PlatformEmbeddingDims, c.EmbedDims("text-embedding-3-small")) +} + +func TestOpenAI_Embed_RespectsResponseIndex(t *testing.T) { + // Server returns vectors out of order; Embed must place them by Index. + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + resp := map[string]any{ + "object": "list", + "data": []map[string]any{ + {"object": "embedding", "index": 1, "embedding": []float64{9, 9}}, + {"object": "embedding", "index": 0, "embedding": []float64{1, 1}}, + }, + "model": "m", + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(resp) + })) + defer srv.Close() + + c, _ := NewOpenAI("k", srv.URL) + vecs, err := c.Embed(context.Background(), "m", []string{"a", "b"}) + require.NoError(t, err) + require.Equal(t, []float32{1, 1}, vecs[0]) + require.Equal(t, []float32{9, 9}, vecs[1]) +} + +func TestOpenAI_Embed_EmptyInputs(t *testing.T) { + c, _ := NewOpenAI("k", "") + vecs, err := c.Embed(context.Background(), "m", nil) + require.NoError(t, err) + require.Nil(t, vecs) +} + +func TestAnthropic_Embed_Unsupported(t *testing.T) { + c, _ := NewAnthropic("k", "") + _, err := c.Embed(context.Background(), "m", []string{"x"}) + require.ErrorIs(t, err, ErrEmbeddingsUnsupported) + require.Equal(t, 0, c.EmbedDims("m")) +} + +func TestFakeEmbedder_Deterministic(t *testing.T) { + e := NewFakeEmbedder() + v1, err := e.Embed(context.Background(), "m", []string{"hello"}) + require.NoError(t, err) + v2, err := e.Embed(context.Background(), "m", []string{"hello"}) + require.NoError(t, err) + require.Equal(t, v1[0], v2[0], "same input must yield identical vector") + require.Len(t, v1[0], PlatformEmbeddingDims) + + other, err := e.Embed(context.Background(), "m", []string{"world"}) + require.NoError(t, err) + require.NotEqual(t, v1[0], other[0], "different inputs must differ") +} + +func TestFakeEmbedder_Error(t *testing.T) { + e := &FakeEmbedder{Dims: 4, Err: errors.New("boom")} + _, err := e.Embed(context.Background(), "m", []string{"x"}) + require.Error(t, err) +} + +func TestRegistry_GetEmbedder_TypeAsserts(t *testing.T) { + // OpenAI client implements Embedder. + id := uuid.New() + reg := NewRegistry(&fakeLoader{endpoints: map[uuid.UUID]fakeEndpoint{ + id: {ID: id, Provider: "openai", BaseURL: "http://localhost", APIKey: "k"}, + }}) + emb, err := reg.GetEmbedder(context.Background(), id) + require.NoError(t, err) + require.NotNil(t, emb) + require.Equal(t, PlatformEmbeddingDims, emb.EmbedDims("m")) +} + +func TestRegistry_GetEmbedder_AnthropicSupportedButUnsupportedEmbed(t *testing.T) { + // Anthropic client implements Embedder (Embed returns ErrEmbeddingsUnsupported). + id := uuid.New() + reg := NewRegistry(&fakeLoader{endpoints: map[uuid.UUID]fakeEndpoint{ + id: {ID: id, Provider: "anthropic", BaseURL: "http://localhost", APIKey: "k"}, + }}) + emb, err := reg.GetEmbedder(context.Background(), id) + require.NoError(t, err) + _, eerr := emb.Embed(context.Background(), "m", []string{"x"}) + require.ErrorIs(t, eerr, ErrEmbeddingsUnsupported) +} diff --git a/internal/infra/llm/fake.go b/internal/infra/llm/fake.go index 697f685..e7fd538 100644 --- a/internal/infra/llm/fake.go +++ b/internal/infra/llm/fake.go @@ -64,3 +64,62 @@ func (f *FakeClient) Stream(ctx context.Context, modelID string, req Request) (< func (f *FakeClient) CountTokens(ctx context.Context, modelID string, req Request) (int, error) { return f.TokenCount, nil } + +// FakeEmbedder is a deterministic Embedder for tests. Each input maps to a +// fixed-length vector derived from its bytes, so identical text always yields the +// same vector (lets tests assert idempotency / ranking without a real endpoint). +type FakeEmbedder struct { + Dims int // output dimension; defaults to PlatformEmbeddingDims when 0 + Err error // if non-nil, Embed returns it + mu sync.Mutex + Calls int + Inputs []string // accumulated inputs across calls (for assertions) +} + +// NewFakeEmbedder constructs a FakeEmbedder with the platform dimension. +func NewFakeEmbedder() *FakeEmbedder { return &FakeEmbedder{Dims: PlatformEmbeddingDims} } + +var _ Embedder = (*FakeEmbedder)(nil) + +func (e *FakeEmbedder) dims() int { + if e.Dims > 0 { + return e.Dims + } + return PlatformEmbeddingDims +} + +// EmbedDims returns the configured dimension. +func (e *FakeEmbedder) EmbedDims(string) int { return e.dims() } + +// Embed returns one deterministic vector per input. The vector is a normalized +// projection of a small hash of the input across the dimension, so distinct +// inputs are distinguishable and identical inputs are identical. +func (e *FakeEmbedder) Embed(_ context.Context, _ string, inputs []string) ([][]float32, error) { + e.mu.Lock() + e.Calls++ + e.Inputs = append(e.Inputs, inputs...) + err := e.Err + e.mu.Unlock() + if err != nil { + return nil, err + } + d := e.dims() + out := make([][]float32, len(inputs)) + for i, s := range inputs { + v := make([]float32, d) + // Seed from a stable rolling hash of the bytes. + var h uint32 = 2166136261 + for _, b := range []byte(s) { + h ^= uint32(b) + h *= 16777619 + } + for j := 0; j < d; j++ { + h ^= uint32(j) * 2654435761 + h *= 16777619 + // Map to [-1,1). + v[j] = float32(int32(h)) / float32(1<<31) + } + out[i] = v + } + return out, nil +} diff --git a/internal/infra/llm/gemini.go b/internal/infra/llm/gemini.go index 1c963f0..13ec020 100644 --- a/internal/infra/llm/gemini.go +++ b/internal/infra/llm/gemini.go @@ -13,8 +13,11 @@ type GeminiClient struct { sdk *genai.Client } -// Compile-time assertion: GeminiClient must satisfy LLMClient. -var _ LLMClient = (*GeminiClient)(nil) +// Compile-time assertion: GeminiClient must satisfy LLMClient + Embedder. +var ( + _ LLMClient = (*GeminiClient)(nil) + _ Embedder = (*GeminiClient)(nil) +) // NewGemini 构造 GeminiClient。apiKey 不可空;baseURL 可空(用 SDK 默认端点)。 func NewGemini(apiKey, baseURL string) (*GeminiClient, error) { @@ -142,3 +145,37 @@ func (c *GeminiClient) CountTokens(ctx context.Context, modelID string, req Requ } return int(res.TotalTokens), nil } + +// EmbedDims returns the platform-standard embedding dimension. gemini-embedding +// honors OutputDimensionality so Embed pins the output to PlatformEmbeddingDims. +func (c *GeminiClient) EmbedDims(string) int { return PlatformEmbeddingDims } + +// Embed calls EmbedContent with all inputs batched as separate Contents, pinning +// OutputDimensionality to the fixed pgvector column width. Returns one vector per +// input in order. +func (c *GeminiClient) Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error) { + if len(inputs) == 0 { + return nil, nil + } + contents := make([]*genai.Content, len(inputs)) + for i, in := range inputs { + contents[i] = &genai.Content{Role: "user", Parts: []*genai.Part{genai.NewPartFromText(in)}} + } + dims := int32(PlatformEmbeddingDims) + resp, err := c.sdk.Models.EmbedContent(ctx, modelID, contents, &genai.EmbedContentConfig{ + OutputDimensionality: &dims, + }) + if err != nil { + return nil, fmt.Errorf("gemini embeddings: %w", err) + } + if len(resp.Embeddings) != len(inputs) { + return nil, fmt.Errorf("gemini embeddings: got %d vectors for %d inputs", len(resp.Embeddings), len(inputs)) + } + out := make([][]float32, len(resp.Embeddings)) + for i, e := range resp.Embeddings { + v := make([]float32, len(e.Values)) + copy(v, e.Values) + out[i] = v + } + return out, nil +} diff --git a/internal/infra/llm/openai.go b/internal/infra/llm/openai.go index 820c6e9..609e3ba 100644 --- a/internal/infra/llm/openai.go +++ b/internal/infra/llm/openai.go @@ -12,8 +12,11 @@ import ( tiktoken "github.com/pkoukk/tiktoken-go" ) -// Compile-time assertion: OpenAIClient must satisfy LLMClient. -var _ LLMClient = (*OpenAIClient)(nil) +// Compile-time assertion: OpenAIClient must satisfy LLMClient + Embedder. +var ( + _ LLMClient = (*OpenAIClient)(nil) + _ Embedder = (*OpenAIClient)(nil) +) // OpenAIClient 通过官方 SDK 调用 Chat Completions API。 // baseURL 改写后兼容 DeepSeek / Qwen / Ollama / vLLM / Moonshot 等 OpenAI-compatible 端点。 @@ -54,7 +57,7 @@ func toOpenAIMessages(msgs []Message) []openai.ChatCompletionMessageParamUnion { parts = append(parts, openai.ImageContentPart(openai.ChatCompletionContentPartImageImageURLParam{ URL: dataURI, })) - // document/PDF 不翻译,由上层能力检查拦截 + // document/PDF 不翻译,由上层能力检查拦截 } } out = append(out, openai.UserMessage(parts)) @@ -199,3 +202,40 @@ func (c *OpenAIClient) CountTokens(_ context.Context, modelID string, req Reques } return total, nil } + +// EmbedDims returns the platform-standard embedding dimension. text-embedding-3-* +// models honor the `dimensions` request param, so Embed always pins the output to +// PlatformEmbeddingDims to match the fixed pgvector column width. +func (c *OpenAIClient) EmbedDims(string) int { return PlatformEmbeddingDims } + +// Embed calls POST /embeddings once for the whole batch (input order preserved) +// and converts the returned []float64 vectors to []float32 for pgvector storage. +func (c *OpenAIClient) Embed(ctx context.Context, modelID string, inputs []string) ([][]float32, error) { + if len(inputs) == 0 { + return nil, nil + } + resp, err := c.sdk.Embeddings.New(ctx, openai.EmbeddingNewParams{ + Model: openai.EmbeddingModel(modelID), + Input: openai.EmbeddingNewParamsInputUnion{OfArrayOfStrings: inputs}, + Dimensions: param.NewOpt(int64(PlatformEmbeddingDims)), + EncodingFormat: openai.EmbeddingNewParamsEncodingFormatFloat, + }) + if err != nil { + return nil, fmt.Errorf("openai embeddings: %w", err) + } + if len(resp.Data) != len(inputs) { + return nil, fmt.Errorf("openai embeddings: got %d vectors for %d inputs", len(resp.Data), len(inputs)) + } + out := make([][]float32, len(resp.Data)) + for _, e := range resp.Data { + if e.Index < 0 || int(e.Index) >= len(out) { + return nil, fmt.Errorf("openai embeddings: out-of-range index %d", e.Index) + } + v := make([]float32, len(e.Embedding)) + for i, f := range e.Embedding { + v[i] = float32(f) + } + out[e.Index] = v + } + return out, nil +} diff --git a/internal/infra/llm/registry.go b/internal/infra/llm/registry.go index 27cabc7..d691b9b 100644 --- a/internal/infra/llm/registry.go +++ b/internal/infra/llm/registry.go @@ -63,6 +63,21 @@ func (r *Registry) GetClient(ctx context.Context, id uuid.UUID) (LLMClient, erro return c, nil } +// GetEmbedder 返回该 endpoint 对应的 Embedder(OpenAI / Gemini 实现;Anthropic +// 客户端虽实现接口但 Embed 返回 ErrEmbeddingsUnsupported)。底层复用 GetClient +// 的缓存与构造,再做类型断言。endpoint 的 client 未实现 Embedder 时返回错误。 +func (r *Registry) GetEmbedder(ctx context.Context, id uuid.UUID) (Embedder, error) { + c, err := r.GetClient(ctx, id) + if err != nil { + return nil, err + } + emb, ok := c.(Embedder) + if !ok { + return nil, fmt.Errorf("llm registry: endpoint %s provider %q does not support embeddings", id, c.Provider()) + } + return emb, nil +} + // Invalidate 删除该 endpoint 的缓存条目。endpoint CRUD 后由 service 层调用。 func (r *Registry) Invalidate(id uuid.UUID) { r.mu.Lock() diff --git a/internal/infra/metrics/metrics.go b/internal/infra/metrics/metrics.go new file mode 100644 index 0000000..6cd858b --- /dev/null +++ b/internal/infra/metrics/metrics.go @@ -0,0 +1,108 @@ +// Package metrics 提供进程内的 Prometheus 指标注册中心与一组类型化的记录助手。 +// +// 设计要点: +// - 用私有 *prometheus.Registry(NOT prometheus.DefaultRegisterer),避免在 +// 热重载 / 测试中重复注册默认注册表导致 panic。 +// - 通过 promhttp.HandlerFor(reg, ...) 暴露 Handler();调用方(router)负责 +// 鉴权门禁(/metrics 泄露成本/会话数,必须 admin/内网受限)。 +// - Record* 助手对外提供窄 API:其它模块只依赖这几个方法(或 acp 包内定义的 +// 窄接口),无需 import prometheus。 +// +// 指标清单(spec §11 §1205-1207): +// - acp_sessions_active GaugeFunc —— 当前活跃 ACP 会话数 +// - acp_session_exit_total{status} CounterVec —— 会话退出计数(exited/crashed/…) +// - acp_permission_decisions_total{outcome} CounterVec —— 权限决策计数 +// (auto/approved/denied/expired) +// - jobs_dead_letter_total{type} CounterVec —— 死信任务计数(按 job type) +// - llm_cost_usd_total GaugeFunc —— 累计 LLM 成本(USD) +package metrics + +import ( + "net/http" + + "github.com/prometheus/client_golang/prometheus" + "github.com/prometheus/client_golang/prometheus/promhttp" +) + +// Metrics 持有私有注册表与各 collector。零值不可用,必须经 New 构造。 +type Metrics struct { + reg *prometheus.Registry + + sessionExits *prometheus.CounterVec + permDecisions *prometheus.CounterVec + deadLetters *prometheus.CounterVec +} + +// New 构造 Metrics。activeSessions / llmCostTotal 是供 GaugeFunc 回调的取值 +// 函数;二者可为 nil(nil 时对应 gauge 恒为 0),便于测试 / feature-off。 +func New(activeSessions func() float64, llmCostTotal func() float64) *Metrics { + reg := prometheus.NewRegistry() + + m := &Metrics{ + reg: reg, + sessionExits: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "acp_session_exit_total", + Help: "Total number of ACP agent session exits, labeled by terminal status.", + }, []string{"status"}), + permDecisions: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "acp_permission_decisions_total", + Help: "Total number of ACP tool-permission decisions, labeled by outcome.", + }, []string{"outcome"}), + deadLetters: prometheus.NewCounterVec(prometheus.CounterOpts{ + Name: "jobs_dead_letter_total", + Help: "Total number of jobs that exhausted retries and were dead-lettered, by job type.", + }, []string{"type"}), + } + + reg.MustRegister(m.sessionExits, m.permDecisions, m.deadLetters) + + if activeSessions == nil { + activeSessions = func() float64 { return 0 } + } + if llmCostTotal == nil { + llmCostTotal = func() float64 { return 0 } + } + reg.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "acp_sessions_active", + Help: "Current number of active (starting/running) ACP agent sessions.", + }, activeSessions)) + reg.MustRegister(prometheus.NewGaugeFunc(prometheus.GaugeOpts{ + Name: "llm_cost_usd_total", + Help: "Cumulative LLM usage cost in USD across all endpoints.", + }, llmCostTotal)) + + return m +} + +// Handler 返回 /metrics 的 http.Handler,绑定到本 Metrics 的私有注册表。 +func (m *Metrics) Handler() http.Handler { + return promhttp.HandlerFor(m.reg, promhttp.HandlerOpts{}) +} + +// Registry 暴露底层注册表,便于测试直接 Gather。 +func (m *Metrics) Registry() *prometheus.Registry { return m.reg } + +// RecordSessionExit 记录一次会话退出。status 取 acp.SessionStatus 字符串值 +// (exited/crashed/…)。 +func (m *Metrics) RecordSessionExit(status string) { + if m == nil { + return + } + m.sessionExits.WithLabelValues(status).Inc() +} + +// RecordPermissionDecision 记录一次权限决策。outcome 取 auto/approved/denied/expired。 +func (m *Metrics) RecordPermissionDecision(outcome string) { + if m == nil { + return + } + m.permDecisions.WithLabelValues(outcome).Inc() +} + +// RecordDeadLetter 记录一次死信任务,按 job type 标签累加。 +func (m *Metrics) RecordDeadLetter(jobType string) { + if m == nil { + return + } + m.deadLetters.WithLabelValues(jobType).Inc() +} diff --git a/internal/infra/metrics/metrics_test.go b/internal/infra/metrics/metrics_test.go new file mode 100644 index 0000000..0f0c291 --- /dev/null +++ b/internal/infra/metrics/metrics_test.go @@ -0,0 +1,90 @@ +package metrics + +import ( + "io" + "net/http/httptest" + "strings" + "testing" +) + +func scrape(t *testing.T, m *Metrics) string { + t.Helper() + rec := httptest.NewRecorder() + req := httptest.NewRequest("GET", "/metrics", nil) + m.Handler().ServeHTTP(rec, req) + if rec.Code != 200 { + t.Fatalf("metrics handler status = %d, want 200", rec.Code) + } + body, _ := io.ReadAll(rec.Result().Body) + return string(body) +} + +func TestNewRegistersAllCollectors(t *testing.T) { + m := New(func() float64 { return 3 }, func() float64 { return 12.5 }) + // CounterVecs emit no series until a label set is observed; touch each once + // so the metric family name surfaces in the scrape. + m.RecordSessionExit("exited") + m.RecordPermissionDecision("auto") + m.RecordDeadLetter("webhook.deliver") + body := scrape(t, m) + + for _, name := range []string{ + "acp_sessions_active", + "acp_session_exit_total", + "acp_permission_decisions_total", + "jobs_dead_letter_total", + "llm_cost_usd_total", + } { + if !strings.Contains(body, name) { + t.Errorf("metrics output missing %q\n%s", name, body) + } + } + // GaugeFunc values surface immediately even without any Record* call. + if !strings.Contains(body, "acp_sessions_active 3") { + t.Errorf("acp_sessions_active gauge not reporting func value; body:\n%s", body) + } + if !strings.Contains(body, "llm_cost_usd_total 12.5") { + t.Errorf("llm_cost_usd_total gauge not reporting func value; body:\n%s", body) + } +} + +func TestRecordHelpersIncrementWithLabels(t *testing.T) { + m := New(nil, nil) + m.RecordSessionExit("crashed") + m.RecordSessionExit("crashed") + m.RecordSessionExit("exited") + m.RecordPermissionDecision("approved") + m.RecordPermissionDecision("denied") + m.RecordPermissionDecision("expired") + m.RecordDeadLetter("webhook.deliver") + + body := scrape(t, m) + wants := []string{ + `acp_session_exit_total{status="crashed"} 2`, + `acp_session_exit_total{status="exited"} 1`, + `acp_permission_decisions_total{outcome="approved"} 1`, + `acp_permission_decisions_total{outcome="denied"} 1`, + `acp_permission_decisions_total{outcome="expired"} 1`, + `jobs_dead_letter_total{type="webhook.deliver"} 1`, + } + for _, w := range wants { + if !strings.Contains(body, w) { + t.Errorf("metrics output missing %q\n%s", w, body) + } + } +} + +func TestRegistryIsolatedNotGlobalDefault(t *testing.T) { + // Two independent Metrics must not collide (would panic if both used the + // global default registry). + _ = New(nil, nil) + _ = New(nil, nil) +} + +func TestNilReceiverRecordSafe(t *testing.T) { + var m *Metrics + // Must not panic — callers may hold a nil *Metrics when metrics disabled. + m.RecordSessionExit("exited") + m.RecordPermissionDecision("auto") + m.RecordDeadLetter("x") +} diff --git a/internal/infra/notify/dispatcher.go b/internal/infra/notify/dispatcher.go index 592f2a6..ac60136 100644 --- a/internal/infra/notify/dispatcher.go +++ b/internal/infra/notify/dispatcher.go @@ -21,6 +21,8 @@ type Dispatcher struct { log *slog.Logger now func() time.Time wg sync.WaitGroup + // hub 是可选的实时推送中心;落库成功后 Publish。nil 时仅持久化 + 外发通道。 + hub StreamHub } // NewDispatcher 用 Repository 与 Logger 构造 Dispatcher。注册 Notifier 通过 @@ -35,6 +37,11 @@ func (d *Dispatcher) Register(n Notifier) { d.notifiers = append(d.notifiers, n) } +// SetStreamHub 注入实时推送中心(装配期)。落库成功后会向其 Publish。 +func (d *Dispatcher) SetStreamHub(h StreamHub) { + d.hub = h +} + // Dispatch 同步落库;落库失败返回错误,不再异步投递。落库成功后, // fire-and-forget 派发给所有外部 notifier,单个 notifier 的失败仅记日志, // 不阻塞其它通道;调用方无法感知异步投递结果。 @@ -54,6 +61,10 @@ func (d *Dispatcher) Dispatch(ctx context.Context, msg Message) error { if err := d.repo.Insert(persistCtx, msg); err != nil { return err } + // 落库成功后才推送实时事件(推送失败 / 慢订阅者不影响落库的权威 inbox)。 + if d.hub != nil { + d.hub.Publish(msg.UserID, msg) + } for _, n := range d.notifiers { d.wg.Add(1) go func(n Notifier) { diff --git a/internal/infra/notify/handler.go b/internal/infra/notify/handler.go index 34a9298..897090e 100644 --- a/internal/infra/notify/handler.go +++ b/internal/infra/notify/handler.go @@ -11,6 +11,8 @@ package notify import ( + "encoding/json" + "fmt" "net/http" "strconv" "time" @@ -23,12 +25,23 @@ import ( "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" ) +// Encryptor 是 prefs handler 加密 im_webhook_url 所需的窄接口,由 crypto.Encryptor +// 满足。以接口注入避免 notify 依赖 crypto 包。 +type Encryptor interface { + Encrypt(plaintext []byte) ([]byte, error) +} + // Handler 暴露 notify 模块的 HTTP 接口;依赖 Repository 与 SessionResolver。 // 不直接持有 Dispatcher——发送通知由其它模块通过 Dispatcher 完成,handler // 仅服务 inbox 读取与状态变更。 type Handler struct { repo Repository resolver middleware.SessionResolver + // hub 为可选的实时推送中心;非 nil 时暴露 GET /stream SSE 端点。 + hub StreamHub + // prefs/enc 为可选的偏好仓库与加密器;二者均非 nil 时暴露 GET/PUT /prefs。 + prefs PrefsRepository + enc Encryptor } // NewHandler 构造 notify 模块的 HTTP handler。 @@ -36,6 +49,19 @@ func NewHandler(repo Repository, resolver middleware.SessionResolver) *Handler { return &Handler{repo: repo, resolver: resolver} } +// WithStreamHub 注入实时推送中心,启用 GET /api/v1/notifications/stream SSE。 +func (h *Handler) WithStreamHub(hub StreamHub) *Handler { + h.hub = hub + return h +} + +// WithPrefs 注入偏好仓库与加密器,启用 GET/PUT /api/v1/notifications/prefs。 +func (h *Handler) WithPrefs(prefs PrefsRepository, enc Encryptor) *Handler { + h.prefs = prefs + h.enc = enc + return h +} + // Mount 把 inbox 路由挂到给定 chi.Router 上。Auth 中间件在子路由内统一注入。 func (h *Handler) Mount(r chi.Router) { r.Route("/api/v1/notifications", func(r chi.Router) { @@ -44,9 +70,74 @@ func (h *Handler) Mount(r chi.Router) { r.Get("/unread-count", h.unreadCount) r.Patch("/read-all", h.readAll) r.Patch("/{id}/read", h.markRead) + if h.hub != nil { + r.Get("/stream", h.stream) + } + if h.prefs != nil && h.enc != nil { + r.Get("/prefs", h.getPrefs) + r.Put("/prefs", h.putPrefs) + } }) } +// stream 实现 GET /api/v1/notifications/stream:把该用户的实时通知以 SSE 推送。 +// 客户端用 EventSource 订阅;连接关闭时(r.Context().Done)自动退订。 +func (h *Handler) stream(w http.ResponseWriter, r *http.Request) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), + errs.New(errs.CodeUnauthorized, "未认证")) + return + } + flusher, ok := w.(http.Flusher) + if !ok { + http.Error(w, "streaming unsupported", http.StatusInternalServerError) + return + } + ch, cancel := h.hub.Subscribe(uid) + defer cancel() + + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set("X-Accel-Buffering", "no") + w.WriteHeader(http.StatusOK) + flusher.Flush() + + for { + select { + case <-r.Context().Done(): + return + case m, ok := <-ch: + if !ok { + return + } + writeNotificationSSE(w, flusher, m) + } + } +} + +// writeNotificationSSE 把一条通知序列化为 SSE 帧并 flush。 +func writeNotificationSSE(w http.ResponseWriter, f http.Flusher, m Message) { + dto := notificationDTO{ + ID: m.ID.String(), + Topic: m.Topic, + Severity: string(m.Severity), + Title: m.Title, + Body: m.Body, + Link: m.Link, + Metadata: m.Metadata, + CreatedAt: m.CreatedAt.UTC().Format(time.RFC3339), + } + if m.ReadAt != nil { + s := m.ReadAt.UTC().Format(time.RFC3339) + dto.ReadAt = &s + } + data, _ := json.Marshal(dto) + fmt.Fprintf(w, "event: notification\ndata: %s\n\n", data) + f.Flush() +} + // notificationDTO 是 inbox 单条通知的对外 JSON 形态。created_at/read_at // 统一序列化为 RFC3339 字符串;read_at 为 nil 时省略(omitempty)。 type notificationDTO struct { diff --git a/internal/infra/notify/notifier_email.go b/internal/infra/notify/notifier_email.go new file mode 100644 index 0000000..303278e --- /dev/null +++ b/internal/infra/notify/notifier_email.go @@ -0,0 +1,141 @@ +// Package notify 内的 notifier_email.go 实现 EmailNotifier:通过 SMTP(net/smtp) +// 把通知投递为邮件。它实现 Notifier 接口,由 Dispatcher 在配置启用时注册。 +// +// 投递前置条件(任一不满足则静默跳过,返回 nil——per-channel 失败/跳过对 +// Dispatcher 非致命): +// - 全局 cfg.Enabled 为 true; +// - 用户 notification_prefs.email_enabled 为 true; +// - 消息 severity 达到用户 min_severity; +// - 能解析出该用户的收件邮箱。 +// +// SMTP 调用经 transport 抽象注入,测试用 fake transport 断言收件人/正文形态, +// 绝不真的发邮件;生产用 smtpTransport(net/smtp.SendMail)。 +package notify + +import ( + "context" + "fmt" + "log/slog" + "net/smtp" + "strings" + + "github.com/google/uuid" +) + +// EmailConfig 是 EmailNotifier 的配置,对应 cfg.Notify.Email。 +type EmailConfig struct { + Enabled bool + SMTPHost string + Port int + From string + Username string + Password string +} + +// addr 返回 "host:port" 形式的 SMTP 地址。 +func (c EmailConfig) addr() string { + return fmt.Sprintf("%s:%d", c.SMTPHost, c.Port) +} + +// EmailLookup 把 userID 解析为收件邮箱。由 user.Service 适配注入,避免 notify +// 反向依赖 user 包造成的循环导入。 +type EmailLookup interface { + EmailForUser(ctx context.Context, userID uuid.UUID) (string, error) +} + +// SMTPTransport 抽象一次 SMTP 发送,便于测试注入 fake。参数与 net/smtp.SendMail +// 对齐:addr 形如 "host:port",auth 可为 nil(无认证),from 为发件人,to 为收件人 +// 列表,msg 为完整 RFC 822 报文(含头)。 +type SMTPTransport interface { + Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error +} + +// smtpTransport 是 SMTPTransport 的生产实现,直接委托 net/smtp.SendMail。 +type smtpTransport struct{} + +func (smtpTransport) Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error { + return smtp.SendMail(addr, auth, from, to, msg) +} + +// EmailNotifier 通过 SMTP 投递邮件,实现 Notifier。 +type EmailNotifier struct { + cfg EmailConfig + prefs PrefsRepository + lookup EmailLookup + tr SMTPTransport + log *slog.Logger +} + +// NewEmailNotifier 构造 EmailNotifier。tr 为 nil 时用生产 net/smtp 实现; +// log 为 nil 时回落到 slog.Default。 +func NewEmailNotifier(cfg EmailConfig, prefs PrefsRepository, lookup EmailLookup, tr SMTPTransport, log *slog.Logger) *EmailNotifier { + if tr == nil { + tr = smtpTransport{} + } + if log == nil { + log = slog.Default() + } + return &EmailNotifier{cfg: cfg, prefs: prefs, lookup: lookup, tr: tr, log: log} +} + +// Name 返回 ChannelEmail。 +func (n *EmailNotifier) Name() Channel { return ChannelEmail } + +// Send 在满足全部前置条件时发送一封邮件;任一条件不满足静默返回 nil。 +// SMTP 调用失败返回错误(Dispatcher 仅记日志,不影响其它通道)。 +func (n *EmailNotifier) Send(ctx context.Context, msg Message) error { + if !n.cfg.Enabled { + return nil + } + p, err := n.prefs.Get(ctx, msg.UserID) + if err != nil { + return err + } + if !p.EmailEnabled { + return nil + } + if !meetsMinSeverity(msg.Severity, p.MinSeverity) { + return nil + } + to, err := n.lookup.EmailForUser(ctx, msg.UserID) + if err != nil { + return err + } + if strings.TrimSpace(to) == "" { + return nil + } + + var auth smtp.Auth + if n.cfg.Username != "" { + auth = smtp.PlainAuth("", n.cfg.Username, n.cfg.Password, n.cfg.SMTPHost) + } + body := buildEmailMessage(n.cfg.From, to, msg) + if err := n.tr.Send(n.cfg.addr(), auth, n.cfg.From, []string{to}, body); err != nil { + return fmt.Errorf("smtp send: %w", err) + } + return nil +} + +// buildEmailMessage 组装一封最小 RFC 822 纯文本邮件报文(含 From/To/Subject 头)。 +// 主题前缀用 severity 便于客户端过滤;正文带标题、正文与可选链接。 +func buildEmailMessage(from, to string, msg Message) []byte { + subject := msg.Title + if subject == "" { + subject = msg.Topic + } + var b strings.Builder + fmt.Fprintf(&b, "From: %s\r\n", from) + fmt.Fprintf(&b, "To: %s\r\n", to) + fmt.Fprintf(&b, "Subject: [%s] %s\r\n", strings.ToUpper(string(msg.Severity)), subject) + b.WriteString("MIME-Version: 1.0\r\n") + b.WriteString("Content-Type: text/plain; charset=\"UTF-8\"\r\n") + b.WriteString("\r\n") + if msg.Body != "" { + b.WriteString(msg.Body) + b.WriteString("\r\n") + } + if msg.Link != "" { + fmt.Fprintf(&b, "\r\n%s\r\n", msg.Link) + } + return []byte(b.String()) +} diff --git a/internal/infra/notify/notifier_email_test.go b/internal/infra/notify/notifier_email_test.go new file mode 100644 index 0000000..5e9be77 --- /dev/null +++ b/internal/infra/notify/notifier_email_test.go @@ -0,0 +1,195 @@ +package notify + +import ( + "context" + "errors" + "net/smtp" + "strings" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// fakePrefsRepo 是 PrefsRepository 的内存实现,供 notifier 测试断言偏好过滤。 +type fakePrefsRepo struct { + mu sync.Mutex + m map[uuid.UUID]Prefs + err error +} + +func newFakePrefs() *fakePrefsRepo { return &fakePrefsRepo{m: map[uuid.UUID]Prefs{}} } + +func (f *fakePrefsRepo) set(p Prefs) { + f.mu.Lock() + defer f.mu.Unlock() + f.m[p.UserID] = p +} + +func (f *fakePrefsRepo) Get(_ context.Context, userID uuid.UUID) (Prefs, error) { + if f.err != nil { + return Prefs{}, f.err + } + f.mu.Lock() + defer f.mu.Unlock() + if p, ok := f.m[userID]; ok { + return p, nil + } + return DefaultPrefs(userID), nil +} + +func (f *fakePrefsRepo) Upsert(_ context.Context, p Prefs) error { + if f.err != nil { + return f.err + } + f.set(p) + return nil +} + +// captureTransport 记录最后一次 SMTP 发送,便于断言收件人 / 报文形态。 +type captureTransport struct { + mu sync.Mutex + calls int + addr string + from string + to []string + msg []byte + auth smtp.Auth +} + +func (c *captureTransport) Send(addr string, auth smtp.Auth, from string, to []string, msg []byte) error { + c.mu.Lock() + defer c.mu.Unlock() + c.calls++ + c.addr = addr + c.from = from + c.to = to + c.msg = msg + c.auth = auth + return nil +} + +// staticLookup 总是返回固定收件邮箱。 +type staticLookup struct { + email string + err error +} + +func (s staticLookup) EmailForUser(context.Context, uuid.UUID) (string, error) { + return s.email, s.err +} + +func emailCfg() EmailConfig { + return EmailConfig{Enabled: true, SMTPHost: "smtp.test", Port: 587, From: "noreply@test"} +} + +func TestEmailNotifier_SendsWhenEnabledAndSeverityMet(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityWarning}) + tr := &captureTransport{} + n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger()) + + err := n.Send(context.Background(), Message{ + UserID: uid, Topic: "acp.permission_pending", Severity: SeverityWarning, + Title: "Approval needed", Body: "session X wants to run rm", Link: "/approvals", + }) + require.NoError(t, err) + require.Equal(t, 1, tr.calls) + require.Equal(t, "smtp.test:587", tr.addr) + require.Equal(t, "noreply@test", tr.from) + require.Equal(t, []string{"user@test"}, tr.to) + require.Nil(t, tr.auth, "无 username 时不应携带 auth") + + body := string(tr.msg) + require.Contains(t, body, "To: user@test") + require.Contains(t, body, "Subject: [WARNING] Approval needed") + require.Contains(t, body, "session X wants to run rm") + require.Contains(t, body, "/approvals") +} + +func TestEmailNotifier_DisabledChannelSkipped(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(Prefs{UserID: uid, EmailEnabled: false, MinSeverity: SeverityInfo}) + tr := &captureTransport{} + n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger()) + + require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) + require.Equal(t, 0, tr.calls, "email_enabled=false 应跳过发送") +} + +func TestEmailNotifier_BelowMinSeveritySkipped(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityError}) + tr := &captureTransport{} + n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "user@test"}, tr, newTestLogger()) + + require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityWarning})) + require.Equal(t, 0, tr.calls, "低于 min_severity 应跳过") + + require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) + require.Equal(t, 1, tr.calls, "达到 min_severity 应发送") +} + +func TestEmailNotifier_GloballyDisabledSkipped(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo}) + tr := &captureTransport{} + cfg := emailCfg() + cfg.Enabled = false + n := NewEmailNotifier(cfg, prefs, staticLookup{email: "user@test"}, tr, newTestLogger()) + + require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) + require.Equal(t, 0, tr.calls) +} + +func TestEmailNotifier_NoRecipientSkipped(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo}) + tr := &captureTransport{} + n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: " "}, tr, newTestLogger()) + + require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) + require.Equal(t, 0, tr.calls, "空收件人应静默跳过") +} + +func TestEmailNotifier_UsesPlainAuthWhenUsernameSet(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(Prefs{UserID: uid, EmailEnabled: true, MinSeverity: SeverityInfo}) + tr := &captureTransport{} + cfg := emailCfg() + cfg.Username = "u" + cfg.Password = "p" + n := NewEmailNotifier(cfg, prefs, staticLookup{email: "user@test"}, tr, newTestLogger()) + + require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError, Title: "x"})) + require.Equal(t, 1, tr.calls) + require.NotNil(t, tr.auth, "配置 username 时应携带 PlainAuth") +} + +func TestEmailNotifier_NameIsEmailChannel(t *testing.T) { + n := NewEmailNotifier(emailCfg(), newFakePrefs(), staticLookup{}, &captureTransport{}, newTestLogger()) + require.Equal(t, ChannelEmail, n.Name()) +} + +func TestBuildEmailMessage_Headers(t *testing.T) { + body := string(buildEmailMessage("from@test", "to@test", Message{ + Topic: "t", Severity: SeverityError, Title: "Title", Body: "Body", Link: "http://l", + })) + require.True(t, strings.HasPrefix(body, "From: from@test\r\n")) + require.Contains(t, body, "Subject: [ERROR] Title") + require.Contains(t, body, "Content-Type: text/plain") +} + +func TestEmailNotifier_PrefsErrorPropagates(t *testing.T) { + prefs := newFakePrefs() + prefs.err = errors.New("db down") + n := NewEmailNotifier(emailCfg(), prefs, staticLookup{email: "u@t"}, &captureTransport{}, newTestLogger()) + require.Error(t, n.Send(context.Background(), Message{UserID: uuid.New(), Severity: SeverityError})) +} diff --git a/internal/infra/notify/notifier_im.go b/internal/infra/notify/notifier_im.go new file mode 100644 index 0000000..8ed745c --- /dev/null +++ b/internal/infra/notify/notifier_im.go @@ -0,0 +1,131 @@ +// Package notify 内的 notifier_im.go 实现 IMNotifier:以通用 webhook POST +// (Slack incoming-webhook 兼容形态)把通知投递到用户配置的 IM 地址。 +// +// 投递地址是 per-user 的 im_webhook_url,以密文落库于 notification_prefs +// (im_webhook_url_enc);发送时经 Decryptor 解密,明文 URL 不在领域内存层 +// 长期驻留。投递前置条件与 EmailNotifier 对称: +// - 全局 cfg.Enabled 为 true; +// - 用户 im_enabled 为 true 且配置了 webhook URL; +// - 消息 severity 达到用户 min_severity。 +// +// 任一不满足静默返回 nil;HTTP/解密失败返回错误(Dispatcher 仅记日志)。 +package notify + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "log/slog" + "net/http" + "strings" +) + +// IMConfig 是 IMNotifier 的全局配置,对应 cfg.Notify.IM。 +type IMConfig struct { + Enabled bool +} + +// Decryptor 抽象 im_webhook_url 密文的解密。由 crypto.Encryptor 满足 +// (Decrypt([]byte) ([]byte, error)),以窄接口注入避免 notify 依赖 crypto 包。 +type Decryptor interface { + Decrypt(ciphertext []byte) ([]byte, error) +} + +// httpDoer 抽象单次 HTTP 调用,便于测试注入 fake transport。 +type httpDoer interface { + Do(req *http.Request) (*http.Response, error) +} + +// IMNotifier 通过 webhook POST 投递 IM 消息,实现 Notifier。 +type IMNotifier struct { + cfg IMConfig + prefs PrefsRepository + dec Decryptor + cl httpDoer + log *slog.Logger +} + +// NewIMNotifier 构造 IMNotifier。cl 为 nil 时用默认 http.Client(含 30s 超时 +// 由 Dispatcher 的 sendCtx 兜底);log 为 nil 时回落 slog.Default。 +func NewIMNotifier(cfg IMConfig, prefs PrefsRepository, dec Decryptor, cl httpDoer, log *slog.Logger) *IMNotifier { + if cl == nil { + cl = http.DefaultClient + } + if log == nil { + log = slog.Default() + } + return &IMNotifier{cfg: cfg, prefs: prefs, dec: dec, cl: cl, log: log} +} + +// Name 返回 ChannelIM。 +func (n *IMNotifier) Name() Channel { return ChannelIM } + +// Send 在满足全部前置条件时向用户的 IM webhook POST 一条消息。 +func (n *IMNotifier) Send(ctx context.Context, msg Message) error { + if !n.cfg.Enabled { + return nil + } + p, err := n.prefs.Get(ctx, msg.UserID) + if err != nil { + return err + } + if !p.IMEnabled || len(p.ImWebhookURLEnc) == 0 { + return nil + } + if !meetsMinSeverity(msg.Severity, p.MinSeverity) { + return nil + } + urlBytes, err := n.dec.Decrypt(p.ImWebhookURLEnc) + if err != nil { + return fmt.Errorf("decrypt im webhook url: %w", err) + } + url := strings.TrimSpace(string(urlBytes)) + if url == "" { + return nil + } + + payload, err := json.Marshal(buildIMPayload(msg)) + if err != nil { + return err + } + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(payload)) + if err != nil { + return err + } + req.Header.Set("Content-Type", "application/json") + resp, err := n.cl.Do(req) + if err != nil { + return fmt.Errorf("im webhook post: %w", err) + } + defer resp.Body.Close() + _, _ = io.Copy(io.Discard, resp.Body) + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + return fmt.Errorf("im webhook status %d", resp.StatusCode) + } + return nil +} + +// buildIMPayload 把 Message 渲染为 Slack incoming-webhook 兼容的 JSON 体。 +// 主要字段是 text(含级别、标题、正文、可选链接);附加结构化字段方便富客户端。 +func buildIMPayload(msg Message) map[string]any { + title := msg.Title + if title == "" { + title = msg.Topic + } + var b strings.Builder + fmt.Fprintf(&b, "[%s] %s", strings.ToUpper(string(msg.Severity)), title) + if msg.Body != "" { + fmt.Fprintf(&b, "\n%s", msg.Body) + } + if msg.Link != "" { + fmt.Fprintf(&b, "\n%s", msg.Link) + } + return map[string]any{ + "text": b.String(), + "topic": msg.Topic, + "severity": string(msg.Severity), + "title": title, + } +} diff --git a/internal/infra/notify/notifier_im_test.go b/internal/infra/notify/notifier_im_test.go new file mode 100644 index 0000000..81de411 --- /dev/null +++ b/internal/infra/notify/notifier_im_test.go @@ -0,0 +1,177 @@ +package notify + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +// reverseDecryptor 是测试用 Decryptor:把密文逐字节取反作为 "解密",与 +// reverseEncrypt 配对,确保 IMNotifier 走解密路径而非裸读明文。 +type reverseDecryptor struct{ err error } + +func reverseBytes(b []byte) []byte { + out := make([]byte, len(b)) + for i, c := range b { + out[i] = ^c + } + return out +} + +func (d reverseDecryptor) Decrypt(ct []byte) ([]byte, error) { + if d.err != nil { + return nil, d.err + } + return reverseBytes(ct), nil +} + +// captureDoer 记录最后一次 HTTP 请求并返回固定响应码。 +type captureDoer struct { + mu sync.Mutex + calls int + url string + body []byte + ctype string + status int + err error +} + +func (c *captureDoer) Do(req *http.Request) (*http.Response, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.err != nil { + return nil, c.err + } + c.calls++ + c.url = req.URL.String() + c.ctype = req.Header.Get("Content-Type") + if req.Body != nil { + c.body, _ = io.ReadAll(req.Body) + } + st := c.status + if st == 0 { + st = http.StatusOK + } + return &http.Response{StatusCode: st, Body: io.NopCloser(strings.NewReader("ok"))}, nil +} + +func imPrefs(uid uuid.UUID, enabled bool, sev Severity, webhookEnc []byte) Prefs { + return Prefs{UserID: uid, IMEnabled: enabled, MinSeverity: sev, ImWebhookURLEnc: webhookEnc} +} + +func TestIMNotifier_PostsDecryptedWebhookWithSlackPayload(t *testing.T) { + uid := uuid.New() + enc := reverseBytes([]byte("https://hooks.slack.test/abc")) + prefs := newFakePrefs() + prefs.set(imPrefs(uid, true, SeverityWarning, enc)) + doer := &captureDoer{} + n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger()) + + err := n.Send(context.Background(), Message{ + UserID: uid, Topic: "acp.permission_pending", Severity: SeverityWarning, + Title: "Approval needed", Body: "run rm", Link: "/approvals", + }) + require.NoError(t, err) + require.Equal(t, 1, doer.calls) + require.Equal(t, "https://hooks.slack.test/abc", doer.url, "应 POST 到解密后的 URL") + require.Equal(t, "application/json", doer.ctype) + + var payload map[string]any + require.NoError(t, json.Unmarshal(doer.body, &payload)) + require.Equal(t, "acp.permission_pending", payload["topic"]) + require.Equal(t, "warning", payload["severity"]) + require.Equal(t, "Approval needed", payload["title"]) + text, _ := payload["text"].(string) + require.Contains(t, text, "[WARNING] Approval needed") + require.Contains(t, text, "run rm") + require.Contains(t, text, "/approvals") +} + +func TestIMNotifier_DisabledChannelSkipped(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(imPrefs(uid, false, SeverityInfo, reverseBytes([]byte("https://x")))) + doer := &captureDoer{} + n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger()) + + require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) + require.Equal(t, 0, doer.calls) +} + +func TestIMNotifier_NoWebhookSkipped(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(imPrefs(uid, true, SeverityInfo, nil)) + doer := &captureDoer{} + n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger()) + + require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) + require.Equal(t, 0, doer.calls, "未配置 webhook 应跳过") +} + +func TestIMNotifier_BelowMinSeveritySkipped(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(imPrefs(uid, true, SeverityError, reverseBytes([]byte("https://x")))) + doer := &captureDoer{} + n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger()) + + require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityWarning})) + require.Equal(t, 0, doer.calls) +} + +func TestIMNotifier_GloballyDisabledSkipped(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x")))) + doer := &captureDoer{} + n := NewIMNotifier(IMConfig{Enabled: false}, prefs, reverseDecryptor{}, doer, newTestLogger()) + + require.NoError(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) + require.Equal(t, 0, doer.calls) +} + +func TestIMNotifier_DecryptErrorReturnsErr(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(imPrefs(uid, true, SeverityInfo, []byte("garbage"))) + doer := &captureDoer{} + n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{err: errors.New("bad key")}, doer, newTestLogger()) + + require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) + require.Equal(t, 0, doer.calls) +} + +func TestIMNotifier_Non2xxReturnsErr(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x")))) + doer := &captureDoer{status: http.StatusInternalServerError} + n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger()) + + require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) + require.Equal(t, 1, doer.calls) +} + +func TestIMNotifier_HTTPErrorReturnsErr(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(imPrefs(uid, true, SeverityInfo, reverseBytes([]byte("https://x")))) + doer := &captureDoer{err: errors.New("dial fail")} + n := NewIMNotifier(IMConfig{Enabled: true}, prefs, reverseDecryptor{}, doer, newTestLogger()) + + require.Error(t, n.Send(context.Background(), Message{UserID: uid, Severity: SeverityError})) +} + +func TestIMNotifier_NameIsIMChannel(t *testing.T) { + n := NewIMNotifier(IMConfig{Enabled: true}, newFakePrefs(), reverseDecryptor{}, &captureDoer{}, newTestLogger()) + require.Equal(t, ChannelIM, n.Name()) +} diff --git a/internal/infra/notify/prefs.go b/internal/infra/notify/prefs.go new file mode 100644 index 0000000..0bb98c0 --- /dev/null +++ b/internal/infra/notify/prefs.go @@ -0,0 +1,107 @@ +// Package notify 内的 prefs.go 实现 per-user 通知偏好(notification_prefs 表)。 +// +// 偏好控制 email / im 两条外发通道的开关与最低投递级别(min_severity)。in-app +// inbox(持久化 + SSE)始终投递,不受偏好影响。im_webhook_url 以密文落库 +// (im_webhook_url_enc BYTEA,经 crypto.Encryptor 加密),repo 层只搬运密文, +// 解密由 IMNotifier 在发送时按需进行——避免明文 webhook URL 进入领域内存层。 +package notify + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + notifysqlc "github.com/yan1h/agent-coding-workflow/internal/infra/notify/sqlc" +) + +// Prefs 是一个用户的通知偏好领域形态。ImWebhookURLEnc 保持密文(BYTEA 原样), +// 解密延迟到 IMNotifier.Send。MinSeverity 取值与 Severity 枚举一致。 +type Prefs struct { + UserID uuid.UUID + EmailEnabled bool + IMEnabled bool + ImWebhookURLEnc []byte + MinSeverity Severity +} + +// DefaultPrefs 是用户尚未显式设置偏好时的兜底:email / im 均关闭,最低级别 +// warning(与 notification_prefs.min_severity 列默认值一致)。 +func DefaultPrefs(userID uuid.UUID) Prefs { + return Prefs{ + UserID: userID, + EmailEnabled: false, + IMEnabled: false, + MinSeverity: SeverityWarning, + } +} + +// PrefsRepository 抽象 notification_prefs 的读写。Get 在无记录时返回 +// DefaultPrefs(不报 not-found),让上层无需区分 "未设置" 与 "全关"。 +type PrefsRepository interface { + Get(ctx context.Context, userID uuid.UUID) (Prefs, error) + Upsert(ctx context.Context, p Prefs) error +} + +// pgPrefsRepo 是 PrefsRepository 的 Postgres 实现。 +type pgPrefsRepo struct { + q *notifysqlc.Queries +} + +// NewPostgresPrefsRepository 用现有 pgxpool 构造偏好仓库。 +func NewPostgresPrefsRepository(pool *pgxpool.Pool) PrefsRepository { + return &pgPrefsRepo{q: notifysqlc.New(pool)} +} + +// Get 读取用户偏好。无记录时返回 DefaultPrefs(userID) 与 nil error。 +func (r *pgPrefsRepo) Get(ctx context.Context, userID uuid.UUID) (Prefs, error) { + row, err := r.q.GetNotificationPrefs(ctx, toPgUUID(userID)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return DefaultPrefs(userID), nil + } + return Prefs{}, errs.Wrap(err, errs.CodeInternal, "get notification prefs") + } + return Prefs{ + UserID: fromPgUUID(row.UserID), + EmailEnabled: row.EmailEnabled, + IMEnabled: row.ImEnabled, + ImWebhookURLEnc: row.ImWebhookUrlEnc, + MinSeverity: Severity(row.MinSeverity), + }, nil +} + +// Upsert 写入(或更新)用户偏好。ImWebhookURLEnc 为 nil 时写 NULL。 +func (r *pgPrefsRepo) Upsert(ctx context.Context, p Prefs) error { + if err := r.q.UpsertNotificationPrefs(ctx, notifysqlc.UpsertNotificationPrefsParams{ + UserID: toPgUUID(p.UserID), + EmailEnabled: p.EmailEnabled, + ImEnabled: p.IMEnabled, + ImWebhookUrlEnc: p.ImWebhookURLEnc, + MinSeverity: string(p.MinSeverity), + }); err != nil { + return errs.Wrap(err, errs.CodeInternal, "upsert notification prefs") + } + return nil +} + +// severityRank 把 Severity 映射为可比较的等级序数,用于 min_severity 过滤。 +// 未知值按最低(info)处理,确保 "至少投递" 而非误丢。 +func severityRank(s Severity) int { + switch s { + case SeverityError: + return 2 + case SeverityWarning: + return 1 + default: + return 0 + } +} + +// meetsMinSeverity 报告消息级别是否达到(>=)用户设定的最低投递级别。 +func meetsMinSeverity(msg Severity, min Severity) bool { + return severityRank(msg) >= severityRank(min) +} diff --git a/internal/infra/notify/prefs_handler.go b/internal/infra/notify/prefs_handler.go new file mode 100644 index 0000000..d7542ad --- /dev/null +++ b/internal/infra/notify/prefs_handler.go @@ -0,0 +1,133 @@ +// Package notify 内的 prefs_handler.go 实现 per-user 通知偏好的 HTTP 接口: +// +// GET /api/v1/notifications/prefs → 当前用户的偏好(im_webhook_url 不回显明文, +// 仅返回 im_webhook_url_set 布尔标记是否已配置)。 +// PUT /api/v1/notifications/prefs → 整体更新偏好。im_webhook_url 提供非空字符串 +// 则加密落库;提供空字符串则清除;字段省略则保留原值。 +// +// im_webhook_url 经 Encryptor 加密为 im_webhook_url_enc BYTEA。偏好只在 Handler +// 注入了 prefsRepo + enc 后才挂载(WithPrefs),未配置 crypto 时该路由不暴露。 +package notify + +import ( + "encoding/json" + "net/http" + "strings" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" +) + +// prefsDTO 是偏好的对外 JSON 形态。im_webhook_url 绝不回显明文:读取时只暴露 +// im_webhook_url_set 标记是否已配置,写入时通过单独的可空字段提交。 +type prefsDTO struct { + EmailEnabled bool `json:"email_enabled"` + IMEnabled bool `json:"im_enabled"` + IMWebhookURLSet bool `json:"im_webhook_url_set"` + MinSeverity string `json:"min_severity"` +} + +// prefsUpdateReq 是 PUT 请求体。IMWebhookURL 用指针区分三态: +// - nil(字段省略) → 保留原密文不变; +// - 指向空字符串 "" → 清除已配置的 webhook; +// - 指向非空字符串 → 加密为新密文。 +type prefsUpdateReq struct { + EmailEnabled *bool `json:"email_enabled"` + IMEnabled *bool `json:"im_enabled"` + IMWebhookURL *string `json:"im_webhook_url"` + MinSeverity *string `json:"min_severity"` +} + +// getPrefs 处理 GET /api/v1/notifications/prefs。 +func (h *Handler) getPrefs(w http.ResponseWriter, r *http.Request) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), + errs.New(errs.CodeUnauthorized, "未认证")) + return + } + p, err := h.prefs.Get(r.Context(), uid) + if err != nil { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) + return + } + httpx.WriteJSON(w, http.StatusOK, prefsDTO{ + EmailEnabled: p.EmailEnabled, + IMEnabled: p.IMEnabled, + IMWebhookURLSet: len(p.ImWebhookURLEnc) > 0, + MinSeverity: string(p.MinSeverity), + }) +} + +// putPrefs 处理 PUT /api/v1/notifications/prefs。 +func (h *Handler) putPrefs(w http.ResponseWriter, r *http.Request) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), + errs.New(errs.CodeUnauthorized, "未认证")) + return + } + var req prefsUpdateReq + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), + errs.New(errs.CodeInvalidInput, "请求体格式错误")) + return + } + + cur, err := h.prefs.Get(r.Context(), uid) + if err != nil { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) + return + } + if req.EmailEnabled != nil { + cur.EmailEnabled = *req.EmailEnabled + } + if req.IMEnabled != nil { + cur.IMEnabled = *req.IMEnabled + } + if req.MinSeverity != nil { + sev := Severity(strings.TrimSpace(*req.MinSeverity)) + if !validSeverity(sev) { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), + errs.New(errs.CodeInvalidInput, "min_severity 取值非法(info|warning|error)")) + return + } + cur.MinSeverity = sev + } + if req.IMWebhookURL != nil { + url := strings.TrimSpace(*req.IMWebhookURL) + if url == "" { + cur.ImWebhookURLEnc = nil + } else { + ct, err := h.enc.Encrypt([]byte(url)) + if err != nil { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), + errs.Wrap(err, errs.CodeInternal, "加密 im_webhook_url")) + return + } + cur.ImWebhookURLEnc = ct + } + } + + if err := h.prefs.Upsert(r.Context(), cur); err != nil { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) + return + } + httpx.WriteJSON(w, http.StatusOK, prefsDTO{ + EmailEnabled: cur.EmailEnabled, + IMEnabled: cur.IMEnabled, + IMWebhookURLSet: len(cur.ImWebhookURLEnc) > 0, + MinSeverity: string(cur.MinSeverity), + }) +} + +// validSeverity 报告 s 是否为合法的 min_severity 取值。 +func validSeverity(s Severity) bool { + switch s { + case SeverityInfo, SeverityWarning, SeverityError: + return true + default: + return false + } +} diff --git a/internal/infra/notify/prefs_handler_test.go b/internal/infra/notify/prefs_handler_test.go new file mode 100644 index 0000000..7f471cf --- /dev/null +++ b/internal/infra/notify/prefs_handler_test.go @@ -0,0 +1,168 @@ +package notify + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +type prefsFakeResolver struct{ uid uuid.UUID } + +func (f *prefsFakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) { + if token == "" { + return uuid.Nil, false, nil + } + return f.uid, true, nil +} + +// reverseEncryptor 是测试用 Encryptor:取反作为 "加密",与 reverseDecryptor 配对。 +type reverseEncryptor struct{} + +func (reverseEncryptor) Encrypt(pt []byte) ([]byte, error) { return reverseBytes(pt), nil } + +func mountPrefs(t *testing.T, uid uuid.UUID, prefs PrefsRepository) http.Handler { + t.Helper() + r := chi.NewRouter() + NewHandler(&noopRepo{}, &prefsFakeResolver{uid: uid}). + WithPrefs(prefs, reverseEncryptor{}).Mount(r) + return r +} + +// noopRepo 满足 inbox Repository(prefs 测试不触达 inbox 端点)。 +type noopRepo struct{} + +func (noopRepo) Insert(context.Context, Message) error { return nil } +func (noopRepo) List(context.Context, uuid.UUID, bool, int) ([]Message, error) { + return nil, nil +} +func (noopRepo) CountUnread(context.Context, uuid.UUID) (int, error) { return 0, nil } +func (noopRepo) MarkRead(context.Context, uuid.UUID, uuid.UUID) error { return nil } +func (noopRepo) MarkAllRead(context.Context, uuid.UUID) error { return nil } + +func TestPrefsHandler_GetReturnsDefaultsForNewUser(t *testing.T) { + uid := uuid.New() + h := mountPrefs(t, uid, newFakePrefs()) + req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil) + req.Header.Set("Authorization", "Bearer tok") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var dto prefsDTO + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &dto)) + require.False(t, dto.EmailEnabled) + require.False(t, dto.IMEnabled) + require.False(t, dto.IMWebhookURLSet) + require.Equal(t, "warning", dto.MinSeverity) +} + +func TestPrefsHandler_GetRequiresAuth(t *testing.T) { + h := mountPrefs(t, uuid.New(), newFakePrefs()) + req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil) // no token + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusUnauthorized, rec.Code) +} + +func TestPrefsHandler_PutPersistsAndEncryptsWebhook(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + h := mountPrefs(t, uid, prefs) + + body, _ := json.Marshal(prefsUpdateReq{ + EmailEnabled: boolPtr(true), + IMEnabled: boolPtr(true), + IMWebhookURL: strPtr("https://hooks.slack.test/xyz"), + MinSeverity: strPtr("error"), + }) + req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer tok") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + var dto prefsDTO + require.NoError(t, json.Unmarshal(rec.Body.Bytes(), &dto)) + require.True(t, dto.EmailEnabled) + require.True(t, dto.IMEnabled) + require.True(t, dto.IMWebhookURLSet) + require.Equal(t, "error", dto.MinSeverity) + + // 落库的 webhook 应为密文(取反),解回应得回原 URL;明文绝不直接存。 + saved, err := prefs.Get(context.Background(), uid) + require.NoError(t, err) + require.NotEmpty(t, saved.ImWebhookURLEnc) + require.NotEqual(t, []byte("https://hooks.slack.test/xyz"), saved.ImWebhookURLEnc) + require.Equal(t, "https://hooks.slack.test/xyz", string(reverseBytes(saved.ImWebhookURLEnc))) +} + +func TestPrefsHandler_PutPartialUpdatePreservesWebhook(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(Prefs{UserID: uid, IMEnabled: true, MinSeverity: SeverityWarning, + ImWebhookURLEnc: reverseBytes([]byte("https://existing"))}) + h := mountPrefs(t, uid, prefs) + + // 仅改 email_enabled,省略 im_webhook_url -> 保留原密文。 + body, _ := json.Marshal(prefsUpdateReq{EmailEnabled: boolPtr(true)}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer tok") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + saved, _ := prefs.Get(context.Background(), uid) + require.Equal(t, "https://existing", string(reverseBytes(saved.ImWebhookURLEnc))) + require.True(t, saved.EmailEnabled) +} + +func TestPrefsHandler_PutEmptyWebhookClearsIt(t *testing.T) { + uid := uuid.New() + prefs := newFakePrefs() + prefs.set(Prefs{UserID: uid, IMEnabled: true, MinSeverity: SeverityWarning, + ImWebhookURLEnc: reverseBytes([]byte("https://existing"))}) + h := mountPrefs(t, uid, prefs) + + body, _ := json.Marshal(prefsUpdateReq{IMWebhookURL: strPtr("")}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer tok") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + require.Equal(t, http.StatusOK, rec.Code) + saved, _ := prefs.Get(context.Background(), uid) + require.Empty(t, saved.ImWebhookURLEnc) +} + +func TestPrefsHandler_PutRejectsBadSeverity(t *testing.T) { + uid := uuid.New() + h := mountPrefs(t, uid, newFakePrefs()) + body, _ := json.Marshal(prefsUpdateReq{MinSeverity: strPtr("catastrophic")}) + req := httptest.NewRequest(http.MethodPut, "/api/v1/notifications/prefs", bytes.NewReader(body)) + req.Header.Set("Authorization", "Bearer tok") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + require.Equal(t, http.StatusBadRequest, rec.Code) +} + +func TestPrefsHandler_NotMountedWithoutEncryptor(t *testing.T) { + uid := uuid.New() + r := chi.NewRouter() + // WithPrefs 未调用 -> /prefs 不应被挂载。 + NewHandler(&noopRepo{}, &prefsFakeResolver{uid: uid}).Mount(r) + req := httptest.NewRequest(http.MethodGet, "/api/v1/notifications/prefs", nil) + req.Header.Set("Authorization", "Bearer tok") + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + require.Equal(t, http.StatusNotFound, rec.Code) +} + +func boolPtr(b bool) *bool { return &b } +func strPtr(s string) *string { return &s } diff --git a/internal/infra/notify/queries/prefs.sql b/internal/infra/notify/queries/prefs.sql new file mode 100644 index 0000000..fa8e644 --- /dev/null +++ b/internal/infra/notify/queries/prefs.sql @@ -0,0 +1,14 @@ +-- name: GetNotificationPrefs :one +SELECT user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at +FROM notification_prefs +WHERE user_id = $1; + +-- name: UpsertNotificationPrefs :exec +INSERT INTO notification_prefs (user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at) +VALUES ($1, $2, $3, $4, $5, now()) +ON CONFLICT (user_id) DO UPDATE SET + email_enabled = EXCLUDED.email_enabled, + im_enabled = EXCLUDED.im_enabled, + im_webhook_url_enc = EXCLUDED.im_webhook_url_enc, + min_severity = EXCLUDED.min_severity, + updated_at = now(); diff --git a/internal/infra/notify/sqlc/models.go b/internal/infra/notify/sqlc/models.go index 3b19478..f511787 100644 --- a/internal/infra/notify/sqlc/models.go +++ b/internal/infra/notify/sqlc/models.go @@ -8,6 +8,7 @@ import ( "net/netip" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type AcpAgentKind struct { @@ -25,6 +26,11 @@ type AcpAgentKind struct { ToolAllowlist []string `json:"tool_allowlist"` ClientType string `json:"client_type"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` } type AcpAgentKindConfigFile struct { @@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct { UpdatedBy pgtype.UUID `json:"updated_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type AcpEvent struct { @@ -64,23 +71,64 @@ type AcpPermissionRequest struct { } type AcpSession struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ProjectID pgtype.UUID `json:"project_id"` - AgentKindID pgtype.UUID `json:"agent_kind_id"` - UserID pgtype.UUID `json:"user_id"` - IssueID pgtype.UUID `json:"issue_id"` - RequirementID pgtype.UUID `json:"requirement_id"` - AgentSessionID *string `json:"agent_session_id"` - Branch string `json:"branch"` - CwdPath string `json:"cwd_path"` - IsMainWorktree bool `json:"is_main_worktree"` - Status string `json:"status"` - Pid *int32 `json:"pid"` - ExitCode *int32 `json:"exit_code"` - LastError *string `json:"last_error"` - StartedAt pgtype.Timestamptz `json:"started_at"` - EndedAt pgtype.Timestamptz `json:"ended_at"` + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` } type AuditLog struct { @@ -94,6 +142,81 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Conversation struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` @@ -110,6 +233,14 @@ type Conversation struct { RequirementID pgtype.UUID `json:"requirement_id"` } +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type GitCredential struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -119,6 +250,7 @@ type GitCredential struct { Fingerprint *string `json:"fingerprint"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type Issue struct { @@ -135,6 +267,17 @@ type Issue struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Job struct { @@ -162,6 +305,7 @@ type LlmEndpoint struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type LlmModel struct { @@ -252,6 +396,50 @@ type Notification struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + type Project struct { ID pgtype.UUID `json:"id"` Slug string `json:"slug"` @@ -264,6 +452,30 @@ type Project struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type PromptTemplate struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` @@ -299,6 +511,16 @@ type RequirementArtifact struct { SourceMessageID *int64 `json:"source_message_id"` CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type User struct { @@ -354,6 +576,7 @@ type WorkspaceRunProfile struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type WorkspaceWorktree struct { diff --git a/internal/infra/notify/sqlc/prefs.sql.go b/internal/infra/notify/sqlc/prefs.sql.go new file mode 100644 index 0000000..b137872 --- /dev/null +++ b/internal/infra/notify/sqlc/prefs.sql.go @@ -0,0 +1,62 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: prefs.sql + +package notifysqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getNotificationPrefs = `-- name: GetNotificationPrefs :one +SELECT user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at +FROM notification_prefs +WHERE user_id = $1 +` + +func (q *Queries) GetNotificationPrefs(ctx context.Context, userID pgtype.UUID) (NotificationPref, error) { + row := q.db.QueryRow(ctx, getNotificationPrefs, userID) + var i NotificationPref + err := row.Scan( + &i.UserID, + &i.EmailEnabled, + &i.ImEnabled, + &i.ImWebhookUrlEnc, + &i.MinSeverity, + &i.UpdatedAt, + ) + return i, err +} + +const upsertNotificationPrefs = `-- name: UpsertNotificationPrefs :exec +INSERT INTO notification_prefs (user_id, email_enabled, im_enabled, im_webhook_url_enc, min_severity, updated_at) +VALUES ($1, $2, $3, $4, $5, now()) +ON CONFLICT (user_id) DO UPDATE SET + email_enabled = EXCLUDED.email_enabled, + im_enabled = EXCLUDED.im_enabled, + im_webhook_url_enc = EXCLUDED.im_webhook_url_enc, + min_severity = EXCLUDED.min_severity, + updated_at = now() +` + +type UpsertNotificationPrefsParams struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` +} + +func (q *Queries) UpsertNotificationPrefs(ctx context.Context, arg UpsertNotificationPrefsParams) error { + _, err := q.db.Exec(ctx, upsertNotificationPrefs, + arg.UserID, + arg.EmailEnabled, + arg.ImEnabled, + arg.ImWebhookUrlEnc, + arg.MinSeverity, + ) + return err +} diff --git a/internal/infra/notify/stream.go b/internal/infra/notify/stream.go new file mode 100644 index 0000000..640bb2e --- /dev/null +++ b/internal/infra/notify/stream.go @@ -0,0 +1,84 @@ +// stream.go 实现通知 inbox 的实时推送(spec §11 步骤8)。 +// +// StreamHub 是 per-user 的订阅扇出中心:每个浏览器标签页 Subscribe 一个有界缓冲 +// channel;Dispatcher 落库成功后 Publish 到该用户的全部订阅者。慢订阅者(缓冲满) +// 直接丢弃该消息(drop-on-slow),绝不阻塞 Publish / Dispatch,避免 goroutine / +// 内存泄漏(镜像 chat StreamerHub 的丢弃纪律)。 +package notify + +import ( + "sync" + + "github.com/google/uuid" +) + +// StreamHub 是 Dispatcher 推送实时通知所需的窄接口。 +type StreamHub interface { + Subscribe(userID uuid.UUID) (<-chan Message, func()) + Publish(userID uuid.UUID, m Message) +} + +// streamSubBuffer 是单个订阅者的缓冲深度。满时新消息被丢弃(客户端可用 inbox +// 列表接口补偿,与 chat WS onopen 主动拉取同理)。 +const streamSubBuffer = 16 + +// memHub 是 StreamHub 的进程内实现。 +type memHub struct { + mu sync.Mutex + subs map[uuid.UUID]map[int]chan Message + next int +} + +// NewStreamHub 构造进程内的 per-user 通知推送中心。 +func NewStreamHub() StreamHub { + return &memHub{subs: map[uuid.UUID]map[int]chan Message{}} +} + +// Subscribe 为某用户注册一个订阅者,返回只读 channel 与取消函数。取消函数幂等: +// 关闭 channel 并从订阅表移除;调用方应在请求结束时 defer 调用。 +func (h *memHub) Subscribe(userID uuid.UUID) (<-chan Message, func()) { + ch := make(chan Message, streamSubBuffer) + h.mu.Lock() + if h.subs[userID] == nil { + h.subs[userID] = map[int]chan Message{} + } + id := h.next + h.next++ + h.subs[userID][id] = ch + h.mu.Unlock() + + var once sync.Once + cancel := func() { + once.Do(func() { + h.mu.Lock() + if m, ok := h.subs[userID]; ok { + if c, ok := m[id]; ok { + delete(m, id) + close(c) + } + if len(m) == 0 { + delete(h.subs, userID) + } + } + h.mu.Unlock() + }) + } + return ch, cancel +} + +// Publish 向某用户的全部订阅者非阻塞投递一条消息。缓冲满的订阅者被跳过。 +// +// 关键:整个投递循环持有 h.mu。因为发送是非阻塞的(select default),持锁开销极小, +// 但这让 Publish 与 cancel()(同样持 h.mu 后 close channel)互斥——否则快照 channel +// 释放锁后、cancel 并发 close,会 panic "send on closed channel"。 +func (h *memHub) Publish(userID uuid.UUID, m Message) { + h.mu.Lock() + defer h.mu.Unlock() + for _, c := range h.subs[userID] { + select { + case c <- m: + default: + // drop-on-slow:缓冲满,丢弃,避免阻塞 Dispatch。 + } + } +} diff --git a/internal/infra/notify/stream_test.go b/internal/infra/notify/stream_test.go new file mode 100644 index 0000000..72aa4a0 --- /dev/null +++ b/internal/infra/notify/stream_test.go @@ -0,0 +1,120 @@ +package notify + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" +) + +func TestStreamHubSubscribeReceivesPublish(t *testing.T) { + hub := NewStreamHub() + uid := uuid.New() + ch, cancel := hub.Subscribe(uid) + defer cancel() + + want := Message{ID: uuid.New(), UserID: uid, Topic: "t", Title: "hi"} + hub.Publish(uid, want) + + select { + case got := <-ch: + if got.ID != want.ID { + t.Fatalf("got %v, want %v", got.ID, want.ID) + } + case <-time.After(time.Second): + t.Fatal("did not receive published message") + } +} + +func TestStreamHubUnsubscribeStopsDelivery(t *testing.T) { + hub := NewStreamHub() + uid := uuid.New() + ch, cancel := hub.Subscribe(uid) + cancel() // immediate unsubscribe closes the channel + + // channel should be closed, draining returns zero value + !ok. + if _, ok := <-ch; ok { + t.Fatal("expected channel closed after cancel") + } + // Publishing to an unsubscribed user must not panic. + hub.Publish(uid, Message{UserID: uid}) +} + +func TestStreamHubDropsSlowSubscriber(t *testing.T) { + hub := NewStreamHub() + uid := uuid.New() + ch, cancel := hub.Subscribe(uid) + defer cancel() + + // Flood beyond buffer; Publish must not block / deadlock. + done := make(chan struct{}) + go func() { + for i := 0; i < streamSubBuffer*4; i++ { + hub.Publish(uid, Message{UserID: uid, Topic: "flood"}) + } + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("Publish deadlocked on slow subscriber") + } + // Buffer holds at most streamSubBuffer; the rest were dropped. + count := 0 + for { + select { + case <-ch: + count++ + default: + if count > streamSubBuffer { + t.Fatalf("buffered %d > cap %d (drop-on-slow violated)", count, streamSubBuffer) + } + return + } + } +} + +func TestDispatcherPublishesAfterInsert(t *testing.T) { + repo := &fakeRepo{} + d := NewDispatcher(repo, newTestLogger()) + hub := NewStreamHub() + d.SetStreamHub(hub) + + uid := uuid.New() + ch, cancel := hub.Subscribe(uid) + defer cancel() + + if err := d.Dispatch(context.Background(), Message{UserID: uid, Topic: "x", Title: "y"}); err != nil { + t.Fatalf("dispatch: %v", err) + } + select { + case got := <-ch: + if got.Topic != "x" { + t.Fatalf("topic = %q", got.Topic) + } + case <-time.After(time.Second): + t.Fatal("hub did not receive dispatched message") + } +} + +func TestDispatcherNoPublishOnInsertFailure(t *testing.T) { + repo := &fakeRepo{failNext: true} + d := NewDispatcher(repo, newTestLogger()) + hub := NewStreamHub() + d.SetStreamHub(hub) + + uid := uuid.New() + ch, cancel := hub.Subscribe(uid) + defer cancel() + + if err := d.Dispatch(context.Background(), Message{UserID: uid}); err == nil { + t.Fatal("expected dispatch error on insert failure") + } + select { + case <-ch: + t.Fatal("hub received message despite insert failure") + case <-time.After(100 * time.Millisecond): + // expected: no publish. + } +} diff --git a/internal/infra/proc/proc_run.go b/internal/infra/proc/proc_run.go new file mode 100644 index 0000000..204dc05 --- /dev/null +++ b/internal/infra/proc/proc_run.go @@ -0,0 +1,168 @@ +package proc + +import ( + "bytes" + "context" + "os/exec" + "time" +) + +// ExecSpec 描述一次一次性(one-shot)命令执行的全部输入。 +// 调用方(run.ExecService)负责前置鉴权、cwd / env 构造与命令白名单校验。 +type ExecSpec struct { + Command string + Args []string + Dir string + Env []string // 完整环境(宿主白名单 + 覆盖),nil 表示空环境而非继承宿主 + // Timeout 是硬上限:超时即对整组发起 TerminateGroup(树终止)并置 TimedOut。 + // <=0 时不设独立超时,仅受 ctx 约束。 + Timeout time.Duration + // KillGrace 是树终止时的优雅宽限(Unix SIGTERM → grace → SIGKILL)。 + KillGrace time.Duration + // MaxOutputBytes 是 stdout / stderr 各自的捕获上限;超出截断并置 truncated。 + // <=0 时使用 defaultMaxOutputBytes。 + MaxOutputBytes int +} + +// ExecResult 是一次性执行的结构化结果。 +type ExecResult struct { + ExitCode int + Stdout string + Stderr string + Duration time.Duration + TimedOut bool + Truncated bool // stdout 或 stderr 任一被截断时为 true + StdoutSize int // 截断前的实际字节数 + StderrSize int // 截断前的实际字节数 +} + +const defaultMaxOutputBytes = 1 << 20 // 1 MiB + +// RunOnce 同步执行 spec 描述的命令直至退出(或超时 / ctx 取消),捕获 stdout / +// stderr(各自封顶 MaxOutputBytes),返回退出码、时长、超时标志。 +// +// 进程树管理沿用 RunSupervisor.Spawn 的次序:Prepare(Start 前)→ Start → +// Adopt(Start 后)→ 退出或超时时 TerminateGroup 整树清理。这样测试运行器派生的 +// 子孙进程(go test 编译出的子进程、node 子进程等)在超时时也会被一并终止。 +// +// 与长驻 profile 的差异:本函数阻塞到进程退出后才返回,不接入 relay;输出在内存 +// 缓冲并随返回值一次性交回。错误仅在无法启动进程(exec.Start 失败)时返回;命令 +// 以非零码退出不算 error,体现在 ExitCode 上。 +func RunOnce(ctx context.Context, spec ExecSpec) (ExecResult, error) { + maxOut := spec.MaxOutputBytes + if maxOut <= 0 { + maxOut = defaultMaxOutputBytes + } + + cmd := exec.Command(spec.Command, spec.Args...) + cmd.Dir = spec.Dir + cmd.Env = spec.Env + + outBuf := &cappedBuffer{limit: maxOut} + errBuf := &cappedBuffer{limit: maxOut} + cmd.Stdout = outBuf + cmd.Stderr = errBuf + + group := NewGroup() + group.Prepare(cmd) + + started := time.Now() + if err := cmd.Start(); err != nil { + return ExecResult{}, err + } + if err := group.Adopt(cmd); err != nil { + _ = cmd.Process.Kill() + _ = cmd.Wait() + group.Close() + return ExecResult{}, err + } + + // waitCh 携带 Wait() 的错误;done 在 Wait 返回后 close,供 TerminateGroup + // 感知组长已被回收(语义对齐 RunSupervisor.monitorProcess 的 done channel)。 + waitCh := make(chan error, 1) + done := make(chan struct{}) + go func() { + err := cmd.Wait() + waitCh <- err + close(done) + }() + + // 超时 / ctx-cancel watchdog:触发整树终止;result 由下方 select 收尾。 + var timeoutTimer <-chan time.Time + if spec.Timeout > 0 { + t := time.NewTimer(spec.Timeout) + defer t.Stop() + timeoutTimer = t.C + } + + var timedOut bool + var waitErr error +loop: + for { + select { + case waitErr = <-waitCh: + break loop + case <-timeoutTimer: + timedOut = true + group.TerminateGroup(done, spec.KillGrace) + waitErr = <-waitCh + break loop + case <-ctx.Done(): + group.TerminateGroup(done, spec.KillGrace) + waitErr = <-waitCh + break loop + } + } + group.Close() + + res := ExecResult{ + ExitCode: exitCodeOf(waitErr), + Stdout: outBuf.String(), + Stderr: errBuf.String(), + Duration: time.Since(started), + TimedOut: timedOut, + Truncated: outBuf.truncated || errBuf.truncated, + StdoutSize: outBuf.total, + StderrSize: errBuf.total, + } + return res, nil +} + +// exitCodeOf 从 Cmd.Wait 的返回错误提取退出码。正常退出(nil)= 0; +// *exec.ExitError 取其 ExitCode(被信号杀死时为 -1);其它错误统一 -1。 +func exitCodeOf(waitErr error) int { + if waitErr == nil { + return 0 + } + if ee, ok := waitErr.(*exec.ExitError); ok { + return ee.ExitCode() + } + return -1 +} + +// cappedBuffer 是一个带上限的 io.Writer:累计写入 limit 字节后丢弃后续数据并置 +// truncated,同时仍记录 total(截断前的总字节数)便于上层提示。 +type cappedBuffer struct { + buf bytes.Buffer + limit int + total int + truncated bool +} + +func (c *cappedBuffer) Write(p []byte) (int, error) { + c.total += len(p) + if c.buf.Len() >= c.limit { + c.truncated = true + return len(p), nil // 谎报全写入:避免 cmd 因 short write 报错而中断 + } + remain := c.limit - c.buf.Len() + if len(p) > remain { + c.buf.Write(p[:remain]) + c.truncated = true + return len(p), nil + } + c.buf.Write(p) + return len(p), nil +} + +func (c *cappedBuffer) String() string { return c.buf.String() } diff --git a/internal/infra/proc/proc_run_test.go b/internal/infra/proc/proc_run_test.go new file mode 100644 index 0000000..fc23381 --- /dev/null +++ b/internal/infra/proc/proc_run_test.go @@ -0,0 +1,143 @@ +package proc + +import ( + "context" + "fmt" + "runtime" + "strings" + "testing" + "time" +) + +// helperShell 返回执行一段 shell 脚本的跨平台命令前缀(command, args...)。 +func helperShell(script string) (string, []string) { + if runtime.GOOS == "windows" { + return "cmd", []string{"/c", script} + } + return "sh", []string{"-c", script} +} + +// TestRunOnce_ExitCodeAndCapture 验证捕获 stdout / stderr 与非零退出码。 +func TestRunOnce_ExitCodeAndCapture(t *testing.T) { + var script string + if runtime.GOOS == "windows" { + script = "echo out& echo err 1>&2& exit 3" + } else { + script = "echo out; echo err 1>&2; exit 3" + } + cmd, args := helperShell(script) + res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, Timeout: 10 * time.Second}) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if res.ExitCode != 3 { + t.Fatalf("exit code = %d, want 3", res.ExitCode) + } + if !strings.Contains(res.Stdout, "out") { + t.Fatalf("stdout = %q, want contains 'out'", res.Stdout) + } + if !strings.Contains(res.Stderr, "err") { + t.Fatalf("stderr = %q, want contains 'err'", res.Stderr) + } + if res.TimedOut { + t.Fatal("TimedOut should be false") + } +} + +// TestRunOnce_Success 验证零退出码路径。 +func TestRunOnce_Success(t *testing.T) { + cmd, args := helperShell("echo hello") + res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, Timeout: 10 * time.Second}) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if res.ExitCode != 0 { + t.Fatalf("exit code = %d, want 0", res.ExitCode) + } + if !strings.Contains(res.Stdout, "hello") { + t.Fatalf("stdout = %q", res.Stdout) + } +} + +// TestRunOnce_Truncation 验证 stdout 超过 MaxOutputBytes 时截断并置标志。 +func TestRunOnce_Truncation(t *testing.T) { + // 打印约 1000 字节,但上限设为 100。 + var cmd string + var args []string + if runtime.GOOS == "windows" { + // PowerShell 直接调用避免 cmd 转义;-NoProfile 加快启动。 + cmd, args = "powershell", []string{"-NoProfile", "-Command", "Write-Output ('a' * 1000)"} + } else { + cmd, args = "sh", []string{"-c", "for i in $(seq 1 1000); do printf a; done"} + } + res, err := RunOnce(context.Background(), ExecSpec{Command: cmd, Args: args, MaxOutputBytes: 100, Timeout: 15 * time.Second}) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if !res.Truncated { + t.Fatalf("Truncated should be true; stdout len=%d size=%d", len(res.Stdout), res.StdoutSize) + } + if len(res.Stdout) > 100 { + t.Fatalf("captured stdout len=%d exceeds limit 100", len(res.Stdout)) + } +} + +// TestRunOnce_Timeout 验证超时触发整树终止并置 TimedOut。 +// +// Unix:父 shell trap 掉 TERM 并 sleep,再后台派生一个 grandchild sleep;超时后 +// TerminateGroup 必须 SIGKILL 整组,确保父退出(done 被关闭)。 +// Windows:直接 timeout 长睡,Job-close 终止。 +func TestRunOnce_Timeout(t *testing.T) { + var cmd string + var args []string + if runtime.GOOS == "windows" { + // ping -n N localhost 是无需控制台的可靠延时(timeout /t 在无 stdin 下立即返回)。 + cmd, args = "cmd", []string{"/c", "ping -n 30 127.0.0.1 >nul"} + } else { + // trap '' TERM 忽略 SIGTERM;后台派生 grandchild;wait 阻塞存活。 + cmd, args = "sh", []string{"-c", "trap '' TERM; sleep 30 & sleep 30; wait"} + } + start := time.Now() + res, err := RunOnce(context.Background(), ExecSpec{ + Command: cmd, + Args: args, + Timeout: 1 * time.Second, + KillGrace: 500 * time.Millisecond, + }) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if !res.TimedOut { + t.Fatal("TimedOut should be true") + } + // 必须在远小于 30s 内返回(超时 + grace + kill),证明整树被回收而非等自然退出。 + if elapsed := time.Since(start); elapsed > 10*time.Second { + t.Fatalf("RunOnce blocked %v, expected prompt tree-kill", elapsed) + } +} + +// TestRunOnce_CtxCancel 验证 ctx 取消亦触发树终止并尽快返回。 +func TestRunOnce_CtxCancel(t *testing.T) { + cmd, args := helperShell(fmt.Sprintf("%s", sleepScript(30))) + ctx, cancel := context.WithCancel(context.Background()) + go func() { + time.Sleep(500 * time.Millisecond) + cancel() + }() + start := time.Now() + res, err := RunOnce(ctx, ExecSpec{Command: cmd, Args: args, KillGrace: 500 * time.Millisecond}) + if err != nil { + t.Fatalf("RunOnce: %v", err) + } + if elapsed := time.Since(start); elapsed > 10*time.Second { + t.Fatalf("RunOnce blocked %v after ctx cancel", elapsed) + } + _ = res +} + +func sleepScript(sec int) string { + if runtime.GOOS == "windows" { + return fmt.Sprintf("ping -n %d 127.0.0.1 >nul", sec) + } + return fmt.Sprintf("sleep %d", sec) +} diff --git a/internal/infra/vcs/gitea.go b/internal/infra/vcs/gitea.go new file mode 100644 index 0000000..5207e93 --- /dev/null +++ b/internal/infra/vcs/gitea.go @@ -0,0 +1,259 @@ +package vcs + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "strings" + "time" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// DefaultGiteaBaseURL is the platform's self-hosted Gitea instance. +const DefaultGiteaBaseURL = "https://git.jerryyan.net" + +// giteaErrBodyLimit caps how much of a non-2xx response body is surfaced in the +// wrapped error message. +const giteaErrBodyLimit = 512 + +// GiteaProvider implements Provider against the Gitea REST v1 API. Auth uses +// the "Authorization: token " header with a single platform-level token. +type GiteaProvider struct { + baseURL string + token string + client *http.Client +} + +// NewGiteaProvider constructs a GiteaProvider. baseURL empty defaults to +// DefaultGiteaBaseURL; timeout <= 0 defaults to 15s. A fresh http.Client is +// used (low call frequency), mirroring the webhook handler pattern. +func NewGiteaProvider(baseURL, token string, timeout time.Duration) *GiteaProvider { + if baseURL == "" { + baseURL = DefaultGiteaBaseURL + } + if timeout <= 0 { + timeout = 15 * time.Second + } + return &GiteaProvider{ + baseURL: strings.TrimRight(baseURL, "/"), + token: token, + client: &http.Client{Timeout: timeout}, + } +} + +// Host returns the bare host (no scheme/port) this provider serves, for +// ParseRepoRef host matching. +func (g *GiteaProvider) Host() string { + u, err := url.Parse(g.baseURL) + if err != nil || u.Host == "" { + return "" + } + return u.Hostname() +} + +// ===== Gitea wire DTOs ===== + +type giteaPR struct { + Number int64 `json:"number"` + State string `json:"state"` + Mergeable bool `json:"mergeable"` + Merged bool `json:"merged"` + MergedCommitID string `json:"merge_commit_sha"` + HTMLURL string `json:"html_url"` + Head giteaBranch `json:"head"` +} + +type giteaBranch struct { + Sha string `json:"sha"` + Ref string `json:"ref"` +} + +type giteaCombinedStatus struct { + State string `json:"state"` // pending|success|error|failure +} + +type giteaIssue struct { + Number int64 `json:"number"` + Title string `json:"title"` + State string `json:"state"` + HTMLURL string `json:"html_url"` +} + +// ===== Provider impl ===== + +func (g *GiteaProvider) CreatePR(ctx context.Context, in CreatePRInput) (*PullRequest, error) { + body := map[string]any{ + "head": in.Head, + "base": in.Base, + "title": in.Title, + "body": in.Body, + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls", esc(in.Repo.Owner), esc(in.Repo.Name)) + var pr giteaPR + if err := g.do(ctx, http.MethodPost, path, body, &pr); err != nil { + return nil, err + } + out := mapPR(pr) + // best-effort CI state from the head commit's combined status + out.CIState = g.ciState(ctx, in.Repo, pr.Head.Sha) + return &out, nil +} + +func (g *GiteaProvider) GetPR(ctx context.Context, repo RepoRef, number int64) (*PullRequest, error) { + path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d", esc(repo.Owner), esc(repo.Name), number) + var pr giteaPR + if err := g.do(ctx, http.MethodGet, path, nil, &pr); err != nil { + return nil, err + } + out := mapPR(pr) + out.CIState = g.ciState(ctx, repo, pr.Head.Sha) + return &out, nil +} + +func (g *GiteaProvider) Comment(ctx context.Context, repo RepoRef, number int64, body string) error { + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues/%d/comments", esc(repo.Owner), esc(repo.Name), number) + return g.do(ctx, http.MethodPost, path, map[string]any{"body": body}, nil) +} + +func (g *GiteaProvider) Merge(ctx context.Context, repo RepoRef, number int64, in MergeInput) (string, error) { + method := in.Method + if method == "" { + method = "merge" + } + body := map[string]any{"Do": method} + if in.Title != "" { + body["MergeTitleField"] = in.Title + } + if in.Message != "" { + body["MergeMessageField"] = in.Message + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/pulls/%d/merge", esc(repo.Owner), esc(repo.Name), number) + if err := g.do(ctx, http.MethodPost, path, body, nil); err != nil { + return "", err + } + // Gitea's merge endpoint returns 200 with no SHA; re-fetch to read the merge commit. + pr, err := g.GetPR(ctx, repo, number) + if err != nil { + return "", nil // merge succeeded; sha lookup best-effort + } + return pr.MergeCommitSHA, nil +} + +func (g *GiteaProvider) ListIssues(ctx context.Context, repo RepoRef, opts IssueListOptions) ([]Issue, error) { + q := url.Values{} + q.Set("type", "issues") + if opts.State != "" { + q.Set("state", opts.State) + } + if opts.Limit > 0 { + q.Set("limit", strconv.Itoa(opts.Limit)) + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/issues?%s", esc(repo.Owner), esc(repo.Name), q.Encode()) + var rows []giteaIssue + if err := g.do(ctx, http.MethodGet, path, nil, &rows); err != nil { + return nil, err + } + out := make([]Issue, 0, len(rows)) + for _, r := range rows { + out = append(out, Issue{Number: r.Number, Title: r.Title, State: r.State, HTMLURL: r.HTMLURL}) + } + return out, nil +} + +// ciState fetches the combined commit status for sha and maps it to the +// platform's ci_state vocabulary. Missing status / errors degrade to "unknown". +func (g *GiteaProvider) ciState(ctx context.Context, repo RepoRef, sha string) string { + if sha == "" { + return "unknown" + } + path := fmt.Sprintf("/api/v1/repos/%s/%s/commits/%s/status", esc(repo.Owner), esc(repo.Name), esc(sha)) + var cs giteaCombinedStatus + if err := g.do(ctx, http.MethodGet, path, nil, &cs); err != nil { + return "unknown" + } + switch cs.State { + case "success": + return "success" + case "pending": + return "pending" + case "error", "failure": + return "failure" + default: + return "unknown" + } +} + +// ===== HTTP plumbing ===== + +// do performs a request, sets the token auth header, and decodes a 2xx JSON +// body into out (out nil => body discarded). Non-2xx becomes a typed upstream +// error carrying a truncated body. +func (g *GiteaProvider) do(ctx context.Context, method, path string, body, out any) error { + var rdr io.Reader + if body != nil { + b, err := json.Marshal(body) + if err != nil { + return errs.Wrap(err, errs.CodeInternal, "vcs: marshal request body") + } + rdr = bytes.NewReader(b) + } + req, err := http.NewRequestWithContext(ctx, method, g.baseURL+path, rdr) + if err != nil { + return errs.Wrap(err, errs.CodeInternal, "vcs: build request") + } + if g.token != "" { + req.Header.Set("Authorization", "token "+g.token) + } + req.Header.Set("Accept", "application/json") + if body != nil { + req.Header.Set("Content-Type", "application/json") + } + + resp, err := g.client.Do(req) + if err != nil { + return errs.Wrap(err, errs.CodeUpstream, "vcs: gitea request failed") + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode < 200 || resp.StatusCode >= 300 { + snippet, _ := io.ReadAll(io.LimitReader(resp.Body, giteaErrBodyLimit)) + code := errs.CodeUpstream + if resp.StatusCode == http.StatusNotFound { + code = errs.CodeNotFound + } + return errs.New(code, fmt.Sprintf("vcs: gitea %s %s -> %d: %s", + method, path, resp.StatusCode, strings.TrimSpace(string(snippet)))) + } + if out == nil { + _, _ = io.Copy(io.Discard, resp.Body) + return nil + } + if err := json.NewDecoder(resp.Body).Decode(out); err != nil { + return errs.Wrap(err, errs.CodeUpstream, "vcs: decode gitea response") + } + return nil +} + +func mapPR(pr giteaPR) PullRequest { + state := pr.State + if pr.Merged { + state = "merged" + } + return PullRequest{ + Number: pr.Number, + State: state, + Mergeable: pr.Mergeable, + Merged: pr.Merged, + MergeCommitSHA: pr.MergedCommitID, + HTMLURL: pr.HTMLURL, + } +} + +// esc path-escapes a single URL segment (owner/name/sha may contain odd chars). +func esc(s string) string { return url.PathEscape(s) } diff --git a/internal/infra/vcs/gitea_test.go b/internal/infra/vcs/gitea_test.go new file mode 100644 index 0000000..d9efc9d --- /dev/null +++ b/internal/infra/vcs/gitea_test.go @@ -0,0 +1,152 @@ +package vcs + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +func newTestProvider(t *testing.T, h http.Handler) *GiteaProvider { + t.Helper() + srv := httptest.NewServer(h) + t.Cleanup(srv.Close) + return NewGiteaProvider(srv.URL, "test-token", 5*time.Second) +} + +func TestGitea_CreatePR(t *testing.T) { + repo := RepoRef{Owner: "owner", Name: "repo"} + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/repos/owner/repo/pulls", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, http.MethodPost, r.Method) + assert.Equal(t, "token test-token", r.Header.Get("Authorization")) + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "feature", body["head"]) + assert.Equal(t, "main", body["base"]) + w.WriteHeader(http.StatusCreated) + _ = json.NewEncoder(w).Encode(giteaPR{ + Number: 42, State: "open", Mergeable: true, + HTMLURL: "https://git.jerryyan.net/owner/repo/pulls/42", + Head: giteaBranch{Sha: "abc123", Ref: "feature"}, + }) + }) + mux.HandleFunc("/api/v1/repos/owner/repo/commits/abc123/status", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(giteaCombinedStatus{State: "success"}) + }) + + p := newTestProvider(t, mux) + pr, err := p.CreatePR(context.Background(), CreatePRInput{ + Repo: repo, Head: "feature", Base: "main", Title: "T", Body: "B", + }) + require.NoError(t, err) + assert.Equal(t, int64(42), pr.Number) + assert.Equal(t, "open", pr.State) + assert.Equal(t, "https://git.jerryyan.net/owner/repo/pulls/42", pr.HTMLURL) + assert.Equal(t, "success", pr.CIState) +} + +func TestGitea_GetPR_MergedAndCI(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/repos/owner/repo/pulls/7", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(giteaPR{ + Number: 7, State: "closed", Merged: true, MergedCommitID: "deadbeef", + HTMLURL: "https://git.jerryyan.net/owner/repo/pulls/7", + Head: giteaBranch{Sha: "sha7"}, + }) + }) + mux.HandleFunc("/api/v1/repos/owner/repo/commits/sha7/status", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(giteaCombinedStatus{State: "failure"}) + }) + + p := newTestProvider(t, mux) + pr, err := p.GetPR(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 7) + require.NoError(t, err) + assert.True(t, pr.Merged) + assert.Equal(t, "merged", pr.State) + assert.Equal(t, "deadbeef", pr.MergeCommitSHA) + assert.Equal(t, "failure", pr.CIState) +} + +func TestGitea_Merge(t *testing.T) { + var mergeCalled bool + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/repos/owner/repo/pulls/3/merge", func(w http.ResponseWriter, r *http.Request) { + mergeCalled = true + assert.Equal(t, http.MethodPost, r.Method) + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + assert.Equal(t, "squash", body["Do"]) + w.WriteHeader(http.StatusOK) + }) + mux.HandleFunc("/api/v1/repos/owner/repo/pulls/3", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(giteaPR{Number: 3, State: "closed", Merged: true, MergedCommitID: "mergesha", Head: giteaBranch{Sha: "h3"}}) + }) + mux.HandleFunc("/api/v1/repos/owner/repo/commits/h3/status", func(w http.ResponseWriter, _ *http.Request) { + _ = json.NewEncoder(w).Encode(giteaCombinedStatus{State: "success"}) + }) + + p := newTestProvider(t, mux) + sha, err := p.Merge(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 3, MergeInput{Method: "squash"}) + require.NoError(t, err) + assert.True(t, mergeCalled) + assert.Equal(t, "mergesha", sha) +} + +func TestGitea_Comment(t *testing.T) { + var gotBody string + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/repos/owner/repo/issues/5/comments", func(w http.ResponseWriter, r *http.Request) { + var body map[string]any + require.NoError(t, json.NewDecoder(r.Body).Decode(&body)) + gotBody, _ = body["body"].(string) + w.WriteHeader(http.StatusCreated) + }) + + p := newTestProvider(t, mux) + require.NoError(t, p.Comment(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 5, "hello")) + assert.Equal(t, "hello", gotBody) +} + +func TestGitea_ListIssues(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/repos/owner/repo/issues", func(w http.ResponseWriter, r *http.Request) { + assert.Equal(t, "issues", r.URL.Query().Get("type")) + _ = json.NewEncoder(w).Encode([]giteaIssue{ + {Number: 1, Title: "bug", State: "open", HTMLURL: "u1"}, + }) + }) + + p := newTestProvider(t, mux) + issues, err := p.ListIssues(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, IssueListOptions{State: "open"}) + require.NoError(t, err) + require.Len(t, issues, 1) + assert.Equal(t, "bug", issues[0].Title) +} + +func TestGitea_Non2xxError(t *testing.T) { + mux := http.NewServeMux() + mux.HandleFunc("/api/v1/repos/owner/repo/pulls/9", func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"message":"not found"}`)) + }) + + p := newTestProvider(t, mux) + _, err := p.GetPR(context.Background(), RepoRef{Owner: "owner", Name: "repo"}, 9) + require.Error(t, err) + ae, ok := errs.As(err) + require.True(t, ok) + assert.Equal(t, errs.CodeNotFound, ae.Code) +} + +func TestGitea_Host(t *testing.T) { + p := NewGiteaProvider("https://git.jerryyan.net:3000", "", 0) + assert.Equal(t, "git.jerryyan.net", p.Host()) +} diff --git a/internal/infra/vcs/parse.go b/internal/infra/vcs/parse.go new file mode 100644 index 0000000..51bbf5f --- /dev/null +++ b/internal/infra/vcs/parse.go @@ -0,0 +1,89 @@ +package vcs + +import ( + "net/url" + "strings" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// ErrUnsupportedHost is returned by ParseRepoRef when the remote points at a +// host other than the one the v1 Gitea provider serves. +var ErrUnsupportedHost = errs.New(errs.CodeInvalidInput, "vcs: unsupported git host for PR operations") + +// ParseRepoRef derives owner/name from a workspace GitRemoteURL. It accepts: +// +// https://host/owner/name(.git) +// http://host/owner/name(.git) +// ssh://git@host/owner/name(.git) +// git@host:owner/name(.git) +// +// wantHost is the bare host the provider serves (e.g. "git.jerryyan.net"); when +// non-empty the remote host must match it (port stripped, case-insensitive), +// otherwise ErrUnsupportedHost is returned. wantHost empty skips the host +// check (host-agnostic parse, used in tests). +func ParseRepoRef(remoteURL, wantHost string) (RepoRef, error) { + remoteURL = strings.TrimSpace(remoteURL) + if remoteURL == "" { + return RepoRef{}, errs.New(errs.CodeInvalidInput, "vcs: empty remote url") + } + + host, path, err := splitHostPath(remoteURL) + if err != nil { + return RepoRef{}, err + } + if wantHost != "" && !sameHost(host, wantHost) { + return RepoRef{}, ErrUnsupportedHost + } + + owner, name, err := splitOwnerName(path) + if err != nil { + return RepoRef{}, err + } + return RepoRef{Owner: owner, Name: name}, nil +} + +// splitHostPath extracts the bare host and the repo path from a remote URL, +// handling both scheme URLs and the scp-like git@host:owner/name form. +func splitHostPath(remoteURL string) (host, path string, err error) { + // scp-like: git@host:owner/name.git (no scheme, single colon before path) + if !strings.Contains(remoteURL, "://") && strings.Contains(remoteURL, "@") && strings.Contains(remoteURL, ":") { + at := strings.Index(remoteURL, "@") + rest := remoteURL[at+1:] + colon := strings.Index(rest, ":") + if colon < 0 { + return "", "", errs.New(errs.CodeInvalidInput, "vcs: malformed scp remote url") + } + return rest[:colon], rest[colon+1:], nil + } + + u, perr := url.Parse(remoteURL) + if perr != nil || u.Host == "" { + return "", "", errs.Wrap(perr, errs.CodeInvalidInput, "vcs: cannot parse remote url") + } + return u.Hostname(), u.Path, nil +} + +// splitOwnerName turns "/owner/name.git" or "owner/name.git" into (owner, name). +func splitOwnerName(path string) (owner, name string, err error) { + path = strings.Trim(path, "/") + path = strings.TrimSuffix(path, ".git") + parts := strings.Split(path, "/") + if len(parts) < 2 || parts[0] == "" || parts[len(parts)-1] == "" { + return "", "", errs.New(errs.CodeInvalidInput, "vcs: remote url must contain owner/name") + } + // Gitea repos are owner/name; deeper paths are not valid repo refs. + return parts[0], parts[len(parts)-1], nil +} + +// sameHost compares hosts case-insensitively, ignoring any :port suffix. +func sameHost(a, b string) bool { + return strings.EqualFold(stripPort(a), stripPort(b)) +} + +func stripPort(h string) string { + if i := strings.LastIndex(h, ":"); i >= 0 { + return h[:i] + } + return h +} diff --git a/internal/infra/vcs/parse_test.go b/internal/infra/vcs/parse_test.go new file mode 100644 index 0000000..ac5dd12 --- /dev/null +++ b/internal/infra/vcs/parse_test.go @@ -0,0 +1,60 @@ +package vcs + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestParseRepoRef_HTTPS(t *testing.T) { + cases := []struct{ url, owner, name string }{ + {"https://git.jerryyan.net/owner/repo.git", "owner", "repo"}, + {"https://git.jerryyan.net/owner/repo", "owner", "repo"}, + {"http://git.jerryyan.net/acme/widgets.git", "acme", "widgets"}, + {"https://git.jerryyan.net:3000/owner/repo.git", "owner", "repo"}, + } + for _, c := range cases { + ref, err := ParseRepoRef(c.url, "git.jerryyan.net") + require.NoError(t, err, c.url) + assert.Equal(t, c.owner, ref.Owner, c.url) + assert.Equal(t, c.name, ref.Name, c.url) + } +} + +func TestParseRepoRef_SSHAndScp(t *testing.T) { + cases := []struct{ url, owner, name string }{ + {"git@git.jerryyan.net:owner/repo.git", "owner", "repo"}, + {"git@git.jerryyan.net:owner/repo", "owner", "repo"}, + {"ssh://git@git.jerryyan.net/owner/repo.git", "owner", "repo"}, + } + for _, c := range cases { + ref, err := ParseRepoRef(c.url, "git.jerryyan.net") + require.NoError(t, err, c.url) + assert.Equal(t, c.owner, ref.Owner, c.url) + assert.Equal(t, c.name, ref.Name, c.url) + } +} + +func TestParseRepoRef_UnsupportedHost(t *testing.T) { + _, err := ParseRepoRef("https://github.com/owner/repo.git", "git.jerryyan.net") + require.ErrorIs(t, err, ErrUnsupportedHost) + + _, err = ParseRepoRef("git@github.com:owner/repo.git", "git.jerryyan.net") + require.ErrorIs(t, err, ErrUnsupportedHost) +} + +func TestParseRepoRef_Invalid(t *testing.T) { + for _, bad := range []string{"", "not-a-url", "https://git.jerryyan.net/only-owner"} { + _, err := ParseRepoRef(bad, "git.jerryyan.net") + require.Error(t, err, bad) + } +} + +func TestParseRepoRef_HostAgnostic(t *testing.T) { + // wantHost empty => skip host check + ref, err := ParseRepoRef("https://github.com/owner/repo.git", "") + require.NoError(t, err) + assert.Equal(t, "owner", ref.Owner) + assert.Equal(t, "repo", ref.Name) +} diff --git a/internal/infra/vcs/provider.go b/internal/infra/vcs/provider.go new file mode 100644 index 0000000..445a8b5 --- /dev/null +++ b/internal/infra/vcs/provider.go @@ -0,0 +1,75 @@ +// Package vcs abstracts the git-host (VCS) REST API the platform uses to open +// and merge pull requests. v1 ships a Gitea-first implementation (gitea.go) +// targeting the self-hosted host git.jerryyan.net; other hosts return an +// Unimplemented error from the RepoRef parser / provider. +// +// The provider is intentionally narrow: only the verbs the change-request +// merge gate needs (CreatePR / GetPR / Comment / Merge / ListIssues). Secrets +// (the platform Gitea token) are held by the concrete provider, never passed +// through the interface. +package vcs + +import "context" + +// Provider is the git-host API surface consumed by the changerequest service. +type Provider interface { + // CreatePR opens a pull request from in.Head into in.Base. + CreatePR(ctx context.Context, in CreatePRInput) (*PullRequest, error) + // GetPR fetches a single PR by its host-side index/number, including a + // best-effort CI state derived from the head commit's combined status. + GetPR(ctx context.Context, repo RepoRef, number int64) (*PullRequest, error) + // Comment posts a comment on the PR (Gitea treats PRs as issues for comments). + Comment(ctx context.Context, repo RepoRef, number int64, body string) error + // Merge merges the PR via in.Method, returning the resulting merge commit SHA. + Merge(ctx context.Context, repo RepoRef, number int64, in MergeInput) (mergeSHA string, err error) + // ListIssues lists repository issues (not PRs) for orientation / linking. + ListIssues(ctx context.Context, repo RepoRef, opts IssueListOptions) ([]Issue, error) +} + +// RepoRef identifies a repository on the host as owner/name. Derived from a +// workspace's GitRemoteURL by ParseRepoRef. +type RepoRef struct { + Owner string + Name string +} + +// CreatePRInput is the input to Provider.CreatePR. +type CreatePRInput struct { + Repo RepoRef + Head string // source branch + Base string // target branch + Title string + Body string +} + +// MergeInput controls how Provider.Merge merges the PR. +type MergeInput struct { + Method string // "merge" | "squash" | "rebase" + Title string // merge commit title (optional) + Message string // merge commit message body (optional) +} + +// PullRequest is the host's view of a PR mapped to platform fields. +type PullRequest struct { + Number int64 + State string // "open" | "closed" (Gitea: PR closed covers merged too) + Mergeable bool + Merged bool + MergeCommitSHA string + HTMLURL string + CIState string // "unknown" | "pending" | "success" | "failure" +} + +// Issue is a minimal host issue projection. +type Issue struct { + Number int64 + Title string + State string + HTMLURL string +} + +// IssueListOptions filters ListIssues. State empty => host default ("open"). +type IssueListOptions struct { + State string // "open" | "closed" | "all" + Limit int +} diff --git a/internal/jobs/domain.go b/internal/jobs/domain.go index bb5e3c0..9f8e052 100644 --- a/internal/jobs/domain.go +++ b/internal/jobs/domain.go @@ -22,6 +22,10 @@ type JobType string const ( // TypeWebhookDeliver delivers a notify.Message to the configured webhook URL. TypeWebhookDeliver JobType = "webhook.deliver" + // TypeSecretReencrypt re-encrypts every encrypted-column row that is still on + // an old key version onto the current active version (master-key rotation). + // Enqueued by the rotate-key admin endpoint; idempotent + resumable. + TypeSecretReencrypt JobType = "secret.reencrypt" ) // JobStatus matches the CHECK constraint on jobs.status. @@ -142,6 +146,12 @@ type MCPTokensPurgeConfig struct { Retention time.Duration } +// AcpSessionReaperConfig controls the ACP session reaper schedule. +type AcpSessionReaperConfig struct { + Enabled bool + Interval time.Duration +} + // Config is the full jobs.Module configuration. type Config struct { Enabled bool @@ -154,4 +164,11 @@ type Config struct { JobPurge JobPurgeConfig AcpEventsPurge AcpEventsPurgeConfig MCPTokensPurge MCPTokensPurgeConfig + AcpSessionReaper AcpSessionReaperConfig + // OrchestratorSchedule controls the task-decomposition parallel scheduler tick + // (autonomy roadmap §10). Registered via RunnerFns key "orchestrator_schedule". + OrchestratorSchedule RunnerConfig + // AuditRetention controls the audit-log retention purge tick (autonomy + // roadmap §11). Registered via RunnerFns key "audit_retention". + AuditRetention RunnerConfig } diff --git a/internal/jobs/handlers/secret_reencrypt.go b/internal/jobs/handlers/secret_reencrypt.go new file mode 100644 index 0000000..8d7ef98 --- /dev/null +++ b/internal/jobs/handlers/secret_reencrypt.go @@ -0,0 +1,100 @@ +package handlers + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + + "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +// ReencryptStore abstracts the DB scan + per-row re-seal so the handler is +// testable without a live Postgres. ReencryptTable processes one batch of rows +// for one logical table that are still below activeVersion, re-sealing each +// blob with enc.EncryptVersion(active) inside a per-row transaction, and returns +// how many rows it advanced. It returns 0 when no rows remain on an old version +// (the table is done). Because each row's UPDATE sets both blob+key_version in +// one tx, the job is idempotent and resumable: re-running after a crash simply +// finds the rows that did not yet advance. +type ReencryptStore interface { + // Tables returns the logical table names this store can re-encrypt. + Tables() []string + // ReencryptTable advances up to batch rows of table below activeVersion onto + // the active version, returning the number of rows updated this call. + ReencryptTable(ctx context.Context, enc *crypto.KeyedEncryptor, table string, activeVersion, batch int) (int, error) + // StaleCount reports how many rows of table remain below activeVersion (for + // the reencrypt-status endpoint and the handler's completion check). + StaleCount(ctx context.Context, table string, activeVersion int) (int, error) +} + +// SecretReencryptPayload is the job payload. ActiveVersion is the target write +// version (the version crypto_keys is now active on); the handler re-encrypts +// every stale row onto it. BatchSize bounds per-iteration work; 0 → default. +type SecretReencryptPayload struct { + ActiveVersion int `json:"active_version"` + BatchSize int `json:"batch_size"` +} + +// SecretReencryptHandler implements jobs.Handler for TypeSecretReencrypt. +type SecretReencryptHandler struct { + store ReencryptStore + enc *crypto.KeyedEncryptor + log *slog.Logger +} + +// NewSecretReencryptHandler builds the handler. +func NewSecretReencryptHandler(store ReencryptStore, enc *crypto.KeyedEncryptor, log *slog.Logger) *SecretReencryptHandler { + if log == nil { + log = slog.Default() + } + return &SecretReencryptHandler{store: store, enc: enc, log: log} +} + +// Type reports the job type this handler serves. +func (h *SecretReencryptHandler) Type() jobs.JobType { return jobs.TypeSecretReencrypt } + +const defaultReencryptBatch = 100 + +// Handle scans every encrypted-column table and re-seals rows still on an old +// key version onto the active version, batch by batch, until none remain. It is +// idempotent (already-advanced rows are skipped by the key_version= active { + return tx.Commit(ctx) // already advanced by a concurrent run; idempotent + } + + // Re-seal each non-nil blob: decrypt under curVer, encrypt under active. + setExpr := "" + args := []any{id} + argN := 2 + for i, c := range spec.blobs { + b := *blobVals[i] + if len(b) == 0 { + continue // NULL/empty blob: leave as-is + } + plain, derr := enc.DecryptVersion(ctx, b, curVer) + if derr != nil { + return fmt.Errorf("decrypt %s/%s.%s v%d: %w", spec.name, id, c, curVer, derr) + } + sealed, err := enc.SealWithVersion(ctx, active, plain) + if err != nil { + return fmt.Errorf("reseal %s/%s.%s: %w", spec.name, id, c, err) + } + if setExpr != "" { + setExpr += ", " + } + setExpr += fmt.Sprintf("%s = $%d", c, argN) + args = append(args, sealed) + argN++ + } + // Always advance key_version (covers the all-NULL-blob defensive case too). + if setExpr != "" { + setExpr += ", " + } + setExpr += fmt.Sprintf("key_version = $%d", argN) + args = append(args, active) + + updQ := fmt.Sprintf(`UPDATE %s SET %s WHERE id = $1`, spec.name, setExpr) + if _, err := tx.Exec(ctx, updQ, args...); err != nil { + return fmt.Errorf("update %s/%s: %w", spec.name, id, err) + } + return tx.Commit(ctx) +} diff --git a/internal/jobs/handlers/secret_reencrypt_test.go b/internal/jobs/handlers/secret_reencrypt_test.go new file mode 100644 index 0000000..369ebc7 --- /dev/null +++ b/internal/jobs/handlers/secret_reencrypt_test.go @@ -0,0 +1,173 @@ +package handlers + +import ( + "context" + "crypto/rand" + "encoding/json" + "testing" + + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/crypto" + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +// memRow models one encrypted-column row across versions. +type memRow struct { + blob []byte + keyVersion int + plaintext []byte // ground truth for assertions + null bool +} + +// memStore is an in-memory ReencryptStore over a single logical table, with a +// failNext hook to model a crash mid-run for the resumability test. +type memStore struct { + rows []*memRow + failNext bool +} + +func (m *memStore) Tables() []string { return []string{"t"} } + +func (m *memStore) StaleCount(_ context.Context, _ string, active int) (int, error) { + n := 0 + for _, r := range m.rows { + if !r.null && r.keyVersion < active { + n++ + } + } + return n, nil +} + +func (m *memStore) ReencryptTable(ctx context.Context, enc *crypto.KeyedEncryptor, _ string, active, batch int) (int, error) { + done := 0 + for _, r := range m.rows { + if done >= batch { + break + } + if r.null || r.keyVersion >= active { + continue + } + if m.failNext { + m.failNext = false + return done, context.Canceled // simulate a crash after some rows + } + plain, err := enc.DecryptVersion(ctx, r.blob, r.keyVersion) + if err != nil { + return done, err + } + sealed, err := enc.SealWithVersion(ctx, active, plain) + if err != nil { + return done, err + } + r.blob = sealed + r.keyVersion = active + done++ + } + return done, nil +} + +func key(t *testing.T) []byte { + t.Helper() + k := make([]byte, 32) + _, err := rand.Read(k) + require.NoError(t, err) + return k +} + +// twoVersionEnc returns a KeyedEncryptor with v1+v2 keys and a store reporting +// active=2, plus a helper to seal under v1. +func twoVersionEnc(t *testing.T) *crypto.KeyedEncryptor { + prov := crypto.NewEnvProvider(nil) + prov.SetKey(1, key(t)) + prov.SetKey(2, key(t)) + return crypto.NewKeyedEncryptor(prov, crypto.NewStaticKeyStore(2)) +} + +func TestSecretReencrypt_AdvancesAllRows(t *testing.T) { + ctx := context.Background() + enc := twoVersionEnc(t) + + // Seed 3 rows encrypted under v1 + 1 NULL row. + store := &memStore{} + for i := 0; i < 3; i++ { + pt := []byte{byte(i), byte(i + 1)} + ct, err := enc.SealWithVersion(ctx, 1, pt) + require.NoError(t, err) + store.rows = append(store.rows, &memRow{blob: ct, keyVersion: 1, plaintext: pt}) + } + store.rows = append(store.rows, &memRow{null: true, keyVersion: 1}) + + h := NewSecretReencryptHandler(store, enc, nil) + payload, _ := json.Marshal(SecretReencryptPayload{ActiveVersion: 2, BatchSize: 2}) + require.NoError(t, h.Handle(ctx, jobs.Job{Payload: payload})) + + // All non-null rows now on v2 and decrypt to identical plaintext. + for _, r := range store.rows { + if r.null { + require.Equal(t, 1, r.keyVersion, "NULL rows are skipped, version unchanged") + continue + } + require.Equal(t, 2, r.keyVersion) + got, err := enc.DecryptVersion(ctx, r.blob, 2) + require.NoError(t, err) + require.Equal(t, r.plaintext, got) + } + left, _ := store.StaleCount(ctx, "t", 2) + require.Equal(t, 0, left) +} + +func TestSecretReencrypt_IdempotentRerun(t *testing.T) { + ctx := context.Background() + enc := twoVersionEnc(t) + store := &memStore{} + pt := []byte("hello") + ct, err := enc.SealWithVersion(ctx, 1, pt) + require.NoError(t, err) + store.rows = append(store.rows, &memRow{blob: ct, keyVersion: 1, plaintext: pt}) + + h := NewSecretReencryptHandler(store, enc, nil) + payload, _ := json.Marshal(SecretReencryptPayload{ActiveVersion: 2}) + require.NoError(t, h.Handle(ctx, jobs.Job{Payload: payload})) + blobAfterFirst := store.rows[0].blob + + // Second run is a no-op: row already on active version, blob unchanged. + require.NoError(t, h.Handle(ctx, jobs.Job{Payload: payload})) + require.Equal(t, blobAfterFirst, store.rows[0].blob) +} + +func TestSecretReencrypt_ResumableAfterCrash(t *testing.T) { + ctx := context.Background() + enc := twoVersionEnc(t) + store := &memStore{failNext: true} + for i := 0; i < 5; i++ { + pt := []byte{byte(i)} + ct, err := enc.SealWithVersion(ctx, 1, pt) + require.NoError(t, err) + store.rows = append(store.rows, &memRow{blob: ct, keyVersion: 1, plaintext: pt}) + } + + h := NewSecretReencryptHandler(store, enc, nil) + payload, _ := json.Marshal(SecretReencryptPayload{ActiveVersion: 2, BatchSize: 10}) + // First run crashes (ctx.Canceled bubbles up). + require.Error(t, h.Handle(ctx, jobs.Job{Payload: payload})) + left, _ := store.StaleCount(ctx, "t", 2) + require.Greater(t, left, 0, "crash left some rows on old version") + + // Re-run completes the rest. + require.NoError(t, h.Handle(ctx, jobs.Job{Payload: payload})) + left, _ = store.StaleCount(ctx, "t", 2) + require.Equal(t, 0, left) + for _, r := range store.rows { + got, err := enc.DecryptVersion(ctx, r.blob, 2) + require.NoError(t, err) + require.Equal(t, r.plaintext, got) + } +} + +func TestSecretReencrypt_BadPayloadIsPermanent(t *testing.T) { + h := NewSecretReencryptHandler(&memStore{}, twoVersionEnc(t), nil) + err := h.Handle(context.Background(), jobs.Job{Payload: []byte("not json")}) + require.Error(t, err) + require.True(t, jobs.IsPermanent(err)) +} diff --git a/internal/jobs/module.go b/internal/jobs/module.go index 4810da0..3001645 100644 --- a/internal/jobs/module.go +++ b/internal/jobs/module.go @@ -146,8 +146,17 @@ func scheduleEntry(cfg Config, name string) (schedEntry, bool) { return schedEntry{cfg.JobPurge.Interval, cfg.JobPurge.Enabled}, true case "acp_events_purge": return schedEntry{cfg.AcpEventsPurge.Interval, cfg.AcpEventsPurge.Enabled}, true + case "acp_turns_purge": + // 复用 acp_events 的保留/调度配置:回合记录与事件同源、同生命周期。 + return schedEntry{cfg.AcpEventsPurge.Interval, cfg.AcpEventsPurge.Enabled}, true case "mcp_tokens_purge": return schedEntry{cfg.MCPTokensPurge.Interval, cfg.MCPTokensPurge.Enabled}, true + case "acp_session_reaper": + return schedEntry{cfg.AcpSessionReaper.Interval, cfg.AcpSessionReaper.Enabled}, true + case "orchestrator_schedule": + return schedEntry{cfg.OrchestratorSchedule.Interval, cfg.OrchestratorSchedule.Enabled}, true + case "audit_retention": + return schedEntry{cfg.AuditRetention.Interval, cfg.AuditRetention.Enabled}, true } return schedEntry{0, false}, false } diff --git a/internal/jobs/runners/acp_session_reaper.go b/internal/jobs/runners/acp_session_reaper.go new file mode 100644 index 0000000..d0ba74e --- /dev/null +++ b/internal/jobs/runners/acp_session_reaper.go @@ -0,0 +1,148 @@ +package runners + +import ( + "context" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/notify" +) + +// ReaperSession is the minimal session projection the reaper needs. +type ReaperSession struct { + ID uuid.UUID + UserID uuid.UUID + StartedAt time.Time + LastActivityAt time.Time + MaxWallClockSeconds *int32 +} + +// AcpSessionReaperRepo is the narrow repo surface this runner needs. +// internal/acp.Repository satisfies it via an adapter in app.go. +type AcpSessionReaperRepo interface { + // ListSessionsForReaper returns active sessions exceeding their wall-clock + // cap (per-session or the global wallClockStartedBefore cutoff) or idle since + // idleSince. A zero wallClockStartedBefore disables the global wall-clock cut. + ListSessionsForReaper(ctx context.Context, wallClockStartedBefore, idleSince time.Time) ([]ReaperSession, error) + // MarkSessionTerminatedReason records why a session was reaped (no-op if a + // reason is already set, protecting against clobbering a budget-gate reason). + MarkSessionTerminatedReason(ctx context.Context, id uuid.UUID, reason string) error +} + +// SessionKiller terminates a session subprocess. internal/acp.Supervisor.Kill +// satisfies it directly. +type SessionKiller interface { + Kill(ctx context.Context, sid uuid.UUID, grace time.Duration) +} + +// AcpSessionReaperConfig controls the reaper's cutoffs. +type AcpSessionReaperConfig struct { + // WallClockTimeout is the global maximum session age (0 = rely only on the + // per-session budget_max_wall_clock_seconds). + WallClockTimeout time.Duration + // IdleTimeout is the max time with no new activity before reaping. + IdleTimeout time.Duration + // KillGrace is passed to SessionKiller.Kill. + KillGrace time.Duration +} + +// AcpSessionReaper terminates ACP sessions that exceed wall-clock or are idle. +type AcpSessionReaper struct { + repo AcpSessionReaperRepo + killer SessionKiller + disp *notify.Dispatcher + cfg AcpSessionReaperConfig + log *slog.Logger +} + +// NewAcpSessionReaper builds the reaper. +func NewAcpSessionReaper(repo AcpSessionReaperRepo, killer SessionKiller, disp *notify.Dispatcher, + cfg AcpSessionReaperConfig, log *slog.Logger) *AcpSessionReaper { + if log == nil { + log = slog.Default() + } + if cfg.KillGrace <= 0 { + cfg.KillGrace = 5 * time.Second + } + return &AcpSessionReaper{repo: repo, killer: killer, disp: disp, cfg: cfg, log: log} +} + +// Run reaps once. Periodic; idempotent (terminated_reason is only set once). +func (r *AcpSessionReaper) Run(ctx context.Context) { + now := time.Now() + var wallBefore time.Time + if r.cfg.WallClockTimeout > 0 { + wallBefore = now.Add(-r.cfg.WallClockTimeout) + } + idleSince := now.Add(-r.cfg.IdleTimeout) + if r.cfg.IdleTimeout <= 0 { + // No idle timeout configured -> make the idle cutoff unreachable so only + // wall-clock rules apply (a zero time would reap everything). + idleSince = time.Time{} + } + + sessions, err := r.repo.ListSessionsForReaper(ctx, wallBefore, idleSince) + if err != nil { + r.log.Error("acp_session_reaper.list", "err", err.Error()) + return + } + for _, s := range sessions { + reason := r.classify(now, s) + if reason == "" { + continue + } + if merr := r.repo.MarkSessionTerminatedReason(ctx, s.ID, reason); merr != nil { + r.log.Error("acp_session_reaper.mark", "session_id", s.ID, "err", merr.Error()) + // continue: still attempt the kill so the resource is freed. + } + // Kill must not be fatal on error (idempotent; session may have already + // exited between listing and now). + r.killer.Kill(ctx, s.ID, r.cfg.KillGrace) + r.log.Info("acp_session_reaper.killed", "session_id", s.ID, "reason", reason) + r.dispatch(ctx, s, reason) + } +} + +// classify decides which reaper reason applies, preferring wall-clock over idle. +func (r *AcpSessionReaper) classify(now time.Time, s ReaperSession) string { + // Per-session wall-clock cap. + if s.MaxWallClockSeconds != nil { + if now.Sub(s.StartedAt) >= time.Duration(*s.MaxWallClockSeconds)*time.Second { + return "reaper_wall_clock" + } + } + // Global wall-clock cap. + if r.cfg.WallClockTimeout > 0 && now.Sub(s.StartedAt) >= r.cfg.WallClockTimeout { + return "reaper_wall_clock" + } + // Idle cap. + if r.cfg.IdleTimeout > 0 { + activity := s.LastActivityAt + if activity.IsZero() { + activity = s.StartedAt + } + if now.Sub(activity) >= r.cfg.IdleTimeout { + return "reaper_idle" + } + } + return "" +} + +func (r *AcpSessionReaper) dispatch(ctx context.Context, s ReaperSession, reason string) { + if r.disp == nil { + return + } + _ = r.disp.Dispatch(ctx, notify.Message{ + UserID: s.UserID, + Topic: "acp.session_reaped", + Severity: notify.SeverityWarning, + Title: "ACP session terminated by reaper", + Body: "An ACP session was terminated: " + reason + ".", + Metadata: map[string]any{ + "session_id": s.ID.String(), + "reason": reason, + }, + }) +} diff --git a/internal/jobs/runners/acp_session_reaper_test.go b/internal/jobs/runners/acp_session_reaper_test.go new file mode 100644 index 0000000..daa9d0d --- /dev/null +++ b/internal/jobs/runners/acp_session_reaper_test.go @@ -0,0 +1,150 @@ +package runners_test + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/jobs/runners" +) + +type fakeReaperRepo struct { + sessions []runners.ReaperSession + listErr error + + mu sync.Mutex + marked map[uuid.UUID]string + markErr error +} + +func (f *fakeReaperRepo) ListSessionsForReaper(_ context.Context, _, _ time.Time) ([]runners.ReaperSession, error) { + if f.listErr != nil { + return nil, f.listErr + } + return f.sessions, nil +} + +func (f *fakeReaperRepo) MarkSessionTerminatedReason(_ context.Context, id uuid.UUID, reason string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.marked == nil { + f.marked = map[uuid.UUID]string{} + } + f.marked[id] = reason + return f.markErr +} + +type fakeKiller struct { + mu sync.Mutex + killed []uuid.UUID +} + +func (k *fakeKiller) Kill(_ context.Context, sid uuid.UUID, _ time.Duration) { + k.mu.Lock() + defer k.mu.Unlock() + k.killed = append(k.killed, sid) +} + +func TestAcpSessionReaper_KillsWallClockAndIdle(t *testing.T) { + now := time.Now() + wallSession := runners.ReaperSession{ + ID: uuid.New(), UserID: uuid.New(), + StartedAt: now.Add(-5 * time.Hour), + LastActivityAt: now.Add(-1 * time.Minute), // active, but wall-clock exceeded + } + idleSession := runners.ReaperSession{ + ID: uuid.New(), UserID: uuid.New(), + StartedAt: now.Add(-10 * time.Minute), + LastActivityAt: now.Add(-1 * time.Hour), // idle + } + repo := &fakeReaperRepo{sessions: []runners.ReaperSession{wallSession, idleSession}} + killer := &fakeKiller{} + r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{ + WallClockTimeout: 4 * time.Hour, + IdleTimeout: 30 * time.Minute, + }, discardLogger()) + + r.Run(context.Background()) + + assert.ElementsMatch(t, []uuid.UUID{wallSession.ID, idleSession.ID}, killer.killed) + assert.Equal(t, "reaper_wall_clock", repo.marked[wallSession.ID]) + assert.Equal(t, "reaper_idle", repo.marked[idleSession.ID]) +} + +func TestAcpSessionReaper_PerSessionWallClock(t *testing.T) { + now := time.Now() + cap30s := int32(30) + s := runners.ReaperSession{ + ID: uuid.New(), UserID: uuid.New(), + StartedAt: now.Add(-1 * time.Minute), + LastActivityAt: now, + MaxWallClockSeconds: &cap30s, // exceeded per-session cap, no global cap set + } + repo := &fakeReaperRepo{sessions: []runners.ReaperSession{s}} + killer := &fakeKiller{} + r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{ + IdleTimeout: time.Hour, // generous idle so only wall-clock triggers + }, discardLogger()) + + r.Run(context.Background()) + + require.Len(t, killer.killed, 1) + assert.Equal(t, s.ID, killer.killed[0]) + assert.Equal(t, "reaper_wall_clock", repo.marked[s.ID]) +} + +func TestAcpSessionReaper_SkipsWithinThresholds(t *testing.T) { + now := time.Now() + s := runners.ReaperSession{ + ID: uuid.New(), UserID: uuid.New(), + StartedAt: now.Add(-5 * time.Minute), + LastActivityAt: now.Add(-1 * time.Minute), + } + repo := &fakeReaperRepo{sessions: []runners.ReaperSession{s}} + killer := &fakeKiller{} + r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{ + WallClockTimeout: 4 * time.Hour, + IdleTimeout: 30 * time.Minute, + }, discardLogger()) + + r.Run(context.Background()) + + assert.Empty(t, killer.killed) + assert.Empty(t, repo.marked) +} + +func TestAcpSessionReaper_MarkErrorStillKills(t *testing.T) { + now := time.Now() + s := runners.ReaperSession{ + ID: uuid.New(), UserID: uuid.New(), + StartedAt: now.Add(-5 * time.Hour), + LastActivityAt: now, + } + repo := &fakeReaperRepo{sessions: []runners.ReaperSession{s}, markErr: errors.New("db down")} + killer := &fakeKiller{} + r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{ + WallClockTimeout: 4 * time.Hour, + IdleTimeout: 30 * time.Minute, + }, discardLogger()) + + r.Run(context.Background()) + + require.Len(t, killer.killed, 1) +} + +func TestAcpSessionReaper_ListErrorIsNotFatal(t *testing.T) { + repo := &fakeReaperRepo{listErr: errors.New("boom")} + killer := &fakeKiller{} + r := runners.NewAcpSessionReaper(repo, killer, nil, runners.AcpSessionReaperConfig{ + IdleTimeout: 30 * time.Minute, + }, discardLogger()) + + r.Run(context.Background()) + assert.Empty(t, killer.killed) +} diff --git a/internal/jobs/runners/acp_turns_purge.go b/internal/jobs/runners/acp_turns_purge.go new file mode 100644 index 0000000..8934088 --- /dev/null +++ b/internal/jobs/runners/acp_turns_purge.go @@ -0,0 +1,41 @@ +package runners + +import ( + "context" + "log/slog" + "time" +) + +// AcpTurnsPurgeRepo is the minimal repo surface this runner needs. +// internal/acp.Repository satisfies it via an adapter in app.go. +type AcpTurnsPurgeRepo interface { + PurgeTurnsBefore(ctx context.Context, before time.Time) (int64, error) +} + +// AcpTurnsPurge deletes acp_turns older than retention. Periodic; idempotent. +type AcpTurnsPurge struct { + repo AcpTurnsPurgeRepo + retention time.Duration + log *slog.Logger +} + +// NewAcpTurnsPurge builds the purger. +func NewAcpTurnsPurge(repo AcpTurnsPurgeRepo, retention time.Duration, log *slog.Logger) *AcpTurnsPurge { + if log == nil { + log = slog.Default() + } + return &AcpTurnsPurge{repo: repo, retention: retention, log: log} +} + +// Run purges once. +func (r *AcpTurnsPurge) Run(ctx context.Context) { + cutoff := time.Now().Add(-r.retention) + rows, err := r.repo.PurgeTurnsBefore(ctx, cutoff) + if err != nil { + r.log.Error("acp_turns.purge", "err", err.Error()) + return + } + if rows > 0 { + r.log.Info("acp_turns.purged", "count", rows, "before", cutoff.UTC().Format(time.RFC3339)) + } +} diff --git a/internal/jobs/runners/audit_retention.go b/internal/jobs/runners/audit_retention.go new file mode 100644 index 0000000..1651dae --- /dev/null +++ b/internal/jobs/runners/audit_retention.go @@ -0,0 +1,45 @@ +package runners + +import ( + "context" + "log/slog" + "time" +) + +// AuditRetentionRepo is the minimal surface this runner needs. +// internal/audit.Reader satisfies it (PurgeBefore). +type AuditRetentionRepo interface { + PurgeBefore(ctx context.Context, before time.Time) (int64, error) +} + +// AuditRetention deletes audit_logs older than retention. Periodic; idempotent. +type AuditRetention struct { + repo AuditRetentionRepo + retention time.Duration + log *slog.Logger +} + +// NewAuditRetention builds the audit-log retention purger. +func NewAuditRetention(repo AuditRetentionRepo, retention time.Duration, log *slog.Logger) *AuditRetention { + if log == nil { + log = slog.Default() + } + return &AuditRetention{repo: repo, retention: retention, log: log} +} + +// Run purges once. A non-positive retention disables the purge (safety: never +// delete the whole table when misconfigured). +func (r *AuditRetention) Run(ctx context.Context) { + if r.retention <= 0 { + return + } + cutoff := time.Now().Add(-r.retention) + rows, err := r.repo.PurgeBefore(ctx, cutoff) + if err != nil { + r.log.Error("audit_retention.purge", "err", err.Error()) + return + } + if rows > 0 { + r.log.Info("audit_retention.purged", "count", rows, "before", cutoff.UTC().Format(time.RFC3339)) + } +} diff --git a/internal/jobs/sqlc/models.go b/internal/jobs/sqlc/models.go index f521470..cb2b643 100644 --- a/internal/jobs/sqlc/models.go +++ b/internal/jobs/sqlc/models.go @@ -8,6 +8,7 @@ import ( "net/netip" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type AcpAgentKind struct { @@ -25,6 +26,11 @@ type AcpAgentKind struct { ToolAllowlist []string `json:"tool_allowlist"` ClientType string `json:"client_type"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` } type AcpAgentKindConfigFile struct { @@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct { UpdatedBy pgtype.UUID `json:"updated_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type AcpEvent struct { @@ -64,23 +71,64 @@ type AcpPermissionRequest struct { } type AcpSession struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ProjectID pgtype.UUID `json:"project_id"` - AgentKindID pgtype.UUID `json:"agent_kind_id"` - UserID pgtype.UUID `json:"user_id"` - IssueID pgtype.UUID `json:"issue_id"` - RequirementID pgtype.UUID `json:"requirement_id"` - AgentSessionID *string `json:"agent_session_id"` - Branch string `json:"branch"` - CwdPath string `json:"cwd_path"` - IsMainWorktree bool `json:"is_main_worktree"` - Status string `json:"status"` - Pid *int32 `json:"pid"` - ExitCode *int32 `json:"exit_code"` - LastError *string `json:"last_error"` - StartedAt pgtype.Timestamptz `json:"started_at"` - EndedAt pgtype.Timestamptz `json:"ended_at"` + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` } type AuditLog struct { @@ -94,6 +142,81 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Conversation struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` @@ -110,6 +233,14 @@ type Conversation struct { RequirementID pgtype.UUID `json:"requirement_id"` } +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type GitCredential struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -119,6 +250,7 @@ type GitCredential struct { Fingerprint *string `json:"fingerprint"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type Issue struct { @@ -135,6 +267,17 @@ type Issue struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Job struct { @@ -162,6 +305,7 @@ type LlmEndpoint struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type LlmModel struct { @@ -252,6 +396,50 @@ type Notification struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + type Project struct { ID pgtype.UUID `json:"id"` Slug string `json:"slug"` @@ -264,6 +452,30 @@ type Project struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type PromptTemplate struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` @@ -299,6 +511,16 @@ type RequirementArtifact struct { SourceMessageID *int64 `json:"source_message_id"` CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type User struct { @@ -354,6 +576,7 @@ type WorkspaceRunProfile struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type WorkspaceWorktree struct { diff --git a/internal/mcp/auth_middleware.go b/internal/mcp/auth_middleware.go index 0e7919b..ba4f320 100644 --- a/internal/mcp/auth_middleware.go +++ b/internal/mcp/auth_middleware.go @@ -5,6 +5,8 @@ import ( "net/http" "strings" + "github.com/google/uuid" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" @@ -23,6 +25,9 @@ type AuthSession struct { UserID string // user_id 字符串形式(避免反复 uuid.UUID -> string 转换) Scope Scope Issuer Issuer // system token(agent 会话持有)受额外限制,见 create_acp_session + // ACPSessionID 仅 system token 非 nil:标识持有该 token 的 agent 所在 acp session。 + // request_subtask 据此把调用方映射回其编排 step。 + ACPSessionID *uuid.UUID } // FromContext 从 ctx 取出 AuthSession,未鉴权时返 (nil, false)。 @@ -51,10 +56,11 @@ func NewAuthMiddleware(svc TokenService) func(http.Handler) http.Handler { return } sess := &AuthSession{ - TokenID: tok.ID.String(), - UserID: tok.UserID.String(), - Scope: tok.Scope, - Issuer: tok.Issuer, + TokenID: tok.ID.String(), + UserID: tok.UserID.String(), + Scope: tok.Scope, + Issuer: tok.Issuer, + ACPSessionID: tok.ACPSessionID, } ctx := context.WithValue(r.Context(), AuthContextKey, sess) next.ServeHTTP(w, r.WithContext(ctx)) diff --git a/internal/mcp/server.go b/internal/mcp/server.go index e448924..2bd3231 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -3,8 +3,11 @@ package mcp import ( mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + "github.com/yan1h/agent-coding-workflow/internal/changerequest" "github.com/yan1h/agent-coding-workflow/internal/chat" + "github.com/yan1h/agent-coding-workflow/internal/codeindex" "github.com/yan1h/agent-coding-workflow/internal/project" + "github.com/yan1h/agent-coding-workflow/internal/projectmemory" "github.com/yan1h/agent-coding-workflow/internal/workspace" ) @@ -18,12 +21,17 @@ type ServerDeps struct { Workspaces workspace.WorkspaceService Worktrees workspace.WorktreeService GitOps workspace.GitOpsService + ChangeReqs changerequest.Service Templates chat.TemplateService Messages chat.MessageService Conversations chat.ConversationService Endpoints chat.EndpointService - ACP ACPBridge // acp 服务桥接(见 tools_acp.go / acp.NewMCPBridge) - Runs RunService // *run.Service(见 tools_run.go) + ACP ACPBridge // acp 服务桥接(见 tools_acp.go / acp.NewMCPBridge) + Runs RunService // *run.Service(见 tools_run.go) + Execs ExecService // *run.ExecService — 一次性 exec(run_command / run_tests) + Orchestrator OrchestratorBridge // 编排器桥接(见 tools_orchestrator.go);可为 nil(禁用时) + CodeIndex codeindex.Service // 代码索引 RAG(见 tools_codeindex.go);可为 nil(禁用时) + Memory projectmemory.Service // 项目记忆(见 tools_memory.go);可为 nil(禁用时) } // NewMCPServer 装配 SDK Server 实例并注册所有工具/prompts/resources。 @@ -41,8 +49,12 @@ func NewMCPServer(deps ServerDeps) *mcpsdk.Server { registerResources(srv, deps) // Phase H: workspace / worktree / git、ACP、run profiles registerWorkspaceTools(srv, deps) + registerChangeRequestTools(srv, deps) registerACPTools(srv, deps) registerRunTools(srv, deps) + registerOrchestratorTools(srv, deps) + registerCodeIndexTools(srv, deps) + registerMemoryTools(srv, deps) return srv } diff --git a/internal/mcp/sqlc/models.go b/internal/mcp/sqlc/models.go index 8e168f0..7e9adb7 100644 --- a/internal/mcp/sqlc/models.go +++ b/internal/mcp/sqlc/models.go @@ -8,6 +8,7 @@ import ( "net/netip" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type AcpAgentKind struct { @@ -25,6 +26,11 @@ type AcpAgentKind struct { ToolAllowlist []string `json:"tool_allowlist"` ClientType string `json:"client_type"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` } type AcpAgentKindConfigFile struct { @@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct { UpdatedBy pgtype.UUID `json:"updated_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type AcpEvent struct { @@ -64,23 +71,64 @@ type AcpPermissionRequest struct { } type AcpSession struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ProjectID pgtype.UUID `json:"project_id"` - AgentKindID pgtype.UUID `json:"agent_kind_id"` - UserID pgtype.UUID `json:"user_id"` - IssueID pgtype.UUID `json:"issue_id"` - RequirementID pgtype.UUID `json:"requirement_id"` - AgentSessionID *string `json:"agent_session_id"` - Branch string `json:"branch"` - CwdPath string `json:"cwd_path"` - IsMainWorktree bool `json:"is_main_worktree"` - Status string `json:"status"` - Pid *int32 `json:"pid"` - ExitCode *int32 `json:"exit_code"` - LastError *string `json:"last_error"` - StartedAt pgtype.Timestamptz `json:"started_at"` - EndedAt pgtype.Timestamptz `json:"ended_at"` + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` } type AuditLog struct { @@ -94,6 +142,81 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Conversation struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` @@ -110,6 +233,14 @@ type Conversation struct { RequirementID pgtype.UUID `json:"requirement_id"` } +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type GitCredential struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -119,6 +250,7 @@ type GitCredential struct { Fingerprint *string `json:"fingerprint"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type Issue struct { @@ -135,6 +267,17 @@ type Issue struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Job struct { @@ -162,6 +305,7 @@ type LlmEndpoint struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type LlmModel struct { @@ -252,6 +396,50 @@ type Notification struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + type Project struct { ID pgtype.UUID `json:"id"` Slug string `json:"slug"` @@ -264,6 +452,30 @@ type Project struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type PromptTemplate struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` @@ -299,6 +511,16 @@ type RequirementArtifact struct { SourceMessageID *int64 `json:"source_message_id"` CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type User struct { @@ -354,6 +576,7 @@ type WorkspaceRunProfile struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type WorkspaceWorktree struct { diff --git a/internal/mcp/token_service.go b/internal/mcp/token_service.go index 823c2ef..b6e22a0 100644 --- a/internal/mcp/token_service.go +++ b/internal/mcp/token_service.go @@ -61,8 +61,21 @@ var defaultSystemTools = []string{ "list_projects", "list_workspaces", "list_requirements", "list_issues", "get_issue", "get_requirement", "create_issue", "update_issue", "close_issue", "reopen_issue", "assign_issue", + // 任务分解 / 依赖图:规划阶段 agent 据此发出子任务与依赖边(autonomy roadmap §10)。 + // create_acp_session 仍对 system token 禁用(checkNotSystemToken 不放松);并行扇出由 + // 服务端调度器以特权身份代办。 + "create_subtask", "add_dependency", "remove_dependency", "list_dependencies", "submit_decomposition", "create_requirement", "update_requirement", "close_requirement", "reopen_requirement", "set_requirement_phase", + // 注意:approve_requirement_artifact 故意不在默认白名单——产物审批是阶段网关, + // 与权限审批 / PR 评审一致,必须由人工(owner/admin)完成,agent 不得自审自批。 "add_requirement_artifact", "list_requirement_artifacts", + // 一次性 exec:agent 自验证(编译 / 测试)闭环。 + "run_command", "run_tests", + // 只读 git 检视:让自验证闭环能查看自己改了什么(git_diff/git_status 均只读,安全)。 + "git_status", "git_diff", + // 受控委派:in-session agent 可请求子任务(深度/扇出在事务内受限)。仅 system token + // 且持有 session 时可用,不破坏 checkNotSystemToken(后者禁的是「创建新 ACP 会话」)。 + "request_subtask", } // NewTokenService 构造 service。now 用于测试注入;nil 时默认 time.Now。 diff --git a/internal/mcp/tools_acp.go b/internal/mcp/tools_acp.go index f98bf12..d73ad80 100644 --- a/internal/mcp/tools_acp.go +++ b/internal/mcp/tools_acp.go @@ -42,8 +42,26 @@ type ACPSession struct { IsMainWorktree bool Status string LastError *string + LastStopReason *string StartedAt time.Time EndedAt *time.Time + // 成本核算(只读):运行时累计 token/cost + 有效预算上限。 + PromptTokens int64 + CompletionTokens int64 + ThinkingTokens int64 + TotalCostUSD float64 + BudgetMaxCostUSD *float64 + BudgetMaxTokens *int64 +} + +// ACPTurn 是 ACP 回合记录的 MCP 投影(只读)。 +type ACPTurn struct { + TurnIndex int + Status string + StopReason *string + UpdateCount int + StartedAt time.Time + CompletedAt *time.Time } // ACPPermission 是待审权限请求的 MCP 投影(只读,不含原始 toolCall/options JSON)。 @@ -72,6 +90,38 @@ type ACPSessionFilter struct { RequirementID *uuid.UUID } +// ACPRunDashboardInput 是 run-history 汇总入参(service 在 caller scope 内归一化)。 +type ACPRunDashboardInput struct { + ProjectID *uuid.UUID + RequirementID *uuid.UUID + From time.Time + To time.Time +} + +// ACPRunDashboard 是 run-history 汇总(计数 + 成功率 + 成本 + 时长 + 按天序列)。 +type ACPRunDashboard struct { + From time.Time + To time.Time + Total int64 + Succeeded int64 + Crashed int64 + Active int64 + SuccessRate float64 + TotalCostUSD float64 + TotalTokens int64 + AvgDurationSeconds float64 + ByDay []ACPRunDayRow +} + +// ACPRunDayRow 是 run-history 按天的一行。 +type ACPRunDayRow struct { + Day time.Time + Total int64 + Succeeded int64 + Crashed int64 + TotalCostUSD float64 +} + // ACPBridge 是 mcp 调用 acp 服务的桥接接口。 type ACPBridge interface { ListAgentKinds(ctx context.Context, userID uuid.UUID, isAdmin bool) ([]ACPAgentKind, error) @@ -81,6 +131,11 @@ type ACPBridge interface { ListSessions(ctx context.Context, userID uuid.UUID, isAdmin bool, f ACPSessionFilter) ([]ACPSession, error) TerminateSession(ctx context.Context, userID uuid.UUID, isAdmin bool, id uuid.UUID) error ListPendingPermissions(ctx context.Context, userID uuid.UUID, isAdmin bool, sessionID uuid.UUID) ([]ACPPermission, error) + // ListPendingApprovals 返回调用者跨所有会话的待审权限请求(HITL inbox,read-only)。 + ListPendingApprovals(ctx context.Context, userID uuid.UUID, isAdmin bool) ([]ACPPermission, error) + ListSessionTurns(ctx context.Context, userID uuid.UUID, isAdmin bool, sessionID uuid.UUID) ([]ACPTurn, error) + // GetRunDashboard 返回 run-history 汇总(owner scope;admin 全量),read-only。 + GetRunDashboard(ctx context.Context, userID uuid.UUID, isAdmin bool, in ACPRunDashboardInput) (*ACPRunDashboard, error) } // registerACPTools 注册 ACP 相关 MCP 工具。 @@ -92,6 +147,9 @@ func registerACPTools(srv *mcpsdk.Server, deps ServerDeps) { registerListACPSessions(srv, deps) registerTerminateACPSession(srv, deps) registerListPendingPermissions(srv, deps) + registerListPendingApprovals(srv, deps) + registerGetACPSessionTurns(srv, deps) + registerGetRunDashboard(srv, deps) } // ===== DTO ===== @@ -124,8 +182,16 @@ type acpSessionDetail struct { IsMainWorktree bool `json:"is_main_worktree"` Status string `json:"status"` LastError string `json:"last_error,omitempty"` + LastStopReason string `json:"last_stop_reason,omitempty"` StartedAt string `json:"started_at"` EndedAt string `json:"ended_at,omitempty"` + // 成本核算(只读)。 + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUSD float64 `json:"total_cost_usd"` + BudgetMaxCostUSD *float64 `json:"budget_max_cost_usd,omitempty"` + BudgetMaxTokens *int64 `json:"budget_max_tokens,omitempty"` } func acpSessionDetailFrom(s *ACPSession) acpSessionDetail { @@ -134,7 +200,13 @@ func acpSessionDetailFrom(s *ACPSession) acpSessionDetail { ProjectID: s.ProjectID.String(), AgentKindID: s.AgentKindID.String(), UserID: s.UserID.String(), Branch: s.Branch, IsMainWorktree: s.IsMainWorktree, Status: s.Status, - StartedAt: s.StartedAt.Format("2006-01-02T15:04:05Z07:00"), + StartedAt: s.StartedAt.Format("2006-01-02T15:04:05Z07:00"), + PromptTokens: s.PromptTokens, + CompletionTokens: s.CompletionTokens, + ThinkingTokens: s.ThinkingTokens, + TotalCostUSD: s.TotalCostUSD, + BudgetMaxCostUSD: s.BudgetMaxCostUSD, + BudgetMaxTokens: s.BudgetMaxTokens, } if s.IssueID != nil { d.IssueID = s.IssueID.String() @@ -145,6 +217,9 @@ func acpSessionDetailFrom(s *ACPSession) acpSessionDetail { if s.LastError != nil { d.LastError = *s.LastError } + if s.LastStopReason != nil { + d.LastStopReason = *s.LastStopReason + } if s.EndedAt != nil { d.EndedAt = s.EndedAt.Format("2006-01-02T15:04:05Z07:00") } @@ -432,3 +507,188 @@ func registerListPendingPermissions(srv *mcpsdk.Server, deps ServerDeps) { return nil, out, nil }) } + +// ===== list_pending_approvals (cross-session HITL inbox) ===== + +func registerListPendingApprovals(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "list_pending_approvals", Description: "List the caller's pending tool-permission requests across all their ACP sessions (read-only HITL inbox; approval stays with humans in the web console)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, _ struct{}) (*mcpsdk.CallToolResult, listPendingPermissionsOut, error) { + if err := CheckToolFromCtx(ctx, "list_pending_approvals"); err != nil { + return nil, listPendingPermissionsOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, listPendingPermissionsOut{}, err + } + ps, err := deps.ACP.ListPendingApprovals(ctx, c.UserID, c.IsAdmin) + if err != nil { + return nil, listPendingPermissionsOut{}, err + } + out := listPendingPermissionsOut{Permissions: make([]permissionDetail, 0, len(ps))} + for _, p := range ps { + out.Permissions = append(out.Permissions, permissionDetail{ + ID: p.ID.String(), SessionID: p.SessionID.String(), + AgentRequestID: p.AgentRequestID, ToolName: p.ToolName, + Status: p.Status, + CreatedAt: p.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + }) + } + return nil, out, nil + }) +} + +// ===== get_acp_session_turns ===== + +type acpTurnDetail struct { + TurnIndex int `json:"turn_index"` + Status string `json:"status"` + StopReason string `json:"stop_reason,omitempty"` + UpdateCount int `json:"update_count"` + StartedAt string `json:"started_at"` + CompletedAt string `json:"completed_at,omitempty"` +} +type getACPSessionTurnsOut struct { + Turns []acpTurnDetail `json:"turns"` +} + +func registerGetACPSessionTurns(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "get_acp_session_turns", Description: "List the agent turns of an ACP session (read-only; turn_index, status, stop_reason, update_count, timestamps)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getACPSessionIn) (*mcpsdk.CallToolResult, getACPSessionTurnsOut, error) { + if err := CheckToolFromCtx(ctx, "get_acp_session_turns"); err != nil { + return nil, getACPSessionTurnsOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, getACPSessionTurnsOut{}, err + } + s, err := scopedACPSession(ctx, deps, c.UserID, c.IsAdmin, in.SessionID) + if err != nil { + return nil, getACPSessionTurnsOut{}, err + } + ts, err := deps.ACP.ListSessionTurns(ctx, c.UserID, c.IsAdmin, s.ID) + if err != nil { + return nil, getACPSessionTurnsOut{}, err + } + out := getACPSessionTurnsOut{Turns: make([]acpTurnDetail, 0, len(ts))} + for _, t := range ts { + d := acpTurnDetail{ + TurnIndex: t.TurnIndex, + Status: t.Status, + UpdateCount: t.UpdateCount, + StartedAt: t.StartedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if t.StopReason != nil { + d.StopReason = *t.StopReason + } + if t.CompletedAt != nil { + d.CompletedAt = t.CompletedAt.Format("2006-01-02T15:04:05Z07:00") + } + out.Turns = append(out.Turns, d) + } + return nil, out, nil + }) +} + +// ===== get_run_dashboard (run-history rollup) ===== + +type getRunDashboardIn struct { + // 可选过滤;留空表示不按该维度过滤。from/to 为 RFC3339;留空走默认 30 天窗口。 + ProjectID string `json:"project_id,omitempty"` + RequirementID string `json:"requirement_id,omitempty"` + From string `json:"from,omitempty"` + To string `json:"to,omitempty"` +} + +type runDayDetail struct { + Day string `json:"day"` + Total int64 `json:"total"` + Succeeded int64 `json:"succeeded"` + Crashed int64 `json:"crashed"` + TotalCostUSD float64 `json:"total_cost_usd"` +} + +type getRunDashboardOut struct { + From string `json:"from"` + To string `json:"to"` + Total int64 `json:"total"` + Succeeded int64 `json:"succeeded"` + Crashed int64 `json:"crashed"` + Active int64 `json:"active"` + SuccessRate float64 `json:"success_rate"` + TotalCostUSD float64 `json:"total_cost_usd"` + TotalTokens int64 `json:"total_tokens"` + AvgDurationSeconds float64 `json:"avg_duration_seconds"` + ByDay []runDayDetail `json:"by_day"` +} + +func registerGetRunDashboard(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "get_run_dashboard", Description: "Get a run-history rollup of ACP sessions (read-only): counts, success rate, total cost/tokens, avg duration, and a per-day series. Scoped to the caller's own sessions (admins see all). Optional filters: project_id, requirement_id, from/to (RFC3339)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getRunDashboardIn) (*mcpsdk.CallToolResult, getRunDashboardOut, error) { + if err := CheckToolFromCtx(ctx, "get_run_dashboard"); err != nil { + return nil, getRunDashboardOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, getRunDashboardOut{}, err + } + var di ACPRunDashboardInput + if in.ProjectID != "" { + id, perr := uuid.Parse(in.ProjectID) + if perr != nil { + return nil, getRunDashboardOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "project_id parse") + } + di.ProjectID = &id + } + if in.RequirementID != "" { + id, perr := uuid.Parse(in.RequirementID) + if perr != nil { + return nil, getRunDashboardOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "requirement_id parse") + } + di.RequirementID = &id + } + if in.From != "" { + t, terr := time.Parse(time.RFC3339, in.From) + if terr != nil { + return nil, getRunDashboardOut{}, errs.Wrap(terr, errs.CodeInvalidInput, "from parse") + } + di.From = t + } + if in.To != "" { + t, terr := time.Parse(time.RFC3339, in.To) + if terr != nil { + return nil, getRunDashboardOut{}, errs.Wrap(terr, errs.CodeInvalidInput, "to parse") + } + di.To = t + } + d, err := deps.ACP.GetRunDashboard(ctx, c.UserID, c.IsAdmin, di) + if err != nil { + return nil, getRunDashboardOut{}, err + } + out := getRunDashboardOut{ + From: d.From.Format("2006-01-02T15:04:05Z07:00"), + To: d.To.Format("2006-01-02T15:04:05Z07:00"), + Total: d.Total, + Succeeded: d.Succeeded, + Crashed: d.Crashed, + Active: d.Active, + SuccessRate: d.SuccessRate, + TotalCostUSD: d.TotalCostUSD, + TotalTokens: d.TotalTokens, + AvgDurationSeconds: d.AvgDurationSeconds, + ByDay: make([]runDayDetail, 0, len(d.ByDay)), + } + for _, r := range d.ByDay { + out.ByDay = append(out.ByDay, runDayDetail{ + Day: r.Day.Format("2006-01-02T15:04:05Z07:00"), + Total: r.Total, + Succeeded: r.Succeeded, + Crashed: r.Crashed, + TotalCostUSD: r.TotalCostUSD, + }) + } + return nil, out, nil + }) +} diff --git a/internal/mcp/tools_acp_test.go b/internal/mcp/tools_acp_test.go index a6951ae..1c6bc04 100644 --- a/internal/mcp/tools_acp_test.go +++ b/internal/mcp/tools_acp_test.go @@ -21,6 +21,9 @@ type fakeACPBridge struct { sessions []ACPSession perms []ACPPermission terminated []uuid.UUID + turns map[uuid.UUID][]ACPTurn + dashboard *ACPRunDashboard + dashIn ACPRunDashboardInput } func (f *fakeACPBridge) ListAgentKinds(_ context.Context, _ uuid.UUID, _ bool) ([]ACPAgentKind, error) { @@ -70,6 +73,21 @@ func (f *fakeACPBridge) ListPendingPermissions(_ context.Context, _ uuid.UUID, _ } return out, nil } +func (f *fakeACPBridge) ListPendingApprovals(_ context.Context, _ uuid.UUID, _ bool) ([]ACPPermission, error) { + out := make([]ACPPermission, 0, len(f.perms)) + out = append(out, f.perms...) + return out, nil +} +func (f *fakeACPBridge) ListSessionTurns(_ context.Context, _ uuid.UUID, _ bool, sessionID uuid.UUID) ([]ACPTurn, error) { + return f.turns[sessionID], nil +} +func (f *fakeACPBridge) GetRunDashboard(_ context.Context, _ uuid.UUID, _ bool, in ACPRunDashboardInput) (*ACPRunDashboard, error) { + f.dashIn = in + if f.dashboard != nil { + return f.dashboard, nil + } + return &ACPRunDashboard{From: time.Now().Add(-24 * time.Hour), To: time.Now()}, nil +} func acpTestDeps() (ServerDeps, *fakeACPBridge) { deps := testDeps() @@ -216,3 +234,152 @@ func TestListPendingPermissions(t *testing.T) { assert.Equal(t, "fs/write", out.Permissions[0].ToolName) assert.Equal(t, "pending", out.Permissions[0].Status) } + +func TestListPendingApprovals(t *testing.T) { + deps, bridge := acpTestDeps() + sidA := uuid.New() + sidB := uuid.New() + bridge.perms = []ACPPermission{ + {ID: uuid.New(), SessionID: sidA, AgentRequestID: "r1", ToolName: "fs/write", Status: "pending", CreatedAt: time.Now()}, + {ID: uuid.New(), SessionID: sidB, AgentRequestID: "r2", ToolName: "shell/exec", Status: "pending", CreatedAt: time.Now()}, + } + + result, err := callTool(ctxWithAuth(Scope{}), deps, "list_pending_approvals", map[string]any{}) + require.NoError(t, err) + require.False(t, result.IsError) + var out listPendingPermissionsOut + unmarshalStructured(t, result, &out) + // Cross-session: both pending requests returned regardless of session. + require.Len(t, out.Permissions, 2) +} + +func TestGetACPSession_IncludesLastStopReason(t *testing.T) { + deps, bridge := acpTestDeps() + sid := uuid.New() + reason := "end_turn" + bridge.sessions = []ACPSession{{ + ID: sid, WorkspaceID: testWSID, ProjectID: testPID, + AgentKindID: uuid.New(), UserID: testUID, Status: "running", + LastStopReason: &reason, StartedAt: time.Now(), + }} + + result, err := callTool(ctxWithAuth(Scope{}), deps, "get_acp_session", map[string]any{ + "session_id": sid.String(), + }) + require.NoError(t, err) + require.False(t, result.IsError) + var out acpSessionDetail + unmarshalStructured(t, result, &out) + assert.Equal(t, "end_turn", out.LastStopReason) +} + +func TestGetACPSessionTurns(t *testing.T) { + deps, bridge := acpTestDeps() + sid := uuid.New() + bridge.sessions = []ACPSession{{ + ID: sid, WorkspaceID: testWSID, ProjectID: testPID, + AgentKindID: uuid.New(), UserID: testUID, Status: "running", StartedAt: time.Now(), + }} + stop := "end_turn" + completed := time.Now() + bridge.turns = map[uuid.UUID][]ACPTurn{ + sid: { + {TurnIndex: 0, Status: "completed", StopReason: &stop, UpdateCount: 3, StartedAt: time.Now(), CompletedAt: &completed}, + {TurnIndex: 1, Status: "in_progress", UpdateCount: 0, StartedAt: time.Now()}, + }, + } + + result, err := callTool(ctxWithAuth(Scope{}), deps, "get_acp_session_turns", map[string]any{ + "session_id": sid.String(), + }) + require.NoError(t, err) + require.False(t, result.IsError) + var out getACPSessionTurnsOut + unmarshalStructured(t, result, &out) + require.Len(t, out.Turns, 2) + assert.Equal(t, 0, out.Turns[0].TurnIndex) + assert.Equal(t, "completed", out.Turns[0].Status) + assert.Equal(t, "end_turn", out.Turns[0].StopReason) + assert.Equal(t, 3, out.Turns[0].UpdateCount) + assert.Equal(t, "in_progress", out.Turns[1].Status) + assert.Empty(t, out.Turns[1].StopReason) +} + +func TestGetACPSessionTurns_CrossProjectDenied(t *testing.T) { + deps, bridge := acpTestDeps() + sid := uuid.New() + otherProject := uuid.New() + bridge.sessions = []ACPSession{{ + ID: sid, WorkspaceID: testWSID, ProjectID: otherProject, + AgentKindID: uuid.New(), UserID: testUID, Status: "running", StartedAt: time.Now(), + }} + + // caller scope 限定到 testPID,session 属于 otherProject → 拒绝 + result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, + "get_acp_session_turns", map[string]any{"session_id": sid.String()}) + require.NoError(t, err) + assert.True(t, result.IsError, "cross-project access must be denied") +} + +func TestGetRunDashboard(t *testing.T) { + deps, bridge := acpTestDeps() + day := time.Now().UTC() + bridge.dashboard = &ACPRunDashboard{ + From: day.Add(-24 * time.Hour), To: day, + Total: 6, Succeeded: 4, Crashed: 2, Active: 0, + SuccessRate: 4.0 / 6.0, TotalCostUSD: 2.25, TotalTokens: 1500, + AvgDurationSeconds: 42.5, + ByDay: []ACPRunDayRow{ + {Day: day, Total: 6, Succeeded: 4, Crashed: 2, TotalCostUSD: 2.25}, + }, + } + + result, err := callTool(ctxWithAuth(Scope{}), deps, "get_run_dashboard", map[string]any{}) + require.NoError(t, err) + require.False(t, result.IsError, "body=%v", result) + + var out getRunDashboardOut + unmarshalStructured(t, result, &out) + assert.Equal(t, int64(6), out.Total) + assert.Equal(t, int64(4), out.Succeeded) + assert.Equal(t, int64(2), out.Crashed) + assert.InDelta(t, 4.0/6.0, out.SuccessRate, 1e-9) + assert.InDelta(t, 2.25, out.TotalCostUSD, 1e-9) + assert.Equal(t, int64(1500), out.TotalTokens) + assert.InDelta(t, 42.5, out.AvgDurationSeconds, 1e-9) + require.Len(t, out.ByDay, 1) + assert.Equal(t, int64(6), out.ByDay[0].Total) +} + +func TestGetRunDashboard_PassesFilters(t *testing.T) { + deps, bridge := acpTestDeps() + pid := uuid.New() + reqID := uuid.New() + from := time.Now().Add(-72 * time.Hour).UTC().Truncate(time.Second) + to := time.Now().UTC().Truncate(time.Second) + + result, err := callTool(ctxWithAuth(Scope{}), deps, "get_run_dashboard", map[string]any{ + "project_id": pid.String(), + "requirement_id": reqID.String(), + "from": from.Format(time.RFC3339), + "to": to.Format(time.RFC3339), + }) + require.NoError(t, err) + require.False(t, result.IsError, "body=%v", result) + + require.NotNil(t, bridge.dashIn.ProjectID) + assert.Equal(t, pid, *bridge.dashIn.ProjectID) + require.NotNil(t, bridge.dashIn.RequirementID) + assert.Equal(t, reqID, *bridge.dashIn.RequirementID) + assert.True(t, from.Equal(bridge.dashIn.From)) + assert.True(t, to.Equal(bridge.dashIn.To)) +} + +func TestGetRunDashboard_BadFilter(t *testing.T) { + deps, _ := acpTestDeps() + result, err := callTool(ctxWithAuth(Scope{}), deps, "get_run_dashboard", map[string]any{ + "project_id": "not-a-uuid", + }) + require.NoError(t, err) + assert.True(t, result.IsError, "invalid project_id must error") +} diff --git a/internal/mcp/tools_changerequest.go b/internal/mcp/tools_changerequest.go new file mode 100644 index 0000000..234f7e9 --- /dev/null +++ b/internal/mcp/tools_changerequest.go @@ -0,0 +1,234 @@ +package mcp + +import ( + "context" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/yan1h/agent-coding-workflow/internal/changerequest" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// registerChangeRequestTools registers the change-request (PR) tools. +// +// Gate boundary (spec decision): create_pull_request / merge_pull_request / +// get_pr_status / list_pull_requests are exposed, but review approval is NOT an +// MCP tool — it is HTTP/web-only so an agent cannot self-approve its own PR and +// bypass the merge gate. merge_pull_request calls ChangeReqs.Merge, which +// hard-blocks unless review_verdict=='approved' (surfaced as a FailedPrecondition +// MCP error). Operators may further exclude create/merge from agent system-token +// scopes via the token whitelist. +func registerChangeRequestTools(srv *mcpsdk.Server, deps ServerDeps) { + registerCreatePullRequest(srv, deps) + registerGetPRStatus(srv, deps) + registerMergePullRequest(srv, deps) + registerListPullRequests(srv, deps) +} + +// ===== shared DTO ===== + +type changeRequestDetail struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + WorkspaceID string `json:"workspace_id"` + Number int `json:"number"` + Title string `json:"title"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CIState string `json:"ci_state"` + ExternalID int64 `json:"external_id,omitempty"` + ExternalURL string `json:"external_url,omitempty"` + MergeSHA string `json:"merge_commit_sha,omitempty"` +} + +func changeRequestDetailFrom(cr *changerequest.ChangeRequest) changeRequestDetail { + d := changeRequestDetail{ + ID: cr.ID.String(), ProjectID: cr.ProjectID.String(), WorkspaceID: cr.WorkspaceID.String(), + Number: cr.Number, Title: cr.Title, + SourceBranch: cr.SourceBranch, TargetBranch: cr.TargetBranch, + State: string(cr.State), ReviewVerdict: string(cr.ReviewVerdict), CIState: string(cr.CIState), + ExternalURL: cr.ExternalURL, MergeSHA: cr.MergeCommitSHA, + } + if cr.ExternalID != nil { + d.ExternalID = *cr.ExternalID + } + return d +} + +// resolvePRByNumber scopes the workspace (CheckToolFromCtx-style project gate) +// and finds the change request whose per-project number matches within that +// workspace. Returns NotFound when no such CR exists in scope. +func resolvePRByNumber(ctx context.Context, deps ServerDeps, workspaceID string, number int) (*changerequest.ChangeRequest, error) { + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, err + } + w, _, err := scopedWorkspace(ctx, deps, c, workspaceID) + if err != nil { + return nil, err + } + list, err := deps.ChangeReqs.ListByWorkspace(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, w.ID) + if err != nil { + return nil, err + } + for _, cr := range list { + if cr.Number == number { + return cr, nil + } + } + return nil, errs.New(errs.CodeNotFound, "change request not found in workspace") +} + +// ===== create_pull_request ===== + +type createPRIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + SourceBranch string `json:"source_branch" jsonschema:"branch to merge from (non-empty)"` + TargetBranch string `json:"target_branch,omitempty" jsonschema:"branch to merge into (defaults to workspace default branch)"` + Title string `json:"title" jsonschema:"PR title (non-empty)"` + Description string `json:"description,omitempty"` + RequirementID string `json:"requirement_id,omitempty" jsonschema:"optional linked requirement UUID"` + IssueID string `json:"issue_id,omitempty" jsonschema:"optional linked issue UUID"` +} +type changeRequestOut struct { + OK bool `json:"ok"` + ChangeRequest changeRequestDetail `json:"change_request"` +} + +func registerCreatePullRequest(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "create_pull_request", Description: "Open a pull request on the git host from a workspace branch and persist it as a change request. The PR must be approved by a human (web only) before it can be merged."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createPRIn) (*mcpsdk.CallToolResult, changeRequestOut, error) { + if err := CheckToolFromCtx(ctx, "create_pull_request"); err != nil { + return nil, changeRequestOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, changeRequestOut{}, err + } + w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, changeRequestOut{}, err + } + input := changerequest.CreateInput{ + SourceBranch: in.SourceBranch, TargetBranch: in.TargetBranch, + Title: in.Title, Description: in.Description, + } + if in.RequirementID != "" { + id, perr := uuid.Parse(in.RequirementID) + if perr != nil { + return nil, changeRequestOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "requirement_id parse") + } + input.RequirementID = &id + } + if in.IssueID != "" { + id, perr := uuid.Parse(in.IssueID) + if perr != nil { + return nil, changeRequestOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "issue_id parse") + } + input.IssueID = &id + } + cr, err := deps.ChangeReqs.Create(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, w.ID, input) + if err != nil { + return nil, changeRequestOut{}, err + } + return nil, changeRequestOut{OK: true, ChangeRequest: changeRequestDetailFrom(cr)}, nil + }) +} + +// ===== get_pr_status ===== + +type prNumberIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + Number int `json:"number" jsonschema:"per-project change-request number"` +} + +func registerGetPRStatus(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "get_pr_status", Description: "Get a change request by number, refreshing its CI/merge state from the git host."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in prNumberIn) (*mcpsdk.CallToolResult, changeRequestOut, error) { + if err := CheckToolFromCtx(ctx, "get_pr_status"); err != nil { + return nil, changeRequestOut{}, err + } + cr, err := resolvePRByNumber(ctx, deps, in.WorkspaceID, in.Number) + if err != nil { + return nil, changeRequestOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, changeRequestOut{}, err + } + cr, err = deps.ChangeReqs.SyncStatus(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, cr.ID) + if err != nil { + return nil, changeRequestOut{}, err + } + return nil, changeRequestOut{OK: true, ChangeRequest: changeRequestDetailFrom(cr)}, nil + }) +} + +// ===== merge_pull_request (HARD-GATED) ===== + +type mergePRIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + Number int `json:"number" jsonschema:"per-project change-request number"` + Method string `json:"method,omitempty" jsonschema:"merge method: merge|squash|rebase"` +} + +func registerMergePullRequest(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "merge_pull_request", Description: "Merge a change request on the git host. HARD-BLOCKED unless review_verdict=='approved' (approval is human/web-only); returns a failed_precondition error otherwise."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in mergePRIn) (*mcpsdk.CallToolResult, changeRequestOut, error) { + if err := CheckToolFromCtx(ctx, "merge_pull_request"); err != nil { + return nil, changeRequestOut{}, err + } + cr, err := resolvePRByNumber(ctx, deps, in.WorkspaceID, in.Number) + if err != nil { + return nil, changeRequestOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, changeRequestOut{}, err + } + cr, err = deps.ChangeReqs.Merge(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, cr.ID, in.Method) + if err != nil { + return nil, changeRequestOut{}, err + } + return nil, changeRequestOut{OK: true, ChangeRequest: changeRequestDetailFrom(cr)}, nil + }) +} + +// ===== list_pull_requests ===== + +type listPRsOut struct { + ChangeRequests []changeRequestDetail `json:"change_requests"` +} + +func registerListPullRequests(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "list_pull_requests", Description: "List change requests (PRs) of a workspace."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in getWorkspaceIn) (*mcpsdk.CallToolResult, listPRsOut, error) { + if err := CheckToolFromCtx(ctx, "list_pull_requests"); err != nil { + return nil, listPRsOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, listPRsOut{}, err + } + w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, listPRsOut{}, err + } + out, err := deps.ChangeReqs.ListByWorkspace(ctx, changerequest.Caller{UserID: c.UserID, IsAdmin: c.IsAdmin}, w.ID) + if err != nil { + return nil, listPRsOut{}, err + } + res := listPRsOut{ChangeRequests: make([]changeRequestDetail, 0, len(out))} + for _, cr := range out { + res.ChangeRequests = append(res.ChangeRequests, changeRequestDetailFrom(cr)) + } + return nil, res, nil + }) +} diff --git a/internal/mcp/tools_changerequest_test.go b/internal/mcp/tools_changerequest_test.go new file mode 100644 index 0000000..95ab877 --- /dev/null +++ b/internal/mcp/tools_changerequest_test.go @@ -0,0 +1,200 @@ +package mcp + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/changerequest" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/workspace" +) + +// fakeChangeReqSvc implements changerequest.Service. +type fakeChangeReqSvc struct { + items []*changerequest.ChangeRequest + mergeErr error + syncState changerequest.CIState +} + +func (f *fakeChangeReqSvc) Create(_ context.Context, c changerequest.Caller, wsID uuid.UUID, in changerequest.CreateInput) (*changerequest.ChangeRequest, error) { + ext := int64(len(f.items) + 1) + cr := &changerequest.ChangeRequest{ + ID: uuid.New(), ProjectID: testPID, WorkspaceID: wsID, Number: len(f.items) + 1, + Title: in.Title, SourceBranch: in.SourceBranch, TargetBranch: firstNonEmpty(in.TargetBranch, "main"), + State: changerequest.StateOpen, ReviewVerdict: changerequest.VerdictPending, CIState: changerequest.CIUnknown, + Provider: "gitea", ExternalID: &ext, ExternalURL: "http://h/pr", CreatedBy: c.UserID, + } + f.items = append(f.items, cr) + return cr, nil +} +func (f *fakeChangeReqSvc) Get(_ context.Context, _ changerequest.Caller, id uuid.UUID) (*changerequest.ChangeRequest, error) { + for _, cr := range f.items { + if cr.ID == id { + return cr, nil + } + } + return nil, errs.New(errs.CodeNotFound, "not found") +} +func (f *fakeChangeReqSvc) GetByNumber(_ context.Context, _ changerequest.Caller, _ string, n int) (*changerequest.ChangeRequest, error) { + for _, cr := range f.items { + if cr.Number == n { + return cr, nil + } + } + return nil, errs.New(errs.CodeNotFound, "not found") +} +func (f *fakeChangeReqSvc) ListByWorkspace(_ context.Context, _ changerequest.Caller, wsID uuid.UUID) ([]*changerequest.ChangeRequest, error) { + out := make([]*changerequest.ChangeRequest, 0, len(f.items)) + for _, cr := range f.items { + if cr.WorkspaceID == wsID { + out = append(out, cr) + } + } + return out, nil +} +func (f *fakeChangeReqSvc) Review(_ context.Context, _ changerequest.Caller, id uuid.UUID, v changerequest.Verdict, _ string) (*changerequest.ChangeRequest, error) { + cr, err := f.Get(context.Background(), changerequest.Caller{}, id) + if err != nil { + return nil, err + } + cr.ReviewVerdict = v + return cr, nil +} +func (f *fakeChangeReqSvc) Merge(_ context.Context, _ changerequest.Caller, id uuid.UUID, _ string) (*changerequest.ChangeRequest, error) { + if f.mergeErr != nil { + return nil, f.mergeErr + } + cr, err := f.Get(context.Background(), changerequest.Caller{}, id) + if err != nil { + return nil, err + } + cr.State = changerequest.StateMerged + cr.MergeCommitSHA = "merged-sha" + return cr, nil +} +func (f *fakeChangeReqSvc) SyncStatus(_ context.Context, _ changerequest.Caller, id uuid.UUID) (*changerequest.ChangeRequest, error) { + cr, err := f.Get(context.Background(), changerequest.Caller{}, id) + if err != nil { + return nil, err + } + if f.syncState != "" { + cr.CIState = f.syncState + } + return cr, nil +} + +func firstNonEmpty(a, b string) string { + if a != "" { + return a + } + return b +} + +// crTestDeps mirrors wsTestDeps but with a change-request service attached. +func crTestDeps() (ServerDeps, *fakeChangeReqSvc) { + deps := testDeps() + deps.Workspaces = &fakeWorkspaceSvc{items: []*workspace.Workspace{{ + ID: testWSID, ProjectID: testPID, Slug: "ws-1", Name: "Workspace 1", + DefaultBranch: "main", SyncStatus: workspace.SyncStatusIdle, CreatedAt: time.Now(), + }}} + deps.Worktrees = &fakeWorktreeSvc{} + deps.GitOps = &fakeGitOpsSvc{} + crSvc := &fakeChangeReqSvc{} + deps.ChangeReqs = crSvc + return deps, crSvc +} + +func TestCreatePullRequest_HappyPath(t *testing.T) { + deps, crSvc := crTestDeps() + result, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{ + "workspace_id": testWSID.String(), "source_branch": "feature", "title": "Add X", + }) + require.NoError(t, err) + require.False(t, result.IsError) + var out changeRequestOut + unmarshalStructured(t, result, &out) + assert.True(t, out.OK) + assert.Equal(t, "feature", out.ChangeRequest.SourceBranch) + require.Len(t, crSvc.items, 1) +} + +func TestCreatePullRequest_ScopeDenied(t *testing.T) { + deps, _ := crTestDeps() + result, err := callTool(ctxWithAuth(Scope{Tools: []string{"git_diff"}}), deps, "create_pull_request", map[string]any{ + "workspace_id": testWSID.String(), "source_branch": "feature", "title": "X", + }) + require.NoError(t, err) + assert.True(t, result.IsError, "token scope without create_pull_request must deny") +} + +func TestGetPRStatus(t *testing.T) { + deps, crSvc := crTestDeps() + crSvc.syncState = changerequest.CISuccess + // seed via create + _, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{ + "workspace_id": testWSID.String(), "source_branch": "feature", "title": "X", + }) + require.NoError(t, err) + + result, err := callTool(ctxWithAuth(Scope{}), deps, "get_pr_status", map[string]any{ + "workspace_id": testWSID.String(), "number": 1, + }) + require.NoError(t, err) + require.False(t, result.IsError) + var out changeRequestOut + unmarshalStructured(t, result, &out) + assert.Equal(t, string(changerequest.CISuccess), out.ChangeRequest.CIState) +} + +func TestMergePullRequest_GateError(t *testing.T) { + deps, crSvc := crTestDeps() + crSvc.mergeErr = errs.New(errs.CodeFailedPrecondition, "merge blocked: review not approved") + _, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{ + "workspace_id": testWSID.String(), "source_branch": "feature", "title": "X", + }) + require.NoError(t, err) + + result, err := callTool(ctxWithAuth(Scope{}), deps, "merge_pull_request", map[string]any{ + "workspace_id": testWSID.String(), "number": 1, + }) + require.NoError(t, err) + assert.True(t, result.IsError, "gated merge must surface as an MCP error") +} + +func TestMergePullRequest_Allowed(t *testing.T) { + deps, _ := crTestDeps() + _, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{ + "workspace_id": testWSID.String(), "source_branch": "feature", "title": "X", + }) + require.NoError(t, err) + + result, err := callTool(ctxWithAuth(Scope{}), deps, "merge_pull_request", map[string]any{ + "workspace_id": testWSID.String(), "number": 1, "method": "squash", + }) + require.NoError(t, err) + require.False(t, result.IsError) + var out changeRequestOut + unmarshalStructured(t, result, &out) + assert.Equal(t, string(changerequest.StateMerged), out.ChangeRequest.State) +} + +func TestListPullRequests(t *testing.T) { + deps, _ := crTestDeps() + _, err := callTool(ctxWithAuth(Scope{}), deps, "create_pull_request", map[string]any{ + "workspace_id": testWSID.String(), "source_branch": "feature", "title": "X", + }) + require.NoError(t, err) + + result, err := callTool(ctxWithAuth(Scope{}), deps, "list_pull_requests", map[string]any{ + "workspace_id": testWSID.String(), + }) + require.NoError(t, err) + var out listPRsOut + unmarshalStructured(t, result, &out) + require.Len(t, out.ChangeRequests, 1) +} diff --git a/internal/mcp/tools_codeindex.go b/internal/mcp/tools_codeindex.go new file mode 100644 index 0000000..b0f7cf8 --- /dev/null +++ b/internal/mcp/tools_codeindex.go @@ -0,0 +1,109 @@ +package mcp + +import ( + "context" + + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/yan1h/agent-coding-workflow/internal/codeindex" +) + +// registerCodeIndexTools registers the pgvector code-search tools. When the +// CodeIndex service is not configured (no embedding endpoint), the tools are not +// registered at all so they don't appear in the agent's tool list. +func registerCodeIndexTools(srv *mcpsdk.Server, deps ServerDeps) { + if deps.CodeIndex == nil { + return + } + registerSearchCode(srv, deps) + registerReindexWorkspace(srv, deps) +} + +// ===== search_code ===== + +type searchCodeIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + Query string `json:"query" jsonschema:"natural-language or code query"` + TopK int `json:"top_k,omitempty" jsonschema:"max results (capped by server)"` + PathPrefix string `json:"path_prefix,omitempty" jsonschema:"restrict to a worktree-relative path prefix"` + Lang string `json:"lang,omitempty" jsonschema:"restrict to a language label (e.g. go, typescript)"` +} + +type codeSnippet struct { + File string `json:"file"` + StartLine int `json:"start_line"` + EndLine int `json:"end_line"` + Snippet string `json:"snippet"` + Score float32 `json:"score"` + CommitSHA string `json:"commit_sha"` +} + +type searchCodeOut struct { + Results []codeSnippet `json:"results"` +} + +func registerSearchCode(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "search_code", Description: "Semantic code search over the workspace's indexed files; returns ranked file:line snippets."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in searchCodeIn) (*mcpsdk.CallToolResult, searchCodeOut, error) { + if err := CheckToolFromCtx(ctx, "search_code"); err != nil { + return nil, searchCodeOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, searchCodeOut{}, err + } + w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, searchCodeOut{}, err + } + snippets, err := deps.CodeIndex.Search(ctx, w.ID, codeindex.SearchQuery{ + Text: in.Query, TopK: in.TopK, PathPrefix: in.PathPrefix, Lang: in.Lang, + }) + if err != nil { + return nil, searchCodeOut{}, err + } + out := searchCodeOut{Results: make([]codeSnippet, 0, len(snippets))} + for _, s := range snippets { + out.Results = append(out.Results, codeSnippet{ + File: s.FilePath, StartLine: s.StartLine, EndLine: s.EndLine, + Snippet: s.Content, Score: s.Score, CommitSHA: s.CommitSHA, + }) + } + return nil, out, nil + }) +} + +// ===== reindex_workspace ===== + +type reindexWorkspaceIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` +} + +type reindexWorkspaceOut struct { + OK bool `json:"ok"` + RunID string `json:"run_id"` +} + +func registerReindexWorkspace(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "reindex_workspace", Description: "Enqueue a fresh code index build at the workspace's current main HEAD."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in reindexWorkspaceIn) (*mcpsdk.CallToolResult, reindexWorkspaceOut, error) { + if err := CheckToolFromCtx(ctx, "reindex_workspace"); err != nil { + return nil, reindexWorkspaceOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, reindexWorkspaceOut{}, err + } + w, _, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, reindexWorkspaceOut{}, err + } + run, err := deps.CodeIndex.EnqueueBuild(ctx, w.ID, nil, "") + if err != nil { + return nil, reindexWorkspaceOut{}, err + } + return nil, reindexWorkspaceOut{OK: true, RunID: run.ID.String()}, nil + }) +} diff --git a/internal/mcp/tools_codeindex_memory_test.go b/internal/mcp/tools_codeindex_memory_test.go new file mode 100644 index 0000000..7eba96f --- /dev/null +++ b/internal/mcp/tools_codeindex_memory_test.go @@ -0,0 +1,219 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/codeindex" + "github.com/yan1h/agent-coding-workflow/internal/projectmemory" + "github.com/yan1h/agent-coding-workflow/internal/workspace" +) + +// ===== fakes ===== + +type fakeCodeIndexSvc struct { + snippets []codeindex.Snippet + lastWS uuid.UUID + lastQuery codeindex.SearchQuery + enqueuedWS uuid.UUID +} + +func (f *fakeCodeIndexSvc) EnqueueBuild(_ context.Context, wsID uuid.UUID, _ *uuid.UUID, _ string) (*codeindex.Run, error) { + f.enqueuedWS = wsID + return &codeindex.Run{ID: uuid.New(), WorkspaceID: wsID}, nil +} +func (f *fakeCodeIndexSvc) Search(_ context.Context, wsID uuid.UUID, q codeindex.SearchQuery) ([]codeindex.Snippet, error) { + f.lastWS = wsID + f.lastQuery = q + return f.snippets, nil +} +func (f *fakeCodeIndexSvc) MarkStale(context.Context, uuid.UUID) error { return nil } + +type fakeMemorySvc struct { + written *projectmemory.WriteInput + searched *projectmemory.Query + entries []projectmemory.Entry +} + +func (f *fakeMemorySvc) Write(_ context.Context, in projectmemory.WriteInput) (*projectmemory.Entry, error) { + f.written = &in + return &projectmemory.Entry{ID: uuid.New(), ProjectID: in.ProjectID, Kind: in.Kind, Title: in.Title}, nil +} +func (f *fakeMemorySvc) Search(_ context.Context, q projectmemory.Query) ([]projectmemory.Entry, error) { + f.searched = &q + return f.entries, nil +} +func (f *fakeMemorySvc) List(_ context.Context, _ uuid.UUID, _ string) ([]projectmemory.Entry, error) { + return f.entries, nil +} +func (f *fakeMemorySvc) Delete(context.Context, uuid.UUID) error { return nil } +func (f *fakeMemorySvc) RenderAgentsMD(context.Context, uuid.UUID, *uuid.UUID) (string, error) { + return "", nil +} + +// depsWithRAG builds testDeps plus a workspace + the code index/memory services. +func depsWithRAG(ci codeindex.Service, mem projectmemory.Service) ServerDeps { + deps := testDeps() + ws := &workspace.Workspace{ + ID: testWSID, ProjectID: testPID, Slug: "ws-1", Name: "WS", + DefaultBranch: "main", SyncStatus: workspace.SyncStatusIdle, + } + deps.Workspaces = &fakeWorkspaceSvc{items: []*workspace.Workspace{ws}} + deps.CodeIndex = ci + deps.Memory = mem + return deps +} + +// ===== search_code ===== + +func TestSearchCode_HappyPath(t *testing.T) { + ci := &fakeCodeIndexSvc{snippets: []codeindex.Snippet{ + {FilePath: "a.go", StartLine: 1, EndLine: 10, Content: "func A", Score: 0.9, CommitSHA: "abc"}, + }} + deps := depsWithRAG(ci, &fakeMemorySvc{}) + result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, + "search_code", map[string]any{"workspace_id": testWSID.String(), "query": "find A", "top_k": 5}) + require.NoError(t, err) + require.False(t, result.IsError) + + var out searchCodeOut + unmarshalStructured(t, result, &out) + require.Len(t, out.Results, 1) + assert.Equal(t, "a.go", out.Results[0].File) + assert.Equal(t, "find A", ci.lastQuery.Text) + assert.Equal(t, 5, ci.lastQuery.TopK) + assert.Equal(t, testWSID, ci.lastWS) +} + +func TestSearchCode_CrossTenantRejected(t *testing.T) { + deps := depsWithRAG(&fakeCodeIndexSvc{}, &fakeMemorySvc{}) + // Scope allows only a different project → project scope check must reject. + result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps, + "search_code", map[string]any{"workspace_id": testWSID.String(), "query": "q"}) + if err == nil { + require.True(t, result.IsError, "cross-tenant workspace must be rejected") + } +} + +func TestSearchCode_ToolScopeDenied(t *testing.T) { + deps := depsWithRAG(&fakeCodeIndexSvc{}, &fakeMemorySvc{}) + // Token scope only allows memory_list → search_code denied. + result, err := callTool(ctxWithAuth(Scope{Tools: []string{"memory_list"}}), deps, + "search_code", map[string]any{"workspace_id": testWSID.String(), "query": "q"}) + if err == nil { + require.True(t, result.IsError) + } +} + +func TestReindexWorkspace_HappyPath(t *testing.T) { + ci := &fakeCodeIndexSvc{} + deps := depsWithRAG(ci, &fakeMemorySvc{}) + result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, + "reindex_workspace", map[string]any{"workspace_id": testWSID.String()}) + require.NoError(t, err) + require.False(t, result.IsError) + var out reindexWorkspaceOut + unmarshalStructured(t, result, &out) + assert.True(t, out.OK) + assert.NotEmpty(t, out.RunID) + assert.Equal(t, testWSID, ci.enqueuedWS) +} + +// When CodeIndex is nil the tool is not registered → call errors (unknown tool). +func TestSearchCode_NotRegisteredWhenDisabled(t *testing.T) { + deps := testDeps() + deps.Memory = &fakeMemorySvc{} // memory present, code index nil + result, err := callTool(ctxWithAuth(Scope{}), deps, + "search_code", map[string]any{"workspace_id": testWSID.String(), "query": "q"}) + if err == nil { + require.True(t, result.IsError, "disabled tool must not succeed") + } +} + +// ===== memory tools ===== + +func TestMemoryWrite_HappyPath(t *testing.T) { + mem := &fakeMemorySvc{} + deps := depsWithRAG(&fakeCodeIndexSvc{}, mem) + result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, + "memory_write", map[string]any{ + "project_slug": "test-project", "kind": "decision", + "title": "Use X", "body": "because Y", "tags": []string{"a", "b"}, + }) + require.NoError(t, err) + require.False(t, result.IsError) + var out memoryWriteOut + unmarshalStructured(t, result, &out) + assert.True(t, out.OK) + require.NotNil(t, mem.written) + assert.Equal(t, testPID, mem.written.ProjectID) + assert.Equal(t, "decision", mem.written.Kind) + assert.Equal(t, []string{"a", "b"}, mem.written.Tags) +} + +func TestMemoryWrite_ByWorkspace(t *testing.T) { + mem := &fakeMemorySvc{} + deps := depsWithRAG(&fakeCodeIndexSvc{}, mem) + result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, + "memory_write", map[string]any{ + "workspace_id": testWSID.String(), "kind": "gotcha", + "title": "T", "body": "B", + }) + require.NoError(t, err) + require.False(t, result.IsError) + require.NotNil(t, mem.written) + assert.Equal(t, testPID, mem.written.ProjectID) + require.NotNil(t, mem.written.WorkspaceID) + assert.Equal(t, testWSID, *mem.written.WorkspaceID) +} + +func TestMemorySearch_HappyPath(t *testing.T) { + mem := &fakeMemorySvc{entries: []projectmemory.Entry{ + {ID: uuid.New(), Kind: "decision", Title: "hit", Body: "x", Tags: []string{}}, + }} + deps := depsWithRAG(&fakeCodeIndexSvc{}, mem) + result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, + "memory_search", map[string]any{"project_slug": "test-project", "query": "hit"}) + require.NoError(t, err) + require.False(t, result.IsError) + var out memorySearchOut + unmarshalStructured(t, result, &out) + require.Len(t, out.Results, 1) + assert.Equal(t, "hit", out.Results[0].Title) + require.NotNil(t, mem.searched) + assert.Equal(t, testPID, mem.searched.ProjectID) +} + +func TestMemoryList_HappyPath(t *testing.T) { + mem := &fakeMemorySvc{entries: []projectmemory.Entry{ + {ID: uuid.New(), Kind: "convention", Title: "c", Tags: []string{}}, + }} + deps := depsWithRAG(&fakeCodeIndexSvc{}, mem) + result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{testPID}}), deps, + "memory_list", map[string]any{"project_slug": "test-project"}) + require.NoError(t, err) + require.False(t, result.IsError) + var out memoryListOut + unmarshalStructured(t, result, &out) + require.Len(t, out.Entries, 1) +} + +func TestMemoryWrite_CrossTenantRejected(t *testing.T) { + deps := depsWithRAG(&fakeCodeIndexSvc{}, &fakeMemorySvc{}) + result, err := callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps, + "memory_write", map[string]any{ + "project_slug": "test-project", "kind": "decision", "title": "T", "body": "B", + }) + if err == nil { + require.True(t, result.IsError, "cross-tenant project must be rejected") + } +} + +var ( + _ codeindex.Service = (*fakeCodeIndexSvc)(nil) + _ projectmemory.Service = (*fakeMemorySvc)(nil) +) diff --git a/internal/mcp/tools_memory.go b/internal/mcp/tools_memory.go new file mode 100644 index 0000000..c6cbbc8 --- /dev/null +++ b/internal/mcp/tools_memory.go @@ -0,0 +1,211 @@ +package mcp + +import ( + "context" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/project" + "github.com/yan1h/agent-coding-workflow/internal/projectmemory" +) + +// registerMemoryTools registers the project_memory tools. When the Memory service +// is nil the tools are not registered. +func registerMemoryTools(srv *mcpsdk.Server, deps ServerDeps) { + if deps.Memory == nil { + return + } + registerMemoryWrite(srv, deps) + registerMemorySearch(srv, deps) + registerMemoryList(srv, deps) +} + +// resolveMemoryScope resolves (projectID, workspaceID?) from either a +// workspace_id or a project_slug, enforcing project scope. Exactly one of the two +// identifiers must be supplied for write/search; list requires project_slug. +func resolveMemoryScope(ctx context.Context, deps ServerDeps, c project.Caller, workspaceID, projectSlug string) (projectID uuid.UUID, wsID *uuid.UUID, err error) { + switch { + case workspaceID != "": + w, _, werr := scopedWorkspace(ctx, deps, c, workspaceID) + if werr != nil { + return uuid.Nil, nil, werr + } + id := w.ID + return w.ProjectID, &id, nil + case projectSlug != "": + p, perr := deps.Projects.Get(ctx, c, projectSlug) + if perr != nil { + return uuid.Nil, nil, perr + } + if cerr := CheckProjectFromCtx(ctx, p.ID); cerr != nil { + return uuid.Nil, nil, cerr + } + return p.ID, nil, nil + default: + return uuid.Nil, nil, errs.New(errs.CodeInvalidInput, "project_slug or workspace_id required") + } +} + +type memoryEntryDTO struct { + ID string `json:"id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + WorkspaceID string `json:"workspace_id,omitempty"` + CreatedAt string `json:"created_at"` +} + +func memoryEntryDTOFrom(e projectmemory.Entry) memoryEntryDTO { + d := memoryEntryDTO{ + ID: e.ID.String(), Kind: e.Kind, Title: e.Title, Body: e.Body, + Tags: e.Tags, Source: e.Source, + CreatedAt: e.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + } + if e.WorkspaceID != nil { + d.WorkspaceID = e.WorkspaceID.String() + } + if d.Tags == nil { + d.Tags = []string{} + } + return d +} + +// ===== memory_write ===== + +type memoryWriteIn struct { + ProjectSlug string `json:"project_slug,omitempty" jsonschema:"project slug (or supply workspace_id)"` + WorkspaceID string `json:"workspace_id,omitempty" jsonschema:"workspace UUID (scopes the entry to this workspace)"` + Kind string `json:"kind" jsonschema:"one of: decision, convention, file_map, gotcha"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags,omitempty"` +} + +type memoryWriteOut struct { + OK bool `json:"ok"` + ID string `json:"id"` +} + +func registerMemoryWrite(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "memory_write", Description: "Record a typed project memory entry (decision/convention/file_map/gotcha)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in memoryWriteIn) (*mcpsdk.CallToolResult, memoryWriteOut, error) { + if err := CheckToolFromCtx(ctx, "memory_write"); err != nil { + return nil, memoryWriteOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, memoryWriteOut{}, err + } + projectID, wsID, err := resolveMemoryScope(ctx, deps, c, in.WorkspaceID, in.ProjectSlug) + if err != nil { + return nil, memoryWriteOut{}, err + } + uid := c.UserID + entry, err := deps.Memory.Write(ctx, projectmemory.WriteInput{ + ProjectID: projectID, + WorkspaceID: wsID, + Kind: in.Kind, + Title: in.Title, + Body: in.Body, + Tags: in.Tags, + Source: projectmemory.SourceAgent, + CreatedBy: &uid, + }) + if err != nil { + return nil, memoryWriteOut{}, err + } + return nil, memoryWriteOut{OK: true, ID: entry.ID.String()}, nil + }) +} + +// ===== memory_search ===== + +type memorySearchIn struct { + ProjectSlug string `json:"project_slug,omitempty" jsonschema:"project slug (or supply workspace_id)"` + WorkspaceID string `json:"workspace_id,omitempty" jsonschema:"workspace UUID"` + Query string `json:"query" jsonschema:"search text"` + Kind string `json:"kind,omitempty" jsonschema:"restrict to a kind"` + TopK int `json:"top_k,omitempty"` +} + +type memorySearchOut struct { + Results []memoryEntryDTO `json:"results"` +} + +func registerMemorySearch(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "memory_search", Description: "Search project memory (vector when embeddings available, keyword/tag fallback otherwise)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in memorySearchIn) (*mcpsdk.CallToolResult, memorySearchOut, error) { + if err := CheckToolFromCtx(ctx, "memory_search"); err != nil { + return nil, memorySearchOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, memorySearchOut{}, err + } + projectID, _, err := resolveMemoryScope(ctx, deps, c, in.WorkspaceID, in.ProjectSlug) + if err != nil { + return nil, memorySearchOut{}, err + } + entries, err := deps.Memory.Search(ctx, projectmemory.Query{ + ProjectID: projectID, Text: in.Query, Kind: in.Kind, TopK: in.TopK, + }) + if err != nil { + return nil, memorySearchOut{}, err + } + out := memorySearchOut{Results: make([]memoryEntryDTO, 0, len(entries))} + for _, e := range entries { + out.Results = append(out.Results, memoryEntryDTOFrom(e)) + } + return nil, out, nil + }) +} + +// ===== memory_list ===== + +type memoryListIn struct { + ProjectSlug string `json:"project_slug" jsonschema:"project slug"` + Kind string `json:"kind,omitempty" jsonschema:"restrict to a kind"` +} + +type memoryListOut struct { + Entries []memoryEntryDTO `json:"entries"` +} + +func registerMemoryList(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "memory_list", Description: "List project memory entries, optionally filtered by kind."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in memoryListIn) (*mcpsdk.CallToolResult, memoryListOut, error) { + if err := CheckToolFromCtx(ctx, "memory_list"); err != nil { + return nil, memoryListOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, memoryListOut{}, err + } + if in.ProjectSlug == "" { + return nil, memoryListOut{}, errs.New(errs.CodeInvalidInput, "project_slug required") + } + p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) + if err != nil { + return nil, memoryListOut{}, err + } + if err := CheckProjectFromCtx(ctx, p.ID); err != nil { + return nil, memoryListOut{}, err + } + entries, err := deps.Memory.List(ctx, p.ID, in.Kind) + if err != nil { + return nil, memoryListOut{}, err + } + out := memoryListOut{Entries: make([]memoryEntryDTO, 0, len(entries))} + for _, e := range entries { + out.Entries = append(out.Entries, memoryEntryDTOFrom(e)) + } + return nil, out, nil + }) +} diff --git a/internal/mcp/tools_orchestrator.go b/internal/mcp/tools_orchestrator.go new file mode 100644 index 0000000..5d5995d --- /dev/null +++ b/internal/mcp/tools_orchestrator.go @@ -0,0 +1,85 @@ +package mcp + +import ( + "context" + + "github.com/google/uuid" + mcpsdk "github.com/modelcontextprotocol/go-sdk/mcp" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// OrchestratorBridge 是 mcp 调用 orchestrator 服务的窄桥接接口(orchestrator → +// mcp 的既有依赖边由 orchestrator 实现该接口,mcp 不反向 import orchestrator,避免循环)。 +type OrchestratorBridge interface { + // RequestSubtask 让运行中的 agent 为其当前编排 step 派生一个子 step。 + // parentACPSessionID 是调用方所在的 acp session(从 AuthSession 解析); + // 返回新建子 step 的 id。 + RequestSubtask(ctx context.Context, parentACPSessionID uuid.UUID, phase string, prompt string, agentKindID *uuid.UUID) (subtaskID string, err error) +} + +// registerOrchestratorTools 注册编排器相关 MCP 工具。 +func registerOrchestratorTools(srv *mcpsdk.Server, deps ServerDeps) { + registerRequestSubtask(srv, deps) +} + +// ===== request_subtask ===== + +type requestSubtaskIn struct { + Phase string `json:"phase,omitempty" jsonschema:"target phase for the subtask (defaults to the parent step phase)"` + Prompt string `json:"prompt" jsonschema:"prompt for the child agent"` + AgentKindID string `json:"agent_kind_id,omitempty" jsonschema:"optional agent kind UUID for the subtask"` +} + +type requestSubtaskOut struct { + OK bool `json:"ok"` + StepID string `json:"step_id"` +} + +func registerRequestSubtask(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{ + Name: "request_subtask", + Description: "Ask the orchestrator to spawn a child step (sub-agent) for the current run, subject to depth and fan-out limits. Only callable from within an active agent session.", + }, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in requestSubtaskIn) (*mcpsdk.CallToolResult, requestSubtaskOut, error) { + if err := CheckToolFromCtx(ctx, "request_subtask"); err != nil { + return nil, requestSubtaskOut{}, err + } + if deps.Orchestrator == nil { + return nil, requestSubtaskOut{}, errs.New(errs.CodeNotFound, "orchestrator not enabled") + } + // request_subtask 是 create_acp_session 的逆向闸门:只有在 session 内的 + // agent(system token)可调,普通用户/admin token 不可(它们走 StartRun)。 + sess, ok := FromContext(ctx) + if !ok { + return nil, requestSubtaskOut{}, errs.New(errs.CodeInternal, "mcp: AuthSession missing") + } + if sess.Issuer != IssuerSystem { + return nil, requestSubtaskOut{}, errs.New(errs.CodeForbidden, + "request_subtask is only callable from within an agent session") + } + if sess.ACPSessionID == nil || *sess.ACPSessionID == uuid.Nil { + return nil, requestSubtaskOut{}, errs.New(errs.CodeForbidden, + "calling token is not bound to an acp session") + } + if in.Prompt == "" { + return nil, requestSubtaskOut{}, errs.New(errs.CodeInvalidInput, "prompt is required") + } + + var akID *uuid.UUID + if in.AgentKindID != "" { + id, perr := uuid.Parse(in.AgentKindID) + if perr != nil { + return nil, requestSubtaskOut{}, errs.Wrap(perr, errs.CodeInvalidInput, "agent_kind_id parse") + } + akID = &id + } + + stepID, err := deps.Orchestrator.RequestSubtask(ctx, *sess.ACPSessionID, in.Phase, in.Prompt, akID) + if err != nil { + return nil, requestSubtaskOut{}, err + } + return nil, requestSubtaskOut{OK: true, StepID: stepID}, nil + }) +} diff --git a/internal/mcp/tools_orchestrator_test.go b/internal/mcp/tools_orchestrator_test.go new file mode 100644 index 0000000..b12ae58 --- /dev/null +++ b/internal/mcp/tools_orchestrator_test.go @@ -0,0 +1,108 @@ +package mcp + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// fakeOrchBridge implements OrchestratorBridge. +type fakeOrchBridge struct { + lastParent uuid.UUID + lastPhase string + lastPrompt string + stepID string + err error +} + +func (f *fakeOrchBridge) RequestSubtask(_ context.Context, parentACPSessionID uuid.UUID, phase, prompt string, _ *uuid.UUID) (string, error) { + if f.err != nil { + return "", f.err + } + f.lastParent = parentACPSessionID + f.lastPhase = phase + f.lastPrompt = prompt + if f.stepID == "" { + f.stepID = uuid.NewString() + } + return f.stepID, nil +} + +func orchTestDeps() (ServerDeps, *fakeOrchBridge) { + deps := testDeps() + bridge := &fakeOrchBridge{} + deps.Orchestrator = bridge + return deps, bridge +} + +// ctxWithSystemToken 构造一个 system token 上下文(绑定 acp session)。 +func ctxWithSystemToken(sessionID uuid.UUID) context.Context { + return context.WithValue(context.Background(), AuthContextKey, &AuthSession{ + UserID: testUID.String(), + TokenID: uuid.NewString(), + Issuer: IssuerSystem, + ACPSessionID: &sessionID, + }) +} + +func TestRequestSubtask_SystemTokenHappyPath(t *testing.T) { + deps, bridge := orchTestDeps() + sessionID := uuid.New() + + result, err := callTool(ctxWithSystemToken(sessionID), deps, "request_subtask", map[string]any{ + "phase": "implementing", + "prompt": "do the subtask", + }) + require.NoError(t, err) + require.False(t, result.IsError, "expected success; got %+v", result) + assert.Equal(t, sessionID, bridge.lastParent) + assert.Equal(t, "implementing", bridge.lastPhase) + assert.Equal(t, "do the subtask", bridge.lastPrompt) +} + +func TestRequestSubtask_RejectsAdminToken(t *testing.T) { + deps, bridge := orchTestDeps() + + // admin token(非 system)→ 拒绝(request_subtask 只允许 in-session agent)。 + ctx := context.WithValue(context.Background(), AuthContextKey, &AuthSession{ + UserID: testUID.String(), + TokenID: uuid.NewString(), + Issuer: IssuerAdmin, + }) + result, err := callTool(ctx, deps, "request_subtask", map[string]any{ + "phase": "implementing", + "prompt": "x", + }) + require.NoError(t, err) + assert.True(t, result.IsError, "admin token must be rejected") + assert.Empty(t, bridge.lastPrompt) +} + +func TestRequestSubtask_RejectsSystemTokenWithoutSession(t *testing.T) { + deps, _ := orchTestDeps() + + // system token 但未绑定 acp session → 拒绝。 + ctx := context.WithValue(context.Background(), AuthContextKey, &AuthSession{ + UserID: testUID.String(), + TokenID: uuid.NewString(), + Issuer: IssuerSystem, + }) + result, err := callTool(ctx, deps, "request_subtask", map[string]any{ + "phase": "implementing", + "prompt": "x", + }) + require.NoError(t, err) + assert.True(t, result.IsError, "system token without acp session must be rejected") +} + +func TestRequestSubtask_RequiresPrompt(t *testing.T) { + deps, _ := orchTestDeps() + result, err := callTool(ctxWithSystemToken(uuid.New()), deps, "request_subtask", map[string]any{ + "phase": "implementing", + }) + require.NoError(t, err) + assert.True(t, result.IsError, "missing prompt must error") +} diff --git a/internal/mcp/tools_pm.go b/internal/mcp/tools_pm.go index 190790e..b89daa2 100644 --- a/internal/mcp/tools_pm.go +++ b/internal/mcp/tools_pm.go @@ -24,6 +24,11 @@ func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) { registerGetIssue(srv, deps) registerGetRequirement(srv, deps) registerCreateIssue(srv, deps) + registerCreateSubtask(srv, deps) + registerAddDependency(srv, deps) + registerRemoveDependency(srv, deps) + registerListDependencies(srv, deps) + registerSubmitDecomposition(srv, deps) registerUpdateIssue(srv, deps) registerCloseIssue(srv, deps) registerReopenIssue(srv, deps) @@ -34,6 +39,7 @@ func registerPMTools(srv *mcpsdk.Server, deps ServerDeps) { registerReopenRequirement(srv, deps) registerSetRequirementPhase(srv, deps) registerAddRequirementArtifact(srv, deps) + registerApproveRequirementArtifact(srv, deps) registerListRequirementArtifacts(srv, deps) } @@ -380,6 +386,312 @@ func registerCreateIssue(srv *mcpsdk.Server, deps ServerDeps) { }) } +// ===== create_subtask ===== + +type createSubtaskIn struct { + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + ParentNumber int `json:"parent_number" jsonschema:"parent issue number (>=1)"` + Title string `json:"title" jsonschema:"title (non-empty)"` + Description string `json:"description,omitempty"` + Priority int `json:"priority,omitempty" jsonschema:"scheduling priority 0..3 (higher = sooner)"` +} + +func registerCreateSubtask(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "create_subtask", Description: "Create a subtask under a parent issue (task decomposition). Sets parent_id + priority."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in createSubtaskIn) (*mcpsdk.CallToolResult, issueCreateOut, error) { + if err := CheckToolFromCtx(ctx, "create_subtask"); err != nil { + return nil, issueCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, issueCreateOut{}, err + } + p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) + if err != nil { + return nil, issueCreateOut{}, err + } + if err := CheckProjectFromCtx(ctx, p.ID); err != nil { + return nil, issueCreateOut{}, err + } + prio := in.Priority + i, err := deps.Issues.CreateSubtask(ctx, c, in.ProjectSlug, in.ParentNumber, project.CreateIssueInput{ + Title: in.Title, Description: in.Description, Priority: &prio, + }) + if err != nil { + return nil, issueCreateOut{}, err + } + return nil, issueCreateOut{OK: true, Issue: issueDetail{ + Number: i.Number, Title: i.Title, Description: i.Description, + Status: string(i.Status), CreatedAt: i.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + }}, nil + }) +} + +// ===== add_dependency ===== + +type addDependencyIn struct { + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + BlockedNumber int `json:"blocked_number" jsonschema:"issue blocked-by blocker_number"` + BlockerNumber int `json:"blocker_number" jsonschema:"blocking issue number"` +} +type dependencyOut struct { + OK bool `json:"ok"` + BlockedNumber int `json:"blocked_number"` + BlockerNumber int `json:"blocker_number"` +} + +func registerAddDependency(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "add_dependency", Description: "Add a dependency edge: blocked_number is blocked-by blocker_number. Rejects cycles and cross-project edges."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in addDependencyIn) (*mcpsdk.CallToolResult, dependencyOut, error) { + if err := CheckToolFromCtx(ctx, "add_dependency"); err != nil { + return nil, dependencyOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, dependencyOut{}, err + } + p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) + if err != nil { + return nil, dependencyOut{}, err + } + if err := CheckProjectFromCtx(ctx, p.ID); err != nil { + return nil, dependencyOut{}, err + } + if _, err := deps.Issues.AddDependency(ctx, c, in.ProjectSlug, project.AddDependencyInput{ + BlockedNumber: in.BlockedNumber, BlockerNumber: in.BlockerNumber, + }); err != nil { + return nil, dependencyOut{}, err + } + return nil, dependencyOut{OK: true, BlockedNumber: in.BlockedNumber, BlockerNumber: in.BlockerNumber}, nil + }) +} + +// ===== remove_dependency ===== + +func registerRemoveDependency(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "remove_dependency", Description: "Remove a dependency edge (plan correction)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in addDependencyIn) (*mcpsdk.CallToolResult, dependencyOut, error) { + if err := CheckToolFromCtx(ctx, "remove_dependency"); err != nil { + return nil, dependencyOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, dependencyOut{}, err + } + p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) + if err != nil { + return nil, dependencyOut{}, err + } + if err := CheckProjectFromCtx(ctx, p.ID); err != nil { + return nil, dependencyOut{}, err + } + if err := deps.Issues.RemoveDependency(ctx, c, in.ProjectSlug, in.BlockedNumber, in.BlockerNumber); err != nil { + return nil, dependencyOut{}, err + } + return nil, dependencyOut{OK: true, BlockedNumber: in.BlockedNumber, BlockerNumber: in.BlockerNumber}, nil + }) +} + +// ===== list_dependencies ===== + +type listDependenciesIn struct { + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Number int `json:"number" jsonschema:"issue number (>=1)"` +} +type listDependenciesOut struct { + BlockedBy []issueSummary `json:"blocked_by"` + Blocks []issueSummary `json:"blocks"` +} + +func registerListDependencies(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "list_dependencies", Description: "List an issue's dependencies: {blocked_by:[...], blocks:[...]}."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in listDependenciesIn) (*mcpsdk.CallToolResult, listDependenciesOut, error) { + if err := CheckToolFromCtx(ctx, "list_dependencies"); err != nil { + return nil, listDependenciesOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, listDependenciesOut{}, err + } + p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) + if err != nil { + return nil, listDependenciesOut{}, err + } + if err := CheckProjectFromCtx(ctx, p.ID); err != nil { + return nil, listDependenciesOut{}, err + } + blockedBy, blocks, err := deps.Issues.ListDependencies(ctx, c, in.ProjectSlug, in.Number) + if err != nil { + return nil, listDependenciesOut{}, err + } + out := listDependenciesOut{ + BlockedBy: make([]issueSummary, 0, len(blockedBy)), + Blocks: make([]issueSummary, 0, len(blocks)), + } + for _, i := range blockedBy { + out.BlockedBy = append(out.BlockedBy, issueSummary{Number: i.Number, Title: i.Title, Status: string(i.Status)}) + } + for _, i := range blocks { + out.Blocks = append(out.Blocks, issueSummary{Number: i.Number, Title: i.Title, Status: string(i.Status)}) + } + return nil, out, nil + }) +} + +// ===== submit_decomposition ===== +// +// 一次原子调用提交整张任务分解图(子任务 + 依赖边)。先做 in-batch DAG 校验,全部 +// 通过后再建子任务、建边。ref 为 batch 内本地引用键,落库后映射为 issue number。 + +type decompSubtaskIn struct { + Ref string `json:"ref" jsonschema:"local reference key used by edges"` + Title string `json:"title" jsonschema:"title (non-empty)"` + Description string `json:"description,omitempty"` + Priority int `json:"priority,omitempty" jsonschema:"0..3 (higher = sooner)"` +} +type decompEdgeIn struct { + BlockedRef string `json:"blocked_ref" jsonschema:"ref of the blocked subtask"` + BlockerRef string `json:"blocker_ref" jsonschema:"ref of the blocking subtask"` +} +type submitDecompositionIn struct { + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + ParentNumber *int `json:"parent_number,omitempty" jsonschema:"optional anchor issue number; subtasks become its children"` + Subtasks []decompSubtaskIn `json:"subtasks"` + Edges []decompEdgeIn `json:"edges,omitempty"` +} +type submitDecompositionOut struct { + OK bool `json:"ok"` + RefToNumber map[string]int `json:"ref_to_number"` +} + +func registerSubmitDecomposition(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "submit_decomposition", Description: "Submit a whole task decomposition (subtasks + blocks/blocked-by edges) atomically. Validates acyclicity before any write; maps refs to created issue numbers."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in submitDecompositionIn) (*mcpsdk.CallToolResult, submitDecompositionOut, error) { + if err := CheckToolFromCtx(ctx, "submit_decomposition"); err != nil { + return nil, submitDecompositionOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, submitDecompositionOut{}, err + } + p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) + if err != nil { + return nil, submitDecompositionOut{}, err + } + if err := CheckProjectFromCtx(ctx, p.ID); err != nil { + return nil, submitDecompositionOut{}, err + } + res, err := applyDecomposition(ctx, deps.Issues, c, in) + if err != nil { + return nil, submitDecompositionOut{}, err + } + return nil, submitDecompositionOut{OK: true, RefToNumber: res}, nil + }) +} + +// applyDecomposition 在 mcp 包内实现分解落库(不引入 orchestrator 以避免 import cycle)。 +// 先 ref 去重 + 边端点校验 + DAG 校验,再建子任务、建边。 +func applyDecomposition(ctx context.Context, issues project.IssueService, c project.Caller, in submitDecompositionIn) (map[string]int, error) { + if len(in.Subtasks) == 0 { + return nil, errs.New(errs.CodeInvalidInput, "decomposition 至少需要一个子任务") + } + refSet := make(map[string]struct{}, len(in.Subtasks)) + for _, st := range in.Subtasks { + if st.Ref == "" { + return nil, errs.New(errs.CodeInvalidInput, "子任务 ref 不能为空") + } + if st.Title == "" { + return nil, errs.New(errs.CodeInvalidInput, "子任务 title 不能为空") + } + if _, dup := refSet[st.Ref]; dup { + return nil, errs.New(errs.CodeInvalidInput, "子任务 ref 重复: "+st.Ref) + } + if st.Priority < 0 || st.Priority > 3 { + return nil, errs.New(errs.CodeInvalidInput, "子任务 priority 必须在 0..3") + } + refSet[st.Ref] = struct{}{} + } + for _, e := range in.Edges { + if _, ok := refSet[e.BlockedRef]; !ok { + return nil, errs.New(errs.CodeInvalidInput, "边引用了未知 blocked ref: "+e.BlockedRef) + } + if _, ok := refSet[e.BlockerRef]; !ok { + return nil, errs.New(errs.CodeInvalidInput, "边引用了未知 blocker ref: "+e.BlockerRef) + } + if e.BlockedRef == e.BlockerRef { + return nil, errs.New(errs.CodeInvalidInput, "边不能自指: "+e.BlockedRef) + } + } + if err := decompositionIsDAG(in.Subtasks, in.Edges); err != nil { + return nil, err + } + + refToNum := make(map[string]int, len(in.Subtasks)) + for _, st := range in.Subtasks { + prio := st.Priority + ci := project.CreateIssueInput{Title: st.Title, Description: st.Description, Priority: &prio} + var iss *project.Issue + var err error + if in.ParentNumber != nil { + iss, err = issues.CreateSubtask(ctx, c, in.ProjectSlug, *in.ParentNumber, ci) + } else { + iss, err = issues.Create(ctx, c, in.ProjectSlug, ci) + } + if err != nil { + return nil, err + } + refToNum[st.Ref] = iss.Number + } + for _, e := range in.Edges { + if _, err := issues.AddDependency(ctx, c, in.ProjectSlug, project.AddDependencyInput{ + BlockedNumber: refToNum[e.BlockedRef], BlockerNumber: refToNum[e.BlockerRef], + }); err != nil { + return nil, err + } + } + return refToNum, nil +} + +// decompositionIsDAG 对 (子任务, 边) 做 Kahn 拓扑排序,存在环返回 CodeInvalidInput。 +func decompositionIsDAG(subtasks []decompSubtaskIn, edges []decompEdgeIn) error { + indeg := make(map[string]int, len(subtasks)) + for _, st := range subtasks { + indeg[st.Ref] = 0 + } + adj := make(map[string][]string, len(subtasks)) + for _, e := range edges { + adj[e.BlockerRef] = append(adj[e.BlockerRef], e.BlockedRef) + indeg[e.BlockedRef]++ + } + queue := make([]string, 0, len(subtasks)) + for ref, d := range indeg { + if d == 0 { + queue = append(queue, ref) + } + } + visited := 0 + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + visited++ + for _, next := range adj[cur] { + indeg[next]-- + if indeg[next] == 0 { + queue = append(queue, next) + } + } + } + if visited != len(subtasks) { + return errs.New(errs.CodeInvalidInput, "decomposition 边集合存在环") + } + return nil +} + // ===== update_issue ===== type updateIssueIn struct { @@ -687,7 +999,7 @@ type setRequirementPhaseIn struct { func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, - &mcpsdk.Tool{Name: "set_requirement_phase", Description: "Change a requirement's phase."}, + &mcpsdk.Tool{Name: "set_requirement_phase", Description: "Change a requirement's phase. Entry gates are enforced: e.g. moving to 'implementing' requires an approved (verdict=pass) auditing artifact; moving to 'done' requires the workspace PR to be merged. A blocked transition returns phase_gate_failed."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in setRequirementPhaseIn) (*mcpsdk.CallToolResult, requirementCreateOut, error) { if err := CheckToolFromCtx(ctx, "set_requirement_phase"); err != nil { return nil, requirementCreateOut{}, err @@ -720,7 +1032,7 @@ func registerSetRequirementPhase(srv *mcpsdk.Server, deps ServerDeps) { type addRequirementArtifactIn struct { ProjectSlug string `json:"project_slug" jsonschema:"required field"` Number int `json:"number" jsonschema:"requirement number (>=1)"` - Phase string `json:"phase" jsonschema:"artifact phase: planning|prototyping|auditing"` + Phase string `json:"phase" jsonschema:"artifact phase: planning|prototyping|auditing|implementing|reviewing"` Content string `json:"content" jsonschema:"artifact content (non-empty)"` Note string `json:"note,omitempty"` } @@ -730,7 +1042,19 @@ type artifactDetail struct { Content string `json:"content"` Note string `json:"note,omitempty"` CreatedAt string `json:"created_at"` + Verdict string `json:"verdict"` + Approved bool `json:"approved"` } + +func artifactDetailFrom(a *project.Artifact) artifactDetail { + return artifactDetail{ + Phase: string(a.Phase), Version: a.Version, Content: a.Content, Note: a.Note, + CreatedAt: a.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), + Verdict: string(a.Verdict), + Approved: a.Verdict == project.VerdictPass, + } +} + type artifactCreateOut struct { OK bool `json:"ok"` Artifact artifactDetail `json:"artifact"` @@ -738,7 +1062,7 @@ type artifactCreateOut struct { func registerAddRequirementArtifact(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, - &mcpsdk.Tool{Name: "add_requirement_artifact", Description: "Add a new phase artifact version (planning/prototyping/auditing) to a requirement."}, + &mcpsdk.Tool{Name: "add_requirement_artifact", Description: "Add a new phase artifact version (planning/prototyping/auditing/implementing/reviewing) to a requirement."}, func(ctx context.Context, _ *mcpsdk.CallToolRequest, in addRequirementArtifactIn) (*mcpsdk.CallToolResult, artifactCreateOut, error) { if err := CheckToolFromCtx(ctx, "add_requirement_artifact"); err != nil { return nil, artifactCreateOut{}, err @@ -760,10 +1084,43 @@ func registerAddRequirementArtifact(srv *mcpsdk.Server, deps ServerDeps) { if err != nil { return nil, artifactCreateOut{}, err } - return nil, artifactCreateOut{OK: true, Artifact: artifactDetail{ - Phase: string(art.Phase), Version: art.Version, Content: art.Content, Note: art.Note, - CreatedAt: art.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), - }}, nil + return nil, artifactCreateOut{OK: true, Artifact: artifactDetailFrom(art)}, nil + }) +} + +// ===== approve_requirement_artifact ===== + +type approveRequirementArtifactIn struct { + ProjectSlug string `json:"project_slug" jsonschema:"required field"` + Number int `json:"number" jsonschema:"requirement number (>=1)"` + Phase string `json:"phase" jsonschema:"artifact phase: planning|prototyping|auditing|implementing|reviewing"` + Version int `json:"version" jsonschema:"artifact version (>=1)"` + Verdict string `json:"verdict" jsonschema:"review verdict: pass|fail"` +} + +func registerApproveRequirementArtifact(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "approve_requirement_artifact", Description: "Set the review verdict (pass/fail) on a requirement artifact version. A 'pass' verdict marks the phase's approved/current artifact, which downstream phase gates require (e.g. an approved auditing artifact unblocks the move to 'implementing')."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in approveRequirementArtifactIn) (*mcpsdk.CallToolResult, artifactCreateOut, error) { + if err := CheckToolFromCtx(ctx, "approve_requirement_artifact"); err != nil { + return nil, artifactCreateOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, artifactCreateOut{}, err + } + p, err := deps.Projects.Get(ctx, c, in.ProjectSlug) + if err != nil { + return nil, artifactCreateOut{}, err + } + if err := CheckProjectFromCtx(ctx, p.ID); err != nil { + return nil, artifactCreateOut{}, err + } + art, err := deps.Artifacts.Approve(ctx, c, in.ProjectSlug, in.Number, project.Phase(in.Phase), in.Version, project.Verdict(in.Verdict)) + if err != nil { + return nil, artifactCreateOut{}, err + } + return nil, artifactCreateOut{OK: true, Artifact: artifactDetailFrom(art)}, nil }) } @@ -772,7 +1129,7 @@ func registerAddRequirementArtifact(srv *mcpsdk.Server, deps ServerDeps) { type listRequirementArtifactsIn struct { ProjectSlug string `json:"project_slug" jsonschema:"required field"` Number int `json:"number" jsonschema:"requirement number (>=1)"` - Phase string `json:"phase,omitempty" jsonschema:"filter by phase: planning|prototyping|auditing; empty = all"` + Phase string `json:"phase,omitempty" jsonschema:"filter by phase: planning|prototyping|auditing|implementing|reviewing; empty = all"` } type listRequirementArtifactsOut struct { Artifacts []artifactDetail `json:"artifacts"` @@ -802,10 +1159,7 @@ func registerListRequirementArtifacts(srv *mcpsdk.Server, deps ServerDeps) { } out := listRequirementArtifactsOut{Artifacts: make([]artifactDetail, 0, len(arts))} for _, art := range arts { - out.Artifacts = append(out.Artifacts, artifactDetail{ - Phase: string(art.Phase), Version: art.Version, Content: art.Content, Note: art.Note, - CreatedAt: art.CreatedAt.Format("2006-01-02T15:04:05Z07:00"), - }) + out.Artifacts = append(out.Artifacts, artifactDetailFrom(art)) } return nil, out, nil }) diff --git a/internal/mcp/tools_pm_decomposition_test.go b/internal/mcp/tools_pm_decomposition_test.go new file mode 100644 index 0000000..980813b --- /dev/null +++ b/internal/mcp/tools_pm_decomposition_test.go @@ -0,0 +1,153 @@ +package mcp + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCreateSubtaskTool(t *testing.T) { + deps := testDeps() + // parent + _, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{ + "project_slug": "test-project", "title": "Parent", + }) + require.NoError(t, err) + + result, err := callTool(ctxWithAuth(Scope{}), deps, "create_subtask", map[string]any{ + "project_slug": "test-project", + "parent_number": float64(1), + "title": "Child", + "priority": float64(2), + }) + require.NoError(t, err) + assert.False(t, result.IsError) + var out issueCreateOut + unmarshalStructured(t, result, &out) + assert.True(t, out.OK) + assert.Equal(t, "Child", out.Issue.Title) +} + +func TestAddListRemoveDependencyTool(t *testing.T) { + deps := testDeps() + for _, title := range []string{"A", "B"} { + _, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{ + "project_slug": "test-project", "title": title, + }) + require.NoError(t, err) + } + // A(#1) blocked-by B(#2) + result, err := callTool(ctxWithAuth(Scope{}), deps, "add_dependency", map[string]any{ + "project_slug": "test-project", + "blocked_number": float64(1), + "blocker_number": float64(2), + }) + require.NoError(t, err) + assert.False(t, result.IsError) + var addOut dependencyOut + unmarshalStructured(t, result, &addOut) + assert.True(t, addOut.OK) + + // list_dependencies on A → blocked_by contains B + result, err = callTool(ctxWithAuth(Scope{}), deps, "list_dependencies", map[string]any{ + "project_slug": "test-project", "number": float64(1), + }) + require.NoError(t, err) + var listOut listDependenciesOut + unmarshalStructured(t, result, &listOut) + require.Len(t, listOut.BlockedBy, 1) + assert.Equal(t, 2, listOut.BlockedBy[0].Number) + + // remove + result, err = callTool(ctxWithAuth(Scope{}), deps, "remove_dependency", map[string]any{ + "project_slug": "test-project", + "blocked_number": float64(1), + "blocker_number": float64(2), + }) + require.NoError(t, err) + assert.False(t, result.IsError) + + result, err = callTool(ctxWithAuth(Scope{}), deps, "list_dependencies", map[string]any{ + "project_slug": "test-project", "number": float64(1), + }) + require.NoError(t, err) + unmarshalStructured(t, result, &listOut) + assert.Empty(t, listOut.BlockedBy) +} + +func TestAddDependencyTool_RejectsSelf(t *testing.T) { + deps := testDeps() + _, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{ + "project_slug": "test-project", "title": "A", + }) + require.NoError(t, err) + result, err := callTool(ctxWithAuth(Scope{}), deps, "add_dependency", map[string]any{ + "project_slug": "test-project", + "blocked_number": float64(1), + "blocker_number": float64(1), + }) + require.NoError(t, err) + assert.True(t, result.IsError, "自依赖应被工具拒绝") +} + +func TestSubmitDecompositionTool(t *testing.T) { + deps := testDeps() + // anchor parent + _, err := callTool(ctxWithAuth(Scope{}), deps, "create_issue", map[string]any{ + "project_slug": "test-project", "title": "Anchor", + }) + require.NoError(t, err) + + result, err := callTool(ctxWithAuth(Scope{}), deps, "submit_decomposition", map[string]any{ + "project_slug": "test-project", + "parent_number": float64(1), + "subtasks": []any{ + map[string]any{"ref": "a", "title": "Task A", "priority": float64(2)}, + map[string]any{"ref": "b", "title": "Task B"}, + }, + "edges": []any{ + map[string]any{"blocked_ref": "a", "blocker_ref": "b"}, + }, + }) + require.NoError(t, err) + assert.False(t, result.IsError) + var out submitDecompositionOut + unmarshalStructured(t, result, &out) + assert.True(t, out.OK) + require.Contains(t, out.RefToNumber, "a") + require.Contains(t, out.RefToNumber, "b") +} + +func TestSubmitDecompositionTool_RejectsCycle(t *testing.T) { + deps := testDeps() + result, err := callTool(ctxWithAuth(Scope{}), deps, "submit_decomposition", map[string]any{ + "project_slug": "test-project", + "subtasks": []any{ + map[string]any{"ref": "a", "title": "A"}, + map[string]any{"ref": "b", "title": "B"}, + }, + "edges": []any{ + map[string]any{"blocked_ref": "a", "blocker_ref": "b"}, + map[string]any{"blocked_ref": "b", "blocker_ref": "a"}, + }, + }) + require.NoError(t, err) + assert.True(t, result.IsError, "环应被拒绝") +} + +func TestDecompositionToolsInSystemScope(t *testing.T) { + // 新工具必须在 system token 默认白名单内,规划阶段 agent 才能调用。 + set := map[string]bool{} + for _, name := range defaultSystemTools { + set[name] = true + } + for _, name := range []string{ + "create_subtask", "add_dependency", "remove_dependency", + "list_dependencies", "submit_decomposition", + } { + assert.True(t, set[name], "%s 应在 defaultSystemTools", name) + } + // create_acp_session 仍对 system token 禁用(不应在白名单)。 + assert.False(t, set["create_acp_session"], "create_acp_session 不应在 system 白名单") +} diff --git a/internal/mcp/tools_pm_test.go b/internal/mcp/tools_pm_test.go index 3336d33..f86b7b2 100644 --- a/internal/mcp/tools_pm_test.go +++ b/internal/mcp/tools_pm_test.go @@ -138,6 +138,18 @@ func (f *fakeProjectSvc) Unarchive(ctx context.Context, c project.Caller, slug s p.ArchivedAt = nil return nil } +func (f *fakeProjectSvc) ListMembers(_ context.Context, _ project.Caller, _ string) ([]*project.Member, error) { + return nil, nil +} +func (f *fakeProjectSvc) AddMember(_ context.Context, _ project.Caller, _ string, _ project.AddMemberInput) (*project.Member, error) { + return nil, nil +} +func (f *fakeProjectSvc) RemoveMember(_ context.Context, _ project.Caller, _ string, _ uuid.UUID) error { + return nil +} +func (f *fakeProjectSvc) MemberRole(_ context.Context, _, _ uuid.UUID) (project.Role, error) { + return project.RoleNone, nil +} // fakeRequirementSvc implements project.RequirementService. type fakeRequirementSvc struct { @@ -203,16 +215,63 @@ func (f *fakeRequirementSvc) Reopen(_ context.Context, _ project.Caller, slug st return nil } +// fakeArtifactSvc implements project.ArtifactService. +type fakeArtifactSvc struct { + items []*project.Artifact +} + +func (f *fakeArtifactSvc) Create(_ context.Context, c project.Caller, _ string, reqNumber int, in project.CreateArtifactInput) (*project.Artifact, error) { + a := &project.Artifact{ + ID: uuid.New(), Phase: in.Phase, Version: len(f.items) + 1, + Content: in.Content, Note: in.Note, CreatedBy: c.UserID, + CreatedAt: time.Now(), Verdict: project.VerdictNone, + } + f.items = append(f.items, a) + return a, nil +} +func (f *fakeArtifactSvc) List(_ context.Context, _ project.Caller, _ string, _ int, phase project.Phase) ([]*project.Artifact, error) { + out := make([]*project.Artifact, 0, len(f.items)) + for _, a := range f.items { + if phase == "" || a.Phase == phase { + out = append(out, a) + } + } + return out, nil +} +func (f *fakeArtifactSvc) GetByVersion(_ context.Context, _ project.Caller, _ string, _ int, phase project.Phase, version int) (*project.Artifact, error) { + for _, a := range f.items { + if a.Phase == phase && a.Version == version { + return a, nil + } + } + return nil, errs.New(errs.CodeNotFound, "artifact") +} +func (f *fakeArtifactSvc) Approve(_ context.Context, _ project.Caller, _ string, _ int, phase project.Phase, version int, verdict project.Verdict) (*project.Artifact, error) { + for _, a := range f.items { + if a.Phase == phase && a.Version == version { + a.Verdict = verdict + return a, nil + } + } + return nil, errs.New(errs.CodeNotFound, "artifact") +} + // fakeIssueSvc implements project.IssueService. type fakeIssueSvc struct { items []*project.Issue + deps []*project.Dependency } func (f *fakeIssueSvc) Create(_ context.Context, _ project.Caller, _ string, in project.CreateIssueInput) (*project.Issue, error) { + prio := 0 + if in.Priority != nil { + prio = *in.Priority + } i := &project.Issue{ ID: uuid.New(), Number: len(f.items) + 1, Title: in.Title, Description: in.Description, Status: project.StatusOpen, + Priority: prio, CreatedAt: time.Now(), } f.items = append(f.items, i) @@ -266,6 +325,80 @@ func (f *fakeIssueSvc) Reopen(_ context.Context, _ project.Caller, slug string, i.Status = project.StatusOpen return nil } +func (f *fakeIssueSvc) CreateSubtask(ctx context.Context, c project.Caller, slug string, parentNumber int, in project.CreateIssueInput) (*project.Issue, error) { + parent, err := f.Get(ctx, c, slug, parentNumber) + if err != nil { + return nil, err + } + i, err := f.Create(ctx, c, slug, in) + if err != nil { + return nil, err + } + pid := parent.ID + i.ParentID = &pid + return i, nil +} +func (f *fakeIssueSvc) AddDependency(ctx context.Context, c project.Caller, slug string, in project.AddDependencyInput) (*project.Dependency, error) { + if in.BlockedNumber == in.BlockerNumber { + return nil, errs.New(errs.CodeInvalidInput, "issue 不能依赖自身") + } + blocked, err := f.Get(ctx, c, slug, in.BlockedNumber) + if err != nil { + return nil, err + } + blocker, err := f.Get(ctx, c, slug, in.BlockerNumber) + if err != nil { + return nil, err + } + d := &project.Dependency{ID: uuid.New(), BlockedID: blocked.ID, BlockerID: blocker.ID, CreatedAt: time.Now()} + f.deps = append(f.deps, d) + return d, nil +} +func (f *fakeIssueSvc) RemoveDependency(ctx context.Context, c project.Caller, slug string, blockedNumber, blockerNumber int) error { + blocked, err := f.Get(ctx, c, slug, blockedNumber) + if err != nil { + return err + } + blocker, err := f.Get(ctx, c, slug, blockerNumber) + if err != nil { + return err + } + for idx, d := range f.deps { + if d.BlockedID == blocked.ID && d.BlockerID == blocker.ID { + f.deps = append(f.deps[:idx], f.deps[idx+1:]...) + return nil + } + } + return errs.New(errs.CodeNotFound, "依赖不存在") +} +func (f *fakeIssueSvc) ListDependencies(ctx context.Context, c project.Caller, slug string, number int) ([]*project.Issue, []*project.Issue, error) { + iss, err := f.Get(ctx, c, slug, number) + if err != nil { + return nil, nil, err + } + byID := func(id uuid.UUID) *project.Issue { + for _, x := range f.items { + if x.ID == id { + return x + } + } + return nil + } + var blockedBy, blocks []*project.Issue + for _, d := range f.deps { + if d.BlockedID == iss.ID { + if b := byID(d.BlockerID); b != nil { + blockedBy = append(blockedBy, b) + } + } + if d.BlockerID == iss.ID { + if b := byID(d.BlockedID); b != nil { + blocks = append(blocks, b) + } + } + } + return blockedBy, blocks, nil +} // fakeWorkspaceSvc implements workspace.WorkspaceService. type fakeWorkspaceSvc struct { @@ -359,6 +492,7 @@ func testDeps() ServerDeps { Projects: newFakeProjectSvc(proj), Reqs: &fakeRequirementSvc{}, Issues: &fakeIssueSvc{}, + Artifacts: &fakeArtifactSvc{}, Workspaces: &fakeWorkspaceSvc{}, } } @@ -607,6 +741,38 @@ func TestSetRequirementPhase(t *testing.T) { assert.Equal(t, "auditing", out.Requirement.Phase) } +func TestApproveRequirementArtifact(t *testing.T) { + deps := testDeps() + + _, err := callTool(ctxWithAuth(Scope{}), deps, "add_requirement_artifact", map[string]any{ + "project_slug": "test-project", "number": float64(1), "phase": "auditing", "content": "# audit", + }) + require.NoError(t, err) + + result, err := callTool(ctxWithAuth(Scope{}), deps, "approve_requirement_artifact", map[string]any{ + "project_slug": "test-project", "number": float64(1), "phase": "auditing", + "version": float64(1), "verdict": "pass", + }) + require.NoError(t, err) + var out artifactCreateOut + unmarshalStructured(t, result, &out) + assert.True(t, out.OK) + assert.Equal(t, "pass", out.Artifact.Verdict) + assert.True(t, out.Artifact.Approved) +} + +func TestApproveRequirementArtifact_ScopeGated(t *testing.T) { + deps := testDeps() + // scope 不含 approve_requirement_artifact → CheckToolFromCtx 拒绝(IsError)。 + result, err := callTool(ctxWithAuth(Scope{Tools: []string{"add_requirement_artifact"}}), deps, + "approve_requirement_artifact", map[string]any{ + "project_slug": "test-project", "number": float64(1), "phase": "auditing", + "version": float64(1), "verdict": "pass", + }) + require.NoError(t, err) + assert.True(t, result.IsError, "token scope without approve_requirement_artifact must deny") +} + func TestListRequirements(t *testing.T) { deps := testDeps() diff --git a/internal/mcp/tools_run.go b/internal/mcp/tools_run.go index 14da01f..6370e53 100644 --- a/internal/mcp/tools_run.go +++ b/internal/mcp/tools_run.go @@ -25,6 +25,12 @@ type RunService interface { Logs(ctx context.Context, c workspace.Caller, profileID uuid.UUID, since int64, limit int) ([]run.LogLineResp, error) } +// ExecService 是一次性 exec 工具依赖的窄接口,*run.ExecService 直接满足。 +type ExecService interface { + RunCommand(ctx context.Context, c workspace.Caller, req run.ExecCommandReq) (run.ExecCommandResp, error) + RunTests(ctx context.Context, c workspace.Caller, req run.ExecTestsReq) (run.ExecTestsResp, error) +} + // registerRunTools 注册 run profile 工具。日志只提供快照式 get_run_logs, // 流式订阅(WebSocket)不经 MCP 暴露。 // @@ -40,6 +46,8 @@ func registerRunTools(srv *mcpsdk.Server, deps ServerDeps) { registerRestartRunProfile(srv, deps) registerGetRunStatus(srv, deps) registerGetRunLogs(srv, deps) + registerRunCommand(srv, deps) + registerRunTests(srv, deps) } // runProfileInWorkspace 校验 profile 归属于给定 workspace 并返回其 UUID。 @@ -324,3 +332,97 @@ func registerGetRunLogs(srv *mcpsdk.Server, deps ServerDeps) { return nil, getRunLogsOut{Lines: lines}, nil }) } + +// ===== run_command ===== + +type runCommandIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + WorktreeID string `json:"worktree_id,omitempty" jsonschema:"worktree UUID; empty runs in workspace main_path"` + Command string `json:"command" jsonschema:"executable to run, e.g. go / npm (non-empty)"` + Args []string `json:"args,omitempty" jsonschema:"command arguments"` + Env map[string]string `json:"env,omitempty" jsonschema:"ephemeral env overrides (plaintext, not persisted)"` + TimeoutSec int `json:"timeout_sec,omitempty" jsonschema:"timeout in seconds; clamped to server max"` +} + +func registerRunCommand(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "run_command", Description: "Run one command to completion in the workspace (main_path or a worktree) and return exit code, stdout, stderr, duration. Sandboxed: only whitelisted env vars are passed; requires project write access."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in runCommandIn) (*mcpsdk.CallToolResult, run.ExecCommandResp, error) { + if err := CheckToolFromCtx(ctx, "run_command"); err != nil { + return nil, run.ExecCommandResp{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, run.ExecCommandResp{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, run.ExecCommandResp{}, err + } + if in.WorktreeID != "" { + if _, err := worktreeInWorkspace(ctx, deps, wc, w.ID, in.WorktreeID); err != nil { + return nil, run.ExecCommandResp{}, err + } + } + resp, err := deps.Execs.RunCommand(ctx, wc, run.ExecCommandReq{ + WorkspaceID: w.ID.String(), + WorktreeID: in.WorktreeID, + Command: in.Command, + Args: in.Args, + Env: in.Env, + TimeoutSec: in.TimeoutSec, + }) + if err != nil { + return nil, run.ExecCommandResp{}, err + } + return nil, resp, nil + }) +} + +// ===== run_tests ===== + +type runTestsIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + WorktreeID string `json:"worktree_id,omitempty" jsonschema:"worktree UUID; empty runs in workspace main_path"` + Command string `json:"command" jsonschema:"test runner, e.g. go (non-empty)"` + Args []string `json:"args,omitempty" jsonschema:"args, e.g. ['test','-json','./...']"` + Format string `json:"format,omitempty" jsonschema:"output format: gotest|junit|vitest|auto (default auto)"` + Env map[string]string `json:"env,omitempty" jsonschema:"ephemeral env overrides (plaintext, not persisted)"` + TimeoutSec int `json:"timeout_sec,omitempty" jsonschema:"timeout in seconds; clamped to server max"` +} + +func registerRunTests(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "run_tests", Description: "Run a test command and parse its output (go test -json / JUnit XML / vitest JSON) into a structured pass/fail summary. Returns raw output plus a summary; never fails on parse errors."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in runTestsIn) (*mcpsdk.CallToolResult, run.ExecTestsResp, error) { + if err := CheckToolFromCtx(ctx, "run_tests"); err != nil { + return nil, run.ExecTestsResp{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, run.ExecTestsResp{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, run.ExecTestsResp{}, err + } + if in.WorktreeID != "" { + if _, err := worktreeInWorkspace(ctx, deps, wc, w.ID, in.WorktreeID); err != nil { + return nil, run.ExecTestsResp{}, err + } + } + resp, err := deps.Execs.RunTests(ctx, wc, run.ExecTestsReq{ + WorkspaceID: w.ID.String(), + WorktreeID: in.WorktreeID, + Command: in.Command, + Args: in.Args, + Format: in.Format, + Env: in.Env, + TimeoutSec: in.TimeoutSec, + }) + if err != nil { + return nil, run.ExecTestsResp{}, err + } + return nil, resp, nil + }) +} diff --git a/internal/mcp/tools_workspace.go b/internal/mcp/tools_workspace.go index 26a9833..383d70d 100644 --- a/internal/mcp/tools_workspace.go +++ b/internal/mcp/tools_workspace.go @@ -35,6 +35,7 @@ func registerWorkspaceTools(srv *mcpsdk.Server, deps ServerDeps) { registerGitStatus(srv, deps) registerGitCommit(srv, deps) registerGitPush(srv, deps) + registerGitDiff(srv, deps) } // ===== 共用 DTO / helper ===== @@ -581,6 +582,59 @@ func registerGitCommit(srv *mcpsdk.Server, deps ServerDeps) { }) } +// ===== git_diff (read-only) ===== + +type gitDiffIn struct { + WorkspaceID string `json:"workspace_id" jsonschema:"workspace UUID"` + WorktreeID string `json:"worktree_id,omitempty" jsonschema:"optional worktree UUID; omit to target the main worktree"` + Ref string `json:"ref,omitempty" jsonschema:"base ref; empty diffs working tree against HEAD"` + Against string `json:"against,omitempty" jsonschema:"second ref; when set with ref produces ref..against"` + Staged bool `json:"staged,omitempty" jsonschema:"diff the staged index (--cached) instead of the working tree"` + Paths []string `json:"paths,omitempty" jsonschema:"optional pathspecs to restrict the diff"` + ContextLines int `json:"context_lines,omitempty" jsonschema:"unified context lines (git default 3 when omitted)"` +} +type gitDiffOut struct { + Diff string `json:"diff"` + Truncated bool `json:"truncated"` +} + +func registerGitDiff(srv *mcpsdk.Server, deps ServerDeps) { + mcpsdk.AddTool(srv, + &mcpsdk.Tool{Name: "git_diff", Description: "Show the unified git diff of the main worktree or a branch worktree. Returns raw diff text (capped at 1 MiB)."}, + func(ctx context.Context, _ *mcpsdk.CallToolRequest, in gitDiffIn) (*mcpsdk.CallToolResult, gitDiffOut, error) { + if err := CheckToolFromCtx(ctx, "git_diff"); err != nil { + return nil, gitDiffOut{}, err + } + c, err := deps.Caller.Resolve(ctx) + if err != nil { + return nil, gitDiffOut{}, err + } + w, wc, err := scopedWorkspace(ctx, deps, c, in.WorkspaceID) + if err != nil { + return nil, gitDiffOut{}, err + } + opts := git.DiffOptions{ + Ref: in.Ref, Against: in.Against, Staged: in.Staged, + Paths: in.Paths, ContextLines: in.ContextLines, + } + var res git.DiffResult + if in.WorktreeID == "" { + res, err = deps.GitOps.DiffOnMain(ctx, wc, w.ID, opts) + } else { + var wt *workspace.Worktree + wt, err = worktreeInWorkspace(ctx, deps, wc, w.ID, in.WorktreeID) + if err != nil { + return nil, gitDiffOut{}, err + } + res, err = deps.GitOps.DiffOnWorktree(ctx, wc, wt.ID, opts) + } + if err != nil { + return nil, gitDiffOut{}, err + } + return nil, gitDiffOut{Diff: res.Diff, Truncated: res.Truncated}, nil + }) +} + func registerGitPush(srv *mcpsdk.Server, deps ServerDeps) { mcpsdk.AddTool(srv, &mcpsdk.Tool{Name: "git_push", Description: "Push the main worktree or a branch worktree to the remote."}, diff --git a/internal/mcp/tools_workspace_test.go b/internal/mcp/tools_workspace_test.go index 5eabf27..f72715f 100644 --- a/internal/mcp/tools_workspace_test.go +++ b/internal/mcp/tools_workspace_test.go @@ -77,6 +77,7 @@ type fakeGitOpsSvc struct { statusFiles []git.FileStatus lastTarget string // "main:" / "worktree:" lastCommit workspace.CommitInput + diffText string } func (f *fakeGitOpsSvc) StatusOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID) ([]git.FileStatus, error) { @@ -113,6 +114,14 @@ func (f *fakeGitOpsSvc) LogOnWorktree(_ context.Context, _ workspace.Caller, wtI f.lastTarget = "worktree:" + wtID.String() return nil, nil } +func (f *fakeGitOpsSvc) DiffOnMain(_ context.Context, _ workspace.Caller, wsID uuid.UUID, _ git.DiffOptions) (git.DiffResult, error) { + f.lastTarget = "main:" + wsID.String() + return git.DiffResult{Diff: f.diffText}, nil +} +func (f *fakeGitOpsSvc) DiffOnWorktree(_ context.Context, _ workspace.Caller, wtID uuid.UUID, _ git.DiffOptions) (git.DiffResult, error) { + f.lastTarget = "worktree:" + wtID.String() + return git.DiffResult{Diff: f.diffText}, nil +} // wsTestDeps 在 testDeps 基础上挂一个已存在的 workspace + worktree 服务。 func wsTestDeps() (ServerDeps, *fakeWorkspaceSvc, *fakeWorktreeSvc, *fakeGitOpsSvc) { @@ -318,3 +327,25 @@ func TestGitCommit_Worktree(t *testing.T) { assert.False(t, gitSvc.lastCommit.AddAll) assert.True(t, gitSvc.lastCommit.Push) } + +func TestGitDiff_MainAndScope(t *testing.T) { + deps, _, _, gitSvc := wsTestDeps() + gitSvc.diffText = "diff --git a/x b/x\n+added\n" + + result, err := callTool(ctxWithAuth(Scope{}), deps, "git_diff", map[string]any{ + "workspace_id": testWSID.String(), + }) + require.NoError(t, err) + require.False(t, result.IsError) + var out gitDiffOut + unmarshalStructured(t, result, &out) + assert.Contains(t, out.Diff, "+added") + assert.Equal(t, "main:"+testWSID.String(), gitSvc.lastTarget) + + // scope to a different project => denied + result, err = callTool(ctxWithAuth(Scope{ProjectIDs: []uuid.UUID{uuid.New()}}), deps, "git_diff", map[string]any{ + "workspace_id": testWSID.String(), + }) + require.NoError(t, err) + assert.True(t, result.IsError) +} diff --git a/internal/orchestrator/config.go b/internal/orchestrator/config.go new file mode 100644 index 0000000..7ca85be --- /dev/null +++ b/internal/orchestrator/config.go @@ -0,0 +1,17 @@ +package orchestrator + +import "time" + +// Config 是编排器模块的运行参数(从 config.OrchestratorConfig 派生)。 +type Config struct { + // MaxAttemptsPerPhase 是单个阶段 step 在 dead-letter 前的最大尝试次数(run.config + // 覆盖优先)。 + MaxAttemptsPerPhase int + // TurnTimeout 约束单个回合(SendPromptAndWait)的最长时长,须 < jobReaper + // LeaseTimeout,避免回合中途被 reaper 重抢租约导致重复驱动。 + TurnTimeout time.Duration + // MaxDepth / FanOutLimit 是 request_subtask 的递归深度与并发子任务上限(run.config + // 覆盖优先)。 + MaxDepth int + FanOutLimit int +} diff --git a/internal/orchestrator/decomposition.go b/internal/orchestrator/decomposition.go new file mode 100644 index 0000000..8b2a04f --- /dev/null +++ b/internal/orchestrator/decomposition.go @@ -0,0 +1,163 @@ +// decomposition.go 实现 ApplyDecomposition:让规划阶段的 agent 在一次 MCP 调用中 +// 原子地提交整张任务分解图(子任务 + blocks/blocked-by 边 + 优先级),而不必发 N 次 +// create_subtask + M 次 add_dependency。先在内存中对 in-batch 图做 DAG 校验(拓扑排序), +// 全部通过后再落库:先建全部子任务,再建全部依赖边。校验失败则在任何写入前返回, +// 不留半成品。 +package orchestrator + +import ( + "context" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// SubtaskSpec 描述分解图里的一个子任务。Ref 是 batch 内的本地引用键(如 "a"/"1"), +// 供 EdgeSpec 跨条目引用;落库后映射为真实 issue number。 +type SubtaskSpec struct { + Ref string + Title string + Description string + Priority int +} + +// EdgeSpec 描述一条依赖边:BlockedRef 被 BlockerRef 阻塞。两端均为 batch 内 ref。 +type EdgeSpec struct { + BlockedRef string + BlockerRef string +} + +// Decomposition 是一次完整任务分解提交。 +type Decomposition struct { + // ParentNumber 非 nil 时所有子任务挂在该 issue 下(通常是 requirement 的锚定 issue)。 + ParentNumber *int + Subtasks []SubtaskSpec + Edges []EdgeSpec +} + +// DecompositionResult 报告落库结果:ref → 真实 issue number。 +type DecompositionResult struct { + // RefToNumber 把每个 SubtaskSpec.Ref 映射到创建出的 issue number。 + RefToNumber map[string]int +} + +// DecompositionApplier 是 ApplyDecomposition 依赖的窄 issue 服务接口(project.IssueService 满足)。 +type DecompositionApplier interface { + CreateSubtask(ctx context.Context, c project.Caller, projectSlug string, parentNumber int, in project.CreateIssueInput) (*project.Issue, error) + Create(ctx context.Context, c project.Caller, projectSlug string, in project.CreateIssueInput) (*project.Issue, error) + AddDependency(ctx context.Context, c project.Caller, projectSlug string, in project.AddDependencyInput) (*project.Dependency, error) +} + +// ApplyDecomposition 原子(best-effort)地落库一张任务分解图。先校验 in-batch 图是 DAG, +// 再建子任务、建边。返回 ref→number 映射。校验在任何写入前完成,故无效图不留半成品。 +func ApplyDecomposition(ctx context.Context, svc DecompositionApplier, c project.Caller, projectSlug string, d Decomposition) (DecompositionResult, error) { + res := DecompositionResult{RefToNumber: map[string]int{}} + if len(d.Subtasks) == 0 { + return res, errs.New(errs.CodeInvalidInput, "decomposition 至少需要一个子任务") + } + + // 1) ref 去重 + 建集合。 + refSet := make(map[string]struct{}, len(d.Subtasks)) + for _, st := range d.Subtasks { + if st.Ref == "" { + return res, errs.New(errs.CodeInvalidInput, "子任务 ref 不能为空") + } + if st.Title == "" { + return res, errs.New(errs.CodeInvalidInput, "子任务 title 不能为空") + } + if _, dup := refSet[st.Ref]; dup { + return res, errs.New(errs.CodeInvalidInput, "子任务 ref 重复: "+st.Ref) + } + if st.Priority < 0 || st.Priority > 3 { + return res, errs.New(errs.CodeInvalidInput, "子任务 priority 必须在 0..3") + } + refSet[st.Ref] = struct{}{} + } + + // 2) 校验边端点存在、非自环。 + for _, e := range d.Edges { + if _, ok := refSet[e.BlockedRef]; !ok { + return res, errs.New(errs.CodeInvalidInput, "边引用了未知 blocked ref: "+e.BlockedRef) + } + if _, ok := refSet[e.BlockerRef]; !ok { + return res, errs.New(errs.CodeInvalidInput, "边引用了未知 blocker ref: "+e.BlockerRef) + } + if e.BlockedRef == e.BlockerRef { + return res, errs.New(errs.CodeInvalidInput, "边不能自指: "+e.BlockedRef) + } + } + + // 3) DAG 校验:沿 blocked-by 边(blocked → blocker)做拓扑排序,存在环则拒绝。 + if err := validateDAG(d.Subtasks, d.Edges); err != nil { + return res, err + } + + // 4) 落库:先建全部子任务(记录 ref→number),再建全部依赖边。 + for _, st := range d.Subtasks { + prio := st.Priority + in := project.CreateIssueInput{ + Title: st.Title, + Description: st.Description, + Priority: &prio, + } + var iss *project.Issue + var err error + if d.ParentNumber != nil { + iss, err = svc.CreateSubtask(ctx, c, projectSlug, *d.ParentNumber, in) + } else { + iss, err = svc.Create(ctx, c, projectSlug, in) + } + if err != nil { + return res, err + } + res.RefToNumber[st.Ref] = iss.Number + } + + for _, e := range d.Edges { + if _, err := svc.AddDependency(ctx, c, projectSlug, project.AddDependencyInput{ + BlockedNumber: res.RefToNumber[e.BlockedRef], + BlockerNumber: res.RefToNumber[e.BlockerRef], + }); err != nil { + return res, err + } + } + + return res, nil +} + +// validateDAG 对 (子任务, 边) 做 Kahn 拓扑排序,存在环返回 CodeInvalidInput。 +// 边语义 blocked blocked-by blocker,建图方向 blocker → blocked(依赖先于被依赖)。 +func validateDAG(subtasks []SubtaskSpec, edges []EdgeSpec) error { + indeg := make(map[string]int, len(subtasks)) + for _, st := range subtasks { + indeg[st.Ref] = 0 + } + adj := make(map[string][]string, len(subtasks)) + // blocker → blocked:blocked 的入度 +1。 + for _, e := range edges { + adj[e.BlockerRef] = append(adj[e.BlockerRef], e.BlockedRef) + indeg[e.BlockedRef]++ + } + queue := make([]string, 0, len(subtasks)) + for ref, d := range indeg { + if d == 0 { + queue = append(queue, ref) + } + } + visited := 0 + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + visited++ + for _, next := range adj[cur] { + indeg[next]-- + if indeg[next] == 0 { + queue = append(queue, next) + } + } + } + if visited != len(subtasks) { + return errs.New(errs.CodeInvalidInput, "decomposition 边集合存在环") + } + return nil +} diff --git a/internal/orchestrator/decomposition_test.go b/internal/orchestrator/decomposition_test.go new file mode 100644 index 0000000..e920fab --- /dev/null +++ b/internal/orchestrator/decomposition_test.go @@ -0,0 +1,130 @@ +package orchestrator + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// fakeApplier: 内存版 DecompositionApplier,记录建出的 issue 与边。 +type fakeApplier struct { + nextNum int + created []string // titles in order + edges [][2]int // (blockedNumber, blockerNumber) + byNum map[int]*project.Issue // number → issue +} + +func newFakeApplier() *fakeApplier { + return &fakeApplier{byNum: map[int]*project.Issue{}} +} + +func (f *fakeApplier) mk(in project.CreateIssueInput, parent *int) *project.Issue { + f.nextNum++ + prio := 0 + if in.Priority != nil { + prio = *in.Priority + } + var pid *uuid.UUID + if parent != nil { + id := uuid.New() + pid = &id + } + iss := &project.Issue{ID: uuid.New(), Number: f.nextNum, Title: in.Title, Priority: prio, ParentID: pid} + f.byNum[iss.Number] = iss + f.created = append(f.created, in.Title) + return iss +} + +func (f *fakeApplier) Create(_ context.Context, _ project.Caller, _ string, in project.CreateIssueInput) (*project.Issue, error) { + return f.mk(in, nil), nil +} +func (f *fakeApplier) CreateSubtask(_ context.Context, _ project.Caller, _ string, parentNumber int, in project.CreateIssueInput) (*project.Issue, error) { + return f.mk(in, &parentNumber), nil +} +func (f *fakeApplier) AddDependency(_ context.Context, _ project.Caller, _ string, in project.AddDependencyInput) (*project.Dependency, error) { + f.edges = append(f.edges, [2]int{in.BlockedNumber, in.BlockerNumber}) + return &project.Dependency{ID: uuid.New()}, nil +} + +func TestApplyDecomposition_RejectsCycleBeforeAnyWrite(t *testing.T) { + f := newFakeApplier() + d := Decomposition{ + Subtasks: []SubtaskSpec{{Ref: "a", Title: "A"}, {Ref: "b", Title: "B"}}, + Edges: []EdgeSpec{ + {BlockedRef: "a", BlockerRef: "b"}, + {BlockedRef: "b", BlockerRef: "a"}, // 环 + }, + } + _, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", d) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) + require.Empty(t, f.created, "校验失败前不应有任何写入") + require.Empty(t, f.edges) +} + +func TestApplyDecomposition_ValidDAGCreatesIssuesThenEdges(t *testing.T) { + f := newFakeApplier() + parent := 100 + f.byNum[parent] = &project.Issue{Number: parent} + d := Decomposition{ + ParentNumber: &parent, + Subtasks: []SubtaskSpec{ + {Ref: "a", Title: "A", Priority: 2}, + {Ref: "b", Title: "B"}, + {Ref: "c", Title: "C"}, + }, + Edges: []EdgeSpec{ + {BlockedRef: "a", BlockerRef: "b"}, + {BlockedRef: "a", BlockerRef: "c"}, + }, + } + res, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", d) + require.NoError(t, err) + require.Len(t, f.created, 3) + require.Len(t, f.edges, 2) + // refs 映射到真实 number + require.Contains(t, res.RefToNumber, "a") + require.Contains(t, res.RefToNumber, "b") + require.Contains(t, res.RefToNumber, "c") + // 边以真实 number 落库 + na, nb := res.RefToNumber["a"], res.RefToNumber["b"] + require.Contains(t, f.edges, [2]int{na, nb}) +} + +func TestApplyDecomposition_RejectsEmpty(t *testing.T) { + f := newFakeApplier() + _, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", Decomposition{}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} + +func TestApplyDecomposition_RejectsUnknownEdgeRef(t *testing.T) { + f := newFakeApplier() + d := Decomposition{ + Subtasks: []SubtaskSpec{{Ref: "a", Title: "A"}}, + Edges: []EdgeSpec{{BlockedRef: "a", BlockerRef: "zzz"}}, + } + _, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", d) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) + require.Empty(t, f.created) +} + +func TestApplyDecomposition_RejectsDuplicateRef(t *testing.T) { + f := newFakeApplier() + d := Decomposition{ + Subtasks: []SubtaskSpec{{Ref: "a", Title: "A"}, {Ref: "a", Title: "A2"}}, + } + _, err := ApplyDecomposition(context.Background(), f, project.Caller{}, "demo", d) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} diff --git a/internal/orchestrator/domain.go b/internal/orchestrator/domain.go new file mode 100644 index 0000000..6379036 --- /dev/null +++ b/internal/orchestrator/domain.go @@ -0,0 +1,159 @@ +// Package orchestrator 实现"编排器/规划器":一个 per-requirement 的 run 聚合根, +// 驱动 6 阶段状态机(planning → prototyping → auditing → implementing → reviewing +// → done)。每个 step 选取一个 agent-kind + prompt,以特权但真实的 owner 身份经 +// acp.SessionService 创建一个 ACP session,发一条 prompt 并阻塞等待回合完成 +// (session/prompt stopReason),评估阶段网关后推进或回环。run 状态落库到 +// orchestrator_runs / orchestrator_steps,每个 step 由 jobs (orchestrator.step) 驱动, +// 因而能跨重启恢复并复用既有 backoff/dead-letter 机制。 +// +// agent-to-agent handoff 发生在编排器层:特权身份创建下一个 session,不放松 +// checkNotSystemToken。request_subtask MCP 工具让运行中的 agent 在 depth/fan-out +// 限制内入队一个子 step。崩溃的 session 在同一 worktree 上重试/恢复。 +package orchestrator + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// RunStatus 是 orchestrator_runs.status 枚举。 +type RunStatus string + +const ( + RunPending RunStatus = "pending" + RunRunning RunStatus = "running" + RunPaused RunStatus = "paused" + RunSucceeded RunStatus = "succeeded" + RunFailed RunStatus = "failed" + RunCanceled RunStatus = "canceled" +) + +// IsTerminal 报告 run 是否已到终态(不再驱动)。 +func (s RunStatus) IsTerminal() bool { + return s == RunSucceeded || s == RunFailed || s == RunCanceled +} + +// IsActive 报告 run 是否处于可驱动状态(pending/running)。paused 不驱动但非终态。 +func (s RunStatus) IsActive() bool { return s == RunPending || s == RunRunning } + +// StepStatus 是 orchestrator_steps.status 枚举。 +type StepStatus string + +const ( + StepPending StepStatus = "pending" + StepSpawning StepStatus = "spawning" + StepRunning StepStatus = "running" + StepAwaitingGate StepStatus = "awaiting_gate" + StepSucceeded StepStatus = "succeeded" + StepFailed StepStatus = "failed" + StepDead StepStatus = "dead" +) + +// Run 是一次编排运行聚合根。 +type Run struct { + ID uuid.UUID + ProjectID uuid.UUID + RequirementID uuid.UUID + WorkspaceID uuid.UUID + OwnerID uuid.UUID + Status RunStatus + CurrentPhase project.Phase + Config RunConfig + LastError *string + CreatedAt time.Time + UpdatedAt time.Time + EndedAt *time.Time +} + +// Step 是 run 内一次阶段尝试。parent_step_id 非 nil 表示由 request_subtask fan-out。 +type Step struct { + ID uuid.UUID + RunID uuid.UUID + Phase project.Phase + Attempt int + ParentStepID *uuid.UUID + Depth int + Status StepStatus + AgentKindID uuid.UUID + ACPSessionID *uuid.UUID + Prompt string + StopReason *string + GateResult *GateResult + LastError *string + JobID *uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time + EndedAt *time.Time +} + +// RunConfig 是 run 的 per-phase 配置(落 JSONB)。零值合法:PhaseToAgentKind 退回 +// defaults,MaxAttemptsPerPhase/MaxDepth/FanOutLimit<=0 时由 service 用配置默认填充。 +type RunConfig struct { + PhaseAgentKinds map[project.Phase]uuid.UUID `json:"phase_agent_kinds,omitempty"` + PromptOverrides map[project.Phase]string `json:"prompt_overrides,omitempty"` + MaxAttemptsPerPhase int `json:"max_attempts_per_phase,omitempty"` + MaxDepth int `json:"max_depth,omitempty"` + FanOutLimit int `json:"fan_out_limit,omitempty"` +} + +// GateResult 是一次阶段网关评估结果。Pass=true 推进;Retry=true 且未通过 → 可重试 +// (回环本阶段);Retry=false 且未通过 → 永久失败(dead-letter)。 +type GateResult struct { + Pass bool `json:"pass"` + Retry bool `json:"retry"` + Reason string `json:"reason,omitempty"` + Detail map[string]any `json:"detail,omitempty"` +} + +// PhaseGate 是可插拔的阶段网关接口。v1 实现见 gate.go。 +type PhaseGate interface { + Evaluate(ctx context.Context, run *Run, step *Step, stopReason string) (GateResult, error) +} + +// Caller 表示发起编排操作的用户上下文(与其他模块的 Caller 同语义)。 +type Caller struct { + UserID uuid.UUID + IsAdmin bool +} + +// StartRunInput 是启动一次编排运行的入参。 +type StartRunInput struct { + ProjectSlug string + RequirementNumber int + StartPhase project.Phase + Config RunConfig +} + +// SubtaskInput 是 request_subtask 的入参(由 MCP bridge 透传)。 +type SubtaskInput struct { + Phase project.Phase + Prompt string + AgentKindID *uuid.UUID +} + +// RunFilter 控制 ListRuns 的过滤维度。零值不过滤。 +type RunFilter struct { + ProjectID *uuid.UUID + RequirementID *uuid.UUID + Status RunStatus + Limit int +} + +// Service 暴露编排器聚合根的应用服务方法。 +type Service interface { + StartRun(ctx context.Context, c Caller, in StartRunInput) (*Run, error) + GetRun(ctx context.Context, c Caller, id uuid.UUID) (*Run, error) + ListRuns(ctx context.Context, c Caller, f RunFilter) ([]*Run, error) + Pause(ctx context.Context, c Caller, id uuid.UUID) error + Resume(ctx context.Context, c Caller, id uuid.UUID) error + Cancel(ctx context.Context, c Caller, id uuid.UUID) error + // RequestSubtask 由 MCP bridge 调用:运行中的 agent 请求编排器为其当前 step 派生子 step。 + RequestSubtask(ctx context.Context, parentACPSessionID uuid.UUID, in SubtaskInput) (*Step, error) + // EnqueueStepRedrive 由 acp supervisor onExit 注入的回调间接调用:把崩溃 session 的 + // step 重新入队(带 backoff)。 + EnqueueStepRedrive(ctx context.Context, stepID uuid.UUID, reason string) +} diff --git a/internal/orchestrator/fakes_test.go b/internal/orchestrator/fakes_test.go new file mode 100644 index 0000000..b941b9a --- /dev/null +++ b/internal/orchestrator/fakes_test.go @@ -0,0 +1,412 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "sync" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/yan1h/agent-coding-workflow/internal/acp" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/jobs" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// ===== fakeRepo: 内存版 orchestrator.Repository ===== + +type fakeRepo struct { + mu sync.Mutex + runs map[uuid.UUID]*Run + steps map[uuid.UUID]*Step +} + +func newFakeRepo() *fakeRepo { + return &fakeRepo{runs: map[uuid.UUID]*Run{}, steps: map[uuid.UUID]*Step{}} +} + +func cloneRun(r *Run) *Run { c := *r; return &c } +func cloneStep(s *Step) *Step { c := *s; return &c } + +func (r *fakeRepo) InsertRun(_ context.Context, run *Run) (*Run, error) { + r.mu.Lock() + defer r.mu.Unlock() + if run.ID == uuid.Nil { + run.ID = uuid.New() + } + run.CreatedAt = time.Now() + run.UpdatedAt = run.CreatedAt + r.runs[run.ID] = cloneRun(run) + return cloneRun(run), nil +} + +func (r *fakeRepo) GetRun(_ context.Context, id uuid.UUID) (*Run, error) { + r.mu.Lock() + defer r.mu.Unlock() + run, ok := r.runs[id] + if !ok { + return nil, errs.New(errs.CodeNotFound, "run not found") + } + return cloneRun(run), nil +} + +func (r *fakeRepo) GetActiveRunByRequirement(_ context.Context, reqID uuid.UUID) (*Run, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, run := range r.runs { + if run.RequirementID == reqID && run.Status.IsActive() || (run.RequirementID == reqID && run.Status == RunPaused) { + return cloneRun(run), nil + } + } + return nil, errs.New(errs.CodeNotFound, "no active run") +} + +func (r *fakeRepo) ListRuns(_ context.Context, f RunFilter) ([]*Run, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := []*Run{} + for _, run := range r.runs { + if f.RequirementID != nil && run.RequirementID != *f.RequirementID { + continue + } + if f.Status != "" && run.Status != f.Status { + continue + } + out = append(out, cloneRun(run)) + } + return out, nil +} + +func (r *fakeRepo) UpdateRunStatus(_ context.Context, id uuid.UUID, status RunStatus, lastErr *string) (*Run, error) { + r.mu.Lock() + defer r.mu.Unlock() + run, ok := r.runs[id] + if !ok { + return nil, errs.New(errs.CodeNotFound, "run not found") + } + run.Status = status + run.LastError = lastErr + run.UpdatedAt = time.Now() + return cloneRun(run), nil +} + +func (r *fakeRepo) UpdateRunPhase(_ context.Context, id uuid.UUID, phase project.Phase) (*Run, error) { + r.mu.Lock() + defer r.mu.Unlock() + run, ok := r.runs[id] + if !ok { + return nil, errs.New(errs.CodeNotFound, "run not found") + } + run.CurrentPhase = phase + run.UpdatedAt = time.Now() + return cloneRun(run), nil +} + +func (r *fakeRepo) InsertStep(_ context.Context, s *Step) (*Step, error) { + r.mu.Lock() + defer r.mu.Unlock() + if s.ID == uuid.Nil { + s.ID = uuid.New() + } + s.CreatedAt = time.Now() + s.UpdatedAt = s.CreatedAt + r.steps[s.ID] = cloneStep(s) + return cloneStep(s), nil +} + +func (r *fakeRepo) GetStep(_ context.Context, id uuid.UUID) (*Step, error) { + r.mu.Lock() + defer r.mu.Unlock() + s, ok := r.steps[id] + if !ok { + return nil, errs.New(errs.CodeNotFound, "step not found") + } + return cloneStep(s), nil +} + +func (r *fakeRepo) GetStepBySession(_ context.Context, sessionID uuid.UUID) (*Step, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, s := range r.steps { + if s.ACPSessionID != nil && *s.ACPSessionID == sessionID { + return cloneStep(s), nil + } + } + return nil, errs.New(errs.CodeNotFound, "step not found") +} + +func (r *fakeRepo) ListStepsByRun(_ context.Context, runID uuid.UUID) ([]*Step, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := []*Step{} + for _, s := range r.steps { + if s.RunID == runID { + out = append(out, cloneStep(s)) + } + } + return out, nil +} + +func (r *fakeRepo) UpdateStepStatus(_ context.Context, s *Step) (*Step, error) { + r.mu.Lock() + defer r.mu.Unlock() + cur, ok := r.steps[s.ID] + if !ok { + return nil, errs.New(errs.CodeNotFound, "step not found") + } + cur.Status = s.Status + cur.ACPSessionID = s.ACPSessionID + cur.StopReason = s.StopReason + cur.GateResult = s.GateResult + cur.LastError = s.LastError + cur.JobID = s.JobID + cur.UpdatedAt = time.Now() + return cloneStep(cur), nil +} + +func (r *fakeRepo) IncrementStepAttempt(_ context.Context, id uuid.UUID) (*Step, error) { + r.mu.Lock() + defer r.mu.Unlock() + s, ok := r.steps[id] + if !ok { + return nil, errs.New(errs.CodeNotFound, "step not found") + } + s.Attempt++ + s.UpdatedAt = time.Now() + return cloneStep(s), nil +} + +func (r *fakeRepo) CountActiveChildSteps(_ context.Context, parentStepID uuid.UUID) (int64, error) { + r.mu.Lock() + defer r.mu.Unlock() + var n int64 + for _, s := range r.steps { + if s.ParentStepID != nil && *s.ParentStepID == parentStepID { + switch s.Status { + case StepPending, StepSpawning, StepRunning, StepAwaitingGate: + n++ + } + } + } + return n, nil +} + +func (r *fakeRepo) ResetStuckStepsOnRestart(_ context.Context) ([]*Step, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := []*Step{} + for _, s := range r.steps { + run, ok := r.runs[s.RunID] + if !ok { + continue + } + active := run.Status.IsActive() || run.Status == RunPaused + switch s.Status { + case StepSpawning, StepRunning, StepAwaitingGate: + if active { + s.Status = StepPending + out = append(out, cloneStep(s)) + } + } + } + return out, nil +} + +// fakeRepo 不支持真正的事务:InTx 直接调用 fn(同一实例),WithTx 返回自身。 +func (r *fakeRepo) InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error { + return fn(ctx, nil) +} +func (r *fakeRepo) WithTx(_ pgx.Tx) Repository { return r } + +// ===== fakePM: 内存版 PMAccess ===== + +type fakePM struct { + mu sync.Mutex + proj *project.Project + req *project.Requirement + artifacts []*project.Artifact + phaseWrites []project.Phase + phaseErr error +} + +func (p *fakePM) GetProjectByID(_ context.Context, _ uuid.UUID) (*project.Project, error) { + return p.proj, nil +} +func (p *fakePM) GetProjectBySlug(_ context.Context, _ string) (*project.Project, error) { + return p.proj, nil +} +func (p *fakePM) GetRequirementByID(_ context.Context, _ uuid.UUID) (*project.Requirement, error) { + return p.req, nil +} +func (p *fakePM) GetRequirementByNumber(_ context.Context, _ uuid.UUID, _ int) (*project.Requirement, error) { + return p.req, nil +} +func (p *fakePM) ListLatestArtifacts(_ context.Context, _ uuid.UUID) ([]*project.Artifact, error) { + return p.artifacts, nil +} +func (p *fakePM) ChangeRequirementPhase(_ context.Context, _ uuid.UUID, _ bool, _ string, _ int, to project.Phase) error { + p.mu.Lock() + defer p.mu.Unlock() + if p.phaseErr != nil { + return p.phaseErr + } + p.phaseWrites = append(p.phaseWrites, to) + if p.req != nil { + p.req.Phase = to + } + return nil +} + +// ===== fakeTurnRunner: acp.TurnRunner ===== + +type fakeTurnRunner struct { + stopReason string + err error + resumeErr error + created *acp.Session + resumed *acp.Session + calls int +} + +func (f *fakeTurnRunner) SendPromptAndWait(_ context.Context, sessionID uuid.UUID, _ string) (string, error) { + f.calls++ + return f.stopReason, f.err +} +func (f *fakeTurnRunner) ResumeSession(_ context.Context, _ acp.Caller, sessionID uuid.UUID) (*acp.Session, error) { + if f.resumeErr != nil { + return nil, f.resumeErr + } + return &acp.Session{ID: sessionID, Status: acp.SessionStarting}, nil +} + +// ===== fakeACPSessions: orchestrator.ACPSessions ===== + +type fakeACPSessions struct { + mu sync.Mutex + created []acp.CreateSessionInput + nextID uuid.UUID + createErr error +} + +func (f *fakeACPSessions) Create(_ context.Context, _ acp.Caller, in acp.CreateSessionInput) (*acp.Session, error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.createErr != nil { + return nil, f.createErr + } + f.created = append(f.created, in) + id := f.nextID + if id == uuid.Nil { + id = uuid.New() + } + return &acp.Session{ID: id, Status: acp.SessionStarting, OrchestratorStepID: in.OrchestratorStepID}, nil +} + +// ===== fakeLookup: orchestrator.ACPSessionLookup ===== + +type fakeLookup struct { + crashed map[uuid.UUID]*acp.Session // stepID → crashed session + live map[uuid.UUID]*acp.Session // stepID → live session +} + +func newFakeLookup() *fakeLookup { + return &fakeLookup{crashed: map[uuid.UUID]*acp.Session{}, live: map[uuid.UUID]*acp.Session{}} +} +func (f *fakeLookup) GetSessionByStepID(_ context.Context, stepID uuid.UUID) (*acp.Session, error) { + if s, ok := f.live[stepID]; ok { + return s, nil + } + return nil, errs.New(errs.CodeNotFound, "no session") +} +func (f *fakeLookup) GetCrashedSessionForResume(_ context.Context, stepID uuid.UUID) (*acp.Session, error) { + if s, ok := f.crashed[stepID]; ok { + return s, nil + } + return nil, errs.New(errs.CodeNotFound, "no crashed session") +} + +// ===== fakeJobs: orchestrator.JobEnqueuer ===== + +type fakeJobs struct { + mu sync.Mutex + enqueued []stepPayload +} + +func (j *fakeJobs) Enqueue(_ context.Context, _ jobs.JobType, payload json.RawMessage, _ time.Time, _ int32) (*jobs.Job, error) { + j.mu.Lock() + defer j.mu.Unlock() + var p stepPayload + _ = json.Unmarshal(payload, &p) + j.enqueued = append(j.enqueued, p) + return &jobs.Job{ID: uuid.New()}, nil +} +func (j *fakeJobs) count() int { + j.mu.Lock() + defer j.mu.Unlock() + return len(j.enqueued) +} + +// ===== fakeGate: orchestrator.PhaseGate ===== + +type fakeGate struct { + result GateResult + err error +} + +func (g fakeGate) Evaluate(_ context.Context, _ *Run, _ *Step, _ string) (GateResult, error) { + return g.result, g.err +} + +// ===== fakeAudit: orchestrator.AuditRecorder ===== + +type fakeAudit struct { + mu sync.Mutex + actions []string +} + +func (a *fakeAudit) Record(_ context.Context, _ uuid.UUID, action, _ string, _ map[string]any) { + a.mu.Lock() + defer a.mu.Unlock() + a.actions = append(a.actions, action) +} +func (a *fakeAudit) has(action string) bool { + a.mu.Lock() + defer a.mu.Unlock() + for _, x := range a.actions { + if x == action { + return true + } + } + return false +} + +// ===== fakeSessTerm: orchestrator.sessionTerminator ===== + +type fakeSessTerm struct { + mu sync.Mutex + terminated []uuid.UUID +} + +func (s *fakeSessTerm) TerminateSession(_ context.Context, _ uuid.UUID, _ bool, sessionID uuid.UUID) error { + s.mu.Lock() + defer s.mu.Unlock() + s.terminated = append(s.terminated, sessionID) + return nil +} + +// ===== fakeACL: orchestrator.ProjectACL ===== + +type fakeACL struct { + allow bool + read bool +} + +func (a fakeACL) CanRead(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, error) { + return a.allow || a.read, nil +} + +func (a fakeACL) CanWrite(_ context.Context, _ uuid.UUID, _ bool, _ uuid.UUID) (bool, error) { + return a.allow, nil +} diff --git a/internal/orchestrator/gate.go b/internal/orchestrator/gate.go new file mode 100644 index 0000000..5f88628 --- /dev/null +++ b/internal/orchestrator/gate.go @@ -0,0 +1,104 @@ +// gate.go 实现阶段网关:把"agent 停了"(stopReason)与"工作确实就绪"(产物/issue +// 谓词)结合,避免仅凭 end_turn 就静默把需求标记完成。v1 是可插拔的 per-phase 谓词。 +package orchestrator + +import ( + "context" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// GateArtifactAccess 是网关查询某需求阶段产物的窄接口(app 层用 project.ArtifactService 适配)。 +type GateArtifactAccess interface { + // ListArtifacts 返回某 requirement 在某 phase 的产物(phase 传零值 "" 表示全部阶段)。 + ListArtifacts(ctx context.Context, projectID, requirementID uuid.UUID, phase project.Phase) ([]*project.Artifact, error) +} + +// GateIssueAccess 是网关查询某需求未关闭 issue 数的窄接口(app 层用 project.IssueService/Repository 适配)。 +type GateIssueAccess interface { + CountOpenIssues(ctx context.Context, requirementID uuid.UUID) (int, error) +} + +// v1Gate 是 v1 阶段网关: +// +// - stopReason 必须是 end_turn,否则不通过。refusal/max_tokens 等明确是 agent +// 主动放弃/受限,不通过且不重试(Retry=false → dead-letter)。其它非空 stopReason +// 不通过但可重试。 +// - 产物型阶段(planning/prototyping/auditing/implementing/reviewing)额外要求该阶段 +// 已有至少一个产物;缺失则不通过但可重试(让 agent 再跑一次产出)。 +// - reviewing/done 额外检查未关闭 issue 数为 0(有开放 issue 视为未就绪,可重试)。 +// - done 阶段:end_turn 即视为通过(汇总视图,无单独产物)。 +type v1Gate struct { + artifacts GateArtifactAccess // 可为 nil(跳过产物谓词) + issues GateIssueAccess // 可为 nil(跳过 issue 谓词) +} + +// NewV1Gate 构造 v1 阶段网关。artifacts/issues 可为 nil(对应谓词被跳过)。 +func NewV1Gate(artifacts GateArtifactAccess, issues GateIssueAccess) PhaseGate { + return &v1Gate{artifacts: artifacts, issues: issues} +} + +func (g *v1Gate) Evaluate(ctx context.Context, run *Run, step *Step, stopReason string) (GateResult, error) { + // 1. stopReason 闸门。 + switch stopReason { + case string(acpStopEndTurn): + // 继续 phase 谓词。 + case string(acpStopRefusal), string(acpStopMaxTokens): + return GateResult{Pass: false, Retry: false, + Reason: "agent stopped with non-recoverable reason: " + stopReason}, nil + default: + // 空串 / cancelled / vendor-specific:不通过,可重试。 + reason := stopReason + if reason == "" { + reason = "no stop reason" + } + return GateResult{Pass: false, Retry: true, + Reason: "turn did not end cleanly: " + reason}, nil + } + + phase := step.Phase + + // 2. done:end_turn 即通过。 + if phase == project.PhaseDone { + return GateResult{Pass: true, Reason: "done"}, nil + } + + // 3. 产物型阶段:要求该阶段已有产物。 + if phase.IsArtifactPhase() && g.artifacts != nil { + arts, err := g.artifacts.ListArtifacts(ctx, run.ProjectID, run.RequirementID, phase) + if err != nil { + return GateResult{}, err + } + if len(arts) == 0 { + return GateResult{Pass: false, Retry: true, + Reason: "no artifact produced for phase " + string(phase), + Detail: map[string]any{"phase": string(phase)}}, nil + } + } + + // 4. reviewing:额外要求无开放 issue(避免遗留缺陷推进)。 + if phase == project.PhaseReviewing && g.issues != nil { + open, err := g.issues.CountOpenIssues(ctx, run.RequirementID) + if err != nil { + return GateResult{}, err + } + if open > 0 { + return GateResult{Pass: false, Retry: true, + Reason: "open issues remain", + Detail: map[string]any{"open_issues": open}}, nil + } + } + + return GateResult{Pass: true, Reason: "phase gate passed", + Detail: map[string]any{"phase": string(phase)}}, nil +} + +// acp stopReason 原始字符串常量(与 internal/acp.StopReason 取值一致,此处复制以免引入 +// acp 依赖到纯网关逻辑)。 +const ( + acpStopEndTurn = "end_turn" + acpStopRefusal = "refusal" + acpStopMaxTokens = "max_tokens" +) diff --git a/internal/orchestrator/gate_test.go b/internal/orchestrator/gate_test.go new file mode 100644 index 0000000..db1146a --- /dev/null +++ b/internal/orchestrator/gate_test.go @@ -0,0 +1,111 @@ +package orchestrator + +import ( + "context" + "testing" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +type fakeGateArtifacts struct { + byPhase map[project.Phase]int // phase → 产物数 +} + +func (f fakeGateArtifacts) ListArtifacts(_ context.Context, _, _ uuid.UUID, phase project.Phase) ([]*project.Artifact, error) { + n := f.byPhase[phase] + out := make([]*project.Artifact, 0, n) + for i := 0; i < n; i++ { + out = append(out, &project.Artifact{Phase: phase, Version: i + 1}) + } + return out, nil +} + +type fakeGateIssues struct{ open int } + +func (f fakeGateIssues) CountOpenIssues(_ context.Context, _ uuid.UUID) (int, error) { + return f.open, nil +} + +func gateRun() *Run { + return &Run{ID: uuid.New(), ProjectID: uuid.New(), RequirementID: uuid.New()} +} + +func TestGate_PassOnEndTurnWithArtifact(t *testing.T) { + g := NewV1Gate(fakeGateArtifacts{byPhase: map[project.Phase]int{project.PhasePlanning: 1}}, nil) + step := &Step{Phase: project.PhasePlanning} + res, err := g.Evaluate(context.Background(), gateRun(), step, "end_turn") + if err != nil { + t.Fatal(err) + } + if !res.Pass { + t.Errorf("expected pass; got %+v", res) + } +} + +func TestGate_RetryOnEndTurnMissingArtifact(t *testing.T) { + g := NewV1Gate(fakeGateArtifacts{byPhase: map[project.Phase]int{}}, nil) + step := &Step{Phase: project.PhasePlanning} + res, err := g.Evaluate(context.Background(), gateRun(), step, "end_turn") + if err != nil { + t.Fatal(err) + } + if res.Pass || !res.Retry { + t.Errorf("expected no-pass retryable; got %+v", res) + } +} + +func TestGate_FailNoRetryOnRefusalAndMaxTokens(t *testing.T) { + g := NewV1Gate(fakeGateArtifacts{byPhase: map[project.Phase]int{project.PhasePlanning: 1}}, nil) + step := &Step{Phase: project.PhasePlanning} + for _, sr := range []string{"refusal", "max_tokens"} { + res, err := g.Evaluate(context.Background(), gateRun(), step, sr) + if err != nil { + t.Fatal(err) + } + if res.Pass || res.Retry { + t.Errorf("stopReason %q: expected fail no-retry; got %+v", sr, res) + } + } +} + +func TestGate_RetryOnEmptyOrCancelled(t *testing.T) { + g := NewV1Gate(fakeGateArtifacts{byPhase: map[project.Phase]int{project.PhasePlanning: 1}}, nil) + step := &Step{Phase: project.PhasePlanning} + for _, sr := range []string{"", "cancelled"} { + res, err := g.Evaluate(context.Background(), gateRun(), step, sr) + if err != nil { + t.Fatal(err) + } + if res.Pass || !res.Retry { + t.Errorf("stopReason %q: expected retryable; got %+v", sr, res) + } + } +} + +func TestGate_ReviewingRequiresNoOpenIssues(t *testing.T) { + arts := fakeGateArtifacts{byPhase: map[project.Phase]int{project.PhaseReviewing: 1}} + step := &Step{Phase: project.PhaseReviewing} + + gOpen := NewV1Gate(arts, fakeGateIssues{open: 2}) + res, _ := gOpen.Evaluate(context.Background(), gateRun(), step, "end_turn") + if res.Pass || !res.Retry { + t.Errorf("open issues should block reviewing (retryable); got %+v", res) + } + + gClean := NewV1Gate(arts, fakeGateIssues{open: 0}) + res, _ = gClean.Evaluate(context.Background(), gateRun(), step, "end_turn") + if !res.Pass { + t.Errorf("no open issues should pass reviewing; got %+v", res) + } +} + +func TestGate_DonePassesOnEndTurn(t *testing.T) { + g := NewV1Gate(nil, nil) + step := &Step{Phase: project.PhaseDone} + res, _ := g.Evaluate(context.Background(), gateRun(), step, "end_turn") + if !res.Pass { + t.Errorf("done should pass on end_turn; got %+v", res) + } +} diff --git a/internal/orchestrator/handler.go b/internal/orchestrator/handler.go new file mode 100644 index 0000000..a866ae0 --- /dev/null +++ b/internal/orchestrator/handler.go @@ -0,0 +1,239 @@ +// handler.go 暴露编排器模块的 HTTP 端点:/api/v1/orchestrator/runs(POST 启动、 +// GET 列表)、/runs/{id}(GET)、/runs/{id}/pause|resume|cancel(POST)。 +package orchestrator + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/project" + httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" +) + +// AdminLookup 解析某用户是否为 admin(与 acp/chat/workspace 一致)。 +type AdminLookup interface { + IsAdmin(ctx context.Context, userID uuid.UUID) (bool, error) +} + +// Handler 暴露编排器 HTTP 端点。 +type Handler struct { + svc Service + resolver middleware.SessionResolver + adminLookup AdminLookup +} + +// NewHandler 构造 Handler。 +func NewHandler(svc Service, resolver middleware.SessionResolver, al AdminLookup) *Handler { + return &Handler{svc: svc, resolver: resolver, adminLookup: al} +} + +// Mount 注册 /api/v1/orchestrator/* 路由。 +func (h *Handler) Mount(r chi.Router) { + r.Route("/api/v1/orchestrator/runs", func(r chi.Router) { + r.Use(middleware.Auth(h.resolver, middleware.AuthOptions{})) + r.Post("/", h.startRun) + r.Get("/", h.listRuns) + r.Get("/{id}", h.getRun) + r.Post("/{id}/pause", h.pauseRun) + r.Post("/{id}/resume", h.resumeRun) + r.Post("/{id}/cancel", h.cancelRun) + }) +} + +func (h *Handler) caller(r *http.Request) (Caller, error) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + return Caller{}, errs.New(errs.CodeUnauthorized, "not authenticated") + } + admin, err := h.adminLookup.IsAdmin(r.Context(), uid) + if err != nil { + return Caller{}, err + } + return Caller{UserID: uid, IsAdmin: admin}, nil +} + +// ===== DTO ===== + +type startRunReq struct { + ProjectSlug string `json:"project_slug"` + RequirementNumber int `json:"requirement_number"` + StartPhase string `json:"start_phase,omitempty"` + PhaseAgentKinds map[string]string `json:"phase_agent_kinds,omitempty"` + PromptOverrides map[string]string `json:"prompt_overrides,omitempty"` + MaxAttemptsPerPhase int `json:"max_attempts_per_phase,omitempty"` + MaxDepth int `json:"max_depth,omitempty"` + FanOutLimit int `json:"fan_out_limit,omitempty"` +} + +type runDTO struct { + ID string `json:"id"` + ProjectID string `json:"project_id"` + RequirementID string `json:"requirement_id"` + WorkspaceID string `json:"workspace_id"` + OwnerID string `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + LastError string `json:"last_error,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` +} + +func runToDTO(r *Run) runDTO { + d := runDTO{ + ID: r.ID.String(), + ProjectID: r.ProjectID.String(), + RequirementID: r.RequirementID.String(), + WorkspaceID: r.WorkspaceID.String(), + OwnerID: r.OwnerID.String(), + Status: string(r.Status), + CurrentPhase: string(r.CurrentPhase), + CreatedAt: r.CreatedAt, + UpdatedAt: r.UpdatedAt, + EndedAt: r.EndedAt, + } + if r.LastError != nil { + d.LastError = *r.LastError + } + return d +} + +// ===== handlers ===== + +func (h *Handler) startRun(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + h.writeErr(w, r, err) + return + } + var body startRunReq + if derr := json.NewDecoder(r.Body).Decode(&body); derr != nil { + h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad json")) + return + } + if body.ProjectSlug == "" || body.RequirementNumber <= 0 { + h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "project_slug and requirement_number are required")) + return + } + + cfg := RunConfig{ + MaxAttemptsPerPhase: body.MaxAttemptsPerPhase, + MaxDepth: body.MaxDepth, + FanOutLimit: body.FanOutLimit, + } + if len(body.PhaseAgentKinds) > 0 { + cfg.PhaseAgentKinds = map[project.Phase]uuid.UUID{} + for ph, idStr := range body.PhaseAgentKinds { + id, perr := uuid.Parse(idStr) + if perr != nil { + h.writeErr(w, r, errs.Wrap(perr, errs.CodeInvalidInput, "phase_agent_kinds id parse")) + return + } + cfg.PhaseAgentKinds[project.Phase(ph)] = id + } + } + if len(body.PromptOverrides) > 0 { + cfg.PromptOverrides = map[project.Phase]string{} + for ph, p := range body.PromptOverrides { + cfg.PromptOverrides[project.Phase(ph)] = p + } + } + + run, err := h.svc.StartRun(r.Context(), c, StartRunInput{ + ProjectSlug: body.ProjectSlug, + RequirementNumber: body.RequirementNumber, + StartPhase: project.Phase(body.StartPhase), + Config: cfg, + }) + if err != nil { + h.writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusCreated, runToDTO(run)) +} + +func (h *Handler) getRun(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + h.writeErr(w, r, err) + return + } + id, perr := uuid.Parse(chi.URLParam(r, "id")) + if perr != nil { + h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id")) + return + } + run, err := h.svc.GetRun(r.Context(), c, id) + if err != nil { + h.writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, runToDTO(run)) +} + +func (h *Handler) listRuns(w http.ResponseWriter, r *http.Request) { + c, err := h.caller(r) + if err != nil { + h.writeErr(w, r, err) + return + } + f := RunFilter{Status: RunStatus(r.URL.Query().Get("status"))} + if rid := r.URL.Query().Get("requirement_id"); rid != "" { + if id, perr := uuid.Parse(rid); perr == nil { + f.RequirementID = &id + } + } + if pid := r.URL.Query().Get("project_id"); pid != "" { + if id, perr := uuid.Parse(pid); perr == nil { + f.ProjectID = &id + } + } + runs, err := h.svc.ListRuns(r.Context(), c, f) + if err != nil { + h.writeErr(w, r, err) + return + } + out := make([]runDTO, 0, len(runs)) + for _, run := range runs { + out = append(out, runToDTO(run)) + } + httpx.WriteJSON(w, http.StatusOK, out) +} + +func (h *Handler) pauseRun(w http.ResponseWriter, r *http.Request) { h.runAction(w, r, h.svc.Pause) } +func (h *Handler) resumeRun(w http.ResponseWriter, r *http.Request) { h.runAction(w, r, h.svc.Resume) } +func (h *Handler) cancelRun(w http.ResponseWriter, r *http.Request) { h.runAction(w, r, h.svc.Cancel) } + +func (h *Handler) runAction(w http.ResponseWriter, r *http.Request, fn func(context.Context, Caller, uuid.UUID) error) { + c, err := h.caller(r) + if err != nil { + h.writeErr(w, r, err) + return + } + id, perr := uuid.Parse(chi.URLParam(r, "id")) + if perr != nil { + h.writeErr(w, r, errs.New(errs.CodeInvalidInput, "bad id")) + return + } + if aerr := fn(r.Context(), c, id); aerr != nil { + h.writeErr(w, r, aerr) + return + } + run, gerr := h.svc.GetRun(r.Context(), c, id) + if gerr != nil { + w.WriteHeader(http.StatusNoContent) + return + } + httpx.WriteJSON(w, http.StatusOK, runToDTO(run)) +} + +func (h *Handler) writeErr(w http.ResponseWriter, r *http.Request, err error) { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) +} diff --git a/internal/orchestrator/helpers.go b/internal/orchestrator/helpers.go new file mode 100644 index 0000000..f53879d --- /dev/null +++ b/internal/orchestrator/helpers.go @@ -0,0 +1,56 @@ +package orchestrator + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// StepEnqueuer 是建好 step 后入队其 orchestrator.step job 的窄接口(service 实现, +// 包装 jobs.Repository.Enqueue)。导出以便 app.go 在启动恢复时直接调用 EnqueueStep。 +type StepEnqueuer interface { + EnqueueStep(ctx context.Context, runID, stepID uuid.UUID, scheduledAt time.Time) error +} + +// AuditRecorder 是编排器写审计的窄接口(app 层用 audit.Recorder 适配)。导出以便 +// 外部包(app)的适配器可实现(unexported 方法的接口无法被跨包实现)。 +type AuditRecorder interface { + Record(ctx context.Context, userID uuid.UUID, action, targetID string, meta map[string]any) +} + +// buildPhaseStep 为 run 在指定 phase 建一个新 step(pending 状态),解析 agent-kind +// 与 prompt 后落库。parent 非 nil 时记录 parent_step_id(request_subtask fan-out)。 +// 解析失败(如缺 agent-kind)返回 error,调用方据此处理(标 run 失败 / 拒绝 subtask)。 +func buildPhaseStep(ctx context.Context, repo Repository, pm PMAccess, run *Run, + phase project.Phase, parentStepID *uuid.UUID, depth int, + defaults map[project.Phase]uuid.UUID) (*Step, error) { + + akID, err := PhaseAgentKind(run.Config, phase, defaults) + if err != nil { + return nil, err + } + + req, err := pm.GetRequirementByID(ctx, run.RequirementID) + if err != nil { + return nil, err + } + artifacts, _ := pm.ListLatestArtifacts(ctx, run.RequirementID) + override := run.Config.PromptOverrides[phase] + prompt := BuildPrompt(phase, req, artifacts, override) + + step := &Step{ + ID: uuid.New(), + RunID: run.ID, + Phase: phase, + Attempt: 1, + ParentStepID: parentStepID, + Depth: depth, + Status: StepPending, + AgentKindID: akID, + Prompt: prompt, + } + return repo.InsertStep(ctx, step) +} diff --git a/internal/orchestrator/mcp_bridge.go b/internal/orchestrator/mcp_bridge.go new file mode 100644 index 0000000..fbfef8a --- /dev/null +++ b/internal/orchestrator/mcp_bridge.go @@ -0,0 +1,37 @@ +// mcp_bridge.go 实现 mcp.OrchestratorBridge:把 request_subtask MCP 工具的调用转译到 +// 本包 Service.RequestSubtask。放在 orchestrator 包内(orchestrator 已 import mcp 间接 +// 无需,故这里只 import mcp 接口),避免 mcp → orchestrator 的循环依赖。 +package orchestrator + +import ( + "context" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/mcp" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// MCPBridge 适配 Service 到 mcp.OrchestratorBridge。 +type MCPBridge struct { + svc Service +} + +// NewMCPBridge 构造桥接适配器,由 app.go 装配 mcp.ServerDeps 时调用。 +func NewMCPBridge(svc Service) *MCPBridge { return &MCPBridge{svc: svc} } + +var _ mcp.OrchestratorBridge = (*MCPBridge)(nil) + +// RequestSubtask 透传到 Service.RequestSubtask;返回新建子 step 的 id 字符串。 +func (b *MCPBridge) RequestSubtask(ctx context.Context, parentACPSessionID uuid.UUID, + phase string, prompt string, agentKindID *uuid.UUID) (string, error) { + step, err := b.svc.RequestSubtask(ctx, parentACPSessionID, SubtaskInput{ + Phase: project.Phase(phase), + Prompt: prompt, + AgentKindID: agentKindID, + }) + if err != nil { + return "", err + } + return step.ID.String(), nil +} diff --git a/internal/orchestrator/planner.go b/internal/orchestrator/planner.go new file mode 100644 index 0000000..bc1bb3b --- /dev/null +++ b/internal/orchestrator/planner.go @@ -0,0 +1,135 @@ +// planner.go 是纯函数(无 I/O)的阶段规划逻辑:阶段→agent-kind 解析、阶段→prompt +// 组装、网关结果→下一阶段。可独立单测(模仿 acp.ResolveBranch 的取舍)。 +package orchestrator + +import ( + "fmt" + "strings" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// phaseOrder 是 6 阶段的线性推进顺序,对应 project.AllPhases。 +var phaseOrder = project.AllPhases + +// NextPhase 根据当前阶段与网关结果决定下一阶段。 +// +// - 网关未通过(!g.Pass)→ 回环本阶段(next==current,terminal=false),让 step +// 在同阶段重试。 +// - 网关通过且 current==done → 终态(terminal=true),run 成功。 +// - 网关通过且 current!=done → 线性推进到下一阶段。 +// +// current 非法(不在枚举内)时视为终态,避免无限循环。 +func NextPhase(current project.Phase, g GateResult) (next project.Phase, terminal bool) { + if !g.Pass { + return current, false + } + if current == project.PhaseDone { + return project.PhaseDone, true + } + for i, p := range phaseOrder { + if p == current { + if i+1 >= len(phaseOrder) { + // current 已是最后一个阶段(done)——由上面的分支处理;兜底终止。 + return project.PhaseDone, true + } + // 推进到下一阶段。即便下一阶段是 done,也非终态:done 仍要跑一个 step, + // 只有 done 阶段网关通过后(current==done 分支)run 才成功。 + return phaseOrder[i+1], false + } + } + // 未知阶段:保守终止。 + return current, true +} + +// PhaseAgentKind 解析某阶段应使用的 agent-kind:优先 run.config 覆盖,否则退回 +// defaults(app 层从配置注入的"阶段→默认 agent-kind"映射)。两者皆缺则报错。 +func PhaseAgentKind(cfg RunConfig, phase project.Phase, defaults map[project.Phase]uuid.UUID) (uuid.UUID, error) { + if cfg.PhaseAgentKinds != nil { + if id, ok := cfg.PhaseAgentKinds[phase]; ok && id != uuid.Nil { + return id, nil + } + } + if defaults != nil { + if id, ok := defaults[phase]; ok && id != uuid.Nil { + return id, nil + } + } + return uuid.Nil, errs.New(errs.CodeInvalidInput, + fmt.Sprintf("no agent kind configured for phase %q", phase)) +} + +// BuildPrompt 组装某阶段发给 agent 的 prompt。config 提供覆盖模板时优先用覆盖 +// (仍前置需求/产物上下文);否则用内置的 per-phase 默认指令。纯字符串拼接,无 I/O。 +func BuildPrompt(phase project.Phase, req *project.Requirement, artifacts []*project.Artifact, override string) string { + var b strings.Builder + + if req != nil { + fmt.Fprintf(&b, "Requirement #%d: %s\n", req.Number, req.Title) + if strings.TrimSpace(req.Description) != "" { + fmt.Fprintf(&b, "\nDescription:\n%s\n", strings.TrimSpace(req.Description)) + } + } + + if len(artifacts) > 0 { + b.WriteString("\nPrior artifacts:\n") + for _, a := range artifacts { + if a == nil { + continue + } + fmt.Fprintf(&b, "- [%s v%d] %s\n", a.Phase, a.Version, summarizeArtifact(a)) + } + } + + b.WriteString("\nCurrent phase: ") + b.WriteString(string(phase)) + b.WriteString("\n\n") + + if strings.TrimSpace(override) != "" { + b.WriteString(strings.TrimSpace(override)) + b.WriteString("\n") + return b.String() + } + + b.WriteString(defaultPhaseInstruction(phase)) + b.WriteString("\n") + return b.String() +} + +// summarizeArtifact 取产物内容首行(截断)作为简介,避免 prompt 过长。 +func summarizeArtifact(a *project.Artifact) string { + content := strings.TrimSpace(a.Content) + if content == "" { + return "(empty)" + } + if idx := strings.IndexByte(content, '\n'); idx >= 0 { + content = content[:idx] + } + if len(content) > 120 { + content = content[:120] + "..." + } + return content +} + +// defaultPhaseInstruction 是每个阶段的内置默认指令。 +func defaultPhaseInstruction(phase project.Phase) string { + switch phase { + case project.PhasePlanning: + return "Produce a clear plan for this requirement and record it as a planning artifact." + case project.PhasePrototyping: + return "Build a prototype validating the approach and record a prototyping artifact." + case project.PhaseAuditing: + return "Audit the plan/prototype for risks and gaps; record an auditing artifact." + case project.PhaseImplementing: + return "Implement the requirement on this worktree; commit your changes." + case project.PhaseReviewing: + return "Review the implementation, fix issues, and record a reviewing artifact." + case project.PhaseDone: + return "Summarize the completed work; no further action is required." + default: + return "Proceed with the current phase." + } +} diff --git a/internal/orchestrator/planner_test.go b/internal/orchestrator/planner_test.go new file mode 100644 index 0000000..1695f8b --- /dev/null +++ b/internal/orchestrator/planner_test.go @@ -0,0 +1,110 @@ +package orchestrator + +import ( + "testing" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +func TestNextPhase_AdvancesLinearly(t *testing.T) { + pass := GateResult{Pass: true} + cases := []struct { + from project.Phase + wantNext project.Phase + wantTerm bool + }{ + {project.PhasePlanning, project.PhasePrototyping, false}, + {project.PhasePrototyping, project.PhaseAuditing, false}, + {project.PhaseAuditing, project.PhaseImplementing, false}, + {project.PhaseImplementing, project.PhaseReviewing, false}, + {project.PhaseReviewing, project.PhaseDone, false}, + {project.PhaseDone, project.PhaseDone, true}, + } + for _, c := range cases { + next, term := NextPhase(c.from, pass) + if next != c.wantNext || term != c.wantTerm { + t.Errorf("NextPhase(%s,pass) = (%s,%v); want (%s,%v)", + c.from, next, term, c.wantNext, c.wantTerm) + } + } +} + +func TestNextPhase_GateFailLoopsBack(t *testing.T) { + fail := GateResult{Pass: false, Retry: true} + for _, ph := range project.AllPhases { + next, term := NextPhase(ph, fail) + if next != ph || term { + t.Errorf("NextPhase(%s, fail) = (%s,%v); want (%s,false)", ph, next, term, ph) + } + } +} + +func TestNextPhase_UnknownPhaseTerminates(t *testing.T) { + next, term := NextPhase(project.Phase("bogus"), GateResult{Pass: true}) + if !term { + t.Errorf("unknown phase should terminate; got next=%s term=%v", next, term) + } +} + +func TestPhaseAgentKind_OverridePreferredThenDefault(t *testing.T) { + override := uuid.New() + def := uuid.New() + cfg := RunConfig{PhaseAgentKinds: map[project.Phase]uuid.UUID{project.PhasePlanning: override}} + defaults := map[project.Phase]uuid.UUID{project.PhasePlanning: def, project.PhaseAuditing: def} + + got, err := PhaseAgentKind(cfg, project.PhasePlanning, defaults) + if err != nil || got != override { + t.Fatalf("override path: got=%s err=%v; want %s", got, err, override) + } + + got, err = PhaseAgentKind(cfg, project.PhaseAuditing, defaults) + if err != nil || got != def { + t.Fatalf("default fallback: got=%s err=%v; want %s", got, err, def) + } +} + +func TestPhaseAgentKind_MissingErrors(t *testing.T) { + _, err := PhaseAgentKind(RunConfig{}, project.PhasePlanning, nil) + if err == nil { + t.Fatal("expected error when no agent kind configured") + } +} + +func TestBuildPrompt_IncludesReqAndArtifacts(t *testing.T) { + req := &project.Requirement{Number: 7, Title: "Login flow", Description: "Add OAuth login"} + arts := []*project.Artifact{ + {Phase: project.PhasePlanning, Version: 2, Content: "Plan: use OAuth2 PKCE\nmore detail"}, + } + out := BuildPrompt(project.PhaseAuditing, req, arts, "") + for _, want := range []string{"#7", "Login flow", "Add OAuth login", "Plan: use OAuth2 PKCE", "auditing"} { + if !contains(out, want) { + t.Errorf("prompt missing %q\n%s", want, out) + } + } +} + +func TestBuildPrompt_OverrideUsed(t *testing.T) { + req := &project.Requirement{Number: 1, Title: "T"} + out := BuildPrompt(project.PhasePlanning, req, nil, "DO THE SPECIAL THING") + if !contains(out, "DO THE SPECIAL THING") { + t.Errorf("override not used:\n%s", out) + } + if contains(out, "Produce a clear plan") { + t.Errorf("default instruction should be suppressed when override present:\n%s", out) + } +} + +func contains(s, sub string) bool { + return len(s) >= len(sub) && (indexOf(s, sub) >= 0) +} + +func indexOf(s, sub string) int { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return i + } + } + return -1 +} diff --git a/internal/orchestrator/pm_access.go b/internal/orchestrator/pm_access.go new file mode 100644 index 0000000..d6c6951 --- /dev/null +++ b/internal/orchestrator/pm_access.go @@ -0,0 +1,26 @@ +// pm_access.go 定义编排器对 project (PM) 模块的窄读写接口。app 层用 project 的 +// Repository / RequirementService 适配,避免在 orchestrator 内直接耦合 project 的 +// Caller/Service 全貌。 +package orchestrator + +import ( + "context" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// PMAccess 是编排器驱动阶段所需的 project 读写能力。 +type PMAccess interface { + // GetProjectByID / GetProjectBySlug 解析项目(slug 用于 ChangePhase / Create 入参)。 + GetProjectByID(ctx context.Context, id uuid.UUID) (*project.Project, error) + GetProjectBySlug(ctx context.Context, slug string) (*project.Project, error) + // GetRequirementByID / GetRequirementByNumber 解析需求(号用于 ChangePhase / session 绑定)。 + GetRequirementByID(ctx context.Context, id uuid.UUID) (*project.Requirement, error) + GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*project.Requirement, error) + // ListLatestArtifacts 返回某需求各阶段最新产物(供 BuildPrompt 注入上下文)。 + ListLatestArtifacts(ctx context.Context, requirementID uuid.UUID) ([]*project.Artifact, error) + // ChangeRequirementPhase 以 owner 身份推进需求阶段(编排器特权身份调用)。 + ChangeRequirementPhase(ctx context.Context, ownerID uuid.UUID, isAdmin bool, projectSlug string, number int, to project.Phase) error +} diff --git a/internal/orchestrator/queries/runs.sql b/internal/orchestrator/queries/runs.sql new file mode 100644 index 0000000..e31a32e --- /dev/null +++ b/internal/orchestrator/queries/runs.sql @@ -0,0 +1,56 @@ +-- name: InsertRun :one +INSERT INTO orchestrator_runs ( + id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error, + created_at, updated_at, ended_at; + +-- name: GetRun :one +SELECT id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error, + created_at, updated_at, ended_at +FROM orchestrator_runs +WHERE id = $1; + +-- name: GetActiveRunByRequirement :one +SELECT id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error, + created_at, updated_at, ended_at +FROM orchestrator_runs +WHERE requirement_id = $1 + AND status IN ('pending','running','paused') +ORDER BY created_at DESC +LIMIT 1; + +-- name: ListRuns :many +SELECT id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error, + created_at, updated_at, ended_at +FROM orchestrator_runs +WHERE ($1::uuid IS NULL OR project_id = $1) + AND ($2::uuid IS NULL OR requirement_id = $2) + AND ($3::text = '' OR status = $3) +ORDER BY created_at DESC +LIMIT $4; + +-- name: UpdateRunStatus :one +UPDATE orchestrator_runs +SET status = $2, + last_error = $3, + ended_at = CASE WHEN $2 IN ('succeeded','failed','canceled') THEN now() ELSE ended_at END, + updated_at = now() +WHERE id = $1 +RETURNING id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error, + created_at, updated_at, ended_at; + +-- name: UpdateRunPhase :one +UPDATE orchestrator_runs +SET current_phase = $2, + updated_at = now() +WHERE id = $1 +RETURNING id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error, + created_at, updated_at, ended_at; diff --git a/internal/orchestrator/queries/steps.sql b/internal/orchestrator/queries/steps.sql new file mode 100644 index 0000000..7eab833 --- /dev/null +++ b/internal/orchestrator/queries/steps.sql @@ -0,0 +1,77 @@ +-- name: InsertStep :one +INSERT INTO orchestrator_steps ( + id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) +RETURNING id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id, created_at, updated_at, ended_at; + +-- name: GetStep :one +SELECT id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id, created_at, updated_at, ended_at +FROM orchestrator_steps +WHERE id = $1; + +-- name: GetStepBySession :one +SELECT id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id, created_at, updated_at, ended_at +FROM orchestrator_steps +WHERE acp_session_id = $1 +ORDER BY created_at DESC +LIMIT 1; + +-- name: ListStepsByRun :many +SELECT id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id, created_at, updated_at, ended_at +FROM orchestrator_steps +WHERE run_id = $1 +ORDER BY created_at ASC; + +-- name: UpdateStepStatus :one +UPDATE orchestrator_steps +SET status = $2, + acp_session_id = $3, + stop_reason = $4, + gate_result = $5, + last_error = $6, + job_id = $7, + ended_at = CASE WHEN $2 IN ('succeeded','failed','dead') THEN now() ELSE ended_at END, + updated_at = now() +WHERE id = $1 +RETURNING id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id, created_at, updated_at, ended_at; + +-- name: IncrementStepAttempt :one +UPDATE orchestrator_steps +SET attempt = attempt + 1, + updated_at = now() +WHERE id = $1 +RETURNING id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id, created_at, updated_at, ended_at; + +-- name: CountActiveChildSteps :one +SELECT COUNT(*) FROM orchestrator_steps +WHERE parent_step_id = $1 + AND status IN ('pending','spawning','running','awaiting_gate'); + +-- name: ResetStuckStepsOnRestart :many +-- 把活跃 run 下卡在 spawning/running/awaiting_gate 的 step 重置为 pending,便于 +-- 重新入队恢复(镜像 acp ResetStuckSessionsOnRestart)。succeeded/canceled/failed +-- run 下的 step 不动。 +UPDATE orchestrator_steps s +SET status = 'pending', + updated_at = now() +FROM orchestrator_runs r +WHERE s.run_id = r.id + AND r.status IN ('pending','running','paused') + AND s.status IN ('spawning','running','awaiting_gate') +RETURNING s.id, s.run_id, s.phase, s.attempt, s.parent_step_id, s.depth, + s.status, s.agent_kind_id, s.acp_session_id, s.prompt, s.stop_reason, + s.gate_result, s.last_error, s.job_id, s.created_at, s.updated_at, s.ended_at; diff --git a/internal/orchestrator/repository.go b/internal/orchestrator/repository.go new file mode 100644 index 0000000..fb212f4 --- /dev/null +++ b/internal/orchestrator/repository.go @@ -0,0 +1,381 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "errors" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgerrcode" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + orchsqlc "github.com/yan1h/agent-coding-workflow/internal/orchestrator/sqlc" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// Repository 是 orchestrator 模块对 PG 的全部依赖。Service 单测通过手写 fakeRepo 满足。 +type Repository interface { + // Run + InsertRun(ctx context.Context, r *Run) (*Run, error) + GetRun(ctx context.Context, id uuid.UUID) (*Run, error) + GetActiveRunByRequirement(ctx context.Context, requirementID uuid.UUID) (*Run, error) + ListRuns(ctx context.Context, f RunFilter) ([]*Run, error) + UpdateRunStatus(ctx context.Context, id uuid.UUID, status RunStatus, lastErr *string) (*Run, error) + UpdateRunPhase(ctx context.Context, id uuid.UUID, phase project.Phase) (*Run, error) + + // Step + InsertStep(ctx context.Context, s *Step) (*Step, error) + GetStep(ctx context.Context, id uuid.UUID) (*Step, error) + GetStepBySession(ctx context.Context, sessionID uuid.UUID) (*Step, error) + ListStepsByRun(ctx context.Context, runID uuid.UUID) ([]*Step, error) + UpdateStepStatus(ctx context.Context, s *Step) (*Step, error) + IncrementStepAttempt(ctx context.Context, id uuid.UUID) (*Step, error) + CountActiveChildSteps(ctx context.Context, parentStepID uuid.UUID) (int64, error) + ResetStuckStepsOnRestart(ctx context.Context) ([]*Step, error) + + InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error + WithTx(tx pgx.Tx) Repository +} + +// IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突(用于一 requirement 一活跃 run 的 409 翻译)。 +func IsUniqueViolation(err error) bool { + var pg *pgconn.PgError + return errors.As(err, &pg) && pg.Code == pgerrcode.UniqueViolation +} + +type pgRepo struct { + pool *pgxpool.Pool + q *orchsqlc.Queries +} + +// NewPostgresRepository 用 pgxpool 构造 Repository。 +func NewPostgresRepository(pool *pgxpool.Pool) Repository { + return &pgRepo{pool: pool, q: orchsqlc.New(pool)} +} + +// InTx 在单事务内执行 fn。nil error commit,否则 rollback。StartRun 用它原子地 +// 插入 run + 第一个 step。 +func (r *pgRepo) InTx(ctx context.Context, fn func(ctx context.Context, tx pgx.Tx) error) error { + if r.pool == nil { + return errs.New(errs.CodeInternal, "orchestrator.Repository.InTx: nil pool (tx-bound instance)") + } + return pgx.BeginFunc(ctx, r.pool, func(tx pgx.Tx) error { + return fn(ctx, tx) + }) +} + +// WithTx 返回绑定到指定事务的 Repository 副本。 +func (r *pgRepo) WithTx(tx pgx.Tx) Repository { + return &pgRepo{pool: nil, q: r.q.WithTx(tx)} +} + +// ===== 类型转换 helper ===== + +func toPgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} } +func toPgUUIDPtr(u *uuid.UUID) pgtype.UUID { + if u == nil { + return pgtype.UUID{} + } + return pgtype.UUID{Bytes: *u, Valid: true} +} +func fromPgUUID(p pgtype.UUID) uuid.UUID { + if !p.Valid { + return uuid.Nil + } + return uuid.UUID(p.Bytes) +} +func fromPgUUIDPtr(p pgtype.UUID) *uuid.UUID { + if !p.Valid { + return nil + } + u := uuid.UUID(p.Bytes) + return &u +} + +func timePtr(p pgtype.Timestamptz) *time.Time { + if !p.Valid { + return nil + } + t := p.Time + return &t +} + +func marshalConfig(c RunConfig) ([]byte, error) { + b, err := json.Marshal(c) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "marshal run config") + } + return b, nil +} + +func unmarshalConfig(b []byte) RunConfig { + var c RunConfig + if len(b) > 0 { + _ = json.Unmarshal(b, &c) + } + return c +} + +func marshalGate(g *GateResult) ([]byte, error) { + if g == nil { + return nil, nil + } + b, err := json.Marshal(g) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "marshal gate result") + } + return b, nil +} + +func unmarshalGate(b []byte) *GateResult { + if len(b) == 0 { + return nil + } + var g GateResult + if err := json.Unmarshal(b, &g); err != nil { + return nil + } + return &g +} + +// ===== Run ===== + +func (r *pgRepo) InsertRun(ctx context.Context, run *Run) (*Run, error) { + cfg, err := marshalConfig(run.Config) + if err != nil { + return nil, err + } + row, err := r.q.InsertRun(ctx, orchsqlc.InsertRunParams{ + ID: toPgUUID(run.ID), + ProjectID: toPgUUID(run.ProjectID), + RequirementID: toPgUUID(run.RequirementID), + WorkspaceID: toPgUUID(run.WorkspaceID), + OwnerID: toPgUUID(run.OwnerID), + Status: string(run.Status), + CurrentPhase: string(run.CurrentPhase), + Config: cfg, + LastError: run.LastError, + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "insert run") + } + return rowToRun(row), nil +} + +func (r *pgRepo) GetRun(ctx context.Context, id uuid.UUID) (*Run, error) { + row, err := r.q.GetRun(ctx, toPgUUID(id)) + if err != nil { + return nil, pgxNoRowsToNotFound(err, "orchestrator run not found") + } + return rowToRun(row), nil +} + +func (r *pgRepo) GetActiveRunByRequirement(ctx context.Context, requirementID uuid.UUID) (*Run, error) { + row, err := r.q.GetActiveRunByRequirement(ctx, toPgUUID(requirementID)) + if err != nil { + return nil, pgxNoRowsToNotFound(err, "no active orchestrator run for requirement") + } + return rowToRun(row), nil +} + +func (r *pgRepo) ListRuns(ctx context.Context, f RunFilter) ([]*Run, error) { + limit := int32(f.Limit) + if limit <= 0 { + limit = 100 + } + rows, err := r.q.ListRuns(ctx, orchsqlc.ListRunsParams{ + Column1: toPgUUIDPtr(f.ProjectID), + Column2: toPgUUIDPtr(f.RequirementID), + Column3: string(f.Status), + Limit: limit, + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list runs") + } + out := make([]*Run, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToRun(row)) + } + return out, nil +} + +func (r *pgRepo) UpdateRunStatus(ctx context.Context, id uuid.UUID, status RunStatus, lastErr *string) (*Run, error) { + row, err := r.q.UpdateRunStatus(ctx, orchsqlc.UpdateRunStatusParams{ + ID: toPgUUID(id), + Status: string(status), + LastError: lastErr, + }) + if err != nil { + return nil, pgxNoRowsToNotFound(err, "orchestrator run not found") + } + return rowToRun(row), nil +} + +func (r *pgRepo) UpdateRunPhase(ctx context.Context, id uuid.UUID, phase project.Phase) (*Run, error) { + row, err := r.q.UpdateRunPhase(ctx, orchsqlc.UpdateRunPhaseParams{ + ID: toPgUUID(id), + CurrentPhase: string(phase), + }) + if err != nil { + return nil, pgxNoRowsToNotFound(err, "orchestrator run not found") + } + return rowToRun(row), nil +} + +// ===== Step ===== + +func (r *pgRepo) InsertStep(ctx context.Context, s *Step) (*Step, error) { + gate, err := marshalGate(s.GateResult) + if err != nil { + return nil, err + } + row, err := r.q.InsertStep(ctx, orchsqlc.InsertStepParams{ + ID: toPgUUID(s.ID), + RunID: toPgUUID(s.RunID), + Phase: string(s.Phase), + Attempt: int32(s.Attempt), + ParentStepID: toPgUUIDPtr(s.ParentStepID), + Depth: int32(s.Depth), + Status: string(s.Status), + AgentKindID: toPgUUID(s.AgentKindID), + AcpSessionID: toPgUUIDPtr(s.ACPSessionID), + Prompt: s.Prompt, + StopReason: s.StopReason, + GateResult: gate, + LastError: s.LastError, + JobID: toPgUUIDPtr(s.JobID), + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "insert step") + } + return rowToStep(row), nil +} + +func (r *pgRepo) GetStep(ctx context.Context, id uuid.UUID) (*Step, error) { + row, err := r.q.GetStep(ctx, toPgUUID(id)) + if err != nil { + return nil, pgxNoRowsToNotFound(err, "orchestrator step not found") + } + return rowToStep(row), nil +} + +func (r *pgRepo) GetStepBySession(ctx context.Context, sessionID uuid.UUID) (*Step, error) { + row, err := r.q.GetStepBySession(ctx, toPgUUID(sessionID)) + if err != nil { + return nil, pgxNoRowsToNotFound(err, "orchestrator step not found for session") + } + return rowToStep(row), nil +} + +func (r *pgRepo) ListStepsByRun(ctx context.Context, runID uuid.UUID) ([]*Step, error) { + rows, err := r.q.ListStepsByRun(ctx, toPgUUID(runID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list steps by run") + } + out := make([]*Step, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToStep(row)) + } + return out, nil +} + +func (r *pgRepo) UpdateStepStatus(ctx context.Context, s *Step) (*Step, error) { + gate, err := marshalGate(s.GateResult) + if err != nil { + return nil, err + } + row, err := r.q.UpdateStepStatus(ctx, orchsqlc.UpdateStepStatusParams{ + ID: toPgUUID(s.ID), + Status: string(s.Status), + AcpSessionID: toPgUUIDPtr(s.ACPSessionID), + StopReason: s.StopReason, + GateResult: gate, + LastError: s.LastError, + JobID: toPgUUIDPtr(s.JobID), + }) + if err != nil { + return nil, pgxNoRowsToNotFound(err, "orchestrator step not found") + } + return rowToStep(row), nil +} + +func (r *pgRepo) IncrementStepAttempt(ctx context.Context, id uuid.UUID) (*Step, error) { + row, err := r.q.IncrementStepAttempt(ctx, toPgUUID(id)) + if err != nil { + return nil, pgxNoRowsToNotFound(err, "orchestrator step not found") + } + return rowToStep(row), nil +} + +func (r *pgRepo) CountActiveChildSteps(ctx context.Context, parentStepID uuid.UUID) (int64, error) { + n, err := r.q.CountActiveChildSteps(ctx, toPgUUIDPtr(&parentStepID)) + if err != nil { + return 0, errs.Wrap(err, errs.CodeInternal, "count active child steps") + } + return n, nil +} + +func (r *pgRepo) ResetStuckStepsOnRestart(ctx context.Context) ([]*Step, error) { + rows, err := r.q.ResetStuckStepsOnRestart(ctx) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "reset stuck steps on restart") + } + out := make([]*Step, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToStep(row)) + } + return out, nil +} + +// ===== row → domain ===== + +func rowToRun(row orchsqlc.OrchestratorRun) *Run { + return &Run{ + ID: fromPgUUID(row.ID), + ProjectID: fromPgUUID(row.ProjectID), + RequirementID: fromPgUUID(row.RequirementID), + WorkspaceID: fromPgUUID(row.WorkspaceID), + OwnerID: fromPgUUID(row.OwnerID), + Status: RunStatus(row.Status), + CurrentPhase: project.Phase(row.CurrentPhase), + Config: unmarshalConfig(row.Config), + LastError: row.LastError, + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + EndedAt: timePtr(row.EndedAt), + } +} + +func rowToStep(row orchsqlc.OrchestratorStep) *Step { + return &Step{ + ID: fromPgUUID(row.ID), + RunID: fromPgUUID(row.RunID), + Phase: project.Phase(row.Phase), + Attempt: int(row.Attempt), + ParentStepID: fromPgUUIDPtr(row.ParentStepID), + Depth: int(row.Depth), + Status: StepStatus(row.Status), + AgentKindID: fromPgUUID(row.AgentKindID), + ACPSessionID: fromPgUUIDPtr(row.AcpSessionID), + Prompt: row.Prompt, + StopReason: row.StopReason, + GateResult: unmarshalGate(row.GateResult), + LastError: row.LastError, + JobID: fromPgUUIDPtr(row.JobID), + CreatedAt: row.CreatedAt.Time, + UpdatedAt: row.UpdatedAt.Time, + EndedAt: timePtr(row.EndedAt), + } +} + +func pgxNoRowsToNotFound(err error, msg string) error { + if errors.Is(err, pgx.ErrNoRows) { + return errs.New(errs.CodeNotFound, msg) + } + return errs.Wrap(err, errs.CodeInternal, msg) +} diff --git a/internal/orchestrator/runner.go b/internal/orchestrator/runner.go new file mode 100644 index 0000000..97b6402 --- /dev/null +++ b/internal/orchestrator/runner.go @@ -0,0 +1,107 @@ +// runner.go 是回合驱动器:给定一个 Step,要么经 acp.SessionService.Create 以特权 +// owner 身份创建新 session,要么在崩溃 session 的同一 worktree 上 ResumeSession,然后 +// 经 acp.TurnRunner.SendPromptAndWait 阻塞跑完一个回合。封装重试/恢复策略。 +package orchestrator + +import ( + "context" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/acp" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// ACPSessions 是 runner 创建 session 所需的窄接口(acp.SessionService 满足)。 +type ACPSessions interface { + Create(ctx context.Context, c acp.Caller, in acp.CreateSessionInput) (*acp.Session, error) +} + +// ACPSessionLookup 是 runner 查找 step 已有 session 所需的窄接口(acp.Repository 满足)。 +type ACPSessionLookup interface { + GetSessionByStepID(ctx context.Context, stepID uuid.UUID) (*acp.Session, error) + GetCrashedSessionForResume(ctx context.Context, stepID uuid.UUID) (*acp.Session, error) +} + +// turnResult 是一次回合驱动的结果。 +type turnResult struct { + SessionID uuid.UUID + StopReason string +} + +// runner 封装 spawn-or-resume + SendPromptAndWait。 +type runner struct { + sessions ACPSessions + turns acp.TurnRunner + lookup ACPSessionLookup + turnTimeout time.Duration +} + +// NewRunner 构造回合驱动器(由 app.go 装配 StepHandler 时调用)。turnTimeout<=0 时 +// 退回 10 分钟默认值。 +func NewRunner(sessions ACPSessions, turns acp.TurnRunner, lookup ACPSessionLookup, turnTimeout time.Duration) *runner { + if turnTimeout <= 0 { + turnTimeout = 10 * time.Minute + } + return &runner{sessions: sessions, turns: turns, lookup: lookup, turnTimeout: turnTimeout} +} + +// driveTurn 为 step 驱动一个回合: +// +// 1. 若该 step 已有崩溃/退出 session → ResumeSession(同 worktree)。 +// 2. 否则若该 step 已有活跃 session → 复用(幂等:避免重试重复 spawn)。 +// 3. 否则 → Create 一个新 session(特权 owner 身份;OrchestratorStepID 反向关联)。 +// +// requirementNumber 用于把新 session 绑定到 req/-N worktree,使同一 run 的各 +// 阶段 step 共享同一 worktree(resume 也据此回到原 worktree)。 +// +// 然后 SendPromptAndWait(受 turnTimeout 约束)。返回 sessionID + 原始 stopReason。 +func (r *runner) driveTurn(ctx context.Context, run *Run, step *Step, requirementNumber int) (turnResult, error) { + owner := acp.Caller{UserID: run.OwnerID, IsAdmin: false} + + var sessionID uuid.UUID + + // 1. 崩溃 session → resume(同一 worktree)。 + if crashed, err := r.lookup.GetCrashedSessionForResume(ctx, step.ID); err == nil && crashed != nil { + resumed, rerr := r.turns.ResumeSession(ctx, owner, crashed.ID) + if rerr != nil { + return turnResult{}, rerr + } + sessionID = resumed.ID + } else if live, lerr := r.lookup.GetSessionByStepID(ctx, step.ID); lerr == nil && live != nil && live.Status.IsActive() { + // 2. 已有活跃 session → 复用。 + sessionID = live.ID + } else { + // 3. 创建新 session(特权 owner;反向关联到 step)。 + stepID := step.ID + in := acp.CreateSessionInput{ + WorkspaceID: run.WorkspaceID, + AgentKindID: step.AgentKindID, + InitialPrompt: nil, // 由 SendPromptAndWait 单独发送,确保阻塞等待回合 + OrchestratorStepID: &stepID, + } + if requirementNumber > 0 { + // 绑定到 req 分支 worktree,使同一 run 的各阶段共享同一 worktree。 + rn := requirementNumber + in.RequirementNumber = &rn + } + created, cerr := r.sessions.Create(ctx, owner, in) + if cerr != nil { + return turnResult{}, cerr + } + sessionID = created.ID + } + + if sessionID == uuid.Nil { + return turnResult{}, errs.New(errs.CodeInternal, "orchestrator: no session id after spawn/resume") + } + + turnCtx, cancel := context.WithTimeout(ctx, r.turnTimeout) + defer cancel() + stop, err := r.turns.SendPromptAndWait(turnCtx, sessionID, step.Prompt) + if err != nil { + return turnResult{SessionID: sessionID}, err + } + return turnResult{SessionID: sessionID, StopReason: stop}, nil +} diff --git a/internal/orchestrator/scheduler.go b/internal/orchestrator/scheduler.go new file mode 100644 index 0000000..877555b --- /dev/null +++ b/internal/orchestrator/scheduler.go @@ -0,0 +1,184 @@ +// scheduler.go 实现任务分解的并行调度器(autonomy roadmap §10)。Tick 拓扑式地 +// 选取"就绪"(未被阻塞、open、叶子、无活跃 session)的子任务,并把相互独立的若干个 +// 扇出到独立 worktree 上的并行 ACP session。调度器是服务端组件,以特权 owner 身份 +// 调用 acp.SessionService.Create,因而不受 checkNotSystemToken 限制。 +// +// 调度器无状态、幂等:ListReadyLeafSubtasks 已排除有活跃 session 的 issue,故重复 tick +// 不会重复派单。并发硬上限由 acp 层 MaxActiveGlobal/MaxActivePerUser 兜底(命中时 +// CodeAcpSessionQuotaExceeded 当作软失败,下一 tick 重试);软上限由 MaxConcurrentPerProject +// 在调度器内预检。把 Scheduler.Tick 注册为 jobs RunnerFn(单实例、单 tick),即可跨重启存活。 +package orchestrator + +import ( + "context" + "errors" + "log/slog" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// ReadyTaskSource 是调度器读取就绪子任务的窄接口(app 层用 project.Repository 适配)。 +type ReadyTaskSource interface { + // ListSchedulableProjects 返回当前至少有一个就绪子任务的 project id(预筛)。 + ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error) + // ListReadyLeafSubtasks 返回 project 下就绪叶子子任务(priority DESC, number ASC),最多 limit 条。 + ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*project.Issue, error) + // CountActiveSessionsForProject 返回 project 内活跃 acp_session 数(软并发预检用)。 + CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error) +} + +// SessionStarter 为某 issue 启动一个 ACP session(包装 acp.SessionService.Create)。 +// 实现以特权身份(项目 owner / 系统账户)调用,使 worktree Acquire 鉴权通过。 +type SessionStarter interface { + // StartForIssue 为 issue 创建一个绑定 issue/-N worktree 的 session,返回 session id。 + StartForIssue(ctx context.Context, iss *project.Issue) (uuid.UUID, error) +} + +// SchedulerNotifier 是调度器投递 ready/dispatch/quota 通知的窄接口(app 用 notify.Dispatcher 适配)。 +type SchedulerNotifier interface { + NotifyDispatched(ctx context.Context, iss *project.Issue, sessionID uuid.UUID) + NotifyQuotaBlocked(ctx context.Context, iss *project.Issue) +} + +// SchedulerDeps 是调度器依赖集合。 +type SchedulerDeps struct { + Projects ReadyTaskSource + Sessions SessionStarter + Notify SchedulerNotifier + Log *slog.Logger + // MaxFanoutPerTick 是每个 project 每 tick 最多派发的 session 数(<=0 时默认 8)。 + MaxFanoutPerTick int + // MaxConcurrentPerProject 是 project 内活跃 session 软上限(<=0 时不预检,仅靠 acp 层硬上限)。 + MaxConcurrentPerProject int +} + +// Scheduler 是任务分解并行调度器。单一 Tick 方法便于解耦其宿主(jobs RunnerFn 或 +// 未来的 pipeline host)。 +type Scheduler interface { + // Tick 执行一轮调度,返回本轮派发的 session 数。单个 issue 派发失败不致整轮失败。 + Tick(ctx context.Context) (dispatched int, err error) + // Run 是 Tick 的 jobs.RunnerFunc 适配(func(ctx)):记录日志、吞掉错误。 + Run(ctx context.Context) +} + +type scheduler struct { + projects ReadyTaskSource + sessions SessionStarter + notify SchedulerNotifier + log *slog.Logger + maxFanoutPerTick int + maxConcurrentPerProject int +} + +// NewScheduler 构造调度器。 +func NewScheduler(d SchedulerDeps) Scheduler { + log := d.Log + if log == nil { + log = slog.Default() + } + fanout := d.MaxFanoutPerTick + if fanout <= 0 { + fanout = 8 + } + return &scheduler{ + projects: d.Projects, + sessions: d.Sessions, + notify: d.Notify, + log: log, + maxFanoutPerTick: fanout, + maxConcurrentPerProject: d.MaxConcurrentPerProject, + } +} + +var _ Scheduler = (*scheduler)(nil) + +// Run 把 Tick 适配为 jobs.RunnerFunc 签名(func(ctx)):记录日志、吞掉错误。 +func (s *scheduler) Run(ctx context.Context) { + n, err := s.Tick(ctx) + if err != nil { + s.log.Warn("orchestrator.scheduler.tick_failed", "err", err.Error()) + return + } + if n > 0 { + s.log.Info("orchestrator.scheduler.tick", "dispatched", n) + } +} + +func (s *scheduler) Tick(ctx context.Context) (int, error) { + projects, err := s.projects.ListSchedulableProjects(ctx) + if err != nil { + return 0, err + } + total := 0 + for _, projID := range projects { + n, perr := s.tickProject(ctx, projID) + total += n + if perr != nil { + // 单个 project 失败不致整轮失败:记录并继续下一个 project。 + s.log.Warn("orchestrator.scheduler.project_failed", "project_id", projID.String(), "err", perr.Error()) + continue + } + } + return total, nil +} + +// tickProject 为单个 project 派发就绪子任务。受 MaxFanoutPerTick 与(可选) +// MaxConcurrentPerProject 软上限约束。命中 acp 配额(CodeAcpSessionQuotaExceeded)时 +// 停止本 project(软失败,下一 tick 重试),不返回 error。 +func (s *scheduler) tickProject(ctx context.Context, projectID uuid.UUID) (int, error) { + limit := s.maxFanoutPerTick + // 软并发预检:若已达 project 内活跃上限,本 tick 不派发。 + if s.maxConcurrentPerProject > 0 { + active, err := s.projects.CountActiveSessionsForProject(ctx, projectID) + if err != nil { + return 0, err + } + budget := s.maxConcurrentPerProject - active + if budget <= 0 { + return 0, nil + } + if budget < limit { + limit = budget + } + } + + ready, err := s.projects.ListReadyLeafSubtasks(ctx, projectID, limit) + if err != nil { + return 0, err + } + + dispatched := 0 + for _, iss := range ready { + sessionID, serr := s.sessions.StartForIssue(ctx, iss) + if serr != nil { + if isQuotaExceeded(serr) { + // 软配额:停止本 project 的派发,下一 tick 重试。通知关注者。 + if s.notify != nil { + s.notify.NotifyQuotaBlocked(ctx, iss) + } + return dispatched, nil + } + // 其它失败:记录并跳过该 issue,继续其余就绪 issue。 + s.log.Warn("orchestrator.scheduler.start_failed", + "issue_id", iss.ID.String(), "number", iss.Number, "err", serr.Error()) + continue + } + dispatched++ + if s.notify != nil { + s.notify.NotifyDispatched(ctx, iss, sessionID) + } + } + return dispatched, nil +} + +// isQuotaExceeded 报告 err 是否为 acp 会话配额超限(软失败语义)。 +func isQuotaExceeded(err error) bool { + var ae *errs.AppError + if errors.As(err, &ae) { + return ae.Code == errs.CodeAcpSessionQuotaExceeded + } + return false +} diff --git a/internal/orchestrator/scheduler_test.go b/internal/orchestrator/scheduler_test.go new file mode 100644 index 0000000..b68c59b --- /dev/null +++ b/internal/orchestrator/scheduler_test.go @@ -0,0 +1,209 @@ +package orchestrator + +import ( + "context" + "sync" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// ===== fakeReadySource: orchestrator.ReadyTaskSource ===== + +type fakeReadySource struct { + mu sync.Mutex + projects []uuid.UUID + ready map[uuid.UUID][]*project.Issue // projectID → ready issues + active map[uuid.UUID]int // projectID → active session count + listCalls int +} + +func (f *fakeReadySource) ListSchedulableProjects(_ context.Context) ([]uuid.UUID, error) { + return f.projects, nil +} + +func (f *fakeReadySource) ListReadyLeafSubtasks(_ context.Context, projectID uuid.UUID, limit int) ([]*project.Issue, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.listCalls++ + all := f.ready[projectID] + // 模拟 DB:排除已被启动(active)的 issue 由调用方维护;此处仅按 limit 截断。 + if limit > 0 && len(all) > limit { + return all[:limit], nil + } + return all, nil +} + +func (f *fakeReadySource) CountActiveSessionsForProject(_ context.Context, projectID uuid.UUID) (int, error) { + f.mu.Lock() + defer f.mu.Unlock() + return f.active[projectID], nil +} + +// ===== fakeSessionStarter: orchestrator.SessionStarter ===== + +type fakeSessionStarter struct { + mu sync.Mutex + started []uuid.UUID // issue ids + quotaFrom int // 第 quotaFrom 次起返回配额错误(0 表示不触发) + calls int + failAll bool +} + +func (f *fakeSessionStarter) StartForIssue(_ context.Context, iss *project.Issue) (uuid.UUID, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.calls++ + if f.failAll { + return uuid.Nil, errs.New(errs.CodeInternal, "boom") + } + if f.quotaFrom > 0 && f.calls >= f.quotaFrom { + return uuid.Nil, errs.New(errs.CodeAcpSessionQuotaExceeded, "quota") + } + f.started = append(f.started, iss.ID) + return uuid.New(), nil +} + +// ===== fakeSchedNotify: orchestrator.SchedulerNotifier ===== + +type fakeSchedNotify struct { + dispatched int + quota int +} + +func (f *fakeSchedNotify) NotifyDispatched(_ context.Context, _ *project.Issue, _ uuid.UUID) { + f.dispatched++ +} +func (f *fakeSchedNotify) NotifyQuotaBlocked(_ context.Context, _ *project.Issue) { f.quota++ } + +func mkReadyIssues(projectID uuid.UUID, n int) []*project.Issue { + out := make([]*project.Issue, 0, n) + for i := 0; i < n; i++ { + out = append(out, &project.Issue{ID: uuid.New(), ProjectID: projectID, Number: i + 1}) + } + return out +} + +func TestScheduler_DispatchesUpToFanout(t *testing.T) { + projID := uuid.New() + src := &fakeReadySource{ + projects: []uuid.UUID{projID}, + ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 5)}, + active: map[uuid.UUID]int{}, + } + starter := &fakeSessionStarter{} + notify := &fakeSchedNotify{} + sched := NewScheduler(SchedulerDeps{ + Projects: src, Sessions: starter, Notify: notify, + MaxFanoutPerTick: 3, + }) + + n, err := sched.Tick(context.Background()) + require.NoError(t, err) + require.Equal(t, 3, n, "应只派发 min(ready=5, fanout=3)") + require.Len(t, starter.started, 3) + require.Equal(t, 3, notify.dispatched) +} + +func TestScheduler_QuotaIsSoftFailure(t *testing.T) { + projID := uuid.New() + src := &fakeReadySource{ + projects: []uuid.UUID{projID}, + ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 5)}, + active: map[uuid.UUID]int{}, + } + // 第 3 次起返回配额错误:前 2 个成功,第 3 个软失败 → 停止本 project。 + starter := &fakeSessionStarter{quotaFrom: 3} + notify := &fakeSchedNotify{} + sched := NewScheduler(SchedulerDeps{ + Projects: src, Sessions: starter, Notify: notify, + MaxFanoutPerTick: 5, + }) + + n, err := sched.Tick(context.Background()) + require.NoError(t, err, "配额超限是软失败,不应返回 error") + require.Equal(t, 2, n) + require.Len(t, starter.started, 2) + require.Equal(t, 1, notify.quota) +} + +func TestScheduler_OtherErrorSkipsIssueNotProject(t *testing.T) { + projID := uuid.New() + src := &fakeReadySource{ + projects: []uuid.UUID{projID}, + ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 3)}, + active: map[uuid.UUID]int{}, + } + starter := &fakeSessionStarter{failAll: true} + sched := NewScheduler(SchedulerDeps{ + Projects: src, Sessions: starter, MaxFanoutPerTick: 5, + }) + n, err := sched.Tick(context.Background()) + require.NoError(t, err) + require.Equal(t, 0, n, "全部 start 失败但非配额错误:跳过 issue,整轮不报错") +} + +func TestScheduler_MaxConcurrentPerProjectPreCheck(t *testing.T) { + projID := uuid.New() + src := &fakeReadySource{ + projects: []uuid.UUID{projID}, + ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 10)}, + active: map[uuid.UUID]int{projID: 3}, // 已有 3 个活跃 + } + starter := &fakeSessionStarter{} + sched := NewScheduler(SchedulerDeps{ + Projects: src, Sessions: starter, + MaxFanoutPerTick: 10, + MaxConcurrentPerProject: 4, // budget = 4-3 = 1 + }) + n, err := sched.Tick(context.Background()) + require.NoError(t, err) + require.Equal(t, 1, n, "软并发预检:budget=1,本 tick 只派 1 个") +} + +func TestScheduler_AtConcurrencyCapDispatchesNothing(t *testing.T) { + projID := uuid.New() + src := &fakeReadySource{ + projects: []uuid.UUID{projID}, + ready: map[uuid.UUID][]*project.Issue{projID: mkReadyIssues(projID, 5)}, + active: map[uuid.UUID]int{projID: 4}, + } + starter := &fakeSessionStarter{} + sched := NewScheduler(SchedulerDeps{ + Projects: src, Sessions: starter, + MaxFanoutPerTick: 10, MaxConcurrentPerProject: 4, + }) + n, err := sched.Tick(context.Background()) + require.NoError(t, err) + require.Equal(t, 0, n) + require.Empty(t, starter.started) +} + +func TestScheduler_IdempotentAcrossTicks(t *testing.T) { + projID := uuid.New() + issues := mkReadyIssues(projID, 2) + src := &fakeReadySource{ + projects: []uuid.UUID{projID}, + ready: map[uuid.UUID][]*project.Issue{projID: issues}, + active: map[uuid.UUID]int{}, + } + starter := &fakeSessionStarter{} + sched := NewScheduler(SchedulerDeps{Projects: src, Sessions: starter, MaxFanoutPerTick: 5}) + + n1, err := sched.Tick(context.Background()) + require.NoError(t, err) + require.Equal(t, 2, n1) + + // 第二 tick:模拟 DB 已排除被启动的 issue(清空 ready),不应重复派发。 + src.mu.Lock() + src.ready[projID] = nil + src.mu.Unlock() + n2, err := sched.Tick(context.Background()) + require.NoError(t, err) + require.Equal(t, 0, n2) + require.Len(t, starter.started, 2, "跨 tick 不重复派发") +} diff --git a/internal/orchestrator/service.go b/internal/orchestrator/service.go new file mode 100644 index 0000000..0469471 --- /dev/null +++ b/internal/orchestrator/service.go @@ -0,0 +1,500 @@ +// service.go 实现 orchestrator.Service:StartRun / GetRun / ListRuns / Pause / +// Resume / Cancel / RequestSubtask / EnqueueStepRedrive。step 的实际驱动在 +// step_handler.go(jobs worker 内执行)。 +package orchestrator + +import ( + "context" + "encoding/json" + "log/slog" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/jobs" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// JobEnqueuer 是 service 入队 orchestrator.step job 所需的窄接口(jobs.Repository 满足)。 +type JobEnqueuer interface { + Enqueue(ctx context.Context, typ jobs.JobType, payload json.RawMessage, scheduledAt time.Time, maxAttempts int32) (*jobs.Job, error) +} + +// ProjectACL 是 StartRun 校验 owner 对项目写权限的窄接口(app 层用 project 的 +// owner+admin ACL 适配)。 +type ProjectACL interface { + CanRead(ctx context.Context, userID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) + CanWrite(ctx context.Context, userID uuid.UUID, isAdmin bool, projectID uuid.UUID) (bool, error) +} + +// ServiceDeps 是 service 的依赖集合。 +type ServiceDeps struct { + Repo Repository + PM PMAccess + ACL ProjectACL + Jobs JobEnqueuer + Sessions sessionTerminator + Defaults map[project.Phase]uuid.UUID + Config Config + Audit AuditRecorder + Log *slog.Logger +} + +// sessionTerminator 终止某 acp session(Pause/Cancel 用)。acp.SessionService 经 adapter 适配。 +type sessionTerminator interface { + TerminateSession(ctx context.Context, ownerID uuid.UUID, isAdmin bool, sessionID uuid.UUID) error +} + +type service struct { + repo Repository + pm PMAccess + acl ProjectACL + jobs JobEnqueuer + sessions sessionTerminator + defaults map[project.Phase]uuid.UUID + cfg Config + audit AuditRecorder + log *slog.Logger +} + +// NewService 构造编排器 Service。 +func NewService(d ServiceDeps) Service { + log := d.Log + if log == nil { + log = slog.Default() + } + return &service{ + repo: d.Repo, pm: d.PM, acl: d.ACL, jobs: d.Jobs, sessions: d.Sessions, + defaults: d.Defaults, cfg: d.Config, audit: d.Audit, log: log, + } +} + +var _ Service = (*service)(nil) + +// EnqueueStep 实现 stepEnqueuer:入队一个 orchestrator.step job。 +func (s *service) EnqueueStep(ctx context.Context, runID, stepID uuid.UUID, scheduledAt time.Time) error { + payload, err := json.Marshal(stepPayload{RunID: runID, StepID: stepID}) + if err != nil { + return errs.Wrap(err, errs.CodeInternal, "marshal step payload") + } + // maxAttempts: jobs 层的尝试计数仅用于 reaper/worker 调度;编排器自身以 step.attempt + // + MaxAttemptsPerPhase 控制重试上限。给一个较大的 jobs maxAttempts,让 worker 在 + // 编排器返回非 Permanent error 时继续重试,直到编排器返回 Permanent。 + maxAttempts := int32(s.effectiveMaxAttempts() + 2) + if _, err := s.jobs.Enqueue(ctx, JobTypeOrchestratorStep, payload, scheduledAt, maxAttempts); err != nil { + return err + } + return nil +} + +func (s *service) effectiveMaxAttempts() int { + if s.cfg.MaxAttemptsPerPhase > 0 { + return s.cfg.MaxAttemptsPerPhase + } + return 3 +} + +// StartRun 启动一次编排运行:解析项目/需求 + ACL 校验 + 在一个事务内插入 run + 第一个 +// step,事务提交后入队第一个 orchestrator.step job。每需求只允许一个活跃 run(DB 唯一 +// 部分索引兜底,命中翻译为 409)。 +func (s *service) StartRun(ctx context.Context, c Caller, in StartRunInput) (*Run, error) { + proj, err := s.pm.GetProjectBySlug(ctx, in.ProjectSlug) + if err != nil { + return nil, err + } + // 写权限校验:owner 须对项目可写(不依赖 admin 放行)。 + if s.acl != nil { + ok, aerr := s.acl.CanWrite(ctx, c.UserID, c.IsAdmin, proj.ID) + if aerr != nil { + return nil, aerr + } + if !ok { + return nil, errs.New(errs.CodeForbidden, "no write access to project") + } + } + + req, err := s.pm.GetRequirementByNumber(ctx, proj.ID, in.RequirementNumber) + if err != nil { + return nil, err + } + if req.WorkspaceID == nil { + return nil, errs.New(errs.CodeInvalidInput, + "requirement has no workspace; link a workspace before starting a run") + } + if req.Status == project.StatusClosed { + return nil, errs.New(errs.CodeRequirementClosed, "requirement is closed") + } + + startPhase := in.StartPhase + if startPhase == "" { + startPhase = req.Phase + } + if !startPhase.IsValid() { + return nil, errs.New(errs.CodeInvalidInput, "invalid start phase") + } + + // 规范化 config 默认值。 + cfg := in.Config + if cfg.MaxAttemptsPerPhase <= 0 { + cfg.MaxAttemptsPerPhase = s.effectiveMaxAttempts() + } + if cfg.MaxDepth <= 0 { + cfg.MaxDepth = s.cfg.MaxDepth + } + if cfg.FanOutLimit <= 0 { + cfg.FanOutLimit = s.cfg.FanOutLimit + } + + run := &Run{ + ID: uuid.New(), + ProjectID: proj.ID, + RequirementID: req.ID, + WorkspaceID: *req.WorkspaceID, + OwnerID: proj.OwnerID, + Status: RunPending, + CurrentPhase: startPhase, + Config: cfg, + } + + var ( + outRun *Run + outStep *Step + ) + txErr := s.repo.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { + txRepo := s.repo.WithTx(tx) + inserted, ierr := txRepo.InsertRun(txCtx, run) + if ierr != nil { + return ierr + } + outRun = inserted + step, serr := buildPhaseStep(txCtx, txRepo, s.pm, inserted, startPhase, nil, 0, s.defaults) + if serr != nil { + return serr + } + outStep = step + return nil + }) + if txErr != nil { + if IsUniqueViolation(txErr) { + return nil, errs.New(errs.CodeConflict, "an active orchestrator run already exists for this requirement") + } + return nil, txErr + } + + // 事务已提交:入队第一个 step job。 + if eerr := s.EnqueueStep(ctx, outRun.ID, outStep.ID, time.Now().UTC()); eerr != nil { + // 入队失败:标 run 失败(step 已落库但永不会被驱动)。 + msg := eerr.Error() + _, _ = s.repo.UpdateRunStatus(ctx, outRun.ID, RunFailed, &msg) + return nil, eerr + } + + s.recordAudit(ctx, c.UserID, "orchestrator.run.start", outRun.ID.String(), map[string]any{ + "project_slug": in.ProjectSlug, + "requirement": in.RequirementNumber, + "start_phase": string(startPhase), + "first_step_id": outStep.ID.String(), + "execution_owner_id": outRun.OwnerID.String(), + }) + return outRun, nil +} + +func (s *service) GetRun(ctx context.Context, c Caller, id uuid.UUID) (*Run, error) { + run, err := s.repo.GetRun(ctx, id) + if err != nil { + return nil, err + } + if !s.canReadRun(ctx, c, run) { + return nil, errs.New(errs.CodeNotFound, "orchestrator run not found") + } + return run, nil +} + +func (s *service) ListRuns(ctx context.Context, c Caller, f RunFilter) ([]*Run, error) { + runs, err := s.repo.ListRuns(ctx, f) + if err != nil { + return nil, err + } + if c.IsAdmin { + return runs, nil + } + out := make([]*Run, 0, len(runs)) + for _, r := range runs { + if s.canReadRun(ctx, c, r) { + out = append(out, r) + } + } + return out, nil +} + +// Pause 把活跃 run 置 paused 并终止其当前活跃 session(停止驱动;已入队 step job 在 +// Handle 时见 run 非活跃即 no-op)。 +func (s *service) Pause(ctx context.Context, c Caller, id uuid.UUID) error { + run, err := s.GetRun(ctx, c, id) + if err != nil { + return err + } + if !run.Status.IsActive() { + return errs.New(errs.CodeConflict, "run is not active") + } + if _, uerr := s.repo.UpdateRunStatus(ctx, run.ID, RunPaused, nil); uerr != nil { + return uerr + } + s.terminateActiveSessions(ctx, run) + s.recordAudit(ctx, c.UserID, "orchestrator.run.pause", run.ID.String(), nil) + return nil +} + +// Resume 把 paused run 置回 running 并重新入队当前阶段的一个 step(让驱动继续)。 +func (s *service) Resume(ctx context.Context, c Caller, id uuid.UUID) error { + run, err := s.GetRun(ctx, c, id) + if err != nil { + return err + } + if run.Status != RunPaused { + return errs.New(errs.CodeConflict, "run is not paused") + } + if _, uerr := s.repo.UpdateRunStatus(ctx, run.ID, RunRunning, nil); uerr != nil { + return uerr + } + // 找当前阶段最近一个非终态 step 重新入队;若没有则新建一个。 + step, serr := s.latestResumableStep(ctx, run) + if serr != nil { + return serr + } + if step == nil { + built, berr := buildPhaseStep(ctx, s.repo, s.pm, run, run.CurrentPhase, nil, 0, s.defaults) + if berr != nil { + return berr + } + step = built + } + if eerr := s.EnqueueStep(ctx, run.ID, step.ID, time.Now().UTC()); eerr != nil { + return eerr + } + s.recordAudit(ctx, c.UserID, "orchestrator.run.resume", run.ID.String(), nil) + return nil +} + +// Cancel 把 run 置 canceled 并终止其活跃 session。 +func (s *service) Cancel(ctx context.Context, c Caller, id uuid.UUID) error { + run, err := s.GetRun(ctx, c, id) + if err != nil { + return err + } + if run.Status.IsTerminal() { + return errs.New(errs.CodeConflict, "run already terminal") + } + if _, uerr := s.repo.UpdateRunStatus(ctx, run.ID, RunCanceled, nil); uerr != nil { + return uerr + } + s.terminateActiveSessions(ctx, run) + s.recordAudit(ctx, c.UserID, "orchestrator.run.cancel", run.ID.String(), nil) + return nil +} + +// RequestSubtask 让运行中的 agent 为其当前 step 派生子 step。在一个事务内校验 +// depth 0 && childDepth > maxDepth { + return nil, errs.New(errs.CodeForbidden, "subtask depth limit reached") + } + + akID := uuid.Nil + if in.AgentKindID != nil { + akID = *in.AgentKindID + } + + var outStep *Step + txErr := s.repo.InTx(ctx, func(txCtx context.Context, tx pgx.Tx) error { + txRepo := s.repo.WithTx(tx) + // 在同一事务内计活跃子 step,避免并发突破 fan-out。 + n, cerr := txRepo.CountActiveChildSteps(txCtx, parent.ID) + if cerr != nil { + return cerr + } + if fanOut > 0 && int(n) >= fanOut { + return errs.New(errs.CodeForbidden, "subtask fan-out limit reached") + } + + // 解析 agent-kind:显式给定优先,否则走 planner(config/defaults)。 + resolvedAK := akID + if resolvedAK == uuid.Nil { + id, perr := PhaseAgentKind(run.Config, phase, s.defaults) + if perr != nil { + return perr + } + resolvedAK = id + } + + req, rerr := s.pm.GetRequirementByID(txCtx, run.RequirementID) + if rerr != nil { + return rerr + } + artifacts, _ := s.pm.ListLatestArtifacts(txCtx, run.RequirementID) + prompt := in.Prompt + if prompt == "" { + prompt = BuildPrompt(phase, req, artifacts, run.Config.PromptOverrides[phase]) + } + parentID := parent.ID + step := &Step{ + ID: uuid.New(), + RunID: run.ID, + Phase: phase, + Attempt: 1, + ParentStepID: &parentID, + Depth: childDepth, + Status: StepPending, + AgentKindID: resolvedAK, + Prompt: prompt, + } + inserted, ierr := txRepo.InsertStep(txCtx, step) + if ierr != nil { + return ierr + } + outStep = inserted + return nil + }) + if txErr != nil { + return nil, txErr + } + + if eerr := s.EnqueueStep(ctx, run.ID, outStep.ID, time.Now().UTC()); eerr != nil { + return nil, eerr + } + s.recordAudit(ctx, run.OwnerID, "orchestrator.subtask.request", outStep.ID.String(), map[string]any{ + "parent_step_id": parent.ID.String(), + "phase": string(phase), + "depth": childDepth, + }) + return outStep, nil +} + +// EnqueueStepRedrive 由 acp supervisor onExit 的注入回调间接调用:把崩溃 session 的 +// step 重新入队(短延迟,给 worktree 释放留窗口)。 +func (s *service) EnqueueStepRedrive(ctx context.Context, stepID uuid.UUID, reason string) { + step, err := s.repo.GetStep(ctx, stepID) + if err != nil { + s.log.Warn("orchestrator.redrive.step_missing", "step_id", stepID, "err", err.Error()) + return + } + switch step.Status { + case StepSucceeded, StepFailed, StepDead: + return // 已终态:无需再驱动 + case StepSpawning, StepRunning, StepAwaitingGate: + // 仍有活跃 job 在驱动本 step:其 driveTurn 会因 session 崩溃触发 relayCtx 取消而 + // 返回错误 → 走 worker 重试(driveTurn 是 spawn-or-resume,会在同一 worktree 续跑); + // worker 进程整体崩溃的情形由 job_reaper 重新租约兜底。此处若再入队会让同一 step + // 产生两条 job、双重驱动(两个 agent 会话)。故跳过,恢复只走活跃 job 这一条路径。 + s.log.Info("orchestrator.redrive.skip_active", + "step_id", stepID, "status", step.Status, "reason", reason) + return + } + run, err := s.repo.GetRun(ctx, step.RunID) + if err != nil || !run.Status.IsActive() { + return // run 不活跃:不再驱动 + } + // 短延迟入队,让 onExit 的 worktree 释放先落定。 + if eerr := s.EnqueueStep(ctx, run.ID, step.ID, time.Now().UTC().Add(2*time.Second)); eerr != nil { + s.log.Warn("orchestrator.redrive.enqueue_failed", "step_id", stepID, "err", eerr.Error()) + return + } + s.recordAudit(ctx, run.OwnerID, "orchestrator.step.redrive", step.ID.String(), map[string]any{ + "reason": reason, + }) +} + +// ===== helpers ===== + +// latestResumableStep 返回当前阶段最近一个非终态 step(用于 Resume 重新入队)。 +func (s *service) latestResumableStep(ctx context.Context, run *Run) (*Step, error) { + steps, err := s.repo.ListStepsByRun(ctx, run.ID) + if err != nil { + return nil, err + } + var found *Step + for _, st := range steps { + if st.Phase != run.CurrentPhase { + continue + } + switch st.Status { + case StepSucceeded, StepFailed, StepDead: + continue + } + found = st // ListStepsByRun 按 created_at 升序:取最后一个匹配 + } + return found, nil +} + +// terminateActiveSessions 终止 run 下当前活跃 step 的 session(best-effort)。 +func (s *service) terminateActiveSessions(ctx context.Context, run *Run) { + if s.sessions == nil { + return + } + steps, err := s.repo.ListStepsByRun(ctx, run.ID) + if err != nil { + return + } + for _, st := range steps { + if st.ACPSessionID == nil { + continue + } + switch st.Status { + case StepSpawning, StepRunning, StepAwaitingGate: + if terr := s.sessions.TerminateSession(ctx, run.OwnerID, false, *st.ACPSessionID); terr != nil { + s.log.Warn("orchestrator.terminate_session_failed", + "session_id", *st.ACPSessionID, "err", terr.Error()) + } + } + } +} + +func (s *service) recordAudit(ctx context.Context, userID uuid.UUID, action, targetID string, meta map[string]any) { + if s.audit == nil { + return + } + s.audit.Record(context.WithoutCancel(ctx), userID, action, targetID, meta) +} + +func (s *service) canReadRun(ctx context.Context, c Caller, run *Run) bool { + if c.IsAdmin || run.OwnerID == c.UserID { + return true + } + if s.acl == nil { + return false + } + ok, err := s.acl.CanRead(ctx, c.UserID, c.IsAdmin, run.ProjectID) + return err == nil && ok +} diff --git a/internal/orchestrator/service_test.go b/internal/orchestrator/service_test.go new file mode 100644 index 0000000..02c16a3 --- /dev/null +++ b/internal/orchestrator/service_test.go @@ -0,0 +1,263 @@ +package orchestrator + +import ( + "context" + "errors" + "testing" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// codeOf 从 error 链中提取 errs.Code(测试断言用)。 +func codeOf(err error) errs.Code { + var ae *errs.AppError + if errors.As(err, &ae) { + return ae.Code + } + return "" +} + +func newServiceHarness(maxDepth, fanOut int) (*service, *fakeRepo, *fakePM, *fakeJobs, uuid.UUID) { + repo := newFakeRepo() + wsID := uuid.New() + akID := uuid.New() + ownerID := uuid.New() + pm := &fakePM{ + proj: &project.Project{ID: uuid.New(), Slug: "demo", OwnerID: ownerID}, + req: &project.Requirement{ID: uuid.New(), Number: 5, Title: "T", WorkspaceID: &wsID, Phase: project.PhasePlanning, Status: project.StatusOpen, OwnerID: ownerID}, + } + jq := &fakeJobs{} + svc := NewService(ServiceDeps{ + Repo: repo, PM: pm, ACL: fakeACL{allow: true}, Jobs: jq, + Sessions: &fakeSessTerm{}, + Defaults: allPhaseKinds(akID), + Config: Config{MaxAttemptsPerPhase: 3, MaxDepth: maxDepth, FanOutLimit: fanOut}, + Audit: &fakeAudit{}, + }).(*service) + return svc, repo, pm, jq, akID +} + +func TestStartRun_CreatesRunStepAndEnqueues(t *testing.T) { + svc, repo, _, jq, _ := newServiceHarness(3, 3) + run, err := svc.StartRun(context.Background(), Caller{UserID: uuid.New()}, StartRunInput{ + ProjectSlug: "demo", RequirementNumber: 5, + }) + if err != nil { + t.Fatalf("StartRun: %v", err) + } + if run.Status != RunPending { + t.Errorf("run status = %s; want pending", run.Status) + } + steps, _ := repo.ListStepsByRun(context.Background(), run.ID) + if len(steps) != 1 { + t.Fatalf("expected 1 step; got %d", len(steps)) + } + if steps[0].Phase != project.PhasePlanning { + t.Errorf("first step phase = %s; want planning", steps[0].Phase) + } + if jq.count() != 1 { + t.Errorf("expected 1 job enqueued; got %d", jq.count()) + } +} + +func TestStartRun_UsesProjectOwnerAsExecutionOwner(t *testing.T) { + svc, _, pm, _, _ := newServiceHarness(3, 3) + starter := uuid.New() + + run, err := svc.StartRun(context.Background(), Caller{UserID: starter, IsAdmin: true}, StartRunInput{ + ProjectSlug: "demo", RequirementNumber: 5, + }) + if err != nil { + t.Fatalf("StartRun: %v", err) + } + if run.OwnerID != pm.proj.OwnerID { + t.Errorf("run owner = %s; want project owner %s", run.OwnerID, pm.proj.OwnerID) + } + if run.OwnerID == starter { + t.Error("run owner must be the autonomous execution identity, not the starter") + } +} + +func TestStartRun_ForbiddenWithoutWriteAccess(t *testing.T) { + repo := newFakeRepo() + wsID := uuid.New() + pm := &fakePM{ + proj: &project.Project{ID: uuid.New(), Slug: "demo"}, + req: &project.Requirement{ID: uuid.New(), Number: 5, WorkspaceID: &wsID, Status: project.StatusOpen}, + } + svc := NewService(ServiceDeps{ + Repo: repo, PM: pm, ACL: fakeACL{allow: false}, Jobs: &fakeJobs{}, + Defaults: allPhaseKinds(uuid.New()), Config: Config{MaxAttemptsPerPhase: 3}, Audit: &fakeAudit{}, + }) + _, err := svc.StartRun(context.Background(), Caller{UserID: uuid.New()}, StartRunInput{ProjectSlug: "demo", RequirementNumber: 5}) + if err == nil || codeOf(err) != errs.CodeForbidden { + t.Fatalf("expected forbidden; got %v", err) + } +} + +func TestGetRun_AllowsProjectReader(t *testing.T) { + svc, repo, pm, _, _ := newServiceHarness(3, 3) + reader := uuid.New() + run := &Run{ + ID: uuid.New(), ProjectID: pm.proj.ID, RequirementID: pm.req.ID, WorkspaceID: *pm.req.WorkspaceID, + OwnerID: pm.req.OwnerID, Status: RunRunning, CurrentPhase: project.PhasePlanning, + } + repo.runs[run.ID] = run + svc.acl = fakeACL{read: true} + + got, err := svc.GetRun(context.Background(), Caller{UserID: reader}, run.ID) + if err != nil { + t.Fatalf("GetRun: %v", err) + } + if got.ID != run.ID { + t.Errorf("run id = %s; want %s", got.ID, run.ID) + } +} + +func TestStartRun_RequiresWorkspace(t *testing.T) { + repo := newFakeRepo() + pm := &fakePM{ + proj: &project.Project{ID: uuid.New(), Slug: "demo"}, + req: &project.Requirement{ID: uuid.New(), Number: 5, WorkspaceID: nil, Status: project.StatusOpen}, + } + svc := NewService(ServiceDeps{ + Repo: repo, PM: pm, ACL: fakeACL{allow: true}, Jobs: &fakeJobs{}, + Defaults: allPhaseKinds(uuid.New()), Config: Config{MaxAttemptsPerPhase: 3}, Audit: &fakeAudit{}, + }) + _, err := svc.StartRun(context.Background(), Caller{UserID: uuid.New()}, StartRunInput{ProjectSlug: "demo", RequirementNumber: 5}) + if err == nil { + t.Fatal("expected error for requirement without workspace") + } +} + +// ===== RequestSubtask ===== + +func seedParentStep(svc *service, repo *fakeRepo, akID uuid.UUID, depth int) (*Run, *Step, uuid.UUID) { + wsID := uuid.New() + run := &Run{ + ID: uuid.New(), ProjectID: uuid.New(), RequirementID: uuid.New(), WorkspaceID: wsID, + OwnerID: uuid.New(), Status: RunRunning, CurrentPhase: project.PhaseImplementing, + Config: RunConfig{PhaseAgentKinds: allPhaseKinds(akID)}, + } + repo.runs[run.ID] = run + sessID := uuid.New() + parent := &Step{ + ID: uuid.New(), RunID: run.ID, Phase: project.PhaseImplementing, Attempt: 1, Depth: depth, + Status: StepRunning, AgentKindID: akID, ACPSessionID: &sessID, Prompt: "p", + } + repo.steps[parent.ID] = parent + return run, parent, sessID +} + +func TestRequestSubtask_HappyInsertsChildAndEnqueues(t *testing.T) { + svc, repo, _, jq, akID := newServiceHarness(3, 3) + _, _, parentSession := seedParentStep(svc, repo, akID, 0) + + child, err := svc.RequestSubtask(context.Background(), parentSession, SubtaskInput{ + Phase: project.PhaseImplementing, Prompt: "do subtask", + }) + if err != nil { + t.Fatalf("RequestSubtask: %v", err) + } + if child.Depth != 1 || child.ParentStepID == nil { + t.Errorf("child depth/parent wrong: depth=%d parent=%v", child.Depth, child.ParentStepID) + } + if jq.count() != 1 { + t.Errorf("expected child step enqueued; got %d", jq.count()) + } +} + +func TestRequestSubtask_DepthLimit(t *testing.T) { + svc, repo, _, _, akID := newServiceHarness(2, 5) + // parent depth 2 == MaxDepth; child would be depth 3 → rejected. + _, _, parentSession := seedParentStep(svc, repo, akID, 2) + + _, err := svc.RequestSubtask(context.Background(), parentSession, SubtaskInput{Prompt: "x"}) + if err == nil || codeOf(err) != errs.CodeForbidden { + t.Fatalf("expected forbidden depth; got %v", err) + } +} + +func TestRequestSubtask_FanOutLimit(t *testing.T) { + svc, repo, _, _, akID := newServiceHarness(5, 1) + run, parent, parentSession := seedParentStep(svc, repo, akID, 0) + // 已有一个活跃子 step,FanOutLimit=1 → 再请求被拒。 + pid := parent.ID + repo.steps[uuid.New()] = &Step{ + ID: uuid.New(), RunID: run.ID, Phase: project.PhaseImplementing, ParentStepID: &pid, + Depth: 1, Status: StepRunning, AgentKindID: akID, + } + _, err := svc.RequestSubtask(context.Background(), parentSession, SubtaskInput{Prompt: "x"}) + if err == nil || codeOf(err) != errs.CodeForbidden { + t.Fatalf("expected forbidden fan-out; got %v", err) + } +} + +func TestRequestSubtask_RejectsUnknownSession(t *testing.T) { + svc, _, _, _, _ := newServiceHarness(3, 3) + _, err := svc.RequestSubtask(context.Background(), uuid.New(), SubtaskInput{Prompt: "x"}) + if err == nil || codeOf(err) != errs.CodeForbidden { + t.Fatalf("expected forbidden for unknown session; got %v", err) + } +} + +// ===== Pause/Cancel/Resume ===== + +func TestPauseTerminatesActiveSessions(t *testing.T) { + repo := newFakeRepo() + term := &fakeSessTerm{} + akID := uuid.New() + wsID := uuid.New() + owner := uuid.New() + run := &Run{ID: uuid.New(), ProjectID: uuid.New(), RequirementID: uuid.New(), WorkspaceID: wsID, OwnerID: owner, Status: RunRunning, CurrentPhase: project.PhasePlanning} + repo.runs[run.ID] = run + sessID := uuid.New() + repo.steps[uuid.New()] = &Step{ID: uuid.New(), RunID: run.ID, Phase: project.PhasePlanning, Status: StepRunning, AgentKindID: akID, ACPSessionID: &sessID} + + svc := NewService(ServiceDeps{Repo: repo, PM: &fakePM{}, ACL: fakeACL{allow: true}, Jobs: &fakeJobs{}, Sessions: term, Defaults: allPhaseKinds(akID), Config: Config{}, Audit: &fakeAudit{}}) + if err := svc.Pause(context.Background(), Caller{UserID: owner}, run.ID); err != nil { + t.Fatalf("Pause: %v", err) + } + gotRun, _ := repo.GetRun(context.Background(), run.ID) + if gotRun.Status != RunPaused { + t.Errorf("run status = %s; want paused", gotRun.Status) + } + if len(term.terminated) != 1 || term.terminated[0] != sessID { + t.Errorf("expected session %s terminated; got %v", sessID, term.terminated) + } +} + +// ===== ResetStuckStepsOnRestart ===== + +func TestResetStuckStepsOnRestart(t *testing.T) { + repo := newFakeRepo() + akID := uuid.New() + activeRun := &Run{ID: uuid.New(), Status: RunRunning} + doneRun := &Run{ID: uuid.New(), Status: RunSucceeded} + repo.runs[activeRun.ID] = activeRun + repo.runs[doneRun.ID] = doneRun + + stuckActive := &Step{ID: uuid.New(), RunID: activeRun.ID, Status: StepRunning, AgentKindID: akID} + awaiting := &Step{ID: uuid.New(), RunID: activeRun.ID, Status: StepAwaitingGate, AgentKindID: akID} + doneStep := &Step{ID: uuid.New(), RunID: doneRun.ID, Status: StepRunning, AgentKindID: akID} + repo.steps[stuckActive.ID] = stuckActive + repo.steps[awaiting.ID] = awaiting + repo.steps[doneStep.ID] = doneStep + + reset, err := repo.ResetStuckStepsOnRestart(context.Background()) + if err != nil { + t.Fatal(err) + } + if len(reset) != 2 { + t.Fatalf("expected 2 reset steps; got %d", len(reset)) + } + if s, _ := repo.GetStep(context.Background(), doneStep.ID); s.Status != StepRunning { + t.Errorf("step under succeeded run must be untouched; got %s", s.Status) + } + if s, _ := repo.GetStep(context.Background(), stuckActive.ID); s.Status != StepPending { + t.Errorf("stuck step under active run should be pending; got %s", s.Status) + } +} diff --git a/internal/orchestrator/sqlc/db.go b/internal/orchestrator/sqlc/db.go new file mode 100644 index 0000000..17c7a49 --- /dev/null +++ b/internal/orchestrator/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package orchestratorsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/orchestrator/sqlc/models.go b/internal/orchestrator/sqlc/models.go new file mode 100644 index 0000000..d9f6506 --- /dev/null +++ b/internal/orchestrator/sqlc/models.go @@ -0,0 +1,593 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package orchestratorsqlc + +import ( + "net/netip" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" +) + +type AcpAgentKind struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` + ClientType string `json:"client_type"` + EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` +} + +type AcpAgentKindConfigFile struct { + ID pgtype.UUID `json:"id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + RelPath string `json:"rel_path"` + EncryptedContent []byte `json:"encrypted_content"` + UpdatedBy pgtype.UUID `json:"updated_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type AcpEvent struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + Direction string `json:"direction"` + RpcKind string `json:"rpc_kind"` + Method *string `json:"method"` + Payload []byte `json:"payload"` + PayloadSize int32 `json:"payload_size"` + Truncated bool `json:"truncated"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpSession struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` +} + +type AuditLog struct { + ID int64 `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Action string `json:"action"` + TargetType *string `json:"target_type"` + TargetID *string `json:"target_id"` + Ip *netip.Addr `json:"ip"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Conversation struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + IssueID pgtype.UUID `json:"issue_id"` + ModelID pgtype.UUID `json:"model_id"` + SystemPrompt string `json:"system_prompt"` + Title *string `json:"title"` + TitleGeneratedAt pgtype.Timestamptz `json:"title_generated_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` +} + +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type GitCredential struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Username *string `json:"username"` + EncryptedSecret []byte `json:"encrypted_secret"` + Fingerprint *string `json:"fingerprint"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type Issue struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + AssigneeID pgtype.UUID `json:"assignee_id"` + CreatedBy pgtype.UUID `json:"created_by"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Job struct { + ID pgtype.UUID `json:"id"` + Type string `json:"type"` + Payload []byte `json:"payload"` + Status string `json:"status"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + Attempts int32 `json:"attempts"` + MaxAttempts int32 `json:"max_attempts"` + LeasedAt pgtype.Timestamptz `json:"leased_at"` + LeasedBy *string `json:"leased_by"` + LastError *string `json:"last_error"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmEndpoint struct { + ID pgtype.UUID `json:"id"` + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + BaseUrl string `json:"base_url"` + ApiKeyEncrypted []byte `json:"api_key_encrypted"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type LlmModel struct { + ID pgtype.UUID `json:"id"` + EndpointID pgtype.UUID `json:"endpoint_id"` + ModelID string `json:"model_id"` + DisplayName string `json:"display_name"` + Capabilities []byte `json:"capabilities"` + ContextWindow int32 `json:"context_window"` + MaxOutputTokens int32 `json:"max_output_tokens"` + PromptPricePerMillionUsd pgtype.Numeric `json:"prompt_price_per_million_usd"` + CompletionPricePerMillionUsd pgtype.Numeric `json:"completion_price_per_million_usd"` + ThinkingPricePerMillionUsd pgtype.Numeric `json:"thinking_price_per_million_usd"` + IsTitleGenerator bool `json:"is_title_generator"` + Enabled bool `json:"enabled"` + SortOrder int32 `json:"sort_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmUsage struct { + ID int64 `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + MessageID int64 `json:"message_id"` + UserID pgtype.UUID `json:"user_id"` + EndpointID pgtype.UUID `json:"endpoint_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int32 `json:"prompt_tokens"` + CompletionTokens int32 `json:"completion_tokens"` + ThinkingTokens int32 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type McpToken struct { + ID pgtype.UUID `json:"id"` + TokenHash []byte `json:"token_hash"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + Issuer string `json:"issuer"` + Scope []byte `json:"scope"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Message struct { + ID int64 `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + Role string `json:"role"` + Content string `json:"content"` + Thinking *string `json:"thinking"` + ToolCalls []byte `json:"tool_calls"` + Status string `json:"status"` + ErrorMessage *string `json:"error_message"` + PromptTokens *int32 `json:"prompt_tokens"` + CompletionTokens *int32 `json:"completion_tokens"` + ThinkingTokens *int32 `json:"thinking_tokens"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type MessageAttachment struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + MessageID *int64 `json:"message_id"` + Filename string `json:"filename"` + MimeType string `json:"mime_type"` + SizeBytes int64 `json:"size_bytes"` + Sha256 string `json:"sha256"` + StoragePath string `json:"storage_path"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Notification struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Topic string `json:"topic"` + Severity string `json:"severity"` + Title string `json:"title"` + Body string `json:"body"` + Link *string `json:"link"` + Metadata []byte `json:"metadata"` + ReadAt pgtype.Timestamptz `json:"read_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type Project struct { + ID pgtype.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + OwnerID pgtype.UUID `json:"owner_id"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type PromptTemplate struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Content string `json:"content"` + Scope string `json:"scope"` + OwnerID pgtype.UUID `json:"owner_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Requirement struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Phase string `json:"phase"` + Status string `json:"status"` + OwnerID pgtype.UUID `json:"owner_id"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type User struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + DisplayName string `json:"display_name"` + IsAdmin bool `json:"is_admin"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` +} + +type UserSession struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + TokenHash []byte `json:"token_hash"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Workspace struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + GitRemoteUrl string `json:"git_remote_url"` + DefaultBranch string `json:"default_branch"` + MainPath string `json:"main_path"` + SyncStatus string `json:"sync_status"` + LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"` + LastSyncError *string `json:"last_sync_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"` +} + +type WorkspaceRunProfile struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Command string `json:"command"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + LastStartedAt pgtype.Timestamptz `json:"last_started_at"` + LastExitCode *int32 `json:"last_exit_code"` + LastError string `json:"last_error"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type WorkspaceWorktree struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Branch string `json:"branch"` + Path string `json:"path"` + Status string `json:"status"` + ActiveHolder *string `json:"active_holder"` + AcquiredAt pgtype.Timestamptz `json:"acquired_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + PruneWarningAt pgtype.Timestamptz `json:"prune_warning_at"` +} diff --git a/internal/orchestrator/sqlc/runs.sql.go b/internal/orchestrator/sqlc/runs.sql.go new file mode 100644 index 0000000..cbd6bb8 --- /dev/null +++ b/internal/orchestrator/sqlc/runs.sql.go @@ -0,0 +1,253 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: runs.sql + +package orchestratorsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getActiveRunByRequirement = `-- name: GetActiveRunByRequirement :one +SELECT id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error, + created_at, updated_at, ended_at +FROM orchestrator_runs +WHERE requirement_id = $1 + AND status IN ('pending','running','paused') +ORDER BY created_at DESC +LIMIT 1 +` + +func (q *Queries) GetActiveRunByRequirement(ctx context.Context, requirementID pgtype.UUID) (OrchestratorRun, error) { + row := q.db.QueryRow(ctx, getActiveRunByRequirement, requirementID) + var i OrchestratorRun + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.RequirementID, + &i.WorkspaceID, + &i.OwnerID, + &i.Status, + &i.CurrentPhase, + &i.Config, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ) + return i, err +} + +const getRun = `-- name: GetRun :one +SELECT id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error, + created_at, updated_at, ended_at +FROM orchestrator_runs +WHERE id = $1 +` + +func (q *Queries) GetRun(ctx context.Context, id pgtype.UUID) (OrchestratorRun, error) { + row := q.db.QueryRow(ctx, getRun, id) + var i OrchestratorRun + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.RequirementID, + &i.WorkspaceID, + &i.OwnerID, + &i.Status, + &i.CurrentPhase, + &i.Config, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ) + return i, err +} + +const insertRun = `-- name: InsertRun :one +INSERT INTO orchestrator_runs ( + id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +RETURNING id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error, + created_at, updated_at, ended_at +` + +type InsertRunParams struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` +} + +func (q *Queries) InsertRun(ctx context.Context, arg InsertRunParams) (OrchestratorRun, error) { + row := q.db.QueryRow(ctx, insertRun, + arg.ID, + arg.ProjectID, + arg.RequirementID, + arg.WorkspaceID, + arg.OwnerID, + arg.Status, + arg.CurrentPhase, + arg.Config, + arg.LastError, + ) + var i OrchestratorRun + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.RequirementID, + &i.WorkspaceID, + &i.OwnerID, + &i.Status, + &i.CurrentPhase, + &i.Config, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ) + return i, err +} + +const listRuns = `-- name: ListRuns :many +SELECT id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error, + created_at, updated_at, ended_at +FROM orchestrator_runs +WHERE ($1::uuid IS NULL OR project_id = $1) + AND ($2::uuid IS NULL OR requirement_id = $2) + AND ($3::text = '' OR status = $3) +ORDER BY created_at DESC +LIMIT $4 +` + +type ListRunsParams struct { + Column1 pgtype.UUID `json:"column_1"` + Column2 pgtype.UUID `json:"column_2"` + Column3 string `json:"column_3"` + Limit int32 `json:"limit"` +} + +func (q *Queries) ListRuns(ctx context.Context, arg ListRunsParams) ([]OrchestratorRun, error) { + rows, err := q.db.Query(ctx, listRuns, + arg.Column1, + arg.Column2, + arg.Column3, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []OrchestratorRun + for rows.Next() { + var i OrchestratorRun + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.RequirementID, + &i.WorkspaceID, + &i.OwnerID, + &i.Status, + &i.CurrentPhase, + &i.Config, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateRunPhase = `-- name: UpdateRunPhase :one +UPDATE orchestrator_runs +SET current_phase = $2, + updated_at = now() +WHERE id = $1 +RETURNING id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error, + created_at, updated_at, ended_at +` + +type UpdateRunPhaseParams struct { + ID pgtype.UUID `json:"id"` + CurrentPhase string `json:"current_phase"` +} + +func (q *Queries) UpdateRunPhase(ctx context.Context, arg UpdateRunPhaseParams) (OrchestratorRun, error) { + row := q.db.QueryRow(ctx, updateRunPhase, arg.ID, arg.CurrentPhase) + var i OrchestratorRun + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.RequirementID, + &i.WorkspaceID, + &i.OwnerID, + &i.Status, + &i.CurrentPhase, + &i.Config, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ) + return i, err +} + +const updateRunStatus = `-- name: UpdateRunStatus :one +UPDATE orchestrator_runs +SET status = $2, + last_error = $3, + ended_at = CASE WHEN $2 IN ('succeeded','failed','canceled') THEN now() ELSE ended_at END, + updated_at = now() +WHERE id = $1 +RETURNING id, project_id, requirement_id, workspace_id, owner_id, + status, current_phase, config, last_error, + created_at, updated_at, ended_at +` + +type UpdateRunStatusParams struct { + ID pgtype.UUID `json:"id"` + Status string `json:"status"` + LastError *string `json:"last_error"` +} + +func (q *Queries) UpdateRunStatus(ctx context.Context, arg UpdateRunStatusParams) (OrchestratorRun, error) { + row := q.db.QueryRow(ctx, updateRunStatus, arg.ID, arg.Status, arg.LastError) + var i OrchestratorRun + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.RequirementID, + &i.WorkspaceID, + &i.OwnerID, + &i.Status, + &i.CurrentPhase, + &i.Config, + &i.LastError, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ) + return i, err +} diff --git a/internal/orchestrator/sqlc/steps.sql.go b/internal/orchestrator/sqlc/steps.sql.go new file mode 100644 index 0000000..3da5d6b --- /dev/null +++ b/internal/orchestrator/sqlc/steps.sql.go @@ -0,0 +1,356 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: steps.sql + +package orchestratorsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countActiveChildSteps = `-- name: CountActiveChildSteps :one +SELECT COUNT(*) FROM orchestrator_steps +WHERE parent_step_id = $1 + AND status IN ('pending','spawning','running','awaiting_gate') +` + +func (q *Queries) CountActiveChildSteps(ctx context.Context, parentStepID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countActiveChildSteps, parentStepID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const getStep = `-- name: GetStep :one +SELECT id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id, created_at, updated_at, ended_at +FROM orchestrator_steps +WHERE id = $1 +` + +func (q *Queries) GetStep(ctx context.Context, id pgtype.UUID) (OrchestratorStep, error) { + row := q.db.QueryRow(ctx, getStep, id) + var i OrchestratorStep + err := row.Scan( + &i.ID, + &i.RunID, + &i.Phase, + &i.Attempt, + &i.ParentStepID, + &i.Depth, + &i.Status, + &i.AgentKindID, + &i.AcpSessionID, + &i.Prompt, + &i.StopReason, + &i.GateResult, + &i.LastError, + &i.JobID, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ) + return i, err +} + +const getStepBySession = `-- name: GetStepBySession :one +SELECT id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id, created_at, updated_at, ended_at +FROM orchestrator_steps +WHERE acp_session_id = $1 +ORDER BY created_at DESC +LIMIT 1 +` + +func (q *Queries) GetStepBySession(ctx context.Context, acpSessionID pgtype.UUID) (OrchestratorStep, error) { + row := q.db.QueryRow(ctx, getStepBySession, acpSessionID) + var i OrchestratorStep + err := row.Scan( + &i.ID, + &i.RunID, + &i.Phase, + &i.Attempt, + &i.ParentStepID, + &i.Depth, + &i.Status, + &i.AgentKindID, + &i.AcpSessionID, + &i.Prompt, + &i.StopReason, + &i.GateResult, + &i.LastError, + &i.JobID, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ) + return i, err +} + +const incrementStepAttempt = `-- name: IncrementStepAttempt :one +UPDATE orchestrator_steps +SET attempt = attempt + 1, + updated_at = now() +WHERE id = $1 +RETURNING id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id, created_at, updated_at, ended_at +` + +func (q *Queries) IncrementStepAttempt(ctx context.Context, id pgtype.UUID) (OrchestratorStep, error) { + row := q.db.QueryRow(ctx, incrementStepAttempt, id) + var i OrchestratorStep + err := row.Scan( + &i.ID, + &i.RunID, + &i.Phase, + &i.Attempt, + &i.ParentStepID, + &i.Depth, + &i.Status, + &i.AgentKindID, + &i.AcpSessionID, + &i.Prompt, + &i.StopReason, + &i.GateResult, + &i.LastError, + &i.JobID, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ) + return i, err +} + +const insertStep = `-- name: InsertStep :one +INSERT INTO orchestrator_steps ( + id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14) +RETURNING id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id, created_at, updated_at, ended_at +` + +type InsertStepParams struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` +} + +func (q *Queries) InsertStep(ctx context.Context, arg InsertStepParams) (OrchestratorStep, error) { + row := q.db.QueryRow(ctx, insertStep, + arg.ID, + arg.RunID, + arg.Phase, + arg.Attempt, + arg.ParentStepID, + arg.Depth, + arg.Status, + arg.AgentKindID, + arg.AcpSessionID, + arg.Prompt, + arg.StopReason, + arg.GateResult, + arg.LastError, + arg.JobID, + ) + var i OrchestratorStep + err := row.Scan( + &i.ID, + &i.RunID, + &i.Phase, + &i.Attempt, + &i.ParentStepID, + &i.Depth, + &i.Status, + &i.AgentKindID, + &i.AcpSessionID, + &i.Prompt, + &i.StopReason, + &i.GateResult, + &i.LastError, + &i.JobID, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ) + return i, err +} + +const listStepsByRun = `-- name: ListStepsByRun :many +SELECT id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id, created_at, updated_at, ended_at +FROM orchestrator_steps +WHERE run_id = $1 +ORDER BY created_at ASC +` + +func (q *Queries) ListStepsByRun(ctx context.Context, runID pgtype.UUID) ([]OrchestratorStep, error) { + rows, err := q.db.Query(ctx, listStepsByRun, runID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []OrchestratorStep + for rows.Next() { + var i OrchestratorStep + if err := rows.Scan( + &i.ID, + &i.RunID, + &i.Phase, + &i.Attempt, + &i.ParentStepID, + &i.Depth, + &i.Status, + &i.AgentKindID, + &i.AcpSessionID, + &i.Prompt, + &i.StopReason, + &i.GateResult, + &i.LastError, + &i.JobID, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const resetStuckStepsOnRestart = `-- name: ResetStuckStepsOnRestart :many +UPDATE orchestrator_steps s +SET status = 'pending', + updated_at = now() +FROM orchestrator_runs r +WHERE s.run_id = r.id + AND r.status IN ('pending','running','paused') + AND s.status IN ('spawning','running','awaiting_gate') +RETURNING s.id, s.run_id, s.phase, s.attempt, s.parent_step_id, s.depth, + s.status, s.agent_kind_id, s.acp_session_id, s.prompt, s.stop_reason, + s.gate_result, s.last_error, s.job_id, s.created_at, s.updated_at, s.ended_at +` + +// 把活跃 run 下卡在 spawning/running/awaiting_gate 的 step 重置为 pending,便于 +// 重新入队恢复(镜像 acp ResetStuckSessionsOnRestart)。succeeded/canceled/failed +// run 下的 step 不动。 +func (q *Queries) ResetStuckStepsOnRestart(ctx context.Context) ([]OrchestratorStep, error) { + rows, err := q.db.Query(ctx, resetStuckStepsOnRestart) + if err != nil { + return nil, err + } + defer rows.Close() + var items []OrchestratorStep + for rows.Next() { + var i OrchestratorStep + if err := rows.Scan( + &i.ID, + &i.RunID, + &i.Phase, + &i.Attempt, + &i.ParentStepID, + &i.Depth, + &i.Status, + &i.AgentKindID, + &i.AcpSessionID, + &i.Prompt, + &i.StopReason, + &i.GateResult, + &i.LastError, + &i.JobID, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateStepStatus = `-- name: UpdateStepStatus :one +UPDATE orchestrator_steps +SET status = $2, + acp_session_id = $3, + stop_reason = $4, + gate_result = $5, + last_error = $6, + job_id = $7, + ended_at = CASE WHEN $2 IN ('succeeded','failed','dead') THEN now() ELSE ended_at END, + updated_at = now() +WHERE id = $1 +RETURNING id, run_id, phase, attempt, parent_step_id, depth, + status, agent_kind_id, acp_session_id, prompt, stop_reason, + gate_result, last_error, job_id, created_at, updated_at, ended_at +` + +type UpdateStepStatusParams struct { + ID pgtype.UUID `json:"id"` + Status string `json:"status"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` +} + +func (q *Queries) UpdateStepStatus(ctx context.Context, arg UpdateStepStatusParams) (OrchestratorStep, error) { + row := q.db.QueryRow(ctx, updateStepStatus, + arg.ID, + arg.Status, + arg.AcpSessionID, + arg.StopReason, + arg.GateResult, + arg.LastError, + arg.JobID, + ) + var i OrchestratorStep + err := row.Scan( + &i.ID, + &i.RunID, + &i.Phase, + &i.Attempt, + &i.ParentStepID, + &i.Depth, + &i.Status, + &i.AgentKindID, + &i.AcpSessionID, + &i.Prompt, + &i.StopReason, + &i.GateResult, + &i.LastError, + &i.JobID, + &i.CreatedAt, + &i.UpdatedAt, + &i.EndedAt, + ) + return i, err +} diff --git a/internal/orchestrator/step_handler.go b/internal/orchestrator/step_handler.go new file mode 100644 index 0000000..1a2cf7e --- /dev/null +++ b/internal/orchestrator/step_handler.go @@ -0,0 +1,341 @@ +// step_handler.go 把 orchestrator.step 接入 jobs:每个 step 由一个 orchestrator.step +// job 驱动,因而能跨重启恢复并复用既有 backoff/dead-letter 机制。 +package orchestrator + +import ( + "context" + "encoding/json" + "fmt" + "log/slog" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/jobs" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +// JobTypeOrchestratorStep 是驱动单个编排 step 的 job 类型。 +const JobTypeOrchestratorStep jobs.JobType = "orchestrator.step" + +// stepPayload 是 orchestrator.step job 的 payload。 +type stepPayload struct { + RunID uuid.UUID `json:"run_id"` + StepID uuid.UUID `json:"step_id"` +} + +// StepHandlerDeps 是 StepHandler 的依赖集合。 +type StepHandlerDeps struct { + Repo Repository + Runner *runner + Gate PhaseGate + PM PMAccess + Enqueue StepEnqueuer + DefaultAgentKind map[project.Phase]uuid.UUID + Config Config + Audit AuditRecorder + Log *slog.Logger +} + +// StepHandler 实现 jobs.Handler,驱动一个阶段回合并推进/回环状态机。 +type StepHandler struct { + repo Repository + runner *runner + gate PhaseGate + pm PMAccess + enqueue StepEnqueuer + defaults map[project.Phase]uuid.UUID + cfg Config + audit AuditRecorder + log *slog.Logger +} + +// NewStepHandler 构造 StepHandler。 +func NewStepHandler(d StepHandlerDeps) *StepHandler { + log := d.Log + if log == nil { + log = slog.Default() + } + return &StepHandler{ + repo: d.Repo, runner: d.Runner, gate: d.Gate, pm: d.PM, + enqueue: d.Enqueue, defaults: d.DefaultAgentKind, cfg: d.Config, + audit: d.Audit, log: log, + } +} + +// Type 返回 job 类型。 +func (h *StepHandler) Type() jobs.JobType { return JobTypeOrchestratorStep } + +// Handle 驱动一个 step:load step+run;run 非活跃 → no-op;spawn-or-resume 跑回合; +// 评估网关;通过 → ChangePhase + 入队下一 step(或 done → run 成功);否则递增 attempt +// 并返回 error(attempt 耗尽 → jobs.Permanent → dead-letter)。 +// +// 幂等:先按 step.status 短路,避免重复 leased 时重复 spawn(worker 已有 Complete fence)。 +func (h *StepHandler) Handle(ctx context.Context, job jobs.Job) error { + var p stepPayload + if err := json.Unmarshal(job.Payload, &p); err != nil { + return jobs.Permanent(errs.Wrap(err, errs.CodeInvalidInput, "orchestrator.step: bad payload")) + } + + step, err := h.repo.GetStep(ctx, p.StepID) + if err != nil { + // step 不存在:永久失败(无法恢复)。 + return jobs.Permanent(err) + } + // 已到终态:no-op(重复投递 / 重启重入)。 + switch step.Status { + case StepSucceeded, StepFailed, StepDead: + return nil + } + + run, err := h.repo.GetRun(ctx, step.RunID) + if err != nil { + return jobs.Permanent(err) + } + // run 已暂停/取消/终态:不驱动,完成 job(释放 worker)。 + if !run.Status.IsActive() { + h.log.Info("orchestrator.step.skip_inactive_run", + "run_id", run.ID, "step_id", step.ID, "run_status", run.Status) + return nil + } + + // run.status pending → running(首个 step 起跑)。 + if run.Status == RunPending { + if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunRunning, nil); uerr != nil { + h.log.Warn("orchestrator.run.mark_running_failed", "run_id", run.ID, "err", uerr.Error()) + } + } + + // 解析需求(用于 prompt 上下文 + 阶段绑定 + ChangePhase)。 + req, err := h.pm.GetRequirementByID(ctx, run.RequirementID) + if err != nil { + return jobs.Permanent(err) + } + proj, err := h.pm.GetProjectByID(ctx, run.ProjectID) + if err != nil { + return jobs.Permanent(err) + } + + // step.prompt 为空时(首建只填了占位)补建 prompt。通常 service 建 step 时已填。 + if step.Prompt == "" { + artifacts, _ := h.pm.ListLatestArtifacts(ctx, run.RequirementID) + override := run.Config.PromptOverrides[step.Phase] + step.Prompt = BuildPrompt(step.Phase, req, artifacts, override) + } + + if step.Status == StepAwaitingGate && step.GateResult != nil && step.GateResult.Pass { + return h.advancePassedStep(ctx, run, step, req, proj, *step.GateResult, "") + } + + // 标记 spawning(关联本 job)。 + jobID := job.ID + step.Status = StepSpawning + step.JobID = &jobID + if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil { + step = updated + } + + // 驱动回合(spawn-or-resume + SendPromptAndWait)。 + res, terr := h.runner.driveTurn(ctx, run, step, req.Number) + if terr != nil { + return h.failStep(ctx, run, step, terr) + } + + // 记录 session + stopReason,进入 awaiting_gate。 + step.ACPSessionID = &res.SessionID + stop := res.StopReason + step.StopReason = &stop + step.Status = StepAwaitingGate + if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil { + step = updated + } + + // 评估网关。 + gate, gerr := h.gate.Evaluate(ctx, run, step, res.StopReason) + if gerr != nil { + return h.failStep(ctx, run, step, gerr) + } + step.GateResult = &gate + if !gate.Pass { + return h.failStepGate(ctx, run, step, gate) + } + + // 网关通过后先停在 awaiting_gate;只有 project.ChangePhase 与 run phase 都写成功后, + // 才能把 step 标为 succeeded 并入队下一阶段,避免绕过 project 的 entry gate。 + step.Status = StepAwaitingGate + step.GateResult = &gate + step.LastError = nil + if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil { + step = updated + } + return h.advancePassedStep(ctx, run, step, req, proj, gate, res.StopReason) +} + +func (h *StepHandler) advancePassedStep(ctx context.Context, run *Run, step *Step, req *project.Requirement, proj *project.Project, gate GateResult, stopReason string) error { + // 推进阶段。 + next, terminal := NextPhase(step.Phase, gate) + if terminal { + step.Status = StepSucceeded + step.LastError = nil + if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil { + step = updated + } + h.recordAudit(ctx, run, "orchestrator.step.advance", step.ID.String(), map[string]any{ + "phase": string(step.Phase), + "stop_reason": stopReason, + }) + // 整条 run 完成。 + if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunSucceeded, nil); uerr != nil { + h.log.Warn("orchestrator.run.mark_succeeded_failed", "run_id", run.ID, "err", uerr.Error()) + } + h.recordAudit(ctx, run, "orchestrator.run.succeeded", run.ID.String(), nil) + return nil + } + + // 写需求阶段(run.owner 身份)。失败必须阻塞编排推进;project.ChangePhase 是 + // 更靠近领域状态的准入网关,不能被 orchestrator.run.current_phase 越过。 + if cerr := h.pm.ChangeRequirementPhase(ctx, run.OwnerID, false, proj.Slug, req.Number, next); cerr != nil { + h.log.Warn("orchestrator.change_phase_failed", + "run_id", run.ID, "to", next, "err", cerr.Error()) + return h.failPhaseAdvance(ctx, run, step, next, cerr) + } + if _, uerr := h.repo.UpdateRunPhase(ctx, run.ID, next); uerr != nil { + h.log.Warn("orchestrator.run.update_phase_failed", "run_id", run.ID, "err", uerr.Error()) + return h.failPhaseAdvance(ctx, run, step, next, uerr) + } + + step.Status = StepSucceeded + step.LastError = nil + if updated, uerr := h.repo.UpdateStepStatus(ctx, step); uerr == nil { + step = updated + } + h.recordAudit(ctx, run, "orchestrator.step.advance", step.ID.String(), map[string]any{ + "phase": string(step.Phase), + "stop_reason": stopReason, + }) + + // 建并入队下一阶段 step。 + if _, eerr := h.enqueueNextStep(ctx, run, next); eerr != nil { + return eerr + } + return nil +} + +// enqueueNextStep 为 run 在 phase 上建一个新 step 并入队其 job。复用 service 的逻辑。 +func (h *StepHandler) enqueueNextStep(ctx context.Context, run *Run, phase project.Phase) (*Step, error) { + step, err := buildPhaseStep(ctx, h.repo, h.pm, run, phase, nil, 0, h.defaults) + if err != nil { + // 无法为下一阶段建 step(如缺 agent kind):标 run 失败。 + msg := err.Error() + if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunFailed, &msg); uerr != nil { + h.log.Warn("orchestrator.run.mark_failed", "run_id", run.ID, "err", uerr.Error()) + } + return nil, jobs.Permanent(err) + } + if eerr := h.enqueue.EnqueueStep(ctx, run.ID, step.ID, time.Now().UTC()); eerr != nil { + return nil, eerr + } + return step, nil +} + +// failStep 处理回合驱动/网关评估的错误:递增 attempt,返回可重试或 Permanent error。 +func (h *StepHandler) failStep(ctx context.Context, run *Run, step *Step, cause error) error { + msg := cause.Error() + step.Status = StepRunning // 保持非终态,等待重试或 dead-letter 落定 + step.LastError = &msg + _, _ = h.repo.UpdateStepStatus(ctx, step) + updated, ierr := h.repo.IncrementStepAttempt(ctx, step.ID) + if ierr == nil { + step = updated + } + return h.retryOrDead(ctx, run, step, cause, false) +} + +// failStepGate 处理网关未通过:Retry=false → 立即 Permanent;否则递增 attempt 后判断。 +func (h *StepHandler) failStepGate(ctx context.Context, run *Run, step *Step, gate GateResult) error { + reason := gate.Reason + step.LastError = &reason + step.Status = StepRunning + _, _ = h.repo.UpdateStepStatus(ctx, step) + cause := errs.New(errs.CodePhaseGateFailed, gate.Reason) + if !gate.Retry { + return h.markDead(ctx, run, step, cause) + } + updated, ierr := h.repo.IncrementStepAttempt(ctx, step.ID) + if ierr == nil { + step = updated + } + return h.retryOrDead(ctx, run, step, cause, true) +} + +func (h *StepHandler) failPhaseAdvance(ctx context.Context, run *Run, step *Step, next project.Phase, cause error) error { + msg := cause.Error() + step.Status = StepAwaitingGate + step.LastError = &msg + _, _ = h.repo.UpdateStepStatus(ctx, step) + + if ae, ok := errs.As(cause); ok && ae.Code == errs.CodePhaseGateFailed { + if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunPaused, &msg); uerr != nil { + h.log.Warn("orchestrator.run.mark_paused_failed", "run_id", run.ID, "err", uerr.Error()) + } + h.recordAudit(ctx, run, "orchestrator.step.phase_blocked", step.ID.String(), map[string]any{ + "phase": string(step.Phase), + "next": string(next), + "err": msg, + }) + return nil + } + + updated, ierr := h.repo.IncrementStepAttempt(ctx, step.ID) + if ierr == nil { + step = updated + } + return h.retryOrDead(ctx, run, step, cause, false) +} + +// retryOrDead 根据 attempt 与 MaxAttemptsPerPhase 决定可重试还是 dead-letter。 +func (h *StepHandler) retryOrDead(ctx context.Context, run *Run, step *Step, cause error, gateFail bool) error { + maxAttempts := run.Config.MaxAttemptsPerPhase + if maxAttempts <= 0 { + maxAttempts = h.cfg.MaxAttemptsPerPhase + } + if maxAttempts <= 0 { + maxAttempts = 3 + } + if step.Attempt >= maxAttempts { + return h.markDead(ctx, run, step, cause) + } + h.recordAudit(ctx, run, "orchestrator.step.retry", step.ID.String(), map[string]any{ + "phase": string(step.Phase), + "attempt": step.Attempt, + "err": cause.Error(), + "gate": gateFail, + }) + // 返回非 Permanent error:jobs worker 走 backoff 重试,重新驱动本 step。 + return fmt.Errorf("orchestrator.step retry (attempt %d/%d): %w", step.Attempt, maxAttempts, cause) +} + +// markDead 把 step 标 dead、run 标 failed,返回 jobs.Permanent(dead-letter)。 +func (h *StepHandler) markDead(ctx context.Context, run *Run, step *Step, cause error) error { + msg := cause.Error() + step.Status = StepDead + step.LastError = &msg + _, _ = h.repo.UpdateStepStatus(ctx, step) + if _, uerr := h.repo.UpdateRunStatus(ctx, run.ID, RunFailed, &msg); uerr != nil { + h.log.Warn("orchestrator.run.mark_failed", "run_id", run.ID, "err", uerr.Error()) + } + h.recordAudit(ctx, run, "orchestrator.step.dead_letter", step.ID.String(), map[string]any{ + "phase": string(step.Phase), + "err": msg, + }) + return jobs.Permanent(cause) +} + +func (h *StepHandler) recordAudit(ctx context.Context, run *Run, action, targetID string, meta map[string]any) { + if h.audit == nil { + return + } + uid := run.OwnerID + h.audit.Record(ctx, uid, action, targetID, meta) +} diff --git a/internal/orchestrator/step_handler_test.go b/internal/orchestrator/step_handler_test.go new file mode 100644 index 0000000..fcca068 --- /dev/null +++ b/internal/orchestrator/step_handler_test.go @@ -0,0 +1,307 @@ +package orchestrator + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/acp" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/jobs" + "github.com/yan1h/agent-coding-workflow/internal/project" +) + +type stepHandlerHarness struct { + repo *fakeRepo + pm *fakePM + turns *fakeTurnRunner + sess *fakeACPSessions + lookup *fakeLookup + jobs *fakeJobs + audit *fakeAudit + handler *StepHandler + run *Run +} + +func newStepHandlerHarness(t *testing.T, gate PhaseGate, startPhase project.Phase, maxAttempts int) *stepHandlerHarness { + t.Helper() + repo := newFakeRepo() + wsID := uuid.New() + akID := uuid.New() + pm := &fakePM{ + proj: &project.Project{ID: uuid.New(), Slug: "demo"}, + req: &project.Requirement{ID: uuid.New(), Number: 3, Title: "T", WorkspaceID: &wsID, Phase: startPhase, Status: project.StatusOpen}, + } + run := &Run{ + ID: uuid.New(), ProjectID: pm.proj.ID, RequirementID: pm.req.ID, + WorkspaceID: wsID, OwnerID: uuid.New(), Status: RunRunning, + CurrentPhase: startPhase, + Config: RunConfig{MaxAttemptsPerPhase: maxAttempts, PhaseAgentKinds: allPhaseKinds(akID)}, + } + repo.runs[run.ID] = run + + turns := &fakeTurnRunner{stopReason: "end_turn"} + sess := &fakeACPSessions{} + lookup := newFakeLookup() + jq := &fakeJobs{} + audit := &fakeAudit{} + + svc := NewService(ServiceDeps{ + Repo: repo, PM: pm, ACL: fakeACL{allow: true}, Jobs: jq, + Defaults: allPhaseKinds(akID), Config: Config{MaxAttemptsPerPhase: maxAttempts}, Audit: audit, + }) + + h := NewStepHandler(StepHandlerDeps{ + Repo: repo, + Runner: NewRunner(sess, turns, lookup, time.Minute), + Gate: gate, + PM: pm, + Enqueue: svc.(StepEnqueuer), + DefaultAgentKind: allPhaseKinds(akID), + Config: Config{MaxAttemptsPerPhase: maxAttempts}, + Audit: audit, + }) + + return &stepHandlerHarness{repo: repo, pm: pm, turns: turns, sess: sess, lookup: lookup, jobs: jq, audit: audit, handler: h, run: run} +} + +func allPhaseKinds(akID uuid.UUID) map[project.Phase]uuid.UUID { + m := map[project.Phase]uuid.UUID{} + for _, p := range project.AllPhases { + m[p] = akID + } + return m +} + +func (h *stepHandlerHarness) seedStep(phase project.Phase) *Step { + step := &Step{ + ID: uuid.New(), RunID: h.run.ID, Phase: phase, Attempt: 1, + Status: StepPending, AgentKindID: h.run.Config.PhaseAgentKinds[phase], Prompt: "do it", + } + h.repo.steps[step.ID] = step + return step +} + +func jobFor(step *Step) jobs.Job { + payload, _ := json.Marshal(stepPayload{RunID: step.RunID, StepID: step.ID}) + return jobs.Job{ID: uuid.New(), Type: JobTypeOrchestratorStep, Payload: payload} +} + +func TestStepHandler_HappyAdvancesAndEnqueuesNext(t *testing.T) { + h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhasePlanning, 3) + step := h.seedStep(project.PhasePlanning) + + if err := h.handler.Handle(context.Background(), jobFor(step)); err != nil { + t.Fatalf("Handle: %v", err) + } + got, _ := h.repo.GetStep(context.Background(), step.ID) + if got.Status != StepSucceeded { + t.Errorf("step status = %s; want succeeded", got.Status) + } + if h.jobs.count() != 1 { + t.Errorf("expected 1 next-step enqueued; got %d", h.jobs.count()) + } + if len(h.pm.phaseWrites) != 1 || h.pm.phaseWrites[0] != project.PhasePrototyping { + t.Errorf("expected phase advance to prototyping; got %v", h.pm.phaseWrites) + } + if !h.audit.has("orchestrator.step.advance") { + t.Error("expected step.advance audit") + } +} + +func TestStepHandler_PhaseGateFailurePausesWithoutAdvancing(t *testing.T) { + h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhaseAuditing, 3) + h.pm.phaseErr = errs.New(errs.CodePhaseGateFailed, "audit artifact not approved") + step := h.seedStep(project.PhaseAuditing) + + if err := h.handler.Handle(context.Background(), jobFor(step)); err != nil { + t.Fatalf("Handle should complete job and pause run on external phase gate; got %v", err) + } + got, _ := h.repo.GetStep(context.Background(), step.ID) + if got.Status != StepAwaitingGate { + t.Errorf("step status = %s; want awaiting_gate", got.Status) + } + if got.Attempt != 1 { + t.Errorf("phase gate pause must not consume agent attempt; got %d", got.Attempt) + } + gotRun, _ := h.repo.GetRun(context.Background(), h.run.ID) + if gotRun.Status != RunPaused { + t.Errorf("run status = %s; want paused", gotRun.Status) + } + if gotRun.CurrentPhase != project.PhaseAuditing { + t.Errorf("run phase = %s; want auditing", gotRun.CurrentPhase) + } + if h.pm.req.Phase != project.PhaseAuditing { + t.Errorf("requirement phase = %s; want auditing", h.pm.req.Phase) + } + if h.jobs.count() != 0 { + t.Errorf("next step must not be enqueued when project gate blocks; got %d", h.jobs.count()) + } + if !h.audit.has("orchestrator.step.phase_blocked") { + t.Error("expected phase_blocked audit") + } +} + +func TestStepHandler_PhaseWriteFailureRetriesWithoutAdvancing(t *testing.T) { + h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhasePlanning, 3) + h.pm.phaseErr = errors.New("db temporarily unavailable") + step := h.seedStep(project.PhasePlanning) + + err := h.handler.Handle(context.Background(), jobFor(step)) + if err == nil { + t.Fatal("expected retryable error") + } + if jobs.IsPermanent(err) { + t.Fatalf("expected retryable error; got permanent: %v", err) + } + got, _ := h.repo.GetStep(context.Background(), step.ID) + if got.Status != StepAwaitingGate { + t.Errorf("step status = %s; want awaiting_gate", got.Status) + } + if got.Attempt != 2 { + t.Errorf("attempt = %d; want 2", got.Attempt) + } + gotRun, _ := h.repo.GetRun(context.Background(), h.run.ID) + if gotRun.CurrentPhase != project.PhasePlanning { + t.Errorf("run phase = %s; want planning", gotRun.CurrentPhase) + } + if h.jobs.count() != 0 { + t.Errorf("next step must not be enqueued after failed phase write; got %d", h.jobs.count()) + } +} + +func TestStepHandler_AwaitingGateResumeDoesNotRerunAgent(t *testing.T) { + h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhasePlanning, 3) + step := h.seedStep(project.PhasePlanning) + gate := GateResult{Pass: true, Reason: "ready"} + step.Status = StepAwaitingGate + step.GateResult = &gate + h.repo.steps[step.ID] = step + + if err := h.handler.Handle(context.Background(), jobFor(step)); err != nil { + t.Fatalf("Handle: %v", err) + } + if h.turns.calls != 0 { + t.Errorf("awaiting gate resume should not rerun agent; calls=%d", h.turns.calls) + } + if len(h.sess.created) != 0 { + t.Errorf("awaiting gate resume should not create session; got %d", len(h.sess.created)) + } + if h.jobs.count() != 1 { + t.Errorf("expected next step enqueued after phase write succeeds; got %d", h.jobs.count()) + } +} + +func TestStepHandler_GateRetryReturnsRetryableError(t *testing.T) { + h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: false, Retry: true, Reason: "no artifact"}}, project.PhasePlanning, 3) + step := h.seedStep(project.PhasePlanning) + + err := h.handler.Handle(context.Background(), jobFor(step)) + if err == nil { + t.Fatal("expected retryable error") + } + if jobs.IsPermanent(err) { + t.Errorf("expected non-permanent error; got permanent: %v", err) + } + got, _ := h.repo.GetStep(context.Background(), step.ID) + if got.Attempt != 2 { + t.Errorf("attempt = %d; want 2", got.Attempt) + } + if h.jobs.count() != 0 { + t.Errorf("no next step should be enqueued on retry; got %d", h.jobs.count()) + } +} + +func TestStepHandler_AttemptsExhaustedDeadLetters(t *testing.T) { + h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: false, Retry: true}}, project.PhasePlanning, 1) + step := h.seedStep(project.PhasePlanning) // attempt already 1 == max + + err := h.handler.Handle(context.Background(), jobFor(step)) + if !jobs.IsPermanent(err) { + t.Fatalf("expected permanent error; got %v", err) + } + got, _ := h.repo.GetStep(context.Background(), step.ID) + if got.Status != StepDead { + t.Errorf("step status = %s; want dead", got.Status) + } + gotRun, _ := h.repo.GetRun(context.Background(), h.run.ID) + if gotRun.Status != RunFailed { + t.Errorf("run status = %s; want failed", gotRun.Status) + } + if !h.audit.has("orchestrator.step.dead_letter") { + t.Error("expected dead_letter audit") + } +} + +func TestStepHandler_GateNoRetryImmediateDeadLetter(t *testing.T) { + h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: false, Retry: false, Reason: "refusal"}}, project.PhasePlanning, 5) + step := h.seedStep(project.PhasePlanning) + + err := h.handler.Handle(context.Background(), jobFor(step)) + if !jobs.IsPermanent(err) { + t.Fatalf("expected permanent error on no-retry gate; got %v", err) + } + got, _ := h.repo.GetStep(context.Background(), step.ID) + if got.Status != StepDead { + t.Errorf("step status = %s; want dead", got.Status) + } +} + +func TestStepHandler_PausedRunNoOp(t *testing.T) { + h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhasePlanning, 3) + h.run.Status = RunPaused + h.repo.runs[h.run.ID].Status = RunPaused + step := h.seedStep(project.PhasePlanning) + + if err := h.handler.Handle(context.Background(), jobFor(step)); err != nil { + t.Fatalf("Handle should no-op complete; got %v", err) + } + if h.turns.calls != 0 { + t.Errorf("paused run should not drive a turn; calls=%d", h.turns.calls) + } + if len(h.sess.created) != 0 { + t.Errorf("paused run should not spawn a session; got %d", len(h.sess.created)) + } +} + +func TestStepHandler_DoneMarksRunSucceeded(t *testing.T) { + h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhaseDone, 3) + step := h.seedStep(project.PhaseDone) + + if err := h.handler.Handle(context.Background(), jobFor(step)); err != nil { + t.Fatalf("Handle: %v", err) + } + gotRun, _ := h.repo.GetRun(context.Background(), h.run.ID) + if gotRun.Status != RunSucceeded { + t.Errorf("run status = %s; want succeeded", gotRun.Status) + } + if h.jobs.count() != 0 { + t.Errorf("done should enqueue nothing; got %d", h.jobs.count()) + } + if !h.audit.has("orchestrator.run.succeeded") { + t.Error("expected run.succeeded audit") + } +} + +func TestStepHandler_ResumesCrashedSession(t *testing.T) { + h := newStepHandlerHarness(t, fakeGate{result: GateResult{Pass: true}}, project.PhasePlanning, 3) + step := h.seedStep(project.PhasePlanning) + // 该 step 已有崩溃 session:driveTurn 应走 resume,而非 Create。 + crashedID := uuid.New() + h.lookup.crashed[step.ID] = &acp.Session{ID: crashedID, Status: acp.SessionCrashed} + + if err := h.handler.Handle(context.Background(), jobFor(step)); err != nil { + t.Fatalf("Handle: %v", err) + } + if len(h.sess.created) != 0 { + t.Errorf("should resume not create; created=%d", len(h.sess.created)) + } + got, _ := h.repo.GetStep(context.Background(), step.ID) + if got.ACPSessionID == nil || *got.ACPSessionID != crashedID { + t.Errorf("step should reference resumed session %s; got %v", crashedID, got.ACPSessionID) + } +} diff --git a/internal/project/artifact_service.go b/internal/project/artifact_service.go index 8076345..a7a6b37 100644 --- a/internal/project/artifact_service.go +++ b/internal/project/artifact_service.go @@ -3,7 +3,9 @@ // 设计取舍: // - Version 在 (Requirement, Phase) 内自增(max+1),由 service 计算后落库; // UNIQUE(requirement_id, phase, version) 兜底竞争,Create 捕获 unique_violation 重试 1 次。 -// - Phase 仅允许 ArtifactPhases(planning/prototyping/auditing),与 DB CHECK 一致。 +// - Phase 仅允许 ArtifactPhases(planning/prototyping/auditing/implementing/reviewing),与 DB CHECK 一致。 +// - Approve 给某版本打 verdict(pass/fail);verdict=pass 时把阶段指针指向该产物, +// 供 RequirementService 的阶段网关放行后续 phase。 // - 已 closed requirement:禁止新建产物版本,返回 CodeRequirementClosed。 // - 已归档 project:所有写操作经 loadProjectForWrite 拒绝。 // - 读操作经 loadProjectForRead 校验可见性。 @@ -30,7 +32,7 @@ func NewArtifactService(repo Repository, rec audit.Recorder) ArtifactService { func (s *artifactService) Create(ctx context.Context, c Caller, slug string, reqNumber int, in CreateArtifactInput) (*Artifact, error) { if !in.Phase.IsArtifactPhase() { - return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一") + return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing/implementing/reviewing 之一") } if in.Content == "" { return nil, errs.New(errs.CodeInvalidInput, "content 必填") @@ -82,7 +84,7 @@ func (s *artifactService) createWithRetry(ctx context.Context, reqID, createdBy func (s *artifactService) List(ctx context.Context, c Caller, slug string, reqNumber int, phase Phase) ([]*Artifact, error) { if phase != "" && !phase.IsArtifactPhase() { - return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一") + return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing/implementing/reviewing 之一") } p, err := loadProjectForRead(ctx, s.repo, c, slug) if err != nil { @@ -97,7 +99,7 @@ func (s *artifactService) List(ctx context.Context, c Caller, slug string, reqNu func (s *artifactService) GetByVersion(ctx context.Context, c Caller, slug string, reqNumber int, phase Phase, version int) (*Artifact, error) { if !phase.IsArtifactPhase() { - return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing 之一") + return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing/implementing/reviewing 之一") } p, err := loadProjectForRead(ctx, s.repo, c, slug) if err != nil { @@ -110,6 +112,44 @@ func (s *artifactService) GetByVersion(ctx context.Context, c Caller, slug strin return s.repo.GetArtifactByVersion(ctx, r.ID, phase, version) } +func (s *artifactService) Approve(ctx context.Context, c Caller, slug string, reqNumber int, phase Phase, version int, verdict Verdict) (*Artifact, error) { + if !phase.IsArtifactPhase() { + return nil, errs.New(errs.CodeInvalidInput, "phase 必须是 planning/prototyping/auditing/implementing/reviewing 之一") + } + if verdict != VerdictPass && verdict != VerdictFail { + return nil, errs.New(errs.CodeInvalidInput, "verdict 必须是 pass 或 fail") + } + p, err := loadProjectForWrite(ctx, s.repo, c, slug) + if err != nil { + return nil, err + } + r, err := s.repo.GetRequirementByNumber(ctx, p.ID, reqNumber) + if err != nil { + return nil, err + } + if r.Status == StatusClosed { + return nil, errs.New(errs.CodeRequirementClosed, "已关闭需求禁止批准产物") + } + art, err := s.repo.GetArtifactByVersion(ctx, r.ID, phase, version) + if err != nil { + return nil, err + } + out, err := s.repo.ApproveArtifact(ctx, art.ID, verdict) + if err != nil { + return nil, err + } + // verdict=pass 才把阶段指针指向此产物(网关据此放行);fail 仅落 verdict、不动指针。 + if verdict == VerdictPass { + if _, err := s.repo.UpsertPhasePointer(ctx, r.ID, phase, out.ID, c.UserID); err != nil { + return nil, err + } + } + s.recordAudit(ctx, c, "requirement_artifact.approve", out.ID.String(), map[string]any{ + "requirement_id": r.ID.String(), "phase": string(phase), "version": version, "verdict": string(verdict), + }) + return out, nil +} + func (s *artifactService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) { if s.audit == nil { return diff --git a/internal/project/artifact_service_test.go b/internal/project/artifact_service_test.go index 8efbafc..137aaf7 100644 --- a/internal/project/artifact_service_test.go +++ b/internal/project/artifact_service_test.go @@ -12,7 +12,7 @@ import ( func newArtifactSvc(repo *fakeRepo) (ArtifactService, RequirementService, ProjectService, *spyAudit) { spy := &spyAudit{} - return NewArtifactService(repo, spy), NewRequirementService(repo, spy), NewProjectService(repo, spy), spy + return NewArtifactService(repo, spy), NewRequirementService(repo, spy, nil, nil), NewProjectService(repo, spy), spy } func TestArtifactService_Create_VersionAutoIncrement(t *testing.T) { @@ -68,7 +68,7 @@ func TestArtifactService_Create_RejectsNonArtifactPhase(t *testing.T) { r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) require.NoError(t, err) - for _, phase := range []Phase{PhaseImplementing, PhaseReviewing, PhaseDone, Phase("bogus"), Phase("")} { + for _, phase := range []Phase{PhaseDone, Phase("bogus"), Phase("")} { _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: phase, Content: "x"}) ae, ok := errs.As(err) require.True(t, ok, "phase=%s", phase) @@ -247,3 +247,124 @@ func TestArtifactService_List_ForbiddenForOutsider(t *testing.T) { require.True(t, ok) require.Equal(t, errs.CodeNotFound, ae.Code) } + +func TestArtifactService_Create_AcceptsImplementingReviewing(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + caller := Caller{UserID: owner} + + r, err := reqSvc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "R"}) + require.NoError(t, err) + + // 扩展后的 ArtifactPhases 接受 implementing/reviewing。 + require.True(t, PhaseImplementing.IsArtifactPhase()) + require.True(t, PhaseReviewing.IsArtifactPhase()) + for _, phase := range []Phase{PhaseImplementing, PhaseReviewing} { + a, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: phase, Content: "x"}) + require.NoError(t, err, "phase=%s", phase) + require.Equal(t, VerdictNone, a.Verdict) + } +} + +func TestArtifactService_Approve_SetsVerdictAndPointer(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, _, _, spy := newArtifactSvc(repo) + r := seedRequirement(t, repo, owner, nil) + caller := Caller{UserID: owner} + + _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"}) + require.NoError(t, err) + + out, err := artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass) + require.NoError(t, err) + require.Equal(t, VerdictPass, out.Verdict) + + ptr, err := repo.GetPhasePointer(context.Background(), r.ID, PhaseAuditing) + require.NoError(t, err) + require.NotNil(t, ptr.ApprovedArtifactID) + require.Equal(t, out.ID, *ptr.ApprovedArtifactID) + require.Contains(t, spy.actions(), "requirement_artifact.approve") +} + +func TestArtifactService_Approve_FailDoesNotSetPointer(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, _, _, _ := newArtifactSvc(repo) + r := seedRequirement(t, repo, owner, nil) + caller := Caller{UserID: owner} + + _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"}) + require.NoError(t, err) + + out, err := artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictFail) + require.NoError(t, err) + require.Equal(t, VerdictFail, out.Verdict) + + _, err = repo.GetPhasePointer(context.Background(), r.ID, PhaseAuditing) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeNotFound, ae.Code) +} + +func TestArtifactService_Approve_MovesPointerToNewerVersion(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, _, _, _ := newArtifactSvc(repo) + r := seedRequirement(t, repo, owner, nil) + caller := Caller{UserID: owner} + + _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "v1"}) + require.NoError(t, err) + v2, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "v2"}) + require.NoError(t, err) + + _, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass) + require.NoError(t, err) + _, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 2, VerdictPass) + require.NoError(t, err) + + ptr, err := repo.GetPhasePointer(context.Background(), r.ID, PhaseAuditing) + require.NoError(t, err) + require.Equal(t, v2.ID, *ptr.ApprovedArtifactID) +} + +func TestArtifactService_Approve_BlockedWhenClosed(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, reqSvc, _, _ := newArtifactSvc(repo) + r := seedRequirement(t, repo, owner, nil) + caller := Caller{UserID: owner} + + _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"}) + require.NoError(t, err) + require.NoError(t, reqSvc.Close(context.Background(), caller, "demo", r.Number)) + + _, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeRequirementClosed, ae.Code) +} + +func TestArtifactService_Approve_RejectsInvalidVerdict(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, _, _, _ := newArtifactSvc(repo) + r := seedRequirement(t, repo, owner, nil) + caller := Caller{UserID: owner} + + _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"}) + require.NoError(t, err) + + _, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictNone) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} diff --git a/internal/project/dependency_service_test.go b/internal/project/dependency_service_test.go new file mode 100644 index 0000000..71fbb21 --- /dev/null +++ b/internal/project/dependency_service_test.go @@ -0,0 +1,220 @@ +package project + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// mkIssue 在 demo 项目创建一个游离 issue,返回其编号。 +func mkIssue(t *testing.T, svc IssueService, caller Caller, title string) int { + t.Helper() + i, err := svc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: title}) + require.NoError(t, err) + return i.Number +} + +func TestCreateSubtask_SetsParentAndPriority(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + + parentNum := mkIssue(t, svc, caller, "Parent") + prio := 2 + sub, err := svc.CreateSubtask(context.Background(), caller, "demo", parentNum, + CreateIssueInput{Title: "Child", Priority: &prio}) + require.NoError(t, err) + require.NotNil(t, sub.ParentID) + require.Equal(t, 2, sub.Priority) + + // 父不存在 → NotFound + _, err = svc.CreateSubtask(context.Background(), caller, "demo", 9999, CreateIssueInput{Title: "X"}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeNotFound, ae.Code) +} + +func TestCreateIssue_RejectsBadPriority(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + + bad := 5 + _, err := svc.Create(context.Background(), caller, "demo", CreateIssueInput{Title: "X", Priority: &bad}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} + +func TestAddDependency_RejectsSelf(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + + a := mkIssue(t, svc, caller, "A") + _, err := svc.AddDependency(context.Background(), caller, "demo", + AddDependencyInput{BlockedNumber: a, BlockerNumber: a}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} + +func TestAddDependency_RejectsDuplicate(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + + a := mkIssue(t, svc, caller, "A") + b := mkIssue(t, svc, caller, "B") + _, err := svc.AddDependency(context.Background(), caller, "demo", + AddDependencyInput{BlockedNumber: a, BlockerNumber: b}) + require.NoError(t, err) + _, err = svc.AddDependency(context.Background(), caller, "demo", + AddDependencyInput{BlockedNumber: a, BlockerNumber: b}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} + +func TestAddDependency_RejectsDirectCycle(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + + a := mkIssue(t, svc, caller, "A") + b := mkIssue(t, svc, caller, "B") + // A blocked-by B + _, err := svc.AddDependency(context.Background(), caller, "demo", + AddDependencyInput{BlockedNumber: a, BlockerNumber: b}) + require.NoError(t, err) + // B blocked-by A → 直接环,应拒绝 + _, err = svc.AddDependency(context.Background(), caller, "demo", + AddDependencyInput{BlockedNumber: b, BlockerNumber: a}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} + +func TestAddDependency_RejectsTransitiveCycle(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + + a := mkIssue(t, svc, caller, "A") + b := mkIssue(t, svc, caller, "B") + c := mkIssue(t, svc, caller, "C") + // A blocked-by B, B blocked-by C + _, err := svc.AddDependency(context.Background(), caller, "demo", + AddDependencyInput{BlockedNumber: a, BlockerNumber: b}) + require.NoError(t, err) + _, err = svc.AddDependency(context.Background(), caller, "demo", + AddDependencyInput{BlockedNumber: b, BlockerNumber: c}) + require.NoError(t, err) + // C blocked-by A → A->B->C->A 传递环,应拒绝 + _, err = svc.AddDependency(context.Background(), caller, "demo", + AddDependencyInput{BlockedNumber: c, BlockerNumber: a}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} + +func TestAddDependency_AllowsValidDAG(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + + a := mkIssue(t, svc, caller, "A") + b := mkIssue(t, svc, caller, "B") + c := mkIssue(t, svc, caller, "C") + // A blocked-by B, A blocked-by C —— 合法 DAG(无环) + _, err := svc.AddDependency(context.Background(), caller, "demo", + AddDependencyInput{BlockedNumber: a, BlockerNumber: b}) + require.NoError(t, err) + _, err = svc.AddDependency(context.Background(), caller, "demo", + AddDependencyInput{BlockedNumber: a, BlockerNumber: c}) + require.NoError(t, err) + + blockedBy, blocks, err := svc.ListDependencies(context.Background(), caller, "demo", a) + require.NoError(t, err) + require.Len(t, blockedBy, 2) + require.Len(t, blocks, 0) +} + +func TestRemoveDependency(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + + a := mkIssue(t, svc, caller, "A") + b := mkIssue(t, svc, caller, "B") + _, err := svc.AddDependency(context.Background(), caller, "demo", + AddDependencyInput{BlockedNumber: a, BlockerNumber: b}) + require.NoError(t, err) + + require.NoError(t, svc.RemoveDependency(context.Background(), caller, "demo", a, b)) + // 再删 → NotFound + err = svc.RemoveDependency(context.Background(), caller, "demo", a, b) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeNotFound, ae.Code) + + blockedBy, _, err := svc.ListDependencies(context.Background(), caller, "demo", a) + require.NoError(t, err) + require.Len(t, blockedBy, 0) +} + +func TestListReadyLeafSubtasks_OnlyUnblockedOpen(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc, _, _ := newIssueSvc(repo) + caller := Caller{UserID: owner} + + parent := mkIssue(t, svc, caller, "Parent") + prio1 := 1 + prio3 := 3 + s1, err := svc.CreateSubtask(context.Background(), caller, "demo", parent, CreateIssueInput{Title: "S1", Priority: &prio1}) + require.NoError(t, err) + s2, err := svc.CreateSubtask(context.Background(), caller, "demo", parent, CreateIssueInput{Title: "S2", Priority: &prio3}) + require.NoError(t, err) + blocker := mkIssue(t, svc, caller, "Blocker") + s3, err := svc.CreateSubtask(context.Background(), caller, "demo", parent, CreateIssueInput{Title: "S3"}) + require.NoError(t, err) + // S3 blocked-by Blocker(未关闭)→ 不就绪 + _, err = svc.AddDependency(context.Background(), caller, "demo", + AddDependencyInput{BlockedNumber: s3.Number, BlockerNumber: blocker}) + require.NoError(t, err) + + proj, err := repo.GetProjectBySlug(context.Background(), "demo") + require.NoError(t, err) + ready, err := repo.ListReadyLeafSubtasks(context.Background(), proj.ID, 10) + require.NoError(t, err) + + got := map[uuid.UUID]bool{} + for _, i := range ready { + got[i.ID] = true + } + require.True(t, got[s1.ID]) + require.True(t, got[s2.ID]) + require.False(t, got[s3.ID], "S3 被未关闭 blocker 阻塞,不应就绪") +} diff --git a/internal/project/domain.go b/internal/project/domain.go index 5dc00a0..89af112 100644 --- a/internal/project/domain.go +++ b/internal/project/domain.go @@ -40,9 +40,10 @@ func (p Phase) IsValid() bool { return false } -// ArtifactPhases 是允许产出文档型产物的三个阶段(与 requirement_artifacts.phase -// CHECK 约束一致):实施/验收阶段的产出是代码(走 ACP),不在此列。 -var ArtifactPhases = []Phase{PhasePlanning, PhasePrototyping, PhaseAuditing} +// ArtifactPhases 是允许产出文档型产物的五个阶段(与 requirement_artifacts.phase +// CHECK 约束一致):implementing/reviewing 的产出是审计报告 / 评审记录(也是文档型 +// 产物),done 阶段是汇总视图、不单独建产物,故只留前五阶段。 +var ArtifactPhases = []Phase{PhasePlanning, PhasePrototyping, PhaseAuditing, PhaseImplementing, PhaseReviewing} // IsArtifactPhase 报告 p 是否允许创建产物版本。 func (p Phase) IsArtifactPhase() bool { @@ -54,6 +55,23 @@ func (p Phase) IsArtifactPhase() bool { return false } +// Verdict 是单个产物版本的评审结论,与 requirement_artifacts.verdict CHECK 一致。 +// 'none' 为默认(未评审);'pass' 表示通过、可作为阶段网关的"已批准产物";'fail' +// 表示驳回。仅 'pass' 会被 ArtifactService.Approve 写入阶段指针。 +type Verdict string + +// 评审结论枚举值。 +const ( + VerdictNone Verdict = "none" + VerdictPass Verdict = "pass" + VerdictFail Verdict = "fail" +) + +// IsValid 报告 v 是否在枚举集合内。 +func (v Verdict) IsValid() bool { + return v == VerdictNone || v == VerdictPass || v == VerdictFail +} + // Status 是 Requirement / Issue 通用的开/关状态。 type Status string @@ -127,6 +145,22 @@ type Issue struct { ClosedAt *time.Time CreatedAt time.Time UpdatedAt time.Time + // ParentID 非 nil 表示本 issue 是某 issue 的子任务(任务分解)。父删除时 + // ON DELETE SET NULL 置空(orphan,不级联删子)。 + ParentID *uuid.UUID + // Priority 0..3,越大越早被调度器选取。默认 0(normal)。 + Priority int +} + +// Dependency 表示一条 issue 依赖边:BlockedID 被 BlockerID 阻塞(blocked-by)。 +// BlockedID 仅在 BlockerID 关闭后才"就绪"。ProjectID 冗余存储用于作用域校验。 +type Dependency struct { + ID uuid.UUID + ProjectID uuid.UUID + BlockedID uuid.UUID + BlockerID uuid.UUID + CreatedBy uuid.UUID + CreatedAt time.Time } // Artifact 是某条 Requirement 在某阶段的一个产物文档版本快照。Version 在 @@ -143,6 +177,19 @@ type Artifact struct { SourceMessageID *int64 CreatedBy uuid.UUID CreatedAt time.Time + // Verdict 是该版本的评审结论(none/pass/fail)。新建产物默认 none。 + Verdict Verdict +} + +// PhasePointer 是某条 Requirement 在某阶段的"已批准/当前"产物指针。仅在首次 +// Approve(verdict=pass) 后才存在一行;ApprovedArtifactID 在被指向的产物删除时 +// 由 DB(ON DELETE SET NULL)置空。Done 等阶段网关据此判定前置产物是否就绪。 +type PhasePointer struct { + RequirementID uuid.UUID + Phase Phase + ApprovedArtifactID *uuid.UUID + ApprovedBy *uuid.UUID + ApprovedAt *time.Time } // Caller 表示发起请求的用户上下文。Service 层据此判定鉴权。 @@ -151,6 +198,48 @@ type Caller struct { IsAdmin bool } +// Role 是 project_members.role 的角色枚举(与 0026 迁移的 CHECK 约束一致)。 +// 语义(在 owner+global-admin+internal-visibility 既有规则之上"叠加"授权, +// 只增不减): +// +// admin 读 + 写 + 成员管理(owner 经迁移回填为本角色) +// member 读 + 写(需求/issue 等) +// viewer 只读 +// +// RoleNone 是非成员的零值,仅在代码内部表示"无成员关系",不写入 DB。 +type Role string + +// 角色枚举值。注意 DB CHECK 仅允许 viewer/member/admin;RoleNone 不入库。 +const ( + RoleNone Role = "" + RoleViewer Role = "viewer" + RoleMember Role = "member" + RoleAdmin Role = "admin" +) + +// IsValid 报告 r 是否为可写入 DB 的合法角色(不含 RoleNone)。 +func (r Role) IsValid() bool { + return r == RoleViewer || r == RoleMember || r == RoleAdmin +} + +// CanWrite 报告该角色是否授予写权限(member/admin)。 +func (r Role) CanWrite() bool { return r == RoleMember || r == RoleAdmin } + +// CanRead 报告该角色是否授予读权限(任何已建立的成员关系都可读)。 +func (r Role) CanRead() bool { return r == RoleViewer || r == RoleMember || r == RoleAdmin } + +// CanManageMembers 报告该角色是否可管理项目成员(仅 admin)。 +func (r Role) CanManageMembers() bool { return r == RoleAdmin } + +// Member 是一条 project_members 关系:某用户在某 project 的角色。 +type Member struct { + ProjectID uuid.UUID + UserID uuid.UUID + Role Role + AddedBy *uuid.UUID + CreatedAt time.Time +} + // ProjectService 暴露 Project 聚合根的应用服务方法。包内有三个并列服务 // (Project/Requirement/Issue),这里保留聚合名前缀以避免歧义。 // @@ -163,6 +252,18 @@ type ProjectService interface { Update(ctx context.Context, c Caller, slug string, in UpdateProjectInput) (*Project, error) Archive(ctx context.Context, c Caller, slug string) error Unarchive(ctx context.Context, c Caller, slug string) error + + // ===== 成员/角色 ACL(autonomy roadmap §11)===== + + // ListMembers 返回 slug 项目的全部成员。要求 caller 对项目可读。 + ListMembers(ctx context.Context, c Caller, slug string) ([]*Member, error) + // AddMember 新增或更新一名成员的角色。仅 project-admin 或 global-admin 可调用。 + AddMember(ctx context.Context, c Caller, slug string, in AddMemberInput) (*Member, error) + // RemoveMember 移除一名成员。仅 project-admin 或 global-admin 可调用。 + RemoveMember(ctx context.Context, c Caller, slug string, userID uuid.UUID) error + // MemberRole 返回 userID 在 projectID 的成员角色(非成员返回 RoleNone)。 + // 供 app 层 projectACL 桥接成员感知鉴权;不做可见性校验(调用方已校验)。 + MemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error) } // RequirementService 暴露 Requirement 聚合根的应用服务方法。 @@ -185,6 +286,19 @@ type IssueService interface { Assign(ctx context.Context, c Caller, projectSlug string, number int, assignee *uuid.UUID) (*Issue, error) Close(ctx context.Context, c Caller, projectSlug string, number int) error Reopen(ctx context.Context, c Caller, projectSlug string, number int) error + + // ===== 任务分解 / 依赖图(autonomy roadmap §10)===== + + // CreateSubtask 在 parentNumber 指向的 issue 下创建一个子任务(设置 parent_id + + // priority),继承 project。父 issue 须与目标 project 同属。 + CreateSubtask(ctx context.Context, c Caller, projectSlug string, parentNumber int, in CreateIssueInput) (*Issue, error) + // AddDependency 新增一条依赖边:blocked 被 blocker 阻塞。拒绝自依赖、跨 project、 + // 重复、以及会形成环的边(沿 blocked-by 边做可达性检查)。 + AddDependency(ctx context.Context, c Caller, projectSlug string, in AddDependencyInput) (*Dependency, error) + // RemoveDependency 删除一条依赖边(plan 修正用)。边不存在返回 NotFound。 + RemoveDependency(ctx context.Context, c Caller, projectSlug string, blockedNumber, blockerNumber int) error + // ListDependencies 返回 number 指向 issue 的 (被谁阻塞 blockedBy, 阻塞了谁 blocks)。 + ListDependencies(ctx context.Context, c Caller, projectSlug string, number int) (blockedBy []*Issue, blocks []*Issue, err error) } // ArtifactService 暴露 Requirement 阶段产物版本的应用服务方法。 @@ -193,6 +307,46 @@ type ArtifactService interface { // List 的 phase 传零值("")表示返回全部阶段产物(done 阶段汇总视图用)。 List(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase) ([]*Artifact, error) GetByVersion(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase, version int) (*Artifact, error) + // Approve 给指定 (phase, version) 产物打 verdict(pass/fail)。verdict==pass 时 + // 同时把该阶段指针指向此产物(阶段网关据此放行后续 phase)。verdict==fail 仅 + // 落 verdict、不动指针。已关闭 requirement 拒绝批准。 + Approve(ctx context.Context, c Caller, projectSlug string, reqNumber int, phase Phase, version int, verdict Verdict) (*Artifact, error) +} + +// PhaseChangeEvent 是一次 requirement phase 流转的领域事件载荷。下游自动化 +// (如 webhook、触发 ACP run)通过 PhaseEventEmitter 订阅。 +type PhaseChangeEvent struct { + ProjectID uuid.UUID + ProjectSlug string + RequirementID uuid.UUID + RequirementNumber int + From Phase + To Phase + ActorID uuid.UUID + // OwnerID 是该需求的 owner——phase 变更通知的主要接收人(owner 关心进度,而非 + // 发起变更的 actor,后者可能是编排器/其它协作者)。 + OwnerID uuid.UUID +} + +// PhaseEventEmitter 把 phase 流转事件投递到通知/自动化总线。project 是领域模块, +// 不直接 import infra/notify;由 app 层用 notify.Dispatcher 实现该窄接口。 +// 实现必须是 fire-and-forget 语义:投递失败不得阻塞 phase 流转。 +type PhaseEventEmitter interface { + EmitPhaseChange(ctx context.Context, e PhaseChangeEvent) +} + +// WorkspaceStateLookup 是 Done 网关查询 workspace PR/合并状态的窄接口。由 app +// 层用 changerequest/workspace 服务实现,避免 project 直接依赖那些模块。 +type WorkspaceStateLookup interface { + // PullRequestMerged 报告该 requirement 是否已有合入主干的 PR(merged)。按 + // requirementID 过滤而非整个 workspace,避免同一 workspace 下其它需求的已合 PR 误判。 + PullRequestMerged(ctx context.Context, workspaceID, requirementID uuid.UUID) (bool, error) +} + +// GateRunner 在 ChangePhase 写库前运行目标 phase 的所有 entry 网关,全部通过才 +// 返回 nil;任一失败返回 CodePhaseGateFailed。实现见 phase_gate.go。 +type GateRunner interface { + Check(ctx context.Context, r *Requirement, from, to Phase) error } // ===== 输入 DTO ===== @@ -216,6 +370,13 @@ type UpdateProjectInput struct { Visibility *Visibility } +// AddMemberInput 携带新增/更新一名 project 成员所需字段。Role 必须是合法角色 +// (viewer/member/admin)。 +type AddMemberInput struct { + UserID uuid.UUID + Role Role +} + // CreateRequirementInput 创建一条需求。OwnerID 为 nil 时由 Service 默认为调用者。 // WorkspaceID 为 nil 表示不关联任何 workspace;非 nil 时直接落库(FK 由 PG 校验)。 type CreateRequirementInput struct { @@ -257,6 +418,18 @@ type CreateIssueInput struct { RequirementNumber *int AssigneeID *uuid.UUID WorkspaceID *uuid.UUID + // ParentNumber 非 nil 表示创建为该编号 issue 的子任务(任务分解)。Service 校验 + // 父 issue 与目标 project 同属。 + ParentNumber *int + // Priority 非 nil 时设置优先级(0..3);nil 时默认 0。 + Priority *int +} + +// AddDependencyInput 携带新增依赖边的两端 issue 编号。语义:BlockedNumber 被 +// BlockerNumber 阻塞。两端须在同一 project(由 Service 在同 project 上下文解析保证)。 +type AddDependencyInput struct { + BlockedNumber int + BlockerNumber int } // CreateArtifactInput 创建一个阶段产物版本。Version 由 Service 自动计算 diff --git a/internal/project/handler.go b/internal/project/handler.go index e2508a2..cca8c27 100644 --- a/internal/project/handler.go +++ b/internal/project/handler.go @@ -60,6 +60,12 @@ func (h *Handler) Mount(r chi.Router) { r.Post("/{slug}/archive", h.archiveProject) r.Post("/{slug}/unarchive", h.unarchiveProject) + // 成员/角色 ACL(autonomy roadmap §11):list 需可读;add/remove 仅 + // project-admin 或 global-admin(service 层 assertCanManageMembers 兜底)。 + r.Get("/{slug}/members", h.listMembers) + r.Post("/{slug}/members", h.addMember) + r.Delete("/{slug}/members/{userID}", h.removeMember) + r.Post("/{slug}/requirements", h.createRequirement) r.Get("/{slug}/requirements", h.listRequirements) r.Get("/{slug}/requirements/{number}", h.getRequirement) @@ -70,6 +76,7 @@ func (h *Handler) Mount(r chi.Router) { r.Post("/{slug}/requirements/{number}/artifacts", h.createArtifact) r.Get("/{slug}/requirements/{number}/artifacts", h.listArtifacts) r.Get("/{slug}/requirements/{number}/artifacts/{phase}/{version}", h.getArtifact) + r.Post("/{slug}/requirements/{number}/artifacts/{phase}/{version}/approve", h.approveArtifact) r.Post("/{slug}/issues", h.createIssue) r.Get("/{slug}/issues", h.listIssues) @@ -171,6 +178,29 @@ type artifactDTO struct { SourceMessageID *int64 `json:"source_message_id,omitempty"` CreatedBy string `json:"created_by"` CreatedAt string `json:"created_at"` + Verdict string `json:"verdict"` +} + +type memberDTO struct { + ProjectID string `json:"project_id"` + UserID string `json:"user_id"` + Role string `json:"role"` + AddedBy *string `json:"added_by,omitempty"` + CreatedAt string `json:"created_at"` +} + +func toMemberDTO(m *Member) memberDTO { + d := memberDTO{ + ProjectID: m.ProjectID.String(), + UserID: m.UserID.String(), + Role: string(m.Role), + CreatedAt: m.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + } + if m.AddedBy != nil { + s := m.AddedBy.String() + d.AddedBy = &s + } + return d } func toProjectDTO(p *Project) projectDTO { @@ -240,6 +270,7 @@ func toArtifactDTO(a *Artifact) artifactDTO { SourceMessageID: a.SourceMessageID, CreatedBy: a.CreatedBy.String(), CreatedAt: a.CreatedAt.UTC().Format("2006-01-02T15:04:05Z"), + Verdict: string(a.Verdict), } } @@ -364,6 +395,75 @@ func (h *Handler) toggleArchive(w http.ResponseWriter, r *http.Request, archive w.WriteHeader(http.StatusNoContent) } +// ===== Member endpoints(autonomy roadmap §11)===== + +type addMemberReq struct { + UserID string `json:"user_id"` + Role string `json:"role"` +} + +func (h *Handler) listMembers(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + out, err := h.projects.ListMembers(r.Context(), c, chi.URLParam(r, "slug")) + if err != nil { + writeErr(w, r, err) + return + } + dtos := make([]memberDTO, 0, len(out)) + for _, m := range out { + dtos = append(dtos, toMemberDTO(m)) + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"items": dtos}) +} + +func (h *Handler) addMember(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + var req addMemberReq + if err := decodeBody(r, &req); err != nil { + writeErr(w, r, err) + return + } + uid, err := uuid.Parse(req.UserID) + if err != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 非法")) + return + } + out, err := h.projects.AddMember(r.Context(), c, chi.URLParam(r, "slug"), AddMemberInput{ + UserID: uid, Role: Role(req.Role), + }) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toMemberDTO(out)) +} + +func (h *Handler) removeMember(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + uid, err := uuid.Parse(chi.URLParam(r, "userID")) + if err != nil { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "user_id 非法")) + return + } + if err := h.projects.RemoveMember(r.Context(), c, chi.URLParam(r, "slug"), uid); err != nil { + writeErr(w, r, err) + return + } + w.WriteHeader(http.StatusNoContent) +} + // ===== Requirement endpoints ===== type createRequirementReq struct { @@ -672,6 +772,40 @@ func (h *Handler) getArtifact(w http.ResponseWriter, r *http.Request) { httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(out)) } +type approveArtifactReq struct { + Verdict string `json:"verdict"` +} + +func (h *Handler) approveArtifact(w http.ResponseWriter, r *http.Request) { + c, err := h.callerFromCtx(r) + if err != nil { + writeErr(w, r, err) + return + } + num, err := numberParam(r) + if err != nil { + writeErr(w, r, err) + return + } + ver, err := strconv.Atoi(chi.URLParam(r, "version")) + if err != nil || ver <= 0 { + writeErr(w, r, errs.New(errs.CodeInvalidInput, "version 非法")) + return + } + var req approveArtifactReq + if err := decodeBody(r, &req); err != nil { + writeErr(w, r, err) + return + } + phase := Phase(chi.URLParam(r, "phase")) + out, err := h.artifacts.Approve(r.Context(), c, chi.URLParam(r, "slug"), num, phase, ver, Verdict(req.Verdict)) + if err != nil { + writeErr(w, r, err) + return + } + httpx.WriteJSON(w, http.StatusOK, toArtifactDTO(out)) +} + // ===== Issue endpoints ===== type createIssueReq struct { diff --git a/internal/project/handler_test.go b/internal/project/handler_test.go index 5b30221..1bdc103 100644 --- a/internal/project/handler_test.go +++ b/internal/project/handler_test.go @@ -32,7 +32,8 @@ func mountFullHandler(t *testing.T, ownerToken string, ownerID uuid.UUID) (*fake t.Helper() repo := newFakeRepo() pSvc := NewProjectService(repo, nil) - rSvc := NewRequirementService(repo, nil) + gate := NewGateRunner(GateDeps{Repo: repo, Workspace: nil}, nil) + rSvc := NewRequirementService(repo, nil, nil, gate) iSvc := NewIssueService(repo, nil) artSvc := NewArtifactService(repo, nil) resolver := &fakeResolver{valid: map[string]uuid.UUID{ownerToken: ownerID}} @@ -235,9 +236,9 @@ func TestHandler_Artifacts_Routes(t *testing.T) { checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok, map[string]any{"phase": "auditing", "content": "# audit"}, http.StatusCreated) - // 非法 phase → 400 + // 非法 phase(done 不在 ArtifactPhases)→ 400 checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok, - map[string]any{"phase": "implementing", "content": "x"}, http.StatusBadRequest) + map[string]any{"phase": "done", "content": "x"}, http.StatusBadRequest) // list 裸数组:全量 2 条;?phase=planning 过滤 1 条 respList := doJSON(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts", tok, nil) @@ -265,3 +266,30 @@ func TestHandler_Artifacts_Routes(t *testing.T) { checkStatus(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/bogus/1", tok, nil, http.StatusBadRequest) checkStatus(t, h, "GET", "/api/v1/projects/demo/requirements/1/artifacts/planning/9", tok, nil, http.StatusNotFound) } + +// TestHandler_ApproveArtifactAndGatedPhase 验证 approve 端点回写 verdict,且未满足 +// gate 的 phase 流转返回 409 phase_gate_failed,批准后放行 200。 +func TestHandler_ApproveArtifactAndGatedPhase(t *testing.T) { + owner := uuid.New() + tok := "tok" + _, h := mountFullHandler(t, tok, owner) + checkStatus(t, h, "POST", "/api/v1/projects/", tok, map[string]any{"slug": "demo", "name": "Demo"}, http.StatusCreated) + checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements", tok, map[string]any{"title": "R"}, http.StatusCreated) + checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts", tok, + map[string]any{"phase": "auditing", "content": "# audit"}, http.StatusCreated) + + // 未批准 auditing 产物时流转到 implementing → 409 phase_gate_failed + checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/phase", tok, + map[string]any{"phase": "implementing"}, http.StatusConflict) + + // approve 端点回写 verdict + respApprove := doJSON(t, h, "POST", "/api/v1/projects/demo/requirements/1/artifacts/auditing/1/approve", tok, + map[string]any{"verdict": "pass"}) + require.Equal(t, http.StatusOK, respApprove.StatusCode) + approved := decodeBodyMap(t, respApprove) + require.Equal(t, "pass", approved["verdict"]) + + // 批准后流转放行 → 200 + checkStatus(t, h, "POST", "/api/v1/projects/demo/requirements/1/phase", tok, + map[string]any{"phase": "implementing"}, http.StatusOK) +} diff --git a/internal/project/issue_service.go b/internal/project/issue_service.go index 08f6c51..979b0bb 100644 --- a/internal/project/issue_service.go +++ b/internal/project/issue_service.go @@ -59,11 +59,20 @@ func (s *issueService) Create(ctx context.Context, c Caller, slug string, in Cre if r != nil { reqID = &r.ID } + parentID, err := s.resolveParent(ctx, p, in.ParentNumber) + if err != nil { + return nil, err + } + prio, err := normalizePriority(in.Priority) + if err != nil { + return nil, err + } in1 := &Issue{ ID: uuid.New(), ProjectID: p.ID, RequirementID: reqID, WorkspaceID: in.WorkspaceID, Title: in.Title, Description: in.Description, AssigneeID: in.AssigneeID, CreatedBy: c.UserID, + ParentID: parentID, Priority: prio, } out, err := s.createWithRetry(ctx, in1) if err != nil { @@ -76,10 +85,38 @@ func (s *issueService) Create(ctx context.Context, c Caller, slug string, in Cre if in.WorkspaceID != nil { meta["workspace_id"] = in.WorkspaceID.String() } + if parentID != nil { + meta["parent_id"] = parentID.String() + } s.recordAudit(ctx, c, "issue.create", out.ID.String(), meta) return out, nil } +// resolveParent 把 *number 解析为父 issue 的 *id,并校验同 project。number == nil → +// (nil, nil)。父 issue 不存在 → NotFound。 +func (s *issueService) resolveParent(ctx context.Context, p *Project, number *int) (*uuid.UUID, error) { + if number == nil { + return nil, nil + } + parent, err := s.repo.GetIssueByNumber(ctx, p.ID, *number) + if err != nil { + return nil, err + } + id := parent.ID + return &id, nil +} + +// normalizePriority 校验 priority 在 0..3,nil → 0(默认)。 +func normalizePriority(p *int) (int, error) { + if p == nil { + return 0, nil + } + if *p < 0 || *p > 3 { + return 0, errs.New(errs.CodeInvalidInput, "priority 必须在 0..3") + } + return *p, nil +} + func (s *issueService) createWithRetry(ctx context.Context, in *Issue) (*Issue, error) { for attempt := 0; attempt < 2; attempt++ { out, err := s.repo.CreateIssue(ctx, in) @@ -267,6 +304,137 @@ func (s *issueService) Reopen(ctx context.Context, c Caller, slug string, number return nil } +// ===== 任务分解 / 依赖图(autonomy roadmap §10)===== + +// CreateSubtask 在 parentNumber 指向的 issue 下创建子任务。复用 Create 的校验/重试/审计, +// 把 parentNumber 注入 CreateIssueInput.ParentNumber。 +func (s *issueService) CreateSubtask(ctx context.Context, c Caller, slug string, parentNumber int, in CreateIssueInput) (*Issue, error) { + pn := parentNumber + in.ParentNumber = &pn + out, err := s.Create(ctx, c, slug, in) + if err != nil { + return nil, err + } + s.recordAudit(ctx, c, "issue.create_subtask", out.ID.String(), + map[string]any{"parent_number": parentNumber, "number": out.Number}) + return out, nil +} + +// AddDependency 新增依赖边:blocked 被 blocker 阻塞。拒绝自依赖、跨 project、重复、成环。 +func (s *issueService) AddDependency(ctx context.Context, c Caller, slug string, in AddDependencyInput) (*Dependency, error) { + if in.BlockedNumber == in.BlockerNumber { + return nil, errs.New(errs.CodeInvalidInput, "issue 不能依赖自身") + } + p, err := loadProjectForWrite(ctx, s.repo, c, slug) + if err != nil { + return nil, err + } + blocked, err := s.repo.GetIssueByNumber(ctx, p.ID, in.BlockedNumber) + if err != nil { + return nil, err + } + blocker, err := s.repo.GetIssueByNumber(ctx, p.ID, in.BlockerNumber) + if err != nil { + return nil, err + } + // 环检查:新增 "blocked blocked-by blocker" 成环,当且仅当 blocked 已能沿 blocked-by + // 边到达 blocker(即 blocker 已(传递地)依赖 blocked)。从 blocker 出发遍历其 + // blocker 集合,若到达 blocked 则成环。 + cyclic, err := s.reachable(ctx, blocker.ID, blocked.ID) + if err != nil { + return nil, err + } + if cyclic { + return nil, errs.New(errs.CodeInvalidInput, "新增依赖会形成环") + } + dep := &Dependency{ + ID: uuid.New(), ProjectID: p.ID, + BlockedID: blocked.ID, BlockerID: blocker.ID, CreatedBy: c.UserID, + } + out, err := s.repo.CreateDependency(ctx, dep) + if err != nil { + return nil, err + } + s.recordAudit(ctx, c, "issue.add_dependency", out.ID.String(), + map[string]any{"blocked_number": in.BlockedNumber, "blocker_number": in.BlockerNumber}) + return out, nil +} + +// reachable 报告从 start 出发沿 blocked-by 边(start 的 blocker、blocker 的 blocker …) +// 是否能到达 target。迭代 BFS + visited 防环死循环。 +func (s *issueService) reachable(ctx context.Context, start, target uuid.UUID) (bool, error) { + if start == target { + return true, nil + } + visited := map[uuid.UUID]bool{start: true} + queue := []uuid.UUID{start} + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + blockers, err := s.repo.ListBlockerIDs(ctx, cur) + if err != nil { + return false, err + } + for _, b := range blockers { + if b == target { + return true, nil + } + if !visited[b] { + visited[b] = true + queue = append(queue, b) + } + } + } + return false, nil +} + +// RemoveDependency 删除一条依赖边。边不存在返回 NotFound。 +func (s *issueService) RemoveDependency(ctx context.Context, c Caller, slug string, blockedNumber, blockerNumber int) error { + p, err := loadProjectForWrite(ctx, s.repo, c, slug) + if err != nil { + return err + } + blocked, err := s.repo.GetIssueByNumber(ctx, p.ID, blockedNumber) + if err != nil { + return err + } + blocker, err := s.repo.GetIssueByNumber(ctx, p.ID, blockerNumber) + if err != nil { + return err + } + n, err := s.repo.DeleteDependency(ctx, p.ID, blocked.ID, blocker.ID) + if err != nil { + return err + } + if n == 0 { + return errs.New(errs.CodeNotFound, "依赖不存在") + } + s.recordAudit(ctx, c, "issue.remove_dependency", blocked.ID.String(), + map[string]any{"blocked_number": blockedNumber, "blocker_number": blockerNumber}) + return nil +} + +// ListDependencies 返回 (blockedBy: 阻塞本 issue 的 issues, blocks: 被本 issue 阻塞的 issues)。 +func (s *issueService) ListDependencies(ctx context.Context, c Caller, slug string, number int) ([]*Issue, []*Issue, error) { + p, err := loadProjectForRead(ctx, s.repo, c, slug) + if err != nil { + return nil, nil, err + } + iss, err := s.repo.GetIssueByNumber(ctx, p.ID, number) + if err != nil { + return nil, nil, err + } + blockedBy, err := s.repo.ListBlockers(ctx, iss.ID) + if err != nil { + return nil, nil, err + } + blocks, err := s.repo.ListBlocked(ctx, iss.ID) + if err != nil { + return nil, nil, err + } + return blockedBy, blocks, nil +} + func (s *issueService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) { if s.audit == nil { return diff --git a/internal/project/issue_service_test.go b/internal/project/issue_service_test.go index d41325a..ee6326b 100644 --- a/internal/project/issue_service_test.go +++ b/internal/project/issue_service_test.go @@ -12,7 +12,7 @@ import ( func newIssueSvc(repo *fakeRepo) (IssueService, RequirementService, *spyAudit) { spy := &spyAudit{} - return NewIssueService(repo, spy), NewRequirementService(repo, spy), spy + return NewIssueService(repo, spy), NewRequirementService(repo, spy, nil, nil), spy } func TestIssueService_Create_FreelanceAndAttached(t *testing.T) { diff --git a/internal/project/members_test.go b/internal/project/members_test.go new file mode 100644 index 0000000..2fc05ef --- /dev/null +++ b/internal/project/members_test.go @@ -0,0 +1,254 @@ +package project + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// 成员/角色 ACL(autonomy roadmap §11)测试。证明:member(editor) 可写、viewer +// 只读、非成员对 private 仍被拒、owner/admin 行为不变、project-admin 可管理成员、 +// 跨项目成员隔离。 + +func TestMembers_ViewerCanReadNotWrite(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + viewer := uuid.New() + p := seedProject(t, repo, owner) // private + _, err := repo.UpsertMember(context.Background(), p.ID, viewer, RoleViewer, &owner) + require.NoError(t, err) + + svc := NewProjectService(repo, nil) + c := Caller{UserID: viewer} + + // 读:viewer 可读 private 项目(成员叠加读权限)。 + got, err := svc.Get(context.Background(), c, "demo") + require.NoError(t, err) + require.Equal(t, "demo", got.Slug) + + // 写:viewer 不可写。能读但不能写 → forbidden(其存在性已可见)。 + name := "Renamed" + _, err = svc.Update(context.Background(), c, "demo", UpdateProjectInput{Name: &name}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeForbidden, ae.Code) +} + +func TestMembers_EditorCanWriteRequirements(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + editor := uuid.New() + p := seedProject(t, repo, owner) // private + _, err := repo.UpsertMember(context.Background(), p.ID, editor, RoleMember, &owner) + require.NoError(t, err) + + reqSvc := NewRequirementService(repo, nil, nil, nil) + c := Caller{UserID: editor} + + // member 角色叠加写权限:可在 private 项目创建需求。 + got, err := reqSvc.Create(context.Background(), c, "demo", CreateRequirementInput{Title: "feat"}) + require.NoError(t, err) + require.Equal(t, "feat", got.Title) + require.Equal(t, editor, got.OwnerID, "默认 owner 为创建者") + + // member 也可读。 + _, err = reqSvc.Get(context.Background(), c, "demo", got.Number) + require.NoError(t, err) +} + +func TestMembers_ViewerCannotWriteRequirements(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + viewer := uuid.New() + p := seedProject(t, repo, owner) + _, err := repo.UpsertMember(context.Background(), p.ID, viewer, RoleViewer, &owner) + require.NoError(t, err) + + reqSvc := NewRequirementService(repo, nil, nil, nil) + _, err = reqSvc.Create(context.Background(), Caller{UserID: viewer}, "demo", CreateRequirementInput{Title: "x"}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeForbidden, ae.Code, "viewer 能读但不能写 → forbidden") + + // viewer 仍可读需求列表。 + _, err = reqSvc.List(context.Background(), Caller{UserID: viewer}, "demo", RequirementFilter{}) + require.NoError(t, err) +} + +func TestMembers_NonMemberDeniedOnPrivate(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) // private, 无其它成员 + svc := NewProjectService(repo, nil) + + stranger := Caller{UserID: uuid.New()} + // 非成员看 private:屏蔽存在性 → not_found(既有防探测语义保持)。 + _, err := svc.Get(context.Background(), stranger, "demo") + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeNotFound, ae.Code) + + // 非成员写:同样 not_found(既有语义)。 + name := "X" + _, err = svc.Update(context.Background(), stranger, "demo", UpdateProjectInput{Name: &name}) + ae, ok = errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeNotFound, ae.Code) +} + +func TestMembers_OwnerAndAdminUnchanged(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) // private, 无 project_members 行(模拟未回填或被移除) + svc := NewProjectService(repo, nil) + + // owner 即便没有 project_members 行也可读写(OwnerID 兜底)。 + name := "ByOwner" + _, err := svc.Update(context.Background(), Caller{UserID: owner}, "demo", UpdateProjectInput{Name: &name}) + require.NoError(t, err) + + // global-admin 旁路:可读写任意 private 项目。 + name2 := "ByAdmin" + _, err = svc.Update(context.Background(), Caller{UserID: uuid.New(), IsAdmin: true}, "demo", UpdateProjectInput{Name: &name2}) + require.NoError(t, err) + got, err := svc.Get(context.Background(), Caller{UserID: uuid.New(), IsAdmin: true}, "demo") + require.NoError(t, err) + require.Equal(t, "ByAdmin", got.Name) +} + +func TestMembers_ProjectAdminCanManageMembers(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + padmin := uuid.New() + target := uuid.New() + p := seedProject(t, repo, owner) + _, err := repo.UpsertMember(context.Background(), p.ID, padmin, RoleAdmin, &owner) + require.NoError(t, err) + + svc := NewProjectService(repo, nil) + cAdmin := Caller{UserID: padmin} + + // project-admin 可新增成员。 + m, err := svc.AddMember(context.Background(), cAdmin, "demo", AddMemberInput{UserID: target, Role: RoleMember}) + require.NoError(t, err) + require.Equal(t, RoleMember, m.Role) + + // project-admin 可改角色(upsert)。 + m, err = svc.AddMember(context.Background(), cAdmin, "demo", AddMemberInput{UserID: target, Role: RoleViewer}) + require.NoError(t, err) + require.Equal(t, RoleViewer, m.Role) + + // project-admin 可列成员。 + members, err := svc.ListMembers(context.Background(), cAdmin, "demo") + require.NoError(t, err) + require.NotEmpty(t, members) + + // project-admin 可移除成员。 + require.NoError(t, svc.RemoveMember(context.Background(), cAdmin, "demo", target)) + role, err := repo.GetMemberRole(context.Background(), p.ID, target) + require.NoError(t, err) + require.Equal(t, RoleNone, role) + + // 不能移除 owner。 + err = svc.RemoveMember(context.Background(), cAdmin, "demo", owner) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeForbidden, ae.Code) +} + +func TestMembers_NonAdminCannotManageMembers(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + editor := uuid.New() + p := seedProject(t, repo, owner) + _, err := repo.UpsertMember(context.Background(), p.ID, editor, RoleMember, &owner) + require.NoError(t, err) + + svc := NewProjectService(repo, nil) + // member(editor) 不可管理成员 → forbidden(能读,但无管理权)。 + _, err = svc.AddMember(context.Background(), Caller{UserID: editor}, "demo", AddMemberInput{UserID: uuid.New(), Role: RoleViewer}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeForbidden, ae.Code) + + // 非成员对 private 管理成员 → not_found 防探测。 + _, err = svc.AddMember(context.Background(), Caller{UserID: uuid.New()}, "demo", AddMemberInput{UserID: uuid.New(), Role: RoleViewer}) + ae, ok = errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeNotFound, ae.Code) +} + +func TestMembers_AddMemberValidatesInput(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + svc := NewProjectService(repo, nil) + + // 非法角色被拒。 + _, err := svc.AddMember(context.Background(), Caller{UserID: owner}, "demo", AddMemberInput{UserID: uuid.New(), Role: Role("superuser")}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) + + // 空 user_id 被拒。 + _, err = svc.AddMember(context.Background(), Caller{UserID: owner}, "demo", AddMemberInput{UserID: uuid.Nil, Role: RoleViewer}) + ae, ok = errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeInvalidInput, ae.Code) +} + +func TestMembers_CrossProjectIsolation(t *testing.T) { + repo := newFakeRepo() + ownerA := uuid.New() + ownerB := uuid.New() + pa := seedProject(t, repo, ownerA) // slug "demo" + + pb := &Project{ID: uuid.New(), Slug: "other", Name: "Other", Visibility: VisibilityPrivate, OwnerID: ownerB} + _, err := repo.CreateProject(context.Background(), pb) + require.NoError(t, err) + + // 用户是 A 项目的 member,但不是 B 项目成员。 + member := uuid.New() + _, err = repo.UpsertMember(context.Background(), pa.ID, member, RoleMember, &ownerA) + require.NoError(t, err) + + svc := NewProjectService(repo, nil) + + // A 项目可写。 + nameA := "A-renamed" + _, err = svc.Update(context.Background(), Caller{UserID: member}, "demo", UpdateProjectInput{Name: &nameA}) + require.NoError(t, err) + + // B 项目(private,非成员)→ not_found。 + nameB := "B-renamed" + _, err = svc.Update(context.Background(), Caller{UserID: member}, "other", UpdateProjectInput{Name: &nameB}) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeNotFound, ae.Code) +} + +func TestMembers_ListIncludesMembershipProjects(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + pa := seedProject(t, repo, owner) // private "demo" + + viewer := uuid.New() + _, err := repo.UpsertMember(context.Background(), pa.ID, viewer, RoleViewer, &owner) + require.NoError(t, err) + + svc := NewProjectService(repo, nil) + // viewer 列项目应包含其作为成员的 private 项目。 + got, err := svc.List(context.Background(), Caller{UserID: viewer}, false) + require.NoError(t, err) + require.Len(t, got, 1) + require.Equal(t, "demo", got[0].Slug) + + // 完全无关用户列不到该 private 项目。 + got, err = svc.List(context.Background(), Caller{UserID: uuid.New()}, false) + require.NoError(t, err) + require.Empty(t, got) +} diff --git a/internal/project/phase_gate.go b/internal/project/phase_gate.go new file mode 100644 index 0000000..3f69e08 --- /dev/null +++ b/internal/project/phase_gate.go @@ -0,0 +1,116 @@ +// Package project 内的 phase_gate.go 实现"阶段状态机网关":把原来 ChangePhase +// 的 any-to-any 替换为按目标 phase 声明 entry 网关的谓词引擎。 +// +// 设计取舍: +// - 网关只在"流转进入"目标 phase 时运行,不校验已有状态——历史上已处于 +// implementing/done 的需求(迁移前数据)天然被豁免,无需数据迁移。 +// - 未声明网关的目标 phase(planning/prototyping/auditing/reviewing)保持 +// any-to-any 旧行为(空网关列表 = 永远放行)。 +// - 网关依赖通过 GateDeps 注入(Repo 查阶段指针、Workspace 查 PR 合并状态), +// 便于单测用 fake 覆盖;Done 网关在 Workspace 缺失时按"未合并"优雅降级。 +package project + +import ( + "context" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// PhaseGate 是单个 entry 谓词:返回 nil 表示 from->to 对 r 允许。 +type PhaseGate interface { + Check(ctx context.Context, dep GateDeps, r *Requirement, from, to Phase) error +} + +// GateDeps 是网关运行所需的外部依赖。Workspace 可为 nil(Done 网关据此降级为 +// "未合并",从而阻止流转——更安全的默认)。 +type GateDeps struct { + Repo Repository + Workspace WorkspaceStateLookup +} + +// phaseGates 是 target phase -> entry 网关列表的转换表。只有 implementing/done +// 声明网关;其余 phase 列表为空(保持 any-to-any)。 +// +// implementing: 需要 auditing 阶段有 verdict=pass 的已批准产物(阶段指针就绪) +// done: 需要 requirement 关联 workspace 且其 PR 已合并 +var phaseGates = map[Phase][]PhaseGate{ + PhaseImplementing: {requireApprovedArtifact(PhaseAuditing)}, + PhaseDone: {requireWorkspaceMerged{}}, +} + +// gateRunner 是 GateRunner 的默认实现:解析 to 的网关列表并逐个运行。 +type gateRunner struct { + gates map[Phase][]PhaseGate + dep GateDeps +} + +// NewGateRunner 用转换表与依赖构造 GateRunner。gates 传 nil 时用包内默认表。 +func NewGateRunner(dep GateDeps, gates map[Phase][]PhaseGate) GateRunner { + if gates == nil { + gates = phaseGates + } + return &gateRunner{gates: gates, dep: dep} +} + +// Check 运行目标 phase 的全部 entry 网关,遇首个失败即返回。 +func (g *gateRunner) Check(ctx context.Context, r *Requirement, from, to Phase) error { + for _, gate := range g.gates[to] { + if err := gate.Check(ctx, g.dep, r, from, to); err != nil { + return err + } + } + return nil +} + +// requireApprovedArtifact 网关:命名 phase 必须存在已批准(指针非空)且其产物 +// verdict==pass 的产物。implementing 据此要求 auditing 报告先过审。 +type requireApprovedArtifact Phase + +func (g requireApprovedArtifact) Check(ctx context.Context, dep GateDeps, r *Requirement, _, to Phase) error { + gatedPhase := Phase(g) + ptr, err := dep.Repo.GetPhasePointer(ctx, r.ID, gatedPhase) + if err != nil { + if ae, ok := errs.As(err); ok && ae.Code == errs.CodeNotFound { + return errs.New(errs.CodePhaseGateFailed, + "流转到 "+string(to)+" 前需先批准 "+string(gatedPhase)+" 阶段产物(verdict=pass)") + } + return err + } + if ptr.ApprovedArtifactID == nil { + return errs.New(errs.CodePhaseGateFailed, + "流转到 "+string(to)+" 前需先批准 "+string(gatedPhase)+" 阶段产物(verdict=pass)") + } + art, err := dep.Repo.GetArtifactByID(ctx, *ptr.ApprovedArtifactID) + if err != nil { + return err + } + if art.Verdict != VerdictPass { + return errs.New(errs.CodePhaseGateFailed, + "流转到 "+string(to)+" 前 "+string(gatedPhase)+" 阶段已批准产物的 verdict 必须为 pass") + } + return nil +} + +// requireWorkspaceMerged 网关:requirement 必须关联 workspace,且该 workspace 的 +// PR 已合并。Workspace 依赖缺失或查询失败时优雅降级为"未合并"(阻止流转)。 +type requireWorkspaceMerged struct{} + +func (requireWorkspaceMerged) Check(ctx context.Context, dep GateDeps, r *Requirement, _, to Phase) error { + if r.WorkspaceID == nil { + return errs.New(errs.CodePhaseGateFailed, + "流转到 "+string(to)+" 前需先关联 workspace 并合入其 PR") + } + if dep.Workspace == nil { + return errs.New(errs.CodePhaseGateFailed, + "无法确认 workspace PR 合并状态,暂不允许流转到 "+string(to)) + } + merged, err := dep.Workspace.PullRequestMerged(ctx, *r.WorkspaceID, r.ID) + if err != nil { + return errs.Wrap(err, errs.CodePhaseGateFailed, "查询 workspace PR 合并状态失败") + } + if !merged { + return errs.New(errs.CodePhaseGateFailed, + "流转到 "+string(to)+" 前需先合入 workspace 的 PR") + } + return nil +} diff --git a/internal/project/phase_gate_test.go b/internal/project/phase_gate_test.go new file mode 100644 index 0000000..1f82fdb --- /dev/null +++ b/internal/project/phase_gate_test.go @@ -0,0 +1,142 @@ +package project + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" +) + +// fakeWorkspaceState is a controllable WorkspaceStateLookup for Done-gate tests. +type fakeWorkspaceState struct { + merged bool + err error +} + +func (f fakeWorkspaceState) PullRequestMerged(_ context.Context, _, _ uuid.UUID) (bool, error) { + return f.merged, f.err +} + +// seedRequirement creates an open requirement under the seeded "demo" project. +func seedRequirement(t *testing.T, repo *fakeRepo, owner uuid.UUID, ws *uuid.UUID) *Requirement { + t.Helper() + reqSvc, _, _ := newReqSvc(repo) + r, err := reqSvc.Create(context.Background(), Caller{UserID: owner}, "demo", CreateRequirementInput{ + Title: "R", WorkspaceID: ws, + }) + require.NoError(t, err) + return r +} + +func requireGateFailed(t *testing.T, err error) { + t.Helper() + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodePhaseGateFailed, ae.Code) +} + +func TestPhaseGate_Implementing_BlockedWithoutApprovedAuditing(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + r := seedRequirement(t, repo, owner, nil) + + gate := NewGateRunner(GateDeps{Repo: repo}, nil) + err := gate.Check(context.Background(), r, PhasePlanning, PhaseImplementing) + requireGateFailed(t, err) +} + +func TestPhaseGate_Implementing_AllowedAfterApproval(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, _, _, _ := newArtifactSvc(repo) + r := seedRequirement(t, repo, owner, nil) + caller := Caller{UserID: owner} + + _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"}) + require.NoError(t, err) + _, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass) + require.NoError(t, err) + + gate := NewGateRunner(GateDeps{Repo: repo}, nil) + require.NoError(t, gate.Check(context.Background(), r, PhasePlanning, PhaseImplementing)) +} + +func TestPhaseGate_Implementing_BlockedWhenVerdictFail(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + artSvc, _, _, _ := newArtifactSvc(repo) + r := seedRequirement(t, repo, owner, nil) + caller := Caller{UserID: owner} + + _, err := artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"}) + require.NoError(t, err) + // verdict=fail 不写指针,gate 仍应阻止。 + _, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictFail) + require.NoError(t, err) + + gate := NewGateRunner(GateDeps{Repo: repo}, nil) + requireGateFailed(t, gate.Check(context.Background(), r, PhasePlanning, PhaseImplementing)) +} + +func TestPhaseGate_Done_BlockedWhenNoWorkspace(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + r := seedRequirement(t, repo, owner, nil) // no workspace + + gate := NewGateRunner(GateDeps{Repo: repo, Workspace: fakeWorkspaceState{merged: true}}, nil) + requireGateFailed(t, gate.Check(context.Background(), r, PhaseReviewing, PhaseDone)) +} + +func TestPhaseGate_Done_BlockedWhenNotMerged(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + ws := uuid.New() + r := seedRequirement(t, repo, owner, &ws) + + gate := NewGateRunner(GateDeps{Repo: repo, Workspace: fakeWorkspaceState{merged: false}}, nil) + requireGateFailed(t, gate.Check(context.Background(), r, PhaseReviewing, PhaseDone)) +} + +func TestPhaseGate_Done_AllowedWhenMerged(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + ws := uuid.New() + r := seedRequirement(t, repo, owner, &ws) + + gate := NewGateRunner(GateDeps{Repo: repo, Workspace: fakeWorkspaceState{merged: true}}, nil) + require.NoError(t, gate.Check(context.Background(), r, PhaseReviewing, PhaseDone)) +} + +func TestPhaseGate_Done_BlockedWhenWorkspaceLookupNil(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + ws := uuid.New() + r := seedRequirement(t, repo, owner, &ws) + + // Workspace 缺失 → 优雅降级为"未合并",阻止流转。 + gate := NewGateRunner(GateDeps{Repo: repo, Workspace: nil}, nil) + requireGateFailed(t, gate.Check(context.Background(), r, PhaseReviewing, PhaseDone)) +} + +func TestPhaseGate_OpenPhases_AlwaysAllowed(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + r := seedRequirement(t, repo, owner, nil) + + gate := NewGateRunner(GateDeps{Repo: repo}, nil) + // planning/prototyping/auditing/reviewing 无 entry 网关 → 任意流转放行(回归 any-to-any)。 + for _, to := range []Phase{PhasePlanning, PhasePrototyping, PhaseAuditing, PhaseReviewing} { + require.NoError(t, gate.Check(context.Background(), r, PhasePlanning, to), "to=%s", to) + } +} diff --git a/internal/project/project_service.go b/internal/project/project_service.go index e89bcc0..23115cf 100644 --- a/internal/project/project_service.go +++ b/internal/project/project_service.go @@ -30,24 +30,39 @@ func NewProjectService(repo Repository, rec audit.Recorder) ProjectService { return &projectService{repo: repo, audit: rec} } -// assertReadable 是 read 类操作的鉴权门面:private 仅 owner+admin,internal -// 任意登录用户。失败统一返回 CodeNotFound(避免存在性探测)。 -func assertReadable(p *Project, c Caller) error { +// assertReadable 是 read 类操作的鉴权门面。成员/角色 ACL(autonomy roadmap §11) +// 在既有规则之上"叠加"成员授权(只增不减): +// - internal 可见性:任意登录用户可读(既有) +// - owner / global-admin:可读(既有) +// - 任何成员角色(viewer/member/admin):可读(新增叠加) +// +// 失败统一返回 CodeNotFound(避免存在性探测)。role 由 service 在调用前查得, +// 非成员传 RoleNone。 +func assertReadable(p *Project, c Caller, role Role) error { if p.Visibility == VisibilityInternal { return nil } if c.IsAdmin || p.OwnerID == c.UserID { return nil } + if role.CanRead() { + return nil + } return errs.New(errs.CodeNotFound, "project 不存在") } -// assertWritable 是 write 类操作的鉴权门面:仅 owner+admin。已归档项目额外 -// 拒绝;调用方应在执行写入前调用此函数。 -func assertWritable(p *Project, c Caller) error { - if !c.IsAdmin && p.OwnerID != c.UserID { - // stranger 对 private 看不见;对 internal 也不能写。统一 not_found 防探测。 - if p.Visibility == VisibilityPrivate { +// assertWritable 是 write 类操作的鉴权门面。成员/角色 ACL 叠加写授权: +// - owner / global-admin:可写(既有) +// - member / admin 成员角色:可写(新增叠加) +// - viewer / 非成员:不可写 +// +// 已归档项目额外拒绝;调用方应在执行写入前调用此函数。role 由 service 查得。 +func assertWritable(p *Project, c Caller, role Role) error { + if !c.IsAdmin && p.OwnerID != c.UserID && !role.CanWrite() { + // stranger 对 private 看不见;对 internal/viewer 也不能写。统一 not_found + // 防探测——但若 caller 至少能读(internal 可见性或 viewer 成员),暴露 + // forbidden 比 not_found 更准确(其存在性本就可见)。 + if p.Visibility == VisibilityPrivate && !role.CanRead() { return errs.New(errs.CodeNotFound, "project 不存在") } return errs.New(errs.CodeForbidden, "无权修改该项目") @@ -58,6 +73,21 @@ func assertWritable(p *Project, c Caller) error { return nil } +// memberRoleFor 查 caller 在 project 的成员角色。owner/global-admin 隐式视为 +// RoleAdmin(与回填一致,避免对 owner 多查一次也无妨——这里仍走 DB 但 owner +// 经迁移已有 admin 行)。查询失败按 RoleNone 处理(best-effort,既有 owner/admin +// 规则仍兜底)。 +func (s *projectService) memberRoleFor(ctx context.Context, p *Project, c Caller) Role { + if c.IsAdmin || p.OwnerID == c.UserID { + return RoleAdmin + } + role, err := s.repo.GetMemberRole(ctx, p.ID, c.UserID) + if err != nil { + return RoleNone + } + return role +} + func (s *projectService) Create(ctx context.Context, c Caller, in CreateProjectInput) (*Project, error) { if in.Slug == "" || in.Name == "" { return nil, errs.New(errs.CodeInvalidInput, "slug / name 必填") @@ -87,7 +117,7 @@ func (s *projectService) Get(ctx context.Context, c Caller, slug string) (*Proje if err != nil { return nil, err } - if err := assertReadable(p, c); err != nil { + if err := assertReadable(p, c, s.memberRoleFor(ctx, p, c)); err != nil { return nil, err } return p, nil @@ -100,7 +130,7 @@ func (s *projectService) GetByID(ctx context.Context, c Caller, id uuid.UUID) (* if err != nil { return nil, err } - if err := assertReadable(p, c); err != nil { + if err := assertReadable(p, c, s.memberRoleFor(ctx, p, c)); err != nil { return nil, err } return p, nil @@ -113,7 +143,13 @@ func (s *projectService) List(ctx context.Context, c Caller, includeArchived boo } out := make([]*Project, 0, len(all)) for _, p := range all { - if assertReadable(p, c) == nil { + // 列表场景:internal/owner/admin 命中即收,避免对每个 private 项目都查一次 + // 成员表;仅当上述既有规则不命中时才回退查成员角色(叠加 viewer/member)。 + if assertReadable(p, c, RoleNone) == nil { + out = append(out, p) + continue + } + if role := s.memberRoleFor(ctx, p, c); assertReadable(p, c, role) == nil { out = append(out, p) } } @@ -125,7 +161,7 @@ func (s *projectService) Update(ctx context.Context, c Caller, slug string, in U if err != nil { return nil, err } - if err := assertWritable(p, c); err != nil { + if err := assertWritable(p, c, s.memberRoleFor(ctx, p, c)); err != nil { return nil, err } changed := map[string]any{} @@ -160,8 +196,9 @@ func (s *projectService) Archive(ctx context.Context, c Caller, slug string) err if err != nil { return err } - // 归档/取消归档仍要 owner+admin 校验,但已归档不再额外阻塞 archive。 - if !c.IsAdmin && p.OwnerID != c.UserID { + // 归档/取消归档要 owner / global-admin / project-admin 角色,但已归档不再额外 + // 阻塞 archive。project-admin 视为项目管理者,可执行生命周期操作。 + if !c.IsAdmin && p.OwnerID != c.UserID && !s.memberRoleFor(ctx, p, c).CanManageMembers() { if p.Visibility == VisibilityPrivate { return errs.New(errs.CodeNotFound, "project 不存在") } @@ -182,7 +219,7 @@ func (s *projectService) Unarchive(ctx context.Context, c Caller, slug string) e if err != nil { return err } - if !c.IsAdmin && p.OwnerID != c.UserID { + if !c.IsAdmin && p.OwnerID != c.UserID && !s.memberRoleFor(ctx, p, c).CanManageMembers() { if p.Visibility == VisibilityPrivate { return errs.New(errs.CodeNotFound, "project 不存在") } @@ -198,6 +235,101 @@ func (s *projectService) Unarchive(ctx context.Context, c Caller, slug string) e return nil } +// ===== 成员/角色 ACL(autonomy roadmap §11)===== + +// ListMembers 返回 slug 项目的全部成员。要求 caller 对项目可读(owner/admin/ +// 任意成员/internal 可见性)。 +func (s *projectService) ListMembers(ctx context.Context, c Caller, slug string) ([]*Member, error) { + p, err := s.repo.GetProjectBySlug(ctx, slug) + if err != nil { + return nil, err + } + if err := assertReadable(p, c, s.memberRoleFor(ctx, p, c)); err != nil { + return nil, err + } + return s.repo.ListMembers(ctx, p.ID) +} + +// AddMember 新增或更新一名成员的角色。仅 project-admin 角色或 global-admin 可调用。 +func (s *projectService) AddMember(ctx context.Context, c Caller, slug string, in AddMemberInput) (*Member, error) { + if in.UserID == uuid.Nil { + return nil, errs.New(errs.CodeInvalidInput, "user_id 必填") + } + if !in.Role.IsValid() { + return nil, errs.New(errs.CodeInvalidInput, "role 非法(仅 viewer/member/admin)") + } + p, err := s.repo.GetProjectBySlug(ctx, slug) + if err != nil { + return nil, err + } + if err := s.assertCanManageMembers(ctx, p, c); err != nil { + return nil, err + } + addedBy := c.UserID + m, err := s.repo.UpsertMember(ctx, p.ID, in.UserID, in.Role, &addedBy) + if err != nil { + return nil, err + } + s.recordAudit(ctx, c, "project.member_add", p.ID.String(), map[string]any{ + "user_id": in.UserID.String(), "role": string(in.Role), + }) + return m, nil +} + +// RemoveMember 移除一名成员。仅 project-admin 角色或 global-admin 可调用。 +// 拒绝移除项目 owner(owner 经回填为 admin,移除会让其失去隐式 owner 之外的角色行, +// 但 owner 仍由 OwnerID 兜底拥有权限——为避免误导,直接拒绝移除 owner)。 +func (s *projectService) RemoveMember(ctx context.Context, c Caller, slug string, userID uuid.UUID) error { + if userID == uuid.Nil { + return errs.New(errs.CodeInvalidInput, "user_id 必填") + } + p, err := s.repo.GetProjectBySlug(ctx, slug) + if err != nil { + return err + } + if err := s.assertCanManageMembers(ctx, p, c); err != nil { + return err + } + if userID == p.OwnerID { + return errs.New(errs.CodeForbidden, "不能移除项目 owner") + } + n, err := s.repo.DeleteMember(ctx, p.ID, userID) + if err != nil { + return err + } + if n == 0 { + return errs.New(errs.CodeNotFound, "成员不存在") + } + s.recordAudit(ctx, c, "project.member_remove", p.ID.String(), map[string]any{ + "user_id": userID.String(), + }) + return nil +} + +// MemberRole 返回 userID 在 projectID 的成员角色(非成员返回 RoleNone)。 +// owner/global-admin 的隐式 admin 不在此体现——本方法只读 project_members 行; +// app 层 projectACL 自行叠加 owner/admin 判定。 +func (s *projectService) MemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error) { + return s.repo.GetMemberRole(ctx, projectID, userID) +} + +// assertCanManageMembers 校验 caller 可管理 p 的成员:global-admin、owner 或 +// project-admin 角色。private 不可见者返回 NotFound 防探测。 +func (s *projectService) assertCanManageMembers(ctx context.Context, p *Project, c Caller) error { + if c.IsAdmin || p.OwnerID == c.UserID { + return nil + } + role := s.memberRoleFor(ctx, p, c) + if role.CanManageMembers() { + return nil + } + // 不可读者:private 返回 NotFound 防探测;否则 Forbidden。 + if assertReadable(p, c, role) != nil { + return errs.New(errs.CodeNotFound, "project 不存在") + } + return errs.New(errs.CodeForbidden, "无权管理项目成员") +} + // recordAudit 是 best-effort 写入:失败仅吞错(不影响业务)。ctx 用 // context.WithoutCancel 派生,使 HTTP 响应已返回但审计仍能落库。 func (s *projectService) recordAudit(ctx context.Context, c Caller, action, targetID string, meta map[string]any) { diff --git a/internal/project/project_service_test.go b/internal/project/project_service_test.go index a9bb6a5..5d09f4f 100644 --- a/internal/project/project_service_test.go +++ b/internal/project/project_service_test.go @@ -19,11 +19,14 @@ import ( // fakeRepo 是内存版 Repository,仅用于 service 单测;按 ID/slug 查找用 map, // number 自增手动维护。并发安全(一把大锁就够)。 type fakeRepo struct { - mu sync.Mutex - projects map[uuid.UUID]*Project - requirements map[uuid.UUID]*Requirement - issues map[uuid.UUID]*Issue - artifacts map[uuid.UUID]*Artifact + mu sync.Mutex + projects map[uuid.UUID]*Project + requirements map[uuid.UUID]*Requirement + issues map[uuid.UUID]*Issue + artifacts map[uuid.UUID]*Artifact + phasePointers map[string]*PhasePointer // key: requirementID + "|" + phase + dependencies map[uuid.UUID]*Dependency + members map[string]*Member // key: projectID + "|" + userID // 置 true 时,下一次 CreateArtifact 返回一次真实的 PG 唯一约束冲突后自动复位, // 用于覆盖 artifactService.createWithRetry 的 version 竞争重试分支。 @@ -32,13 +35,24 @@ type fakeRepo struct { func newFakeRepo() *fakeRepo { return &fakeRepo{ - projects: map[uuid.UUID]*Project{}, - requirements: map[uuid.UUID]*Requirement{}, - issues: map[uuid.UUID]*Issue{}, - artifacts: map[uuid.UUID]*Artifact{}, + projects: map[uuid.UUID]*Project{}, + requirements: map[uuid.UUID]*Requirement{}, + issues: map[uuid.UUID]*Issue{}, + artifacts: map[uuid.UUID]*Artifact{}, + phasePointers: map[string]*PhasePointer{}, + dependencies: map[uuid.UUID]*Dependency{}, + members: map[string]*Member{}, } } +func memberKey(projectID, userID uuid.UUID) string { + return projectID.String() + "|" + userID.String() +} + +func phasePointerKey(reqID uuid.UUID, phase Phase) string { + return reqID.String() + "|" + string(phase) +} + func (r *fakeRepo) cloneProject(p *Project) *Project { v := *p; return &v } func (r *fakeRepo) cloneReq(p *Requirement) *Requirement { v := *p; return &v } func (r *fakeRepo) cloneIssue(p *Issue) *Issue { v := *p; return &v } @@ -127,6 +141,51 @@ func (r *fakeRepo) UnarchiveProject(_ context.Context, id uuid.UUID) error { return nil } +// ===== ProjectMember ===== + +func (r *fakeRepo) UpsertMember(_ context.Context, projectID, userID uuid.UUID, role Role, addedBy *uuid.UUID) (*Member, error) { + r.mu.Lock() + defer r.mu.Unlock() + m := &Member{ProjectID: projectID, UserID: userID, Role: role, AddedBy: addedBy, CreatedAt: time.Now()} + r.members[memberKey(projectID, userID)] = m + cp := *m + return &cp, nil +} + +func (r *fakeRepo) DeleteMember(_ context.Context, projectID, userID uuid.UUID) (int64, error) { + r.mu.Lock() + defer r.mu.Unlock() + k := memberKey(projectID, userID) + if _, ok := r.members[k]; !ok { + return 0, nil + } + delete(r.members, k) + return 1, nil +} + +func (r *fakeRepo) GetMemberRole(_ context.Context, projectID, userID uuid.UUID) (Role, error) { + r.mu.Lock() + defer r.mu.Unlock() + m, ok := r.members[memberKey(projectID, userID)] + if !ok { + return RoleNone, nil + } + return m.Role, nil +} + +func (r *fakeRepo) ListMembers(_ context.Context, projectID uuid.UUID) ([]*Member, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*Member, 0) + for _, m := range r.members { + if m.ProjectID == projectID { + cp := *m + out = append(out, &cp) + } + } + return out, nil +} + // ===== Requirement ===== func (r *fakeRepo) nextRequirementNumber(projectID uuid.UUID) int { @@ -279,6 +338,17 @@ func (r *fakeRepo) GetIssueByNumber(_ context.Context, projectID uuid.UUID, numb return nil, errs.New(errs.CodeNotFound, "issue 不存在") } +func (r *fakeRepo) GetIssueByID(_ context.Context, id uuid.UUID) (*Issue, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, x := range r.issues { + if x.ID == id { + return r.cloneIssue(x), nil + } + } + return nil, errs.New(errs.CodeNotFound, "issue 不存在") +} + func (r *fakeRepo) ListIssues(_ context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error) { r.mu.Lock() defer r.mu.Unlock() @@ -369,6 +439,145 @@ func (r *fakeRepo) ReopenIssue(_ context.Context, id uuid.UUID) error { return nil } +func (r *fakeRepo) UpdateIssuePriority(_ context.Context, id uuid.UUID, priority int) (*Issue, error) { + r.mu.Lock() + defer r.mu.Unlock() + cur, ok := r.issues[id] + if !ok { + return nil, errs.New(errs.CodeNotFound, "issue 不存在") + } + cur.Priority = priority + cur.UpdatedAt = time.Now() + return r.cloneIssue(cur), nil +} + +func (r *fakeRepo) CreateDependency(_ context.Context, d *Dependency) (*Dependency, error) { + r.mu.Lock() + defer r.mu.Unlock() + for _, x := range r.dependencies { + if x.BlockedID == d.BlockedID && x.BlockerID == d.BlockerID { + return nil, errs.New(errs.CodeInvalidInput, "依赖已存在") + } + } + d.CreatedAt = time.Now() + v := *d + r.dependencies[d.ID] = &v + out := *d + return &out, nil +} + +func (r *fakeRepo) DeleteDependency(_ context.Context, projectID, blockedID, blockerID uuid.UUID) (int64, error) { + r.mu.Lock() + defer r.mu.Unlock() + for id, x := range r.dependencies { + if x.ProjectID == projectID && x.BlockedID == blockedID && x.BlockerID == blockerID { + delete(r.dependencies, id) + return 1, nil + } + } + return 0, nil +} + +func (r *fakeRepo) ListBlockers(_ context.Context, blockedID uuid.UUID) ([]*Issue, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*Issue, 0) + for _, d := range r.dependencies { + if d.BlockedID == blockedID { + if iss, ok := r.issues[d.BlockerID]; ok { + out = append(out, r.cloneIssue(iss)) + } + } + } + return out, nil +} + +func (r *fakeRepo) ListBlocked(_ context.Context, blockerID uuid.UUID) ([]*Issue, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*Issue, 0) + for _, d := range r.dependencies { + if d.BlockerID == blockerID { + if iss, ok := r.issues[d.BlockedID]; ok { + out = append(out, r.cloneIssue(iss)) + } + } + } + return out, nil +} + +func (r *fakeRepo) ListBlockerIDs(_ context.Context, blockedID uuid.UUID) ([]uuid.UUID, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]uuid.UUID, 0) + for _, d := range r.dependencies { + if d.BlockedID == blockedID { + out = append(out, d.BlockerID) + } + } + return out, nil +} + +func (r *fakeRepo) ListSubtasks(_ context.Context, parentID uuid.UUID) ([]*Issue, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*Issue, 0) + for _, x := range r.issues { + if x.ParentID != nil && *x.ParentID == parentID { + out = append(out, r.cloneIssue(x)) + } + } + return out, nil +} + +func (r *fakeRepo) ListReadyLeafSubtasks(_ context.Context, projectID uuid.UUID, limit int) ([]*Issue, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*Issue, 0) + for _, x := range r.issues { + if x.ProjectID != projectID || x.Status != StatusOpen || x.ParentID == nil { + continue + } + blocked := false + for _, d := range r.dependencies { + if d.BlockedID == x.ID { + if b, ok := r.issues[d.BlockerID]; ok && b.Status != StatusClosed { + blocked = true + break + } + } + } + if blocked { + continue + } + out = append(out, r.cloneIssue(x)) + } + if limit > 0 && len(out) > limit { + out = out[:limit] + } + return out, nil +} + +func (r *fakeRepo) ListSchedulableProjects(_ context.Context) ([]uuid.UUID, error) { + r.mu.Lock() + defer r.mu.Unlock() + seen := map[uuid.UUID]struct{}{} + out := make([]uuid.UUID, 0) + for _, x := range r.issues { + if x.Status == StatusOpen && x.ParentID != nil { + if _, ok := seen[x.ProjectID]; !ok { + seen[x.ProjectID] = struct{}{} + out = append(out, x.ProjectID) + } + } + } + return out, nil +} + +func (r *fakeRepo) CountActiveSessionsForProject(_ context.Context, _ uuid.UUID) (int, error) { + return 0, nil +} + func (r *fakeRepo) GetMaxArtifactVersion(_ context.Context, requirementID uuid.UUID, phase Phase) (int, error) { r.mu.Lock() defer r.mu.Unlock() @@ -395,6 +604,9 @@ func (r *fakeRepo) CreateArtifact(_ context.Context, in *Artifact) (*Artifact, e } } in.CreatedAt = time.Now() + if in.Verdict == "" { + in.Verdict = VerdictNone // 模拟 DB 列默认值 + } r.artifacts[in.ID] = r.cloneArtifact(in) return r.cloneArtifact(in), nil } @@ -428,6 +640,68 @@ func (r *fakeRepo) GetArtifactByVersion(_ context.Context, requirementID uuid.UU return nil, errs.New(errs.CodeNotFound, "artifact 不存在") } +func (r *fakeRepo) GetArtifactByID(_ context.Context, id uuid.UUID) (*Artifact, error) { + r.mu.Lock() + defer r.mu.Unlock() + if x, ok := r.artifacts[id]; ok { + return r.cloneArtifact(x), nil + } + return nil, errs.New(errs.CodeNotFound, "artifact 不存在") +} + +func (r *fakeRepo) ApproveArtifact(_ context.Context, artifactID uuid.UUID, verdict Verdict) (*Artifact, error) { + r.mu.Lock() + defer r.mu.Unlock() + x, ok := r.artifacts[artifactID] + if !ok { + return nil, errs.New(errs.CodeNotFound, "artifact 不存在") + } + x.Verdict = verdict + return r.cloneArtifact(x), nil +} + +func (r *fakeRepo) UpsertPhasePointer(_ context.Context, requirementID uuid.UUID, phase Phase, artifactID, approvedBy uuid.UUID) (*PhasePointer, error) { + r.mu.Lock() + defer r.mu.Unlock() + now := time.Now() + aid, by := artifactID, approvedBy + ptr := &PhasePointer{ + RequirementID: requirementID, + Phase: phase, + ApprovedArtifactID: &aid, + ApprovedBy: &by, + ApprovedAt: &now, + } + r.phasePointers[phasePointerKey(requirementID, phase)] = ptr + v := *ptr + return &v, nil +} + +func (r *fakeRepo) GetPhasePointer(_ context.Context, requirementID uuid.UUID, phase Phase) (*PhasePointer, error) { + r.mu.Lock() + defer r.mu.Unlock() + ptr, ok := r.phasePointers[phasePointerKey(requirementID, phase)] + if !ok { + return nil, errs.New(errs.CodeNotFound, "phase pointer 不存在") + } + v := *ptr + return &v, nil +} + +func (r *fakeRepo) ListPhasePointers(_ context.Context, requirementID uuid.UUID) ([]*PhasePointer, error) { + r.mu.Lock() + defer r.mu.Unlock() + out := make([]*PhasePointer, 0) + for _, ptr := range r.phasePointers { + if ptr.RequirementID == requirementID { + v := *ptr + out = append(out, &v) + } + } + sort.Slice(out, func(i, j int) bool { return out[i].Phase < out[j].Phase }) + return out, nil +} + // spyAudit 收集所有写入的 audit entry,方便断言。 type spyAudit struct { mu sync.Mutex diff --git a/internal/project/queries/artifacts.sql b/internal/project/queries/artifacts.sql index 15d9870..76eacea 100644 --- a/internal/project/queries/artifacts.sql +++ b/internal/project/queries/artifacts.sql @@ -1,19 +1,28 @@ -- name: CreateRequirementArtifact :one INSERT INTO requirement_artifacts (id, requirement_id, phase, version, content, note, source_message_id, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at; +RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict; -- name: ListRequirementArtifactsByRequirement :many -SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at +SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict FROM requirement_artifacts WHERE requirement_id = $1 AND ($2::text = '' OR phase = $2) ORDER BY phase ASC, version ASC; -- name: GetRequirementArtifactByVersion :one -SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at +SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict FROM requirement_artifacts WHERE requirement_id = $1 AND phase = $2 AND version = $3; +-- name: GetRequirementArtifactByID :one +SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict +FROM requirement_artifacts +WHERE id = $1; + -- name: GetMaxRequirementArtifactVersion :one SELECT COALESCE(MAX(version), 0)::int FROM requirement_artifacts WHERE requirement_id = $1 AND phase = $2; + +-- name: ApproveRequirementArtifact :one +UPDATE requirement_artifacts SET verdict = $2 WHERE id = $1 +RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict; diff --git a/internal/project/queries/dependencies.sql b/internal/project/queries/dependencies.sql new file mode 100644 index 0000000..109d972 --- /dev/null +++ b/internal/project/queries/dependencies.sql @@ -0,0 +1,92 @@ +-- name: CreateDependency :one +INSERT INTO issue_dependencies (id, project_id, blocked_id, blocker_id, created_by) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, project_id, blocked_id, blocker_id, created_by, created_at; + +-- name: DeleteDependency :execrows +DELETE FROM issue_dependencies +WHERE project_id = $1 AND blocked_id = $2 AND blocker_id = $3; + +-- name: ListBlockers :many +-- Issues that block $1 (the dependencies of blocked issue $1). +SELECT i.id, i.project_id, i.requirement_id, i.number, i.title, i.description, i.status, + i.assignee_id, i.created_by, i.closed_at, i.created_at, i.updated_at, i.workspace_id, i.parent_id, i.priority +FROM issue_dependencies d +JOIN issues i ON i.id = d.blocker_id +WHERE d.blocked_id = $1 +ORDER BY i.number ASC; + +-- name: ListBlocked :many +-- Issues blocked by $1 (issues that depend on blocker $1). +SELECT i.id, i.project_id, i.requirement_id, i.number, i.title, i.description, i.status, + i.assignee_id, i.created_by, i.closed_at, i.created_at, i.updated_at, i.workspace_id, i.parent_id, i.priority +FROM issue_dependencies d +JOIN issues i ON i.id = d.blocked_id +WHERE d.blocker_id = $1 +ORDER BY i.number ASC; + +-- name: ListSubtasks :many +SELECT id, project_id, requirement_id, number, title, description, status, + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority +FROM issues +WHERE parent_id = $1 +ORDER BY priority DESC, number ASC; + +-- name: ListBlockerIDs :many +-- Direct blocker issue ids of $1 (one hop along blocked-by edges). Used by the +-- service-layer iterative reachability cycle check in AddDependency. +SELECT blocker_id FROM issue_dependencies WHERE blocked_id = $1; + +-- name: ListReadyLeafSubtasks :many +-- Ready leaf subtasks for a project: open subtasks (parent_id NOT NULL) whose +-- every blocker is closed, with no active (starting/running) acp_session, and +-- no open child issue (leaf). Ordered by priority DESC, number ASC. LIMIT $2. +SELECT i.id, i.project_id, i.requirement_id, i.number, i.title, i.description, i.status, + i.assignee_id, i.created_by, i.closed_at, i.created_at, i.updated_at, i.workspace_id, i.parent_id, i.priority +FROM issues i +WHERE i.project_id = $1 + AND i.status = 'open' + AND i.parent_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM issue_dependencies d + JOIN issues b ON b.id = d.blocker_id + WHERE d.blocked_id = i.id AND b.status <> 'closed' + ) + AND NOT EXISTS ( + SELECT 1 FROM acp_sessions s + WHERE s.issue_id = i.id AND s.status IN ('starting', 'running') + ) + AND NOT EXISTS ( + SELECT 1 FROM issues c + WHERE c.parent_id = i.id AND c.status = 'open' + ) +ORDER BY i.priority DESC, i.number ASC +LIMIT $2; + +-- name: ListSchedulableProjects :many +-- Distinct project ids that currently have at least one ready leaf subtask +-- (open subtask whose blockers are all closed and no active session). Cheap +-- pre-filter so the scheduler only iterates projects with pending work. +SELECT DISTINCT i.project_id +FROM issues i +WHERE i.status = 'open' + AND i.parent_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM issue_dependencies d + JOIN issues b ON b.id = d.blocker_id + WHERE d.blocked_id = i.id AND b.status <> 'closed' + ) + AND NOT EXISTS ( + SELECT 1 FROM acp_sessions s + WHERE s.issue_id = i.id AND s.status IN ('starting', 'running') + ) + AND NOT EXISTS ( + SELECT 1 FROM issues c + WHERE c.parent_id = i.id AND c.status = 'open' + ); + +-- name: CountActiveSessionsForProject :one +-- Active (starting/running) acp_sessions in a project. Used by the scheduler's +-- soft per-project concurrency pre-check. +SELECT COUNT(*) FROM acp_sessions +WHERE project_id = $1 AND status IN ('starting', 'running'); diff --git a/internal/project/queries/issues.sql b/internal/project/queries/issues.sql index 1768f6c..4322ad4 100644 --- a/internal/project/queries/issues.sql +++ b/internal/project/queries/issues.sql @@ -1,22 +1,28 @@ -- name: CreateIssue :one -INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by, workspace_id) +INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by, workspace_id, parent_id, priority) VALUES ( $1, $2, $3, (SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE project_id = $2), - $4, $5, $6, $7, $8 + $4, $5, $6, $7, $8, $9, $10 ) RETURNING id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at, workspace_id; + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority; -- name: GetIssueByNumber :one SELECT id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at, workspace_id + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority FROM issues WHERE project_id = $1 AND number = $2; +-- name: GetIssueByID :one +SELECT id, project_id, requirement_id, number, title, description, status, + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority +FROM issues +WHERE id = $1; + -- name: ListIssues :many SELECT id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at, workspace_id + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority FROM issues WHERE project_id = $1 AND (sqlc.narg('status')::text IS NULL OR status = sqlc.narg('status')) @@ -30,7 +36,7 @@ ORDER BY number DESC; -- name: ListIssuesByRequirement :many SELECT id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at, workspace_id + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority FROM issues WHERE requirement_id = $1 ORDER BY number DESC; @@ -44,14 +50,21 @@ SET title = $2, updated_at = now() WHERE id = $1 RETURNING id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at, workspace_id; + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority; -- name: UpdateIssueAssignee :one UPDATE issues SET assignee_id = $2, updated_at = now() WHERE id = $1 RETURNING id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at, workspace_id; + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority; + +-- name: UpdateIssuePriority :one +UPDATE issues +SET priority = $2, updated_at = now() +WHERE id = $1 +RETURNING id, project_id, requirement_id, number, title, description, status, + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority; -- name: CloseIssue :exec UPDATE issues SET status = 'closed', closed_at = now(), updated_at = now() diff --git a/internal/project/queries/members.sql b/internal/project/queries/members.sql new file mode 100644 index 0000000..2c28a75 --- /dev/null +++ b/internal/project/queries/members.sql @@ -0,0 +1,21 @@ +-- name: UpsertProjectMember :one +INSERT INTO project_members (project_id, user_id, role, added_by) +VALUES ($1, $2, $3, $4) +ON CONFLICT (project_id, user_id) +DO UPDATE SET role = EXCLUDED.role +RETURNING project_id, user_id, role, created_at, added_by; + +-- name: DeleteProjectMember :execrows +DELETE FROM project_members +WHERE project_id = $1 AND user_id = $2; + +-- name: GetProjectMember :one +SELECT project_id, user_id, role, created_at, added_by +FROM project_members +WHERE project_id = $1 AND user_id = $2; + +-- name: ListProjectMembers :many +SELECT project_id, user_id, role, created_at, added_by +FROM project_members +WHERE project_id = $1 +ORDER BY created_at ASC; diff --git a/internal/project/queries/phase_pointers.sql b/internal/project/queries/phase_pointers.sql new file mode 100644 index 0000000..40c10b1 --- /dev/null +++ b/internal/project/queries/phase_pointers.sql @@ -0,0 +1,20 @@ +-- name: UpsertRequirementPhasePointer :one +INSERT INTO requirement_phase_pointers (requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at) +VALUES ($1, $2, $3, $4, now(), now()) +ON CONFLICT (requirement_id, phase) DO UPDATE + SET approved_artifact_id = EXCLUDED.approved_artifact_id, + approved_by = EXCLUDED.approved_by, + approved_at = EXCLUDED.approved_at, + updated_at = now() +RETURNING requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at; + +-- name: GetRequirementPhasePointer :one +SELECT requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at +FROM requirement_phase_pointers +WHERE requirement_id = $1 AND phase = $2; + +-- name: ListRequirementPhasePointers :many +SELECT requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at +FROM requirement_phase_pointers +WHERE requirement_id = $1 +ORDER BY phase ASC; diff --git a/internal/project/repository.go b/internal/project/repository.go index a43b867..9bc1762 100644 --- a/internal/project/repository.go +++ b/internal/project/repository.go @@ -31,6 +31,16 @@ type Repository interface { ArchiveProject(ctx context.Context, id uuid.UUID) error UnarchiveProject(ctx context.Context, id uuid.UUID) error + // ProjectMember 团队/角色 ACL(autonomy roadmap §11) + // UpsertMember 新增或更新一名成员的角色(PK 冲突时更新 role)。 + UpsertMember(ctx context.Context, projectID, userID uuid.UUID, role Role, addedBy *uuid.UUID) (*Member, error) + // DeleteMember 移除一名成员,返回受影响行数(0 表示不存在)。 + DeleteMember(ctx context.Context, projectID, userID uuid.UUID) (int64, error) + // GetMemberRole 返回 userID 在 projectID 的成员角色;非成员返回 (RoleNone, nil)。 + GetMemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error) + // ListMembers 返回 projectID 的全部成员(按加入时间升序)。 + ListMembers(ctx context.Context, projectID uuid.UUID) ([]*Member, error) + // Requirement CreateRequirement(ctx context.Context, r *Requirement) (*Requirement, error) GetRequirementByNumber(ctx context.Context, projectID uuid.UUID, number int) (*Requirement, error) @@ -44,19 +54,48 @@ type Repository interface { // Issue CreateIssue(ctx context.Context, i *Issue) (*Issue, error) GetIssueByNumber(ctx context.Context, projectID uuid.UUID, number int) (*Issue, error) + GetIssueByID(ctx context.Context, id uuid.UUID) (*Issue, error) ListIssues(ctx context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error) ListIssuesByRequirement(ctx context.Context, requirementID uuid.UUID) ([]*Issue, error) UpdateIssueCore(ctx context.Context, id uuid.UUID, title, description string, requirementID *uuid.UUID, workspaceID *uuid.UUID) (*Issue, error) UpdateIssueAssignee(ctx context.Context, id uuid.UUID, assignee *uuid.UUID) (*Issue, error) + UpdateIssuePriority(ctx context.Context, id uuid.UUID, priority int) (*Issue, error) CloseIssue(ctx context.Context, id uuid.UUID) error ReopenIssue(ctx context.Context, id uuid.UUID) error + // Issue 依赖图(autonomy roadmap §10) + CreateDependency(ctx context.Context, d *Dependency) (*Dependency, error) + DeleteDependency(ctx context.Context, projectID, blockedID, blockerID uuid.UUID) (int64, error) + // ListBlockers 返回阻塞 blockedID 的 issue 列表(其依赖)。 + ListBlockers(ctx context.Context, blockedID uuid.UUID) ([]*Issue, error) + // ListBlocked 返回被 blockerID 阻塞的 issue 列表。 + ListBlocked(ctx context.Context, blockerID uuid.UUID) ([]*Issue, error) + // ListBlockerIDs 返回 blockedID 的直接 blocker id(一跳),供 service 层环检查。 + ListBlockerIDs(ctx context.Context, blockedID uuid.UUID) ([]uuid.UUID, error) + // ListSubtasks 返回 parentID 的全部子 issue。 + ListSubtasks(ctx context.Context, parentID uuid.UUID) ([]*Issue, error) + // ListReadyLeafSubtasks 返回 project 下"就绪可派"的叶子子任务:open、有 parent、 + // 全部 blocker 已关闭、无活跃 acp_session、无 open 子。按 priority DESC, number ASC 排序。 + ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*Issue, error) + // ListSchedulableProjects 返回当前至少有一个就绪叶子子任务的 project id(调度器预筛)。 + ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error) + // CountActiveSessionsForProject 返回 project 内活跃(starting/running)acp_session 数。 + CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error) + // Artifact CreateArtifact(ctx context.Context, a *Artifact) (*Artifact, error) // ListArtifactsByRequirement 的 phase 传零值("")表示不过滤阶段。 ListArtifactsByRequirement(ctx context.Context, requirementID uuid.UUID, phase Phase) ([]*Artifact, error) GetArtifactByVersion(ctx context.Context, requirementID uuid.UUID, phase Phase, version int) (*Artifact, error) + GetArtifactByID(ctx context.Context, id uuid.UUID) (*Artifact, error) GetMaxArtifactVersion(ctx context.Context, requirementID uuid.UUID, phase Phase) (int, error) + // ApproveArtifact 给指定产物落 verdict(pass/fail/none),返回更新后的产物。 + ApproveArtifact(ctx context.Context, artifactID uuid.UUID, verdict Verdict) (*Artifact, error) + + // PhasePointer:每 (requirement, phase) 的已批准/当前产物指针。 + UpsertPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase, artifactID uuid.UUID, approvedBy uuid.UUID) (*PhasePointer, error) + GetPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase) (*PhasePointer, error) + ListPhasePointers(ctx context.Context, requirementID uuid.UUID) ([]*PhasePointer, error) } // IsUniqueViolation 报告 err 是否为 PG 唯一约束冲突。Service 层在 Create* @@ -211,6 +250,72 @@ func (r *pgRepo) UnarchiveProject(ctx context.Context, id uuid.UUID) error { return nil } +// ===== ProjectMember ===== + +func rowToMember(row projectsqlc.ProjectMember) *Member { + return &Member{ + ProjectID: fromPgUUID(row.ProjectID), + UserID: fromPgUUID(row.UserID), + Role: Role(row.Role), + AddedBy: fromPgUUIDPtr(row.AddedBy), + CreatedAt: row.CreatedAt.Time, + } +} + +func (r *pgRepo) UpsertMember(ctx context.Context, projectID, userID uuid.UUID, role Role, addedBy *uuid.UUID) (*Member, error) { + row, err := r.q.UpsertProjectMember(ctx, projectsqlc.UpsertProjectMemberParams{ + ProjectID: toPgUUID(projectID), + UserID: toPgUUID(userID), + Role: string(role), + AddedBy: toPgUUIDPtr(addedBy), + }) + if err != nil { + // FK 冲突:user 不存在 → invalid_input(不泄漏 5xx)。 + if IsForeignKeyViolation(err) { + return nil, errs.New(errs.CodeInvalidInput, "用户不存在") + } + return nil, errs.Wrap(err, errs.CodeInternal, "upsert project member") + } + return rowToMember(row), nil +} + +func (r *pgRepo) DeleteMember(ctx context.Context, projectID, userID uuid.UUID) (int64, error) { + n, err := r.q.DeleteProjectMember(ctx, projectsqlc.DeleteProjectMemberParams{ + ProjectID: toPgUUID(projectID), + UserID: toPgUUID(userID), + }) + if err != nil { + return 0, errs.Wrap(err, errs.CodeInternal, "delete project member") + } + return n, nil +} + +func (r *pgRepo) GetMemberRole(ctx context.Context, projectID, userID uuid.UUID) (Role, error) { + row, err := r.q.GetProjectMember(ctx, projectsqlc.GetProjectMemberParams{ + ProjectID: toPgUUID(projectID), + UserID: toPgUUID(userID), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return RoleNone, nil + } + return RoleNone, errs.Wrap(err, errs.CodeInternal, "get project member role") + } + return Role(row.Role), nil +} + +func (r *pgRepo) ListMembers(ctx context.Context, projectID uuid.UUID) ([]*Member, error) { + rows, err := r.q.ListProjectMembers(ctx, toPgUUID(projectID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list project members") + } + out := make([]*Member, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToMember(row)) + } + return out, nil +} + // ===== Requirement ===== func rowToRequirement(row projectsqlc.Requirement) *Requirement { @@ -366,6 +471,8 @@ func rowToIssue(row projectsqlc.Issue) *Issue { ClosedAt: fromPgTimePtr(row.ClosedAt), CreatedAt: row.CreatedAt.Time, UpdatedAt: row.UpdatedAt.Time, + ParentID: fromPgUUIDPtr(row.ParentID), + Priority: int(row.Priority), } } @@ -379,6 +486,8 @@ func (r *pgRepo) CreateIssue(ctx context.Context, in *Issue) (*Issue, error) { AssigneeID: toPgUUIDPtr(in.AssigneeID), CreatedBy: toPgUUID(in.CreatedBy), WorkspaceID: toPgUUIDPtr(in.WorkspaceID), + ParentID: toPgUUIDPtr(in.ParentID), + Priority: int32(in.Priority), //nolint:gosec // priority 0..3,CHECK 约束兜底 }) if err != nil { if IsUniqueViolation(err) { @@ -406,6 +515,17 @@ func (r *pgRepo) GetIssueByNumber(ctx context.Context, projectID uuid.UUID, numb return rowToIssue(row), nil } +func (r *pgRepo) GetIssueByID(ctx context.Context, id uuid.UUID) (*Issue, error) { + row, err := r.q.GetIssueByID(ctx, toPgUUID(id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "issue 不存在") + } + return nil, errs.Wrap(err, errs.CodeInternal, "get issue by id") + } + return rowToIssue(row), nil +} + func (r *pgRepo) ListIssues(ctx context.Context, projectID uuid.UUID, filter IssueFilter, requirementID *uuid.UUID) ([]*Issue, error) { var statusPtr, reqFilter *string if filter.Status != "" { @@ -499,6 +619,151 @@ func (r *pgRepo) ReopenIssue(ctx context.Context, id uuid.UUID) error { return nil } +func (r *pgRepo) UpdateIssuePriority(ctx context.Context, id uuid.UUID, priority int) (*Issue, error) { + row, err := r.q.UpdateIssuePriority(ctx, projectsqlc.UpdateIssuePriorityParams{ + ID: toPgUUID(id), + Priority: int32(priority), //nolint:gosec // priority 0..3,CHECK 约束兜底 + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "issue 不存在") + } + return nil, errs.Wrap(err, errs.CodeInternal, "update issue priority") + } + return rowToIssue(row), nil +} + +// ===== Issue 依赖图(autonomy roadmap §10)===== + +func rowToDependency(row projectsqlc.IssueDependency) *Dependency { + return &Dependency{ + ID: fromPgUUID(row.ID), + ProjectID: fromPgUUID(row.ProjectID), + BlockedID: fromPgUUID(row.BlockedID), + BlockerID: fromPgUUID(row.BlockerID), + CreatedBy: fromPgUUID(row.CreatedBy), + CreatedAt: row.CreatedAt.Time, + } +} + +func (r *pgRepo) CreateDependency(ctx context.Context, d *Dependency) (*Dependency, error) { + row, err := r.q.CreateDependency(ctx, projectsqlc.CreateDependencyParams{ + ID: toPgUUID(d.ID), + ProjectID: toPgUUID(d.ProjectID), + BlockedID: toPgUUID(d.BlockedID), + BlockerID: toPgUUID(d.BlockerID), + CreatedBy: toPgUUID(d.CreatedBy), + }) + if err != nil { + if IsUniqueViolation(err) { + return nil, errs.New(errs.CodeInvalidInput, "依赖已存在") + } + if IsForeignKeyViolation(err) { + return nil, errs.Wrap(err, errs.CodeInvalidInput, "依赖两端 issue 不存在或跨 project") + } + return nil, errs.Wrap(err, errs.CodeInternal, "create dependency") + } + return rowToDependency(row), nil +} + +func (r *pgRepo) DeleteDependency(ctx context.Context, projectID, blockedID, blockerID uuid.UUID) (int64, error) { + n, err := r.q.DeleteDependency(ctx, projectsqlc.DeleteDependencyParams{ + ProjectID: toPgUUID(projectID), + BlockedID: toPgUUID(blockedID), + BlockerID: toPgUUID(blockerID), + }) + if err != nil { + return 0, errs.Wrap(err, errs.CodeInternal, "delete dependency") + } + return n, nil +} + +func (r *pgRepo) ListBlockers(ctx context.Context, blockedID uuid.UUID) ([]*Issue, error) { + rows, err := r.q.ListBlockers(ctx, toPgUUID(blockedID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list blockers") + } + out := make([]*Issue, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToIssue(row)) + } + return out, nil +} + +func (r *pgRepo) ListBlocked(ctx context.Context, blockerID uuid.UUID) ([]*Issue, error) { + rows, err := r.q.ListBlocked(ctx, toPgUUID(blockerID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list blocked") + } + out := make([]*Issue, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToIssue(row)) + } + return out, nil +} + +func (r *pgRepo) ListBlockerIDs(ctx context.Context, blockedID uuid.UUID) ([]uuid.UUID, error) { + rows, err := r.q.ListBlockerIDs(ctx, toPgUUID(blockedID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list blocker ids") + } + out := make([]uuid.UUID, 0, len(rows)) + for _, p := range rows { + out = append(out, fromPgUUID(p)) + } + return out, nil +} + +func (r *pgRepo) ListSubtasks(ctx context.Context, parentID uuid.UUID) ([]*Issue, error) { + rows, err := r.q.ListSubtasks(ctx, toPgUUID(parentID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list subtasks") + } + out := make([]*Issue, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToIssue(row)) + } + return out, nil +} + +func (r *pgRepo) ListReadyLeafSubtasks(ctx context.Context, projectID uuid.UUID, limit int) ([]*Issue, error) { + if limit <= 0 { + limit = 50 + } + rows, err := r.q.ListReadyLeafSubtasks(ctx, projectsqlc.ListReadyLeafSubtasksParams{ + ProjectID: toPgUUID(projectID), + Limit: int32(limit), //nolint:gosec // limit 来自配置/调用方,正向小整数 + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list ready leaf subtasks") + } + out := make([]*Issue, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToIssue(row)) + } + return out, nil +} + +func (r *pgRepo) ListSchedulableProjects(ctx context.Context) ([]uuid.UUID, error) { + rows, err := r.q.ListSchedulableProjects(ctx) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list schedulable projects") + } + out := make([]uuid.UUID, 0, len(rows)) + for _, p := range rows { + out = append(out, fromPgUUID(p)) + } + return out, nil +} + +func (r *pgRepo) CountActiveSessionsForProject(ctx context.Context, projectID uuid.UUID) (int, error) { + n, err := r.q.CountActiveSessionsForProject(ctx, toPgUUID(projectID)) + if err != nil { + return 0, errs.Wrap(err, errs.CodeInternal, "count active sessions for project") + } + return int(n), nil +} + // ===== Artifact ===== func rowToArtifact(row projectsqlc.RequirementArtifact) *Artifact { @@ -512,6 +777,17 @@ func rowToArtifact(row projectsqlc.RequirementArtifact) *Artifact { SourceMessageID: row.SourceMessageID, CreatedBy: fromPgUUID(row.CreatedBy), CreatedAt: row.CreatedAt.Time, + Verdict: Verdict(row.Verdict), + } +} + +func rowToPhasePointer(row projectsqlc.RequirementPhasePointer) *PhasePointer { + return &PhasePointer{ + RequirementID: fromPgUUID(row.RequirementID), + Phase: Phase(row.Phase), + ApprovedArtifactID: fromPgUUIDPtr(row.ApprovedArtifactID), + ApprovedBy: fromPgUUIDPtr(row.ApprovedBy), + ApprovedAt: fromPgTimePtr(row.ApprovedAt), } } @@ -575,3 +851,69 @@ func (r *pgRepo) GetMaxArtifactVersion(ctx context.Context, requirementID uuid.U } return int(max), nil } + +func (r *pgRepo) GetArtifactByID(ctx context.Context, id uuid.UUID) (*Artifact, error) { + row, err := r.q.GetRequirementArtifactByID(ctx, toPgUUID(id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "artifact 不存在") + } + return nil, errs.Wrap(err, errs.CodeInternal, "get artifact by id") + } + return rowToArtifact(row), nil +} + +func (r *pgRepo) ApproveArtifact(ctx context.Context, artifactID uuid.UUID, verdict Verdict) (*Artifact, error) { + row, err := r.q.ApproveRequirementArtifact(ctx, projectsqlc.ApproveRequirementArtifactParams{ + ID: toPgUUID(artifactID), + Verdict: string(verdict), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "artifact 不存在") + } + return nil, errs.Wrap(err, errs.CodeInternal, "approve artifact") + } + return rowToArtifact(row), nil +} + +// ===== PhasePointer ===== + +func (r *pgRepo) UpsertPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase, artifactID, approvedBy uuid.UUID) (*PhasePointer, error) { + row, err := r.q.UpsertRequirementPhasePointer(ctx, projectsqlc.UpsertRequirementPhasePointerParams{ + RequirementID: toPgUUID(requirementID), + Phase: string(phase), + ApprovedArtifactID: toPgUUID(artifactID), + ApprovedBy: toPgUUID(approvedBy), + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "upsert phase pointer") + } + return rowToPhasePointer(row), nil +} + +func (r *pgRepo) GetPhasePointer(ctx context.Context, requirementID uuid.UUID, phase Phase) (*PhasePointer, error) { + row, err := r.q.GetRequirementPhasePointer(ctx, projectsqlc.GetRequirementPhasePointerParams{ + RequirementID: toPgUUID(requirementID), + Phase: string(phase), + }) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "phase pointer 不存在") + } + return nil, errs.Wrap(err, errs.CodeInternal, "get phase pointer") + } + return rowToPhasePointer(row), nil +} + +func (r *pgRepo) ListPhasePointers(ctx context.Context, requirementID uuid.UUID) ([]*PhasePointer, error) { + rows, err := r.q.ListRequirementPhasePointers(ctx, toPgUUID(requirementID)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list phase pointers") + } + out := make([]*PhasePointer, 0, len(rows)) + for _, row := range rows { + out = append(out, rowToPhasePointer(row)) + } + return out, nil +} diff --git a/internal/project/requirement_service.go b/internal/project/requirement_service.go index 79456c6..cdea18b 100644 --- a/internal/project/requirement_service.go +++ b/internal/project/requirement_service.go @@ -19,22 +19,28 @@ import ( ) type requirementService struct { - repo Repository - audit audit.Recorder + repo Repository + audit audit.Recorder + emitter PhaseEventEmitter + gate GateRunner } // NewRequirementService 构造 RequirementService。 -func NewRequirementService(repo Repository, rec audit.Recorder) RequirementService { - return &requirementService{repo: repo, audit: rec} +// +// emitter 为 nil 时不发 requirement.phase_change 领域事件(仅审计),gate 为 nil +// 时保持 any-to-any 旧行为(不做 entry 网关校验)——二者均允许测试/精简场景注入 nil。 +func NewRequirementService(repo Repository, rec audit.Recorder, emitter PhaseEventEmitter, gate GateRunner) RequirementService { + return &requirementService{repo: repo, audit: rec, emitter: emitter, gate: gate} } // loadProjectForWrite 解析 slug → 校验 caller 写权限 → 校验未归档。 +// 成员/角色 ACL:member/admin 角色叠加写授权(见 assertWritable)。 func loadProjectForWrite(ctx context.Context, repo Repository, c Caller, slug string) (*Project, error) { p, err := repo.GetProjectBySlug(ctx, slug) if err != nil { return nil, err } - if err := assertWritable(p, c); err != nil { + if err := assertWritable(p, c, callerRole(ctx, repo, p, c)); err != nil { return nil, err } return p, nil @@ -45,12 +51,25 @@ func loadProjectForRead(ctx context.Context, repo Repository, c Caller, slug str if err != nil { return nil, err } - if err := assertReadable(p, c); err != nil { + if err := assertReadable(p, c, callerRole(ctx, repo, p, c)); err != nil { return nil, err } return p, nil } +// callerRole 是 loadProjectFor* 的成员角色查询 helper(不在 projectService 上, +// 故走 repo)。owner/global-admin 隐式 admin;查询失败按 RoleNone(既有规则兜底)。 +func callerRole(ctx context.Context, repo Repository, p *Project, c Caller) Role { + if c.IsAdmin || p.OwnerID == c.UserID { + return RoleAdmin + } + role, err := repo.GetMemberRole(ctx, p.ID, c.UserID) + if err != nil { + return RoleNone + } + return role +} + func (s *requirementService) Create(ctx context.Context, c Caller, slug string, in CreateRequirementInput) (*Requirement, error) { if in.Title == "" { return nil, errs.New(errs.CodeInvalidInput, "title 必填") @@ -193,6 +212,13 @@ func (s *requirementService) ChangePhase(ctx context.Context, c Caller, slug str return r, nil } from := r.Phase + // entry 网关:目标 phase 声明的全部前置谓词必须通过。失败返回 CodePhaseGateFailed, + // 且不写库、不发事件。gate 为 nil 时跳过(保持 any-to-any)。 + if s.gate != nil { + if err := s.gate.Check(ctx, r, from, to); err != nil { + return nil, err + } + } out, err := s.repo.UpdateRequirementPhase(ctx, r.ID, to) if err != nil { return nil, err @@ -200,6 +226,19 @@ func (s *requirementService) ChangePhase(ctx context.Context, c Caller, slug str s.recordAudit(ctx, c, "requirement.phase_change", out.ID.String(), map[string]any{ "from": string(from), "to": string(to), }) + // 领域事件(与审计互补):fire-and-forget,投递由 emitter 异步处理,失败不阻塞流转。 + if s.emitter != nil { + s.emitter.EmitPhaseChange(context.WithoutCancel(ctx), PhaseChangeEvent{ + ProjectID: p.ID, + ProjectSlug: p.Slug, + RequirementID: out.ID, + RequirementNumber: out.Number, + From: from, + To: to, + ActorID: c.UserID, + OwnerID: out.OwnerID, + }) + } return out, nil } diff --git a/internal/project/requirement_service_test.go b/internal/project/requirement_service_test.go index ca4e73d..ad8bd8a 100644 --- a/internal/project/requirement_service_test.go +++ b/internal/project/requirement_service_test.go @@ -13,7 +13,7 @@ import ( func newReqSvc(repo *fakeRepo) (RequirementService, ProjectService, *spyAudit) { spy := &spyAudit{} - return NewRequirementService(repo, spy), NewProjectService(repo, spy), spy + return NewRequirementService(repo, spy, nil, nil), NewProjectService(repo, spy), spy } func TestRequirementService_Create_HappyAndAutoIncrement(t *testing.T) { @@ -175,3 +175,130 @@ func TestRequirementService_Update_WorkspaceID_TriState(t *testing.T) { require.NoError(t, err) require.Nil(t, out.WorkspaceID) } + +// fakeEmitter 收集 EmitPhaseChange 调用,便于断言事件发出且仅发一次。 +type fakeEmitter struct { + events []PhaseChangeEvent +} + +func (e *fakeEmitter) EmitPhaseChange(_ context.Context, ev PhaseChangeEvent) { + e.events = append(e.events, ev) +} + +func newReqSvcWith(repo *fakeRepo, emitter PhaseEventEmitter, gate GateRunner) (RequirementService, *spyAudit) { + spy := &spyAudit{} + return NewRequirementService(repo, spy, emitter, gate), spy +} + +func TestRequirementService_ChangePhase_EmitsEvent(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + emitter := &fakeEmitter{} + svc, spy := newReqSvcWith(repo, emitter, nil) + caller := Caller{UserID: owner} + + r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"}) + require.NoError(t, err) + + out, err := svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseAuditing) + require.NoError(t, err) + require.Equal(t, PhaseAuditing, out.Phase) + + require.Len(t, emitter.events, 1) + ev := emitter.events[0] + require.Equal(t, PhasePlanning, ev.From) + require.Equal(t, PhaseAuditing, ev.To) + require.Equal(t, "demo", ev.ProjectSlug) + require.Equal(t, r.Number, ev.RequirementNumber) + require.Equal(t, owner, ev.ActorID) + // 事件与审计互补:审计仍记一条。 + require.Contains(t, spy.actions(), "requirement.phase_change") +} + +func TestRequirementService_ChangePhase_NoopEmitsNothing(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + emitter := &fakeEmitter{} + svc, _ := newReqSvcWith(repo, emitter, nil) + caller := Caller{UserID: owner} + + r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"}) + require.NoError(t, err) + + // 当前已是 planning,切到 planning 是 no-op。 + _, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhasePlanning) + require.NoError(t, err) + require.Empty(t, emitter.events) +} + +func TestRequirementService_ChangePhase_GateBlocksAndSkipsEmit(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + emitter := &fakeEmitter{} + gate := NewGateRunner(GateDeps{Repo: repo}, nil) + svc, spy := newReqSvcWith(repo, emitter, gate) + caller := Caller{UserID: owner} + + r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"}) + require.NoError(t, err) + + // implementing 需要已批准的 auditing 产物,缺失 → phase_gate_failed。 + _, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseImplementing) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodePhaseGateFailed, ae.Code) + + // gate 失败不写库、不发事件、不记 phase_change 审计。 + require.Empty(t, emitter.events) + require.NotContains(t, spy.actions(), "requirement.phase_change") + got, err := svc.Get(context.Background(), caller, "demo", r.Number) + require.NoError(t, err) + require.Equal(t, PhasePlanning, got.Phase) +} + +func TestRequirementService_ChangePhase_ClosedCheckedBeforeGate(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + emitter := &fakeEmitter{} + gate := NewGateRunner(GateDeps{Repo: repo}, nil) + svc, _ := newReqSvcWith(repo, emitter, gate) + caller := Caller{UserID: owner} + + r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"}) + require.NoError(t, err) + require.NoError(t, svc.Close(context.Background(), caller, "demo", r.Number)) + + // 关闭检查先于 gate:即便目标是被网关的 implementing,也返回 requirement_closed。 + _, err = svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseImplementing) + ae, ok := errs.As(err) + require.True(t, ok) + require.Equal(t, errs.CodeRequirementClosed, ae.Code) + require.Empty(t, emitter.events) +} + +func TestRequirementService_ChangePhase_GateAllowsAfterApproval(t *testing.T) { + repo := newFakeRepo() + owner := uuid.New() + seedProject(t, repo, owner) + emitter := &fakeEmitter{} + gate := NewGateRunner(GateDeps{Repo: repo}, nil) + svc, _ := newReqSvcWith(repo, emitter, gate) + artSvc := NewArtifactService(repo, nil) + caller := Caller{UserID: owner} + + r, err := svc.Create(context.Background(), caller, "demo", CreateRequirementInput{Title: "T"}) + require.NoError(t, err) + _, err = artSvc.Create(context.Background(), caller, "demo", r.Number, CreateArtifactInput{Phase: PhaseAuditing, Content: "report"}) + require.NoError(t, err) + _, err = artSvc.Approve(context.Background(), caller, "demo", r.Number, PhaseAuditing, 1, VerdictPass) + require.NoError(t, err) + + out, err := svc.ChangePhase(context.Background(), caller, "demo", r.Number, PhaseImplementing) + require.NoError(t, err) + require.Equal(t, PhaseImplementing, out.Phase) + require.Len(t, emitter.events, 1) +} diff --git a/internal/project/sqlc/artifacts.sql.go b/internal/project/sqlc/artifacts.sql.go index 1156715..ec1b405 100644 --- a/internal/project/sqlc/artifacts.sql.go +++ b/internal/project/sqlc/artifacts.sql.go @@ -11,10 +11,38 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const approveRequirementArtifact = `-- name: ApproveRequirementArtifact :one +UPDATE requirement_artifacts SET verdict = $2 WHERE id = $1 +RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict +` + +type ApproveRequirementArtifactParams struct { + ID pgtype.UUID `json:"id"` + Verdict string `json:"verdict"` +} + +func (q *Queries) ApproveRequirementArtifact(ctx context.Context, arg ApproveRequirementArtifactParams) (RequirementArtifact, error) { + row := q.db.QueryRow(ctx, approveRequirementArtifact, arg.ID, arg.Verdict) + var i RequirementArtifact + err := row.Scan( + &i.ID, + &i.RequirementID, + &i.Phase, + &i.Version, + &i.Content, + &i.Note, + &i.SourceMessageID, + &i.CreatedBy, + &i.CreatedAt, + &i.Verdict, + ) + return i, err +} + const createRequirementArtifact = `-- name: CreateRequirementArtifact :one INSERT INTO requirement_artifacts (id, requirement_id, phase, version, content, note, source_message_id, created_by) VALUES ($1, $2, $3, $4, $5, $6, $7, $8) -RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at +RETURNING id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict ` type CreateRequirementArtifactParams struct { @@ -50,6 +78,7 @@ func (q *Queries) CreateRequirementArtifact(ctx context.Context, arg CreateRequi &i.SourceMessageID, &i.CreatedBy, &i.CreatedAt, + &i.Verdict, ) return i, err } @@ -71,8 +100,32 @@ func (q *Queries) GetMaxRequirementArtifactVersion(ctx context.Context, arg GetM return column_1, err } +const getRequirementArtifactByID = `-- name: GetRequirementArtifactByID :one +SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict +FROM requirement_artifacts +WHERE id = $1 +` + +func (q *Queries) GetRequirementArtifactByID(ctx context.Context, id pgtype.UUID) (RequirementArtifact, error) { + row := q.db.QueryRow(ctx, getRequirementArtifactByID, id) + var i RequirementArtifact + err := row.Scan( + &i.ID, + &i.RequirementID, + &i.Phase, + &i.Version, + &i.Content, + &i.Note, + &i.SourceMessageID, + &i.CreatedBy, + &i.CreatedAt, + &i.Verdict, + ) + return i, err +} + const getRequirementArtifactByVersion = `-- name: GetRequirementArtifactByVersion :one -SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at +SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict FROM requirement_artifacts WHERE requirement_id = $1 AND phase = $2 AND version = $3 ` @@ -96,12 +149,13 @@ func (q *Queries) GetRequirementArtifactByVersion(ctx context.Context, arg GetRe &i.SourceMessageID, &i.CreatedBy, &i.CreatedAt, + &i.Verdict, ) return i, err } const listRequirementArtifactsByRequirement = `-- name: ListRequirementArtifactsByRequirement :many -SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at +SELECT id, requirement_id, phase, version, content, note, source_message_id, created_by, created_at, verdict FROM requirement_artifacts WHERE requirement_id = $1 AND ($2::text = '' OR phase = $2) ORDER BY phase ASC, version ASC @@ -131,6 +185,7 @@ func (q *Queries) ListRequirementArtifactsByRequirement(ctx context.Context, arg &i.SourceMessageID, &i.CreatedBy, &i.CreatedAt, + &i.Verdict, ); err != nil { return nil, err } diff --git a/internal/project/sqlc/dependencies.sql.go b/internal/project/sqlc/dependencies.sql.go new file mode 100644 index 0000000..6f2bef9 --- /dev/null +++ b/internal/project/sqlc/dependencies.sql.go @@ -0,0 +1,352 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: dependencies.sql + +package projectsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const countActiveSessionsForProject = `-- name: CountActiveSessionsForProject :one +SELECT COUNT(*) FROM acp_sessions +WHERE project_id = $1 AND status IN ('starting', 'running') +` + +// Active (starting/running) acp_sessions in a project. Used by the scheduler's +// soft per-project concurrency pre-check. +func (q *Queries) CountActiveSessionsForProject(ctx context.Context, projectID pgtype.UUID) (int64, error) { + row := q.db.QueryRow(ctx, countActiveSessionsForProject, projectID) + var count int64 + err := row.Scan(&count) + return count, err +} + +const createDependency = `-- name: CreateDependency :one +INSERT INTO issue_dependencies (id, project_id, blocked_id, blocker_id, created_by) +VALUES ($1, $2, $3, $4, $5) +RETURNING id, project_id, blocked_id, blocker_id, created_by, created_at +` + +type CreateDependencyParams struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` +} + +func (q *Queries) CreateDependency(ctx context.Context, arg CreateDependencyParams) (IssueDependency, error) { + row := q.db.QueryRow(ctx, createDependency, + arg.ID, + arg.ProjectID, + arg.BlockedID, + arg.BlockerID, + arg.CreatedBy, + ) + var i IssueDependency + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.BlockedID, + &i.BlockerID, + &i.CreatedBy, + &i.CreatedAt, + ) + return i, err +} + +const deleteDependency = `-- name: DeleteDependency :execrows +DELETE FROM issue_dependencies +WHERE project_id = $1 AND blocked_id = $2 AND blocker_id = $3 +` + +type DeleteDependencyParams struct { + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` +} + +func (q *Queries) DeleteDependency(ctx context.Context, arg DeleteDependencyParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteDependency, arg.ProjectID, arg.BlockedID, arg.BlockerID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const listBlocked = `-- name: ListBlocked :many +SELECT i.id, i.project_id, i.requirement_id, i.number, i.title, i.description, i.status, + i.assignee_id, i.created_by, i.closed_at, i.created_at, i.updated_at, i.workspace_id, i.parent_id, i.priority +FROM issue_dependencies d +JOIN issues i ON i.id = d.blocked_id +WHERE d.blocker_id = $1 +ORDER BY i.number ASC +` + +// Issues blocked by $1 (issues that depend on blocker $1). +func (q *Queries) ListBlocked(ctx context.Context, blockerID pgtype.UUID) ([]Issue, error) { + rows, err := q.db.Query(ctx, listBlocked, blockerID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Issue + for rows.Next() { + var i Issue + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.RequirementID, + &i.Number, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.CreatedBy, + &i.ClosedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.WorkspaceID, + &i.ParentID, + &i.Priority, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listBlockerIDs = `-- name: ListBlockerIDs :many +SELECT blocker_id FROM issue_dependencies WHERE blocked_id = $1 +` + +// Direct blocker issue ids of $1 (one hop along blocked-by edges). Used by the +// service-layer iterative reachability cycle check in AddDependency. +func (q *Queries) ListBlockerIDs(ctx context.Context, blockedID pgtype.UUID) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listBlockerIDs, blockedID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var blocker_id pgtype.UUID + if err := rows.Scan(&blocker_id); err != nil { + return nil, err + } + items = append(items, blocker_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listBlockers = `-- name: ListBlockers :many +SELECT i.id, i.project_id, i.requirement_id, i.number, i.title, i.description, i.status, + i.assignee_id, i.created_by, i.closed_at, i.created_at, i.updated_at, i.workspace_id, i.parent_id, i.priority +FROM issue_dependencies d +JOIN issues i ON i.id = d.blocker_id +WHERE d.blocked_id = $1 +ORDER BY i.number ASC +` + +// Issues that block $1 (the dependencies of blocked issue $1). +func (q *Queries) ListBlockers(ctx context.Context, blockedID pgtype.UUID) ([]Issue, error) { + rows, err := q.db.Query(ctx, listBlockers, blockedID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Issue + for rows.Next() { + var i Issue + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.RequirementID, + &i.Number, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.CreatedBy, + &i.ClosedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.WorkspaceID, + &i.ParentID, + &i.Priority, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listReadyLeafSubtasks = `-- name: ListReadyLeafSubtasks :many +SELECT i.id, i.project_id, i.requirement_id, i.number, i.title, i.description, i.status, + i.assignee_id, i.created_by, i.closed_at, i.created_at, i.updated_at, i.workspace_id, i.parent_id, i.priority +FROM issues i +WHERE i.project_id = $1 + AND i.status = 'open' + AND i.parent_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM issue_dependencies d + JOIN issues b ON b.id = d.blocker_id + WHERE d.blocked_id = i.id AND b.status <> 'closed' + ) + AND NOT EXISTS ( + SELECT 1 FROM acp_sessions s + WHERE s.issue_id = i.id AND s.status IN ('starting', 'running') + ) + AND NOT EXISTS ( + SELECT 1 FROM issues c + WHERE c.parent_id = i.id AND c.status = 'open' + ) +ORDER BY i.priority DESC, i.number ASC +LIMIT $2 +` + +type ListReadyLeafSubtasksParams struct { + ProjectID pgtype.UUID `json:"project_id"` + Limit int32 `json:"limit"` +} + +// Ready leaf subtasks for a project: open subtasks (parent_id NOT NULL) whose +// every blocker is closed, with no active (starting/running) acp_session, and +// no open child issue (leaf). Ordered by priority DESC, number ASC. LIMIT $2. +func (q *Queries) ListReadyLeafSubtasks(ctx context.Context, arg ListReadyLeafSubtasksParams) ([]Issue, error) { + rows, err := q.db.Query(ctx, listReadyLeafSubtasks, arg.ProjectID, arg.Limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Issue + for rows.Next() { + var i Issue + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.RequirementID, + &i.Number, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.CreatedBy, + &i.ClosedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.WorkspaceID, + &i.ParentID, + &i.Priority, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSchedulableProjects = `-- name: ListSchedulableProjects :many +SELECT DISTINCT i.project_id +FROM issues i +WHERE i.status = 'open' + AND i.parent_id IS NOT NULL + AND NOT EXISTS ( + SELECT 1 FROM issue_dependencies d + JOIN issues b ON b.id = d.blocker_id + WHERE d.blocked_id = i.id AND b.status <> 'closed' + ) + AND NOT EXISTS ( + SELECT 1 FROM acp_sessions s + WHERE s.issue_id = i.id AND s.status IN ('starting', 'running') + ) + AND NOT EXISTS ( + SELECT 1 FROM issues c + WHERE c.parent_id = i.id AND c.status = 'open' + ) +` + +// Distinct project ids that currently have at least one ready leaf subtask +// (open subtask whose blockers are all closed and no active session). Cheap +// pre-filter so the scheduler only iterates projects with pending work. +func (q *Queries) ListSchedulableProjects(ctx context.Context) ([]pgtype.UUID, error) { + rows, err := q.db.Query(ctx, listSchedulableProjects) + if err != nil { + return nil, err + } + defer rows.Close() + var items []pgtype.UUID + for rows.Next() { + var project_id pgtype.UUID + if err := rows.Scan(&project_id); err != nil { + return nil, err + } + items = append(items, project_id) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listSubtasks = `-- name: ListSubtasks :many +SELECT id, project_id, requirement_id, number, title, description, status, + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority +FROM issues +WHERE parent_id = $1 +ORDER BY priority DESC, number ASC +` + +func (q *Queries) ListSubtasks(ctx context.Context, parentID pgtype.UUID) ([]Issue, error) { + rows, err := q.db.Query(ctx, listSubtasks, parentID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []Issue + for rows.Next() { + var i Issue + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.RequirementID, + &i.Number, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.CreatedBy, + &i.ClosedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.WorkspaceID, + &i.ParentID, + &i.Priority, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/project/sqlc/issues.sql.go b/internal/project/sqlc/issues.sql.go index c02a0ed..c5d4301 100644 --- a/internal/project/sqlc/issues.sql.go +++ b/internal/project/sqlc/issues.sql.go @@ -22,14 +22,14 @@ func (q *Queries) CloseIssue(ctx context.Context, id pgtype.UUID) error { } const createIssue = `-- name: CreateIssue :one -INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by, workspace_id) +INSERT INTO issues (id, project_id, requirement_id, number, title, description, assignee_id, created_by, workspace_id, parent_id, priority) VALUES ( $1, $2, $3, (SELECT COALESCE(MAX(number), 0) + 1 FROM issues WHERE project_id = $2), - $4, $5, $6, $7, $8 + $4, $5, $6, $7, $8, $9, $10 ) RETURNING id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at, workspace_id + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority ` type CreateIssueParams struct { @@ -41,6 +41,8 @@ type CreateIssueParams struct { AssigneeID pgtype.UUID `json:"assignee_id"` CreatedBy pgtype.UUID `json:"created_by"` WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` } func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue, error) { @@ -53,6 +55,8 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue arg.AssigneeID, arg.CreatedBy, arg.WorkspaceID, + arg.ParentID, + arg.Priority, ) var i Issue err := row.Scan( @@ -69,13 +73,45 @@ func (q *Queries) CreateIssue(ctx context.Context, arg CreateIssueParams) (Issue &i.CreatedAt, &i.UpdatedAt, &i.WorkspaceID, + &i.ParentID, + &i.Priority, + ) + return i, err +} + +const getIssueByID = `-- name: GetIssueByID :one +SELECT id, project_id, requirement_id, number, title, description, status, + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority +FROM issues +WHERE id = $1 +` + +func (q *Queries) GetIssueByID(ctx context.Context, id pgtype.UUID) (Issue, error) { + row := q.db.QueryRow(ctx, getIssueByID, id) + var i Issue + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.RequirementID, + &i.Number, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.CreatedBy, + &i.ClosedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.WorkspaceID, + &i.ParentID, + &i.Priority, ) return i, err } const getIssueByNumber = `-- name: GetIssueByNumber :one SELECT id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at, workspace_id + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority FROM issues WHERE project_id = $1 AND number = $2 ` @@ -102,13 +138,15 @@ func (q *Queries) GetIssueByNumber(ctx context.Context, arg GetIssueByNumberPara &i.CreatedAt, &i.UpdatedAt, &i.WorkspaceID, + &i.ParentID, + &i.Priority, ) return i, err } const listIssues = `-- name: ListIssues :many SELECT id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at, workspace_id + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority FROM issues WHERE project_id = $1 AND ($2::text IS NULL OR status = $2) @@ -158,6 +196,8 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue &i.CreatedAt, &i.UpdatedAt, &i.WorkspaceID, + &i.ParentID, + &i.Priority, ); err != nil { return nil, err } @@ -171,7 +211,7 @@ func (q *Queries) ListIssues(ctx context.Context, arg ListIssuesParams) ([]Issue const listIssuesByRequirement = `-- name: ListIssuesByRequirement :many SELECT id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at, workspace_id + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority FROM issues WHERE requirement_id = $1 ORDER BY number DESC @@ -200,6 +240,8 @@ func (q *Queries) ListIssuesByRequirement(ctx context.Context, requirementID pgt &i.CreatedAt, &i.UpdatedAt, &i.WorkspaceID, + &i.ParentID, + &i.Priority, ); err != nil { return nil, err } @@ -226,7 +268,7 @@ UPDATE issues SET assignee_id = $2, updated_at = now() WHERE id = $1 RETURNING id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at, workspace_id + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority ` type UpdateIssueAssigneeParams struct { @@ -251,6 +293,8 @@ func (q *Queries) UpdateIssueAssignee(ctx context.Context, arg UpdateIssueAssign &i.CreatedAt, &i.UpdatedAt, &i.WorkspaceID, + &i.ParentID, + &i.Priority, ) return i, err } @@ -264,7 +308,7 @@ SET title = $2, updated_at = now() WHERE id = $1 RETURNING id, project_id, requirement_id, number, title, description, status, - assignee_id, created_by, closed_at, created_at, updated_at, workspace_id + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority ` type UpdateIssueCoreParams struct { @@ -298,6 +342,44 @@ func (q *Queries) UpdateIssueCore(ctx context.Context, arg UpdateIssueCoreParams &i.CreatedAt, &i.UpdatedAt, &i.WorkspaceID, + &i.ParentID, + &i.Priority, + ) + return i, err +} + +const updateIssuePriority = `-- name: UpdateIssuePriority :one +UPDATE issues +SET priority = $2, updated_at = now() +WHERE id = $1 +RETURNING id, project_id, requirement_id, number, title, description, status, + assignee_id, created_by, closed_at, created_at, updated_at, workspace_id, parent_id, priority +` + +type UpdateIssuePriorityParams struct { + ID pgtype.UUID `json:"id"` + Priority int32 `json:"priority"` +} + +func (q *Queries) UpdateIssuePriority(ctx context.Context, arg UpdateIssuePriorityParams) (Issue, error) { + row := q.db.QueryRow(ctx, updateIssuePriority, arg.ID, arg.Priority) + var i Issue + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.RequirementID, + &i.Number, + &i.Title, + &i.Description, + &i.Status, + &i.AssigneeID, + &i.CreatedBy, + &i.ClosedAt, + &i.CreatedAt, + &i.UpdatedAt, + &i.WorkspaceID, + &i.ParentID, + &i.Priority, ) return i, err } diff --git a/internal/project/sqlc/members.sql.go b/internal/project/sqlc/members.sql.go new file mode 100644 index 0000000..7788dbf --- /dev/null +++ b/internal/project/sqlc/members.sql.go @@ -0,0 +1,120 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: members.sql + +package projectsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteProjectMember = `-- name: DeleteProjectMember :execrows +DELETE FROM project_members +WHERE project_id = $1 AND user_id = $2 +` + +type DeleteProjectMemberParams struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) DeleteProjectMember(ctx context.Context, arg DeleteProjectMemberParams) (int64, error) { + result, err := q.db.Exec(ctx, deleteProjectMember, arg.ProjectID, arg.UserID) + if err != nil { + return 0, err + } + return result.RowsAffected(), nil +} + +const getProjectMember = `-- name: GetProjectMember :one +SELECT project_id, user_id, role, created_at, added_by +FROM project_members +WHERE project_id = $1 AND user_id = $2 +` + +type GetProjectMemberParams struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` +} + +func (q *Queries) GetProjectMember(ctx context.Context, arg GetProjectMemberParams) (ProjectMember, error) { + row := q.db.QueryRow(ctx, getProjectMember, arg.ProjectID, arg.UserID) + var i ProjectMember + err := row.Scan( + &i.ProjectID, + &i.UserID, + &i.Role, + &i.CreatedAt, + &i.AddedBy, + ) + return i, err +} + +const listProjectMembers = `-- name: ListProjectMembers :many +SELECT project_id, user_id, role, created_at, added_by +FROM project_members +WHERE project_id = $1 +ORDER BY created_at ASC +` + +func (q *Queries) ListProjectMembers(ctx context.Context, projectID pgtype.UUID) ([]ProjectMember, error) { + rows, err := q.db.Query(ctx, listProjectMembers, projectID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ProjectMember + for rows.Next() { + var i ProjectMember + if err := rows.Scan( + &i.ProjectID, + &i.UserID, + &i.Role, + &i.CreatedAt, + &i.AddedBy, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertProjectMember = `-- name: UpsertProjectMember :one +INSERT INTO project_members (project_id, user_id, role, added_by) +VALUES ($1, $2, $3, $4) +ON CONFLICT (project_id, user_id) +DO UPDATE SET role = EXCLUDED.role +RETURNING project_id, user_id, role, created_at, added_by +` + +type UpsertProjectMemberParams struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + AddedBy pgtype.UUID `json:"added_by"` +} + +func (q *Queries) UpsertProjectMember(ctx context.Context, arg UpsertProjectMemberParams) (ProjectMember, error) { + row := q.db.QueryRow(ctx, upsertProjectMember, + arg.ProjectID, + arg.UserID, + arg.Role, + arg.AddedBy, + ) + var i ProjectMember + err := row.Scan( + &i.ProjectID, + &i.UserID, + &i.Role, + &i.CreatedAt, + &i.AddedBy, + ) + return i, err +} diff --git a/internal/project/sqlc/models.go b/internal/project/sqlc/models.go index 373418f..ce5ad01 100644 --- a/internal/project/sqlc/models.go +++ b/internal/project/sqlc/models.go @@ -8,6 +8,7 @@ import ( "net/netip" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type AcpAgentKind struct { @@ -25,6 +26,11 @@ type AcpAgentKind struct { ToolAllowlist []string `json:"tool_allowlist"` ClientType string `json:"client_type"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` } type AcpAgentKindConfigFile struct { @@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct { UpdatedBy pgtype.UUID `json:"updated_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type AcpEvent struct { @@ -64,23 +71,64 @@ type AcpPermissionRequest struct { } type AcpSession struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ProjectID pgtype.UUID `json:"project_id"` - AgentKindID pgtype.UUID `json:"agent_kind_id"` - UserID pgtype.UUID `json:"user_id"` - IssueID pgtype.UUID `json:"issue_id"` - RequirementID pgtype.UUID `json:"requirement_id"` - AgentSessionID *string `json:"agent_session_id"` - Branch string `json:"branch"` - CwdPath string `json:"cwd_path"` - IsMainWorktree bool `json:"is_main_worktree"` - Status string `json:"status"` - Pid *int32 `json:"pid"` - ExitCode *int32 `json:"exit_code"` - LastError *string `json:"last_error"` - StartedAt pgtype.Timestamptz `json:"started_at"` - EndedAt pgtype.Timestamptz `json:"ended_at"` + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` } type AuditLog struct { @@ -94,6 +142,81 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Conversation struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` @@ -110,6 +233,14 @@ type Conversation struct { RequirementID pgtype.UUID `json:"requirement_id"` } +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type GitCredential struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -119,6 +250,7 @@ type GitCredential struct { Fingerprint *string `json:"fingerprint"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type Issue struct { @@ -135,6 +267,17 @@ type Issue struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Job struct { @@ -162,6 +305,7 @@ type LlmEndpoint struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type LlmModel struct { @@ -252,6 +396,50 @@ type Notification struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + type Project struct { ID pgtype.UUID `json:"id"` Slug string `json:"slug"` @@ -264,6 +452,30 @@ type Project struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type PromptTemplate struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` @@ -299,6 +511,16 @@ type RequirementArtifact struct { SourceMessageID *int64 `json:"source_message_id"` CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type User struct { @@ -354,6 +576,7 @@ type WorkspaceRunProfile struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type WorkspaceWorktree struct { diff --git a/internal/project/sqlc/phase_pointers.sql.go b/internal/project/sqlc/phase_pointers.sql.go new file mode 100644 index 0000000..72a64fd --- /dev/null +++ b/internal/project/sqlc/phase_pointers.sql.go @@ -0,0 +1,108 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: phase_pointers.sql + +package projectsqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const getRequirementPhasePointer = `-- name: GetRequirementPhasePointer :one +SELECT requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at +FROM requirement_phase_pointers +WHERE requirement_id = $1 AND phase = $2 +` + +type GetRequirementPhasePointerParams struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` +} + +func (q *Queries) GetRequirementPhasePointer(ctx context.Context, arg GetRequirementPhasePointerParams) (RequirementPhasePointer, error) { + row := q.db.QueryRow(ctx, getRequirementPhasePointer, arg.RequirementID, arg.Phase) + var i RequirementPhasePointer + err := row.Scan( + &i.RequirementID, + &i.Phase, + &i.ApprovedArtifactID, + &i.ApprovedBy, + &i.ApprovedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listRequirementPhasePointers = `-- name: ListRequirementPhasePointers :many +SELECT requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at +FROM requirement_phase_pointers +WHERE requirement_id = $1 +ORDER BY phase ASC +` + +func (q *Queries) ListRequirementPhasePointers(ctx context.Context, requirementID pgtype.UUID) ([]RequirementPhasePointer, error) { + rows, err := q.db.Query(ctx, listRequirementPhasePointers, requirementID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RequirementPhasePointer + for rows.Next() { + var i RequirementPhasePointer + if err := rows.Scan( + &i.RequirementID, + &i.Phase, + &i.ApprovedArtifactID, + &i.ApprovedBy, + &i.ApprovedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const upsertRequirementPhasePointer = `-- name: UpsertRequirementPhasePointer :one +INSERT INTO requirement_phase_pointers (requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at) +VALUES ($1, $2, $3, $4, now(), now()) +ON CONFLICT (requirement_id, phase) DO UPDATE + SET approved_artifact_id = EXCLUDED.approved_artifact_id, + approved_by = EXCLUDED.approved_by, + approved_at = EXCLUDED.approved_at, + updated_at = now() +RETURNING requirement_id, phase, approved_artifact_id, approved_by, approved_at, updated_at +` + +type UpsertRequirementPhasePointerParams struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` +} + +func (q *Queries) UpsertRequirementPhasePointer(ctx context.Context, arg UpsertRequirementPhasePointerParams) (RequirementPhasePointer, error) { + row := q.db.QueryRow(ctx, upsertRequirementPhasePointer, + arg.RequirementID, + arg.Phase, + arg.ApprovedArtifactID, + arg.ApprovedBy, + ) + var i RequirementPhasePointer + err := row.Scan( + &i.RequirementID, + &i.Phase, + &i.ApprovedArtifactID, + &i.ApprovedBy, + &i.ApprovedAt, + &i.UpdatedAt, + ) + return i, err +} diff --git a/internal/projectmemory/agents_md.go b/internal/projectmemory/agents_md.go new file mode 100644 index 0000000..3ed7321 --- /dev/null +++ b/internal/projectmemory/agents_md.go @@ -0,0 +1,88 @@ +package projectmemory + +import ( + "sort" + "strings" + + "github.com/google/uuid" +) + +// kindHeadings maps each memory kind to its AGENTS.md section heading. +var kindHeadings = map[string]string{ + KindDecision: "Decisions", + KindConvention: "Conventions", + KindFileMap: "File Map", + KindGotcha: "Gotchas", +} + +// agentsMDHeader is the generated-file banner. AGENTS.md is written into the +// worktree before spawn; the banner marks it generated so it is not committed or +// picked up by auto-doc diffs. +const agentsMDHeader = "" + +// renderAgentsMD produces deterministic markdown grouped by kind. Within a kind, +// entries are sorted by title then id for stable output. When wsID is set, +// workspace-scoped entries plus project-wide (workspace_id NULL) entries are +// included; otherwise only project-wide entries. +func renderAgentsMD(entries []Entry, wsID *uuid.UUID) string { + byKind := map[string][]Entry{} + for _, e := range entries { + if !includeForWorkspace(e, wsID) { + continue + } + byKind[e.Kind] = append(byKind[e.Kind], e) + } + + var b strings.Builder + b.WriteString(agentsMDHeader) + b.WriteString("\n\n# AGENTS.md\n\n") + b.WriteString("Project knowledge curated by agents and maintainers. Treat as authoritative context.\n") + + wrote := false + for _, kind := range validKinds { + items := byKind[kind] + if len(items) == 0 { + continue + } + sort.SliceStable(items, func(i, j int) bool { + if items[i].Title != items[j].Title { + return items[i].Title < items[j].Title + } + return items[i].ID.String() < items[j].ID.String() + }) + b.WriteString("\n## ") + b.WriteString(kindHeadings[kind]) + b.WriteString("\n\n") + for _, e := range items { + b.WriteString("### ") + b.WriteString(e.Title) + b.WriteString("\n") + if len(e.Tags) > 0 { + tags := append([]string(nil), e.Tags...) + sort.Strings(tags) + b.WriteString("Tags: ") + b.WriteString(strings.Join(tags, ", ")) + b.WriteString("\n") + } + b.WriteString("\n") + b.WriteString(strings.TrimRight(e.Body, "\n")) + b.WriteString("\n") + } + wrote = true + } + if !wrote { + b.WriteString("\n(No project memory recorded yet.)\n") + } + return b.String() +} + +// includeForWorkspace decides whether an entry belongs in a worktree's AGENTS.md. +func includeForWorkspace(e Entry, wsID *uuid.UUID) bool { + if e.WorkspaceID == nil { + return true // project-wide + } + if wsID == nil { + return false + } + return *e.WorkspaceID == *wsID +} diff --git a/internal/projectmemory/agents_md_test.go b/internal/projectmemory/agents_md_test.go new file mode 100644 index 0000000..4c53a33 --- /dev/null +++ b/internal/projectmemory/agents_md_test.go @@ -0,0 +1,63 @@ +package projectmemory + +import ( + "strings" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestRenderAgentsMD_GroupsByKindDeterministic(t *testing.T) { + entries := []Entry{ + {ID: uuid.New(), Kind: KindGotcha, Title: "Zebra gotcha", Body: "watch out"}, + {ID: uuid.New(), Kind: KindDecision, Title: "Use Postgres", Body: "ACID matters", Tags: []string{"db", "arch"}}, + {ID: uuid.New(), Kind: KindDecision, Title: "Adopt Chi router", Body: "lightweight"}, + {ID: uuid.New(), Kind: KindConvention, Title: "Tabs not spaces", Body: "gofmt"}, + } + out := renderAgentsMD(entries, nil) + + // Sections appear in canonical kind order: Decisions, Conventions, Gotchas. + di := strings.Index(out, "## Decisions") + ci := strings.Index(out, "## Conventions") + gi := strings.Index(out, "## Gotchas") + require.Greater(t, di, 0) + require.Greater(t, ci, di) + require.Greater(t, gi, ci) + + // Within Decisions, titles are sorted: "Adopt Chi router" before "Use Postgres". + require.Less(t, strings.Index(out, "Adopt Chi router"), strings.Index(out, "Use Postgres")) + + // Tags are rendered sorted. + require.Contains(t, out, "Tags: arch, db") + + // Generated banner present. + require.True(t, strings.HasPrefix(out, agentsMDHeader)) + + // Determinism: same input → identical output. + require.Equal(t, out, renderAgentsMD(entries, nil)) +} + +func TestRenderAgentsMD_WorkspaceScoping(t *testing.T) { + ws := uuid.New() + other := uuid.New() + entries := []Entry{ + {ID: uuid.New(), Kind: KindDecision, Title: "Project-wide", Body: "x"}, // ws nil → always + {ID: uuid.New(), Kind: KindDecision, Title: "WS-specific", Body: "y", WorkspaceID: &ws}, // matches ws + {ID: uuid.New(), Kind: KindDecision, Title: "Other-WS", Body: "z", WorkspaceID: &other}, // excluded + } + out := renderAgentsMD(entries, &ws) + require.Contains(t, out, "Project-wide") + require.Contains(t, out, "WS-specific") + require.NotContains(t, out, "Other-WS") + + // With no workspace, only project-wide entries appear. + outNil := renderAgentsMD(entries, nil) + require.Contains(t, outNil, "Project-wide") + require.NotContains(t, outNil, "WS-specific") +} + +func TestRenderAgentsMD_Empty(t *testing.T) { + out := renderAgentsMD(nil, nil) + require.Contains(t, out, "No project memory recorded yet") +} diff --git a/internal/projectmemory/domain.go b/internal/projectmemory/domain.go new file mode 100644 index 0000000..24fb46d --- /dev/null +++ b/internal/projectmemory/domain.go @@ -0,0 +1,77 @@ +// Package projectmemory stores durable, typed project knowledge (decisions, +// conventions, file maps, gotchas) with optional embeddings. Write optionally +// embeds title+body (best-effort; null embedding when no embedder is configured). +// Search is hybrid: vector KNN when embeddings exist, ILIKE/tags fallback else. +// RenderAgentsMD groups entries into a deterministic AGENTS.md seeded into the +// worktree before an agent spawns. +package projectmemory + +import ( + "time" + + "github.com/google/uuid" +) + +// Kind values mirror the project_memory.kind CHECK constraint. +const ( + KindDecision = "decision" + KindConvention = "convention" + KindFileMap = "file_map" + KindGotcha = "gotcha" +) + +// validKinds is the ordered set used by RenderAgentsMD for deterministic output. +var validKinds = []string{KindDecision, KindConvention, KindFileMap, KindGotcha} + +// IsValidKind reports whether k is an allowed memory kind. +func IsValidKind(k string) bool { + for _, v := range validKinds { + if v == k { + return true + } + } + return false +} + +// Source values mirror the project_memory.source CHECK constraint. +const ( + SourceAgent = "agent" + SourceUser = "user" + SourceAuto = "auto" +) + +// Entry mirrors a project_memory row (embedding excluded from the domain type). +type Entry struct { + ID uuid.UUID + ProjectID uuid.UUID + WorkspaceID *uuid.UUID + Kind string + Title string + Body string + Tags []string + Source string + EmbeddingModel *string + CreatedBy *uuid.UUID + CreatedAt time.Time + UpdatedAt time.Time +} + +// WriteInput is the projectmemory.Write payload. +type WriteInput struct { + ProjectID uuid.UUID + WorkspaceID *uuid.UUID + Kind string + Title string + Body string + Tags []string + Source string + CreatedBy *uuid.UUID +} + +// Query parameterizes a memory search. +type Query struct { + ProjectID uuid.UUID + Text string + Kind string + TopK int +} diff --git a/internal/projectmemory/queries/memory.sql b/internal/projectmemory/queries/memory.sql new file mode 100644 index 0000000..114291c --- /dev/null +++ b/internal/projectmemory/queries/memory.sql @@ -0,0 +1,38 @@ +-- The embedding (vector) column is excluded from sqlc queries (no native pgvector +-- type). Write sets embedding via a hand-written pgx UPDATE; vector KNN search is +-- hand-written in repository.go. These queries cover the non-vector path. + +-- name: InsertEntry :one +INSERT INTO project_memory ( + id, project_id, workspace_id, kind, title, body, tags, source, + embedding_model, created_by +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, project_id, workspace_id, kind, title, body, tags, source, + embedding_model, created_by, created_at, updated_at; + +-- name: GetEntry :one +SELECT id, project_id, workspace_id, kind, title, body, tags, source, + embedding_model, created_by, created_at, updated_at +FROM project_memory +WHERE id = $1; + +-- name: ListByProject :many +SELECT id, project_id, workspace_id, kind, title, body, tags, source, + embedding_model, created_by, created_at, updated_at +FROM project_memory +WHERE project_id = $1 + AND ($2::text = '' OR kind = $2) +ORDER BY kind ASC, created_at DESC; + +-- name: SearchByKeyword :many +SELECT id, project_id, workspace_id, kind, title, body, tags, source, + embedding_model, created_by, created_at, updated_at +FROM project_memory +WHERE project_id = $1 + AND ($2::text = '' OR kind = $2) + AND ($3::text = '' OR title ILIKE '%' || $3 || '%' OR body ILIKE '%' || $3 || '%' OR $3 = ANY(tags)) +ORDER BY updated_at DESC +LIMIT $4; + +-- name: DeleteEntry :exec +DELETE FROM project_memory WHERE id = $1; diff --git a/internal/projectmemory/repository.go b/internal/projectmemory/repository.go new file mode 100644 index 0000000..6ee7cd1 --- /dev/null +++ b/internal/projectmemory/repository.go @@ -0,0 +1,230 @@ +package projectmemory + +import ( + "context" + "errors" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/pgvector/pgvector-go" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + projectmemorysqlc "github.com/yan1h/agent-coding-workflow/internal/projectmemory/sqlc" +) + +// Repository is the projectmemory persistence contract. +type Repository interface { + Insert(ctx context.Context, in WriteInput, embeddingModel *string) (*Entry, error) + SetEmbedding(ctx context.Context, id uuid.UUID, vec []float32, model string) error + Get(ctx context.Context, id uuid.UUID) (*Entry, error) + ListByProject(ctx context.Context, projectID uuid.UUID, kind string) ([]Entry, error) + SearchKeyword(ctx context.Context, projectID uuid.UUID, kind, text string, topK int) ([]Entry, error) + SearchKNN(ctx context.Context, projectID uuid.UUID, vec []float32, kind string, topK int) ([]Entry, error) + Delete(ctx context.Context, id uuid.UUID) error +} + +// PgRepository is the production Repository on a pgxpool.Pool. +type PgRepository struct { + pool *pgxpool.Pool + q *projectmemorysqlc.Queries +} + +// NewPostgresRepository builds a PgRepository. +func NewPostgresRepository(pool *pgxpool.Pool) *PgRepository { + return &PgRepository{pool: pool, q: projectmemorysqlc.New(pool)} +} + +var _ Repository = (*PgRepository)(nil) + +func pgUUID(u uuid.UUID) pgtype.UUID { return pgtype.UUID{Bytes: u, Valid: true} } + +func pgUUIDPtr(u *uuid.UUID) pgtype.UUID { + if u == nil { + return pgtype.UUID{} + } + return pgtype.UUID{Bytes: *u, Valid: true} +} + +func uuidPtr(p pgtype.UUID) *uuid.UUID { + if !p.Valid { + return nil + } + id := uuid.UUID(p.Bytes) + return &id +} + +// entryFields is the common projection shared by every sqlc row type in this +// package; a tiny adapter keeps a single conversion path. +type entryFields struct { + ID pgtype.UUID + ProjectID pgtype.UUID + WorkspaceID pgtype.UUID + Kind string + Title string + Body string + Tags []string + Source string + EmbeddingModel *string + CreatedBy pgtype.UUID + CreatedAt pgtype.Timestamptz + UpdatedAt pgtype.Timestamptz +} + +func toEntry(f entryFields) Entry { + e := Entry{ + ID: uuid.UUID(f.ID.Bytes), + ProjectID: uuid.UUID(f.ProjectID.Bytes), + WorkspaceID: uuidPtr(f.WorkspaceID), + Kind: f.Kind, + Title: f.Title, + Body: f.Body, + Tags: f.Tags, + Source: f.Source, + EmbeddingModel: f.EmbeddingModel, + CreatedBy: uuidPtr(f.CreatedBy), + } + if e.Tags == nil { + e.Tags = []string{} + } + if f.CreatedAt.Valid { + e.CreatedAt = f.CreatedAt.Time + } + if f.UpdatedAt.Valid { + e.UpdatedAt = f.UpdatedAt.Time + } + return e +} + +func (r *PgRepository) Insert(ctx context.Context, in WriteInput, embeddingModel *string) (*Entry, error) { + tags := in.Tags + if tags == nil { + // TEXT[] NOT NULL: never write a nil slice (would violate the constraint). + tags = []string{} + } + source := in.Source + if source == "" { + source = SourceAgent + } + row, err := r.q.InsertEntry(ctx, projectmemorysqlc.InsertEntryParams{ + ID: pgUUID(uuid.New()), + ProjectID: pgUUID(in.ProjectID), + WorkspaceID: pgUUIDPtr(in.WorkspaceID), + Kind: in.Kind, + Title: in.Title, + Body: in.Body, + Tags: tags, + Source: source, + EmbeddingModel: embeddingModel, + CreatedBy: pgUUIDPtr(in.CreatedBy), + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "insert project memory") + } + e := toEntry(entryFields(row)) + return &e, nil +} + +// SetEmbedding writes the vector + model with a hand-written pgx UPDATE (the +// embedding column is excluded from sqlc). updated_at is bumped. +func (r *PgRepository) SetEmbedding(ctx context.Context, id uuid.UUID, vec []float32, model string) error { + _, err := r.pool.Exec(ctx, + `UPDATE project_memory SET embedding = $2::vector, embedding_model = $3, updated_at = now() WHERE id = $1`, + pgUUID(id), pgvector.NewVector(vec), model) + if err != nil { + return errs.Wrap(err, errs.CodeInternal, "set memory embedding") + } + return nil +} + +func (r *PgRepository) Get(ctx context.Context, id uuid.UUID) (*Entry, error) { + row, err := r.q.GetEntry(ctx, pgUUID(id)) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return nil, errs.New(errs.CodeNotFound, "memory entry not found") + } + return nil, errs.Wrap(err, errs.CodeInternal, "get memory entry") + } + e := toEntry(entryFields(row)) + return &e, nil +} + +func (r *PgRepository) ListByProject(ctx context.Context, projectID uuid.UUID, kind string) ([]Entry, error) { + rows, err := r.q.ListByProject(ctx, projectmemorysqlc.ListByProjectParams{ + ProjectID: pgUUID(projectID), + Column2: kind, + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "list project memory") + } + out := make([]Entry, 0, len(rows)) + for _, row := range rows { + out = append(out, toEntry(entryFields(row))) + } + return out, nil +} + +func (r *PgRepository) SearchKeyword(ctx context.Context, projectID uuid.UUID, kind, text string, topK int) ([]Entry, error) { + if topK <= 0 { + topK = 10 + } + rows, err := r.q.SearchByKeyword(ctx, projectmemorysqlc.SearchByKeywordParams{ + ProjectID: pgUUID(projectID), + Column2: kind, + Column3: text, + Limit: int32(topK), + }) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "search memory keyword") + } + out := make([]Entry, 0, len(rows)) + for _, row := range rows { + out = append(out, toEntry(entryFields(row))) + } + return out, nil +} + +// SearchKNN runs cosine-KNN over entries that have an embedding, scoped to the +// project (and optional kind). Hand-written because the vector column is excluded +// from sqlc. +func (r *PgRepository) SearchKNN(ctx context.Context, projectID uuid.UUID, vec []float32, kind string, topK int) ([]Entry, error) { + if topK <= 0 { + topK = 10 + } + q := ` +SELECT id, project_id, workspace_id, kind, title, body, tags, source, + embedding_model, created_by, created_at, updated_at +FROM project_memory +WHERE project_id = $1 + AND embedding IS NOT NULL + AND ($3::text = '' OR kind = $3) +ORDER BY embedding <=> $2::vector +LIMIT $4` + rows, err := r.pool.Query(ctx, q, pgUUID(projectID), pgvector.NewVector(vec), kind, int32(topK)) + if err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "search memory knn") + } + defer rows.Close() + var out []Entry + for rows.Next() { + var f entryFields + if err := rows.Scan(&f.ID, &f.ProjectID, &f.WorkspaceID, &f.Kind, &f.Title, + &f.Body, &f.Tags, &f.Source, &f.EmbeddingModel, &f.CreatedBy, + &f.CreatedAt, &f.UpdatedAt); err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "scan memory knn row") + } + out = append(out, toEntry(f)) + } + if err := rows.Err(); err != nil { + return nil, errs.Wrap(err, errs.CodeInternal, "memory knn rows") + } + return out, nil +} + +func (r *PgRepository) Delete(ctx context.Context, id uuid.UUID) error { + if err := r.q.DeleteEntry(ctx, pgUUID(id)); err != nil { + return errs.Wrap(err, errs.CodeInternal, "delete memory entry") + } + return nil +} diff --git a/internal/projectmemory/service.go b/internal/projectmemory/service.go new file mode 100644 index 0000000..dac6927 --- /dev/null +++ b/internal/projectmemory/service.go @@ -0,0 +1,129 @@ +package projectmemory + +import ( + "context" + "log/slog" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/infra/llm" +) + +// EmbedderSelector resolves the configured platform embedder, or a typed error +// when embeddings are disabled. Implemented in app.go (shared with codeindex). +type EmbedderSelector interface { + Embedder(ctx context.Context) (emb llm.Embedder, endpointID uuid.UUID, model string, dims int, err error) +} + +// Service is the projectmemory application service. Scope enforcement (project +// access) is performed by callers (MCP tools / supervisor) before invoking it. +type Service interface { + Write(ctx context.Context, in WriteInput) (*Entry, error) + Search(ctx context.Context, q Query) ([]Entry, error) + List(ctx context.Context, projectID uuid.UUID, kind string) ([]Entry, error) + Delete(ctx context.Context, id uuid.UUID) error + RenderAgentsMD(ctx context.Context, projectID uuid.UUID, wsID *uuid.UUID) (string, error) +} + +type service struct { + repo Repository + embedSel EmbedderSelector + log *slog.Logger +} + +// NewService constructs the projectmemory Service. embedSel may be nil (no +// embeddings → keyword fallback only); log may be nil. +func NewService(repo Repository, embedSel EmbedderSelector, log *slog.Logger) Service { + if log == nil { + log = slog.Default() + } + return &service{repo: repo, embedSel: embedSel, log: log} +} + +func (s *service) Write(ctx context.Context, in WriteInput) (*Entry, error) { + if !IsValidKind(in.Kind) { + return nil, errs.New(errs.CodeInvalidInput, "invalid memory kind") + } + if in.Title == "" || in.Body == "" { + return nil, errs.New(errs.CodeInvalidInput, "title and body required") + } + // Best-effort embed of title+body; null embedding when disabled/unavailable. + var model *string + var vec []float32 + if emb, _, m, _, err := s.embedder(ctx); err == nil { + vecs, eerr := emb.Embed(ctx, m, []string{in.Title + "\n\n" + in.Body}) + if eerr != nil { + s.log.Warn("projectmemory.embed_failed", "err", eerr.Error()) + } else if len(vecs) == 1 { + vec = vecs[0] + model = &m + } + } + entry, err := s.repo.Insert(ctx, in, model) + if err != nil { + return nil, err + } + if vec != nil && model != nil { + if err := s.repo.SetEmbedding(ctx, entry.ID, vec, *model); err != nil { + // Non-fatal: the entry exists and is keyword-searchable. + s.log.Warn("projectmemory.set_embedding_failed", "id", entry.ID, "err", err.Error()) + } else { + entry.EmbeddingModel = model + } + } + return entry, nil +} + +func (s *service) Search(ctx context.Context, q Query) ([]Entry, error) { + if q.Text == "" { + return nil, errs.New(errs.CodeInvalidInput, "query text required") + } + if q.Kind != "" && !IsValidKind(q.Kind) { + return nil, errs.New(errs.CodeInvalidInput, "invalid memory kind") + } + // Vector path when an embedder is configured; ILIKE/tags fallback otherwise. + if emb, _, model, _, err := s.embedder(ctx); err == nil { + vecs, eerr := emb.Embed(ctx, model, []string{q.Text}) + if eerr == nil && len(vecs) == 1 { + res, kerr := s.repo.SearchKNN(ctx, q.ProjectID, vecs[0], q.Kind, q.TopK) + if kerr != nil { + return nil, kerr + } + // If the project has no embedded entries yet, fall back to keyword. + if len(res) > 0 { + return res, nil + } + } else if eerr != nil { + s.log.Warn("projectmemory.search_embed_failed", "err", eerr.Error()) + } + } + return s.repo.SearchKeyword(ctx, q.ProjectID, q.Kind, q.Text, q.TopK) +} + +func (s *service) List(ctx context.Context, projectID uuid.UUID, kind string) ([]Entry, error) { + if kind != "" && !IsValidKind(kind) { + return nil, errs.New(errs.CodeInvalidInput, "invalid memory kind") + } + return s.repo.ListByProject(ctx, projectID, kind) +} + +func (s *service) Delete(ctx context.Context, id uuid.UUID) error { + return s.repo.Delete(ctx, id) +} + +func (s *service) RenderAgentsMD(ctx context.Context, projectID uuid.UUID, wsID *uuid.UUID) (string, error) { + entries, err := s.repo.ListByProject(ctx, projectID, "") + if err != nil { + return "", err + } + return renderAgentsMD(entries, wsID), nil +} + +// embedder returns the selector result, or an error when no selector is set. +func (s *service) embedder(ctx context.Context) (llm.Embedder, uuid.UUID, string, int, error) { + if s.embedSel == nil { + return nil, uuid.Nil, "", 0, llm.ErrEmbeddingsUnsupported + } + return s.embedSel.Embedder(ctx) +} diff --git a/internal/projectmemory/service_test.go b/internal/projectmemory/service_test.go new file mode 100644 index 0000000..9f8a90f --- /dev/null +++ b/internal/projectmemory/service_test.go @@ -0,0 +1,133 @@ +package projectmemory + +import ( + "context" + "testing" + "time" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/infra/llm" +) + +// fakeRepo is an in-memory Repository for service tests. +type fakeRepo struct { + entries map[uuid.UUID]*Entry + embedding map[uuid.UUID][]float32 + knnResult []Entry // returned by SearchKNN + keywordHits []Entry // returned by SearchKeyword + knnCalls int + kwCalls int +} + +func newFakeRepo() *fakeRepo { + return &fakeRepo{entries: map[uuid.UUID]*Entry{}, embedding: map[uuid.UUID][]float32{}} +} + +func (r *fakeRepo) Insert(_ context.Context, in WriteInput, model *string) (*Entry, error) { + e := &Entry{ + ID: uuid.New(), ProjectID: in.ProjectID, WorkspaceID: in.WorkspaceID, + Kind: in.Kind, Title: in.Title, Body: in.Body, Tags: in.Tags, Source: in.Source, + EmbeddingModel: model, CreatedBy: in.CreatedBy, CreatedAt: time.Now(), UpdatedAt: time.Now(), + } + if e.Tags == nil { + e.Tags = []string{} + } + r.entries[e.ID] = e + return e, nil +} +func (r *fakeRepo) SetEmbedding(_ context.Context, id uuid.UUID, vec []float32, _ string) error { + r.embedding[id] = vec + return nil +} +func (r *fakeRepo) Get(_ context.Context, id uuid.UUID) (*Entry, error) { return r.entries[id], nil } +func (r *fakeRepo) ListByProject(_ context.Context, _ uuid.UUID, _ string) ([]Entry, error) { + out := make([]Entry, 0, len(r.entries)) + for _, e := range r.entries { + out = append(out, *e) + } + return out, nil +} +func (r *fakeRepo) SearchKeyword(_ context.Context, _ uuid.UUID, _, _ string, _ int) ([]Entry, error) { + r.kwCalls++ + return r.keywordHits, nil +} +func (r *fakeRepo) SearchKNN(_ context.Context, _ uuid.UUID, _ []float32, _ string, _ int) ([]Entry, error) { + r.knnCalls++ + return r.knnResult, nil +} +func (r *fakeRepo) Delete(_ context.Context, id uuid.UUID) error { delete(r.entries, id); return nil } + +type fakeSel struct{ emb llm.Embedder } + +func (s fakeSel) Embedder(context.Context) (llm.Embedder, uuid.UUID, string, int, error) { + if s.emb == nil { + return nil, uuid.Nil, "", 0, llm.ErrEmbeddingsUnsupported + } + return s.emb, uuid.New(), "fake-model", llm.PlatformEmbeddingDims, nil +} + +func TestService_Write_WithEmbedder(t *testing.T) { + repo := newFakeRepo() + svc := NewService(repo, fakeSel{emb: llm.NewFakeEmbedder()}, nil) + e, err := svc.Write(context.Background(), WriteInput{ + ProjectID: uuid.New(), Kind: KindDecision, Title: "t", Body: "b", + }) + require.NoError(t, err) + require.NotNil(t, e.EmbeddingModel) + require.NotEmpty(t, repo.embedding[e.ID], "embedding must be stored") +} + +func TestService_Write_WithoutEmbedder(t *testing.T) { + repo := newFakeRepo() + svc := NewService(repo, fakeSel{emb: nil}, nil) // disabled + e, err := svc.Write(context.Background(), WriteInput{ + ProjectID: uuid.New(), Kind: KindGotcha, Title: "t", Body: "b", + }) + require.NoError(t, err) + require.Nil(t, e.EmbeddingModel) + require.Empty(t, repo.embedding[e.ID]) +} + +func TestService_Write_InvalidKind(t *testing.T) { + svc := NewService(newFakeRepo(), fakeSel{}, nil) + _, err := svc.Write(context.Background(), WriteInput{ProjectID: uuid.New(), Kind: "bogus", Title: "t", Body: "b"}) + require.Error(t, err) +} + +func TestService_Search_VectorPath(t *testing.T) { + repo := newFakeRepo() + repo.knnResult = []Entry{{ID: uuid.New(), Kind: KindDecision, Title: "hit"}} + svc := NewService(repo, fakeSel{emb: llm.NewFakeEmbedder()}, nil) + res, err := svc.Search(context.Background(), Query{ProjectID: uuid.New(), Text: "q"}) + require.NoError(t, err) + require.Len(t, res, 1) + require.Equal(t, 1, repo.knnCalls) + require.Equal(t, 0, repo.kwCalls) +} + +func TestService_Search_KeywordFallback_NoEmbedder(t *testing.T) { + repo := newFakeRepo() + repo.keywordHits = []Entry{{ID: uuid.New(), Kind: KindDecision, Title: "kw"}} + svc := NewService(repo, fakeSel{emb: nil}, nil) // disabled → keyword path + res, err := svc.Search(context.Background(), Query{ProjectID: uuid.New(), Text: "q"}) + require.NoError(t, err) + require.Len(t, res, 1) + require.Equal(t, 0, repo.knnCalls) + require.Equal(t, 1, repo.kwCalls) +} + +func TestService_Search_VectorEmptyFallsBackToKeyword(t *testing.T) { + repo := newFakeRepo() + repo.knnResult = nil // no embedded entries yet + repo.keywordHits = []Entry{{ID: uuid.New(), Title: "kw"}} + svc := NewService(repo, fakeSel{emb: llm.NewFakeEmbedder()}, nil) + res, err := svc.Search(context.Background(), Query{ProjectID: uuid.New(), Text: "q"}) + require.NoError(t, err) + require.Len(t, res, 1) + require.Equal(t, 1, repo.knnCalls) + require.Equal(t, 1, repo.kwCalls) +} + +var _ Repository = (*fakeRepo)(nil) diff --git a/internal/projectmemory/sqlc/db.go b/internal/projectmemory/sqlc/db.go new file mode 100644 index 0000000..8d726fe --- /dev/null +++ b/internal/projectmemory/sqlc/db.go @@ -0,0 +1,32 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package projectmemorysqlc + +import ( + "context" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" +) + +type DBTX interface { + Exec(context.Context, string, ...interface{}) (pgconn.CommandTag, error) + Query(context.Context, string, ...interface{}) (pgx.Rows, error) + QueryRow(context.Context, string, ...interface{}) pgx.Row +} + +func New(db DBTX) *Queries { + return &Queries{db: db} +} + +type Queries struct { + db DBTX +} + +func (q *Queries) WithTx(tx pgx.Tx) *Queries { + return &Queries{ + db: tx, + } +} diff --git a/internal/projectmemory/sqlc/memory.sql.go b/internal/projectmemory/sqlc/memory.sql.go new file mode 100644 index 0000000..00e4f10 --- /dev/null +++ b/internal/projectmemory/sqlc/memory.sql.go @@ -0,0 +1,268 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: memory.sql + +package projectmemorysqlc + +import ( + "context" + + "github.com/jackc/pgx/v5/pgtype" +) + +const deleteEntry = `-- name: DeleteEntry :exec +DELETE FROM project_memory WHERE id = $1 +` + +func (q *Queries) DeleteEntry(ctx context.Context, id pgtype.UUID) error { + _, err := q.db.Exec(ctx, deleteEntry, id) + return err +} + +const getEntry = `-- name: GetEntry :one +SELECT id, project_id, workspace_id, kind, title, body, tags, source, + embedding_model, created_by, created_at, updated_at +FROM project_memory +WHERE id = $1 +` + +type GetEntryRow struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) GetEntry(ctx context.Context, id pgtype.UUID) (GetEntryRow, error) { + row := q.db.QueryRow(ctx, getEntry, id) + var i GetEntryRow + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.Kind, + &i.Title, + &i.Body, + &i.Tags, + &i.Source, + &i.EmbeddingModel, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const insertEntry = `-- name: InsertEntry :one + +INSERT INTO project_memory ( + id, project_id, workspace_id, kind, title, body, tags, source, + embedding_model, created_by +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10) +RETURNING id, project_id, workspace_id, kind, title, body, tags, source, + embedding_model, created_by, created_at, updated_at +` + +type InsertEntryParams struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` +} + +type InsertEntryRow struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +// The embedding (vector) column is excluded from sqlc queries (no native pgvector +// type). Write sets embedding via a hand-written pgx UPDATE; vector KNN search is +// hand-written in repository.go. These queries cover the non-vector path. +func (q *Queries) InsertEntry(ctx context.Context, arg InsertEntryParams) (InsertEntryRow, error) { + row := q.db.QueryRow(ctx, insertEntry, + arg.ID, + arg.ProjectID, + arg.WorkspaceID, + arg.Kind, + arg.Title, + arg.Body, + arg.Tags, + arg.Source, + arg.EmbeddingModel, + arg.CreatedBy, + ) + var i InsertEntryRow + err := row.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.Kind, + &i.Title, + &i.Body, + &i.Tags, + &i.Source, + &i.EmbeddingModel, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ) + return i, err +} + +const listByProject = `-- name: ListByProject :many +SELECT id, project_id, workspace_id, kind, title, body, tags, source, + embedding_model, created_by, created_at, updated_at +FROM project_memory +WHERE project_id = $1 + AND ($2::text = '' OR kind = $2) +ORDER BY kind ASC, created_at DESC +` + +type ListByProjectParams struct { + ProjectID pgtype.UUID `json:"project_id"` + Column2 string `json:"column_2"` +} + +type ListByProjectRow struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) ListByProject(ctx context.Context, arg ListByProjectParams) ([]ListByProjectRow, error) { + rows, err := q.db.Query(ctx, listByProject, arg.ProjectID, arg.Column2) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListByProjectRow + for rows.Next() { + var i ListByProjectRow + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.Kind, + &i.Title, + &i.Body, + &i.Tags, + &i.Source, + &i.EmbeddingModel, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const searchByKeyword = `-- name: SearchByKeyword :many +SELECT id, project_id, workspace_id, kind, title, body, tags, source, + embedding_model, created_by, created_at, updated_at +FROM project_memory +WHERE project_id = $1 + AND ($2::text = '' OR kind = $2) + AND ($3::text = '' OR title ILIKE '%' || $3 || '%' OR body ILIKE '%' || $3 || '%' OR $3 = ANY(tags)) +ORDER BY updated_at DESC +LIMIT $4 +` + +type SearchByKeywordParams struct { + ProjectID pgtype.UUID `json:"project_id"` + Column2 string `json:"column_2"` + Column3 string `json:"column_3"` + Limit int32 `json:"limit"` +} + +type SearchByKeywordRow struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +func (q *Queries) SearchByKeyword(ctx context.Context, arg SearchByKeywordParams) ([]SearchByKeywordRow, error) { + rows, err := q.db.Query(ctx, searchByKeyword, + arg.ProjectID, + arg.Column2, + arg.Column3, + arg.Limit, + ) + if err != nil { + return nil, err + } + defer rows.Close() + var items []SearchByKeywordRow + for rows.Next() { + var i SearchByKeywordRow + if err := rows.Scan( + &i.ID, + &i.ProjectID, + &i.WorkspaceID, + &i.Kind, + &i.Title, + &i.Body, + &i.Tags, + &i.Source, + &i.EmbeddingModel, + &i.CreatedBy, + &i.CreatedAt, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} diff --git a/internal/projectmemory/sqlc/models.go b/internal/projectmemory/sqlc/models.go new file mode 100644 index 0000000..46ffc23 --- /dev/null +++ b/internal/projectmemory/sqlc/models.go @@ -0,0 +1,593 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 + +package projectmemorysqlc + +import ( + "net/netip" + + "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" +) + +type AcpAgentKind struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + DisplayName string `json:"display_name"` + Description string `json:"description"` + BinaryPath string `json:"binary_path"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ToolAllowlist []string `json:"tool_allowlist"` + ClientType string `json:"client_type"` + EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` +} + +type AcpAgentKindConfigFile struct { + ID pgtype.UUID `json:"id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + RelPath string `json:"rel_path"` + EncryptedContent []byte `json:"encrypted_content"` + UpdatedBy pgtype.UUID `json:"updated_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type AcpEvent struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + Direction string `json:"direction"` + RpcKind string `json:"rpc_kind"` + Method *string `json:"method"` + Payload []byte `json:"payload"` + PayloadSize int32 `json:"payload_size"` + Truncated bool `json:"truncated"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpPermissionRequest struct { + ID pgtype.UUID `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + AgentRequestID string `json:"agent_request_id"` + ToolName string `json:"tool_name"` + ToolCall []byte `json:"tool_call"` + Options []byte `json:"options"` + Status string `json:"status"` + ChosenOptionID *string `json:"chosen_option_id"` + DecidedBy pgtype.UUID `json:"decided_by"` + DecidedAt pgtype.Timestamptz `json:"decided_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpSession struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` +} + +type AuditLog struct { + ID int64 `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Action string `json:"action"` + TargetType *string `json:"target_type"` + TargetID *string `json:"target_id"` + Ip *netip.Addr `json:"ip"` + Metadata []byte `json:"metadata"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Conversation struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + IssueID pgtype.UUID `json:"issue_id"` + ModelID pgtype.UUID `json:"model_id"` + SystemPrompt string `json:"system_prompt"` + Title *string `json:"title"` + TitleGeneratedAt pgtype.Timestamptz `json:"title_generated_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + DeletedAt pgtype.Timestamptz `json:"deleted_at"` + RequirementID pgtype.UUID `json:"requirement_id"` +} + +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type GitCredential struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Username *string `json:"username"` + EncryptedSecret []byte `json:"encrypted_secret"` + Fingerprint *string `json:"fingerprint"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type Issue struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Status string `json:"status"` + AssigneeID pgtype.UUID `json:"assignee_id"` + CreatedBy pgtype.UUID `json:"created_by"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Job struct { + ID pgtype.UUID `json:"id"` + Type string `json:"type"` + Payload []byte `json:"payload"` + Status string `json:"status"` + ScheduledAt pgtype.Timestamptz `json:"scheduled_at"` + Attempts int32 `json:"attempts"` + MaxAttempts int32 `json:"max_attempts"` + LeasedAt pgtype.Timestamptz `json:"leased_at"` + LeasedBy *string `json:"leased_by"` + LastError *string `json:"last_error"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmEndpoint struct { + ID pgtype.UUID `json:"id"` + Provider string `json:"provider"` + DisplayName string `json:"display_name"` + BaseUrl string `json:"base_url"` + ApiKeyEncrypted []byte `json:"api_key_encrypted"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type LlmModel struct { + ID pgtype.UUID `json:"id"` + EndpointID pgtype.UUID `json:"endpoint_id"` + ModelID string `json:"model_id"` + DisplayName string `json:"display_name"` + Capabilities []byte `json:"capabilities"` + ContextWindow int32 `json:"context_window"` + MaxOutputTokens int32 `json:"max_output_tokens"` + PromptPricePerMillionUsd pgtype.Numeric `json:"prompt_price_per_million_usd"` + CompletionPricePerMillionUsd pgtype.Numeric `json:"completion_price_per_million_usd"` + ThinkingPricePerMillionUsd pgtype.Numeric `json:"thinking_price_per_million_usd"` + IsTitleGenerator bool `json:"is_title_generator"` + Enabled bool `json:"enabled"` + SortOrder int32 `json:"sort_order"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type LlmUsage struct { + ID int64 `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + MessageID int64 `json:"message_id"` + UserID pgtype.UUID `json:"user_id"` + EndpointID pgtype.UUID `json:"endpoint_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int32 `json:"prompt_tokens"` + CompletionTokens int32 `json:"completion_tokens"` + ThinkingTokens int32 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type McpToken struct { + ID pgtype.UUID `json:"id"` + TokenHash []byte `json:"token_hash"` + UserID pgtype.UUID `json:"user_id"` + Name string `json:"name"` + Issuer string `json:"issuer"` + Scope []byte `json:"scope"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + RevokedAt pgtype.Timestamptz `json:"revoked_at"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Message struct { + ID int64 `json:"id"` + ConversationID pgtype.UUID `json:"conversation_id"` + Role string `json:"role"` + Content string `json:"content"` + Thinking *string `json:"thinking"` + ToolCalls []byte `json:"tool_calls"` + Status string `json:"status"` + ErrorMessage *string `json:"error_message"` + PromptTokens *int32 `json:"prompt_tokens"` + CompletionTokens *int32 `json:"completion_tokens"` + ThinkingTokens *int32 `json:"thinking_tokens"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type MessageAttachment struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + MessageID *int64 `json:"message_id"` + Filename string `json:"filename"` + MimeType string `json:"mime_type"` + SizeBytes int64 `json:"size_bytes"` + Sha256 string `json:"sha256"` + StoragePath string `json:"storage_path"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Notification struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + Topic string `json:"topic"` + Severity string `json:"severity"` + Title string `json:"title"` + Body string `json:"body"` + Link *string `json:"link"` + Metadata []byte `json:"metadata"` + ReadAt pgtype.Timestamptz `json:"read_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type Project struct { + ID pgtype.UUID `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Visibility string `json:"visibility"` + OwnerID pgtype.UUID `json:"owner_id"` + ArchivedAt pgtype.Timestamptz `json:"archived_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type PromptTemplate struct { + ID pgtype.UUID `json:"id"` + Name string `json:"name"` + Content string `json:"content"` + Scope string `json:"scope"` + OwnerID pgtype.UUID `json:"owner_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type Requirement struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + Phase string `json:"phase"` + Status string `json:"status"` + OwnerID pgtype.UUID `json:"owner_id"` + ClosedAt pgtype.Timestamptz `json:"closed_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + WorkspaceID pgtype.UUID `json:"workspace_id"` +} + +type RequirementArtifact struct { + ID pgtype.UUID `json:"id"` + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + Version int32 `json:"version"` + Content string `json:"content"` + Note string `json:"note"` + SourceMessageID *int64 `json:"source_message_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type User struct { + ID pgtype.UUID `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"password_hash"` + DisplayName string `json:"display_name"` + IsAdmin bool `json:"is_admin"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + Enabled bool `json:"enabled"` +} + +type UserSession struct { + ID pgtype.UUID `json:"id"` + UserID pgtype.UUID `json:"user_id"` + TokenHash []byte `json:"token_hash"` + ExpiresAt pgtype.Timestamptz `json:"expires_at"` + LastSeenAt pgtype.Timestamptz `json:"last_seen_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type Workspace struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + GitRemoteUrl string `json:"git_remote_url"` + DefaultBranch string `json:"default_branch"` + MainPath string `json:"main_path"` + SyncStatus string `json:"sync_status"` + LastSyncedAt pgtype.Timestamptz `json:"last_synced_at"` + LastSyncError *string `json:"last_sync_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + ActiveMainSessionID pgtype.UUID `json:"active_main_session_id"` +} + +type WorkspaceRunProfile struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Slug string `json:"slug"` + Name string `json:"name"` + Description string `json:"description"` + Command string `json:"command"` + Args []string `json:"args"` + EncryptedEnv []byte `json:"encrypted_env"` + Enabled bool `json:"enabled"` + LastStartedAt pgtype.Timestamptz `json:"last_started_at"` + LastExitCode *int32 `json:"last_exit_code"` + LastError string `json:"last_error"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` +} + +type WorkspaceWorktree struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Branch string `json:"branch"` + Path string `json:"path"` + Status string `json:"status"` + ActiveHolder *string `json:"active_holder"` + AcquiredAt pgtype.Timestamptz `json:"acquired_at"` + LastUsedAt pgtype.Timestamptz `json:"last_used_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + PruneWarningAt pgtype.Timestamptz `json:"prune_warning_at"` +} diff --git a/internal/run/exec_dto.go b/internal/run/exec_dto.go new file mode 100644 index 0000000..a0236b9 --- /dev/null +++ b/internal/run/exec_dto.go @@ -0,0 +1,62 @@ +package run + +// exec_dto.go 定义一次性 exec(run_command / run_tests)的请求/响应契约。 +// 与 run profile 不同:exec 是同步请求-响应,输出在内存捕获后一次性返回。 + +// ExecCommandReq 是 run_command 的输入。WorktreeID 为空 → 在 workspace main_path 执行。 +type ExecCommandReq struct { + WorkspaceID string `json:"workspace_id"` + WorktreeID string `json:"worktree_id,omitempty"` + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Env map[string]string `json:"env,omitempty"` // 临时覆盖(明文,不落库) + TimeoutSec int `json:"timeout_sec,omitempty"` +} + +// ExecCommandResp 是 run_command 的结构化结果。 +type ExecCommandResp struct { + ExitCode int `json:"exit_code"` + Stdout string `json:"stdout"` + Stderr string `json:"stderr"` + DurationMs int64 `json:"duration_ms"` + TimedOut bool `json:"timed_out"` + Truncated bool `json:"truncated"` +} + +// ExecTestsReq 是 run_tests 的输入:在 run_command 基础上增加 Format(解析格式)。 +type ExecTestsReq struct { + WorkspaceID string `json:"workspace_id"` + WorktreeID string `json:"worktree_id,omitempty"` + Command string `json:"command"` + Args []string `json:"args,omitempty"` + Format string `json:"format,omitempty"` // gotest|junit|vitest|auto(默认 auto) + Env map[string]string `json:"env,omitempty"` + TimeoutSec int `json:"timeout_sec,omitempty"` +} + +// ExecTestsResp 是 run_tests 的结果:run_command 输出 + 结构化测试摘要。 +// ParseError 非空表示解析失败,但原始输出与退出码仍可用——run_tests 永不因解析失败 +// 而向 agent 返回错误。 +type ExecTestsResp struct { + ExecCommandResp + Summary TestSummary `json:"summary"` + ParseError string `json:"parse_error,omitempty"` +} + +// TestSummary 是跨框架(go test / JUnit / vitest)归一化的测试结果摘要。 +type TestSummary struct { + Framework string `json:"framework"` + Total int `json:"total"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Skipped int `json:"skipped"` + DurationMs int64 `json:"duration_ms"` + Failures []TestFailure `json:"failures"` +} + +// TestFailure 描述单个失败用例。Package 对非 go 框架可能为空。 +type TestFailure struct { + Name string `json:"name"` + Package string `json:"package,omitempty"` + Message string `json:"message,omitempty"` +} diff --git a/internal/run/exec_parsers.go b/internal/run/exec_parsers.go new file mode 100644 index 0000000..7abbff6 --- /dev/null +++ b/internal/run/exec_parsers.go @@ -0,0 +1,308 @@ +package run + +import ( + "bufio" + "encoding/json" + "encoding/xml" + "errors" + "strings" +) + +// exec_parsers.go 把常见测试输出(go test -json / JUnit XML / vitest --reporter=json) +// 归一化为 TestSummary。所有解析器都是防御性的:永不 panic;无法解析时返回 error, +// 由 RunTests 折成 ParseError(始终保留原始输出)。 + +// parseTestOutput 按 format 分派;format=="" 或 "auto" 时按内容嗅探。 +func parseTestOutput(format, stdout, stderr string) (TestSummary, error) { + combined := stdout + if strings.TrimSpace(combined) == "" { + combined = stderr + } + switch strings.ToLower(strings.TrimSpace(format)) { + case "gotest": + return parseGoTestJSON([]byte(combined)) + case "junit": + return parseJUnitXML([]byte(combined)) + case "vitest": + return parseVitestJSON([]byte(combined)) + case "", "auto": + return sniffAndParse(combined) + default: + return TestSummary{}, errors.New("unknown test format: " + format) + } +} + +// sniffAndParse 按首个非空 token 推断框架:JSON 行 + Action 字段 → gotest; +// JSON 对象含 numPassedTests → vitest;'testsuite>testsuite)。 + Nested []junitTestSuite `xml:"testsuite"` +} + +type junitTestCase struct { + Name string `xml:"name,attr"` + ClassName string `xml:"classname,attr"` + Time float64 `xml:"time,attr"` + Failure *junitDetail `xml:"failure"` + Error *junitDetail `xml:"error"` + Skipped *junitSkipped `xml:"skipped"` +} + +type junitDetail struct { + Message string `xml:"message,attr"` + Body string `xml:",chardata"` +} + +type junitSkipped struct { + Message string `xml:"message,attr"` +} + +// parseJUnitXML 解析 JUnit XML(顶层 testsuites 或裸 testsuite)。按 testcase 逐个 +// 统计,failure/error → failed,skipped → skipped,其余 → passed。 +func parseJUnitXML(data []byte) (TestSummary, error) { + if strings.TrimSpace(string(data)) == "" { + return TestSummary{}, errors.New("empty junit output") + } + sum := TestSummary{Framework: "junit"} + + var suites []junitTestSuite + var root junitTestSuites + if err := xml.Unmarshal(data, &root); err == nil && len(root.Suites) > 0 { + suites = root.Suites + } else { + var single junitTestSuite + if err := xml.Unmarshal(data, &single); err != nil { + return sum, err + } + suites = []junitTestSuite{single} + } + + var totalTime float64 + var walk func(s junitTestSuite) + walk = func(s junitTestSuite) { + totalTime += s.Time + for _, tc := range s.TestCases { + sum.Total++ + pkg := tc.ClassName + switch { + case tc.Failure != nil || tc.Error != nil: + sum.Failed++ + d := tc.Failure + if d == nil { + d = tc.Error + } + msg := strings.TrimSpace(d.Message) + if msg == "" { + msg = strings.TrimSpace(d.Body) + } + sum.Failures = append(sum.Failures, TestFailure{Name: tc.Name, Package: pkg, Message: msg}) + case tc.Skipped != nil: + sum.Skipped++ + default: + sum.Passed++ + } + } + for _, n := range s.Nested { + walk(n) + } + } + for _, s := range suites { + walk(s) + } + if sum.Total == 0 { + return sum, errors.New("no junit testcases found") + } + sum.DurationMs = int64(totalTime * 1000) + return sum, nil +} + +// ===== vitest --reporter=json ===== + +type vitestReport struct { + NumTotalTests *int `json:"numTotalTests"` + NumPassedTests *int `json:"numPassedTests"` + NumFailedTests *int `json:"numFailedTests"` + NumPendingTests *int `json:"numPendingTests"` + StartTime *int64 `json:"startTime"` + TestResults []vitestFileResult `json:"testResults"` +} + +type vitestFileResult struct { + Name string `json:"name"` + AssertionResults []vitestAssertion `json:"assertionResults"` +} + +type vitestAssertion struct { + Title string `json:"title"` + FullName string `json:"fullName"` + Status string `json:"status"` // passed|failed|skipped|pending + FailureMessages []string `json:"failureMessages"` + Duration float64 `json:"duration"` +} + +// parseVitestJSON 解析 vitest(jest 兼容)的 JSON 报告。优先使用顶层计数; +// 同时遍历 testResults 收集失败用例名与首条 failureMessage。 +func parseVitestJSON(data []byte) (TestSummary, error) { + if strings.TrimSpace(string(data)) == "" { + return TestSummary{}, errors.New("empty vitest output") + } + var rep vitestReport + if err := json.Unmarshal(data, &rep); err != nil { + return TestSummary{}, err + } + sum := TestSummary{Framework: "vitest"} + var durMs float64 + for _, fr := range rep.TestResults { + for _, a := range fr.AssertionResults { + switch a.Status { + case "failed": + sum.Failed++ + msg := "" + if len(a.FailureMessages) > 0 { + msg = strings.TrimSpace(a.FailureMessages[0]) + } + name := a.FullName + if name == "" { + name = a.Title + } + sum.Failures = append(sum.Failures, TestFailure{Name: name, Package: fr.Name, Message: msg}) + case "skipped", "pending", "todo": + sum.Skipped++ + case "passed": + sum.Passed++ + } + durMs += a.Duration + } + } + // 顶层计数优先(更可靠);没有就用遍历得到的计数。 + if rep.NumPassedTests != nil { + sum.Passed = *rep.NumPassedTests + } + if rep.NumFailedTests != nil { + sum.Failed = *rep.NumFailedTests + } + if rep.NumPendingTests != nil { + sum.Skipped = *rep.NumPendingTests + } + if rep.NumTotalTests != nil { + sum.Total = *rep.NumTotalTests + } else { + sum.Total = sum.Passed + sum.Failed + sum.Skipped + } + if sum.Total == 0 && len(rep.TestResults) == 0 && rep.NumTotalTests == nil { + return sum, errors.New("not a vitest report") + } + sum.DurationMs = int64(durMs) + return sum, nil +} diff --git a/internal/run/exec_parsers_test.go b/internal/run/exec_parsers_test.go new file mode 100644 index 0000000..82edcb0 --- /dev/null +++ b/internal/run/exec_parsers_test.go @@ -0,0 +1,129 @@ +package run + +import ( + "os" + "path/filepath" + "testing" +) + +func readFixture(t *testing.T, name string) []byte { + t.Helper() + b, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatalf("read fixture %s: %v", name, err) + } + return b +} + +func TestParseGoTestJSON(t *testing.T) { + sum, err := parseGoTestJSON(readFixture(t, "gotest.jsonl")) + if err != nil { + t.Fatalf("parse: %v", err) + } + if sum.Framework != "gotest" { + t.Fatalf("framework = %q", sum.Framework) + } + if sum.Passed != 1 || sum.Failed != 1 || sum.Skipped != 1 || sum.Total != 3 { + t.Fatalf("counts p=%d f=%d s=%d t=%d", sum.Passed, sum.Failed, sum.Skipped, sum.Total) + } + if len(sum.Failures) != 1 || sum.Failures[0].Name != "TestFail" || sum.Failures[0].Package != "example/pkg" { + t.Fatalf("failures = %+v", sum.Failures) + } + if sum.Failures[0].Message == "" { + t.Fatal("failure message should not be empty") + } +} + +func TestParseJUnitXML(t *testing.T) { + sum, err := parseJUnitXML(readFixture(t, "junit.xml")) + if err != nil { + t.Fatalf("parse: %v", err) + } + if sum.Framework != "junit" { + t.Fatalf("framework = %q", sum.Framework) + } + if sum.Passed != 1 || sum.Failed != 1 || sum.Skipped != 1 || sum.Total != 3 { + t.Fatalf("counts p=%d f=%d s=%d t=%d", sum.Passed, sum.Failed, sum.Skipped, sum.Total) + } + if len(sum.Failures) != 1 || sum.Failures[0].Name != "testFails" { + t.Fatalf("failures = %+v", sum.Failures) + } + if sum.Failures[0].Message != "expected true but was false" { + t.Fatalf("failure message = %q", sum.Failures[0].Message) + } +} + +func TestParseVitestJSON(t *testing.T) { + sum, err := parseVitestJSON(readFixture(t, "vitest.json")) + if err != nil { + t.Fatalf("parse: %v", err) + } + if sum.Framework != "vitest" { + t.Fatalf("framework = %q", sum.Framework) + } + if sum.Passed != 1 || sum.Failed != 1 || sum.Skipped != 1 || sum.Total != 3 { + t.Fatalf("counts p=%d f=%d s=%d t=%d", sum.Passed, sum.Failed, sum.Skipped, sum.Total) + } + if len(sum.Failures) != 1 || sum.Failures[0].Name != "sum > subtracts" { + t.Fatalf("failures = %+v", sum.Failures) + } + if sum.Failures[0].Message != "expected 1 to be 2" { + t.Fatalf("failure message = %q", sum.Failures[0].Message) + } +} + +// TestParseTestOutput_Auto 验证 auto 嗅探把各格式路由到正确解析器。 +func TestParseTestOutput_Auto(t *testing.T) { + cases := []struct { + name string + fixture string + want string + }{ + {"gotest", "gotest.jsonl", "gotest"}, + {"junit", "junit.xml", "junit"}, + {"vitest", "vitest.json", "vitest"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + sum, err := parseTestOutput("auto", string(readFixture(t, tc.fixture)), "") + if err != nil { + t.Fatalf("parse: %v", err) + } + if sum.Framework != tc.want { + t.Fatalf("framework = %q, want %q", sum.Framework, tc.want) + } + }) + } +} + +// TestParseTestOutput_Malformed 验证解析失败返回 error(由 RunTests 折成 ParseError), +// 永不 panic。 +func TestParseTestOutput_Malformed(t *testing.T) { + cases := []struct { + format string + in string + }{ + {"gotest", "not json at all"}, + {"junit", " s.cfg.MaxTimeout { + d = s.cfg.MaxTimeout + } + return d +} + +// resolveCwd 解析执行目录:worktreeID 非空 → 校验归属并返回其 Path;否则 main_path。 +func (s *ExecService) resolveCwd(ctx context.Context, c workspace.Caller, wsID uuid.UUID, ws *workspace.Workspace, worktreeID string) (string, error) { + if strings.TrimSpace(worktreeID) == "" { + return ws.MainPath, nil + } + wtID, err := uuid.Parse(worktreeID) + if err != nil { + return "", errs.Wrap(err, errs.CodeInvalidInput, "worktree_id parse") + } + wts, err := s.wtSvc.List(ctx, c, wsID) + if err != nil { + return "", err + } + for _, wt := range wts { + if wt.ID == wtID { + return wt.Path, nil + } + } + return "", errs.New(errs.CodeNotFound, "worktree not found in workspace") +} + +func (s *ExecService) audit(ctx context.Context, c workspace.Caller, action, targetID string, meta map[string]any) { + if s.rec == nil { + return + } + uid := c.UserID + _ = s.rec.Record(ctx, audit.Entry{ + UserID: &uid, + Action: action, + TargetType: "workspace", + TargetID: targetID, + Metadata: meta, + }) +} diff --git a/internal/run/exec_service_test.go b/internal/run/exec_service_test.go new file mode 100644 index 0000000..a2ea0f9 --- /dev/null +++ b/internal/run/exec_service_test.go @@ -0,0 +1,356 @@ +package run + +import ( + "context" + "os" + "runtime" + "strings" + "testing" + "time" + + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/workspace" +) + +// ===== fakes ===== + +// fakeWsRepo 只实现 GetWorkspaceByID;其余方法经嵌入 interface 满足契约,被调用即 panic。 +type fakeWsRepo struct { + workspace.Repository + ws *workspace.Workspace + err error +} + +func (f *fakeWsRepo) GetWorkspaceByID(context.Context, uuid.UUID) (*workspace.Workspace, error) { + if f.err != nil { + return nil, f.err + } + return f.ws, nil +} + +// fakeWtSvc 只实现 List。 +type fakeWtSvc struct { + workspace.WorktreeService + wts []*workspace.Worktree + err error +} + +func (f *fakeWtSvc) List(context.Context, workspace.Caller, uuid.UUID) ([]*workspace.Worktree, error) { + return f.wts, f.err +} + +// fakePA 返回固定的 read/write 决策。 +type fakePA struct { + canRead bool + canWrite bool + err error +} + +func (f *fakePA) Resolve(context.Context, uuid.UUID, bool, string) (uuid.UUID, bool, bool, error) { + return uuid.Nil, f.canRead, f.canWrite, f.err +} +func (f *fakePA) ResolveByID(context.Context, uuid.UUID, bool, uuid.UUID) (bool, bool, error) { + return f.canRead, f.canWrite, f.err +} + +func newExecSvc(t *testing.T, ws *workspace.Workspace, wts []*workspace.Worktree, pa workspace.ProjectAccess, cfg ExecConfig) *ExecService { + t.Helper() + return NewExecService( + &fakeWsRepo{ws: ws}, + &fakeWtSvc{wts: wts}, + pa, + nil, // audit recorder: nil → audit no-op + nil, // encryptor unused + cfg, + nil, + ) +} + +// hasCode 报告 err 是否携带给定 errs.Code。 +func hasCode(err error, code errs.Code) bool { + ae, ok := errs.As(err) + return ok && ae.Code == code +} + +func shellCmd(script string) (string, []string) { + if runtime.GOOS == "windows" { + return "cmd", []string{"/c", script} + } + return "sh", []string{"-c", script} +} + +func baseCfg() ExecConfig { + return ExecConfig{ + EnvWhitelist: []string{"PATH"}, + DefaultTimeout: 10 * time.Second, + MaxTimeout: 30 * time.Second, + MaxOutputBytes: 1 << 20, + KillGrace: time.Second, + } +} + +// ===== tests ===== + +func TestExecService_RunCommand_Success(t *testing.T) { + dir := t.TempDir() + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: dir} + svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, baseCfg()) + + cmd, args := shellCmd("echo hi") + resp, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{ + WorkspaceID: ws.ID.String(), + Command: cmd, + Args: args, + }) + if err != nil { + t.Fatalf("RunCommand: %v", err) + } + if resp.ExitCode != 0 { + t.Fatalf("exit = %d", resp.ExitCode) + } + if !strings.Contains(resp.Stdout, "hi") { + t.Fatalf("stdout = %q", resp.Stdout) + } +} + +func TestExecService_AuthDenied(t *testing.T) { + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: t.TempDir()} + svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: false}, baseCfg()) + + cmd, args := shellCmd("echo hi") + _, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{ + WorkspaceID: ws.ID.String(), + Command: cmd, + Args: args, + }) + if !hasCode(err, errs.CodeForbidden) { + t.Fatalf("err = %v, want forbidden", err) + } +} + +func TestExecService_CommandNotAllowed(t *testing.T) { + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: t.TempDir()} + cfg := baseCfg() + cfg.AllowedCommands = []string{"go", "npm"} + svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, cfg) + + cmd, args := shellCmd("echo hi") + _, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{ + WorkspaceID: ws.ID.String(), + Command: cmd, + Args: args, + }) + if !hasCode(err, errs.CodeRunExecCommandNotAllowed) { + t.Fatalf("err = %v, want run.exec_command_not_allowed", err) + } +} + +func TestExecService_EmptyCommand(t *testing.T) { + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: t.TempDir()} + svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, baseCfg()) + _, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{ + WorkspaceID: ws.ID.String(), + Command: " ", + }) + if !hasCode(err, errs.CodeRunCommandInvalid) { + t.Fatalf("err = %v, want run.command_invalid", err) + } +} + +// TestExecService_EnvWhitelist 验证子进程环境仅含白名单变量 + 请求覆盖, +// 绝不泄漏 APP_MASTER_KEY 等敏感宿主变量。 +func TestExecService_EnvWhitelist(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("env dump via /bin/sh not portable on windows") + } + t.Setenv("APP_MASTER_KEY", "super-secret") + t.Setenv("PATH", os.Getenv("PATH")) + + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: t.TempDir()} + svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, baseCfg()) + + resp, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{ + WorkspaceID: ws.ID.String(), + Command: "env", + Env: map[string]string{"MY_OVERRIDE": "v1"}, + }) + if err != nil { + t.Fatalf("RunCommand: %v", err) + } + if strings.Contains(resp.Stdout, "super-secret") || strings.Contains(resp.Stdout, "APP_MASTER_KEY") { + t.Fatalf("env leaked master key:\n%s", resp.Stdout) + } + if !strings.Contains(resp.Stdout, "MY_OVERRIDE=v1") { + t.Fatalf("override missing from env:\n%s", resp.Stdout) + } + if !strings.Contains(resp.Stdout, "PATH=") { + t.Fatalf("whitelisted PATH missing from env:\n%s", resp.Stdout) + } +} + +// TestExecService_CwdWorktree 验证 worktree_id 解析到 wt.Path。 +func TestExecService_CwdWorktree(t *testing.T) { + mainDir := t.TempDir() + wtDir := t.TempDir() + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: mainDir} + wtID := uuid.New() + wts := []*workspace.Worktree{{ID: wtID, WorkspaceID: ws.ID, Path: wtDir}} + svc := newExecSvc(t, ws, wts, &fakePA{canRead: true, canWrite: true}, baseCfg()) + + cmd, args := shellCmd(pwdScript()) + resp, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{ + WorkspaceID: ws.ID.String(), + WorktreeID: wtID.String(), + Command: cmd, + Args: args, + }) + if err != nil { + t.Fatalf("RunCommand: %v", err) + } + // 比较 base name 规避 /private 软链等差异。 + if !strings.Contains(resp.Stdout, lastPathElem(wtDir)) { + t.Fatalf("cwd stdout %q does not contain worktree dir %q", resp.Stdout, wtDir) + } +} + +// TestExecService_CwdMainPath 验证无 worktree_id 时使用 main_path。 +func TestExecService_CwdMainPath(t *testing.T) { + mainDir := t.TempDir() + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: mainDir} + svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, baseCfg()) + + cmd, args := shellCmd(pwdScript()) + resp, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{ + WorkspaceID: ws.ID.String(), + Command: cmd, + Args: args, + }) + if err != nil { + t.Fatalf("RunCommand: %v", err) + } + if !strings.Contains(resp.Stdout, lastPathElem(mainDir)) { + t.Fatalf("cwd stdout %q does not contain main dir %q", resp.Stdout, mainDir) + } +} + +func TestExecService_BadWorktree(t *testing.T) { + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: t.TempDir()} + svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, baseCfg()) + + cmd, args := shellCmd("echo hi") + _, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{ + WorkspaceID: ws.ID.String(), + WorktreeID: uuid.New().String(), + Command: cmd, + Args: args, + }) + if !hasCode(err, errs.CodeNotFound) { + t.Fatalf("err = %v, want not_found", err) + } +} + +// TestExecService_Timeout 验证超时返回 TimedOut。 +func TestExecService_Timeout(t *testing.T) { + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: t.TempDir()} + svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, baseCfg()) + + var cmd string + var args []string + if runtime.GOOS == "windows" { + cmd, args = "cmd", []string{"/c", "ping -n 30 127.0.0.1 >nul"} + } else { + cmd, args = "sh", []string{"-c", "sleep 30"} + } + start := time.Now() + resp, err := svc.RunCommand(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecCommandReq{ + WorkspaceID: ws.ID.String(), + Command: cmd, + Args: args, + TimeoutSec: 1, + }) + if err != nil { + t.Fatalf("RunCommand: %v", err) + } + if !resp.TimedOut { + t.Fatal("expected TimedOut") + } + if time.Since(start) > 10*time.Second { + t.Fatalf("timeout took too long: %v", time.Since(start)) + } +} + +// TestExecService_RunTests 端到端跑 go test -json 解析。 +func TestExecService_RunTests(t *testing.T) { + if _, err := os.Stat(os.Getenv("GOROOT")); os.Getenv("GOROOT") != "" && err != nil { + t.Skip("GOROOT not available") + } + dir := t.TempDir() + writeGoModule(t, dir) + ws := &workspace.Workspace{ID: uuid.New(), ProjectID: uuid.New(), MainPath: dir} + // 透传完整 PATH 让 go 可被定位。 + cfg := baseCfg() + cfg.EnvWhitelist = []string{"PATH", "HOME", "GOPATH", "GOROOT", "GOCACHE", "SystemRoot", "TEMP", "TMP", "USERPROFILE", "LOCALAPPDATA"} + cfg.MaxTimeout = 120 * time.Second + cfg.DefaultTimeout = 120 * time.Second + svc := newExecSvc(t, ws, nil, &fakePA{canRead: true, canWrite: true}, cfg) + + resp, err := svc.RunTests(context.Background(), workspace.Caller{UserID: uuid.New()}, ExecTestsReq{ + WorkspaceID: ws.ID.String(), + Command: "go", + Args: []string{"test", "-json", "./..."}, + Format: "gotest", + TimeoutSec: 100, + }) + if err != nil { + t.Fatalf("RunTests: %v\nstderr:\n%s", err, resp.Stderr) + } + if resp.ParseError != "" { + t.Fatalf("parse error: %s\nstdout:\n%s", resp.ParseError, resp.Stdout) + } + if resp.Summary.Framework != "gotest" { + t.Fatalf("framework = %q", resp.Summary.Framework) + } + if resp.Summary.Passed < 1 || resp.Summary.Failed < 1 { + t.Fatalf("summary = %+v\nstdout:\n%s", resp.Summary, resp.Stdout) + } +} + +// ===== helpers ===== + +func pwdScript() string { + if runtime.GOOS == "windows" { + return "cd" + } + return "pwd" +} + +func lastPathElem(p string) string { + p = strings.TrimRight(p, "/\\") + if i := strings.LastIndexAny(p, "/\\"); i >= 0 { + return p[i+1:] + } + return p +} + +func writeGoModule(t *testing.T, dir string) { + t.Helper() + mustWrite(t, dir+"/go.mod", "module selftest\n\ngo 1.21\n") + src := `package selftest + +import "testing" + +func TestPasses(t *testing.T) {} + +func TestFails(t *testing.T) { t.Fatal("intentional failure") } +` + mustWrite(t, dir+"/selftest_test.go", src) +} + +func mustWrite(t *testing.T, path, content string) { + t.Helper() + if err := os.WriteFile(path, []byte(content), 0o644); err != nil { + t.Fatalf("write %s: %v", path, err) + } +} diff --git a/internal/run/run_profile_service.go b/internal/run/run_profile_service.go index cd63490..98d1598 100644 --- a/internal/run/run_profile_service.go +++ b/internal/run/run_profile_service.go @@ -318,8 +318,15 @@ func (s *Service) SubscribeLogs(ctx context.Context, c workspace.Caller, profile // buildEnv 构造被托管命令的环境:宿主白名单基础变量 + profile 覆盖。 // 不继承宿主全部环境,避免泄漏 APP_MASTER_KEY / DB DSN / git 凭据等敏感变量。 func (s *Service) buildEnv(overrides map[string]string) []string { + return buildEnvFromWhitelist(s.cfg.EnvWhitelist, overrides) +} + +// buildEnvFromWhitelist 是 env 构造的包级可复用实现:仅透传 whitelist 列出的宿主 +// 环境变量,再叠加 overrides。供 run profile(长驻)与 ExecService(一次性 exec) +// 共用,保证两条路径相同的安全取舍——绝不泄漏 APP_MASTER_KEY / DB DSN / git 凭据。 +func buildEnvFromWhitelist(whitelist []string, overrides map[string]string) []string { base := map[string]string{} - for _, k := range s.cfg.EnvWhitelist { + for _, k := range whitelist { if v, ok := os.LookupEnv(k); ok { base[k] = v } diff --git a/internal/run/sqlc/models.go b/internal/run/sqlc/models.go index 4f61c55..0c2a599 100644 --- a/internal/run/sqlc/models.go +++ b/internal/run/sqlc/models.go @@ -8,6 +8,7 @@ import ( "net/netip" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type AcpAgentKind struct { @@ -25,6 +26,11 @@ type AcpAgentKind struct { ToolAllowlist []string `json:"tool_allowlist"` ClientType string `json:"client_type"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` } type AcpAgentKindConfigFile struct { @@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct { UpdatedBy pgtype.UUID `json:"updated_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type AcpEvent struct { @@ -64,23 +71,64 @@ type AcpPermissionRequest struct { } type AcpSession struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ProjectID pgtype.UUID `json:"project_id"` - AgentKindID pgtype.UUID `json:"agent_kind_id"` - UserID pgtype.UUID `json:"user_id"` - IssueID pgtype.UUID `json:"issue_id"` - RequirementID pgtype.UUID `json:"requirement_id"` - AgentSessionID *string `json:"agent_session_id"` - Branch string `json:"branch"` - CwdPath string `json:"cwd_path"` - IsMainWorktree bool `json:"is_main_worktree"` - Status string `json:"status"` - Pid *int32 `json:"pid"` - ExitCode *int32 `json:"exit_code"` - LastError *string `json:"last_error"` - StartedAt pgtype.Timestamptz `json:"started_at"` - EndedAt pgtype.Timestamptz `json:"ended_at"` + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` } type AuditLog struct { @@ -94,6 +142,81 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Conversation struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` @@ -110,6 +233,14 @@ type Conversation struct { RequirementID pgtype.UUID `json:"requirement_id"` } +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type GitCredential struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -119,6 +250,7 @@ type GitCredential struct { Fingerprint *string `json:"fingerprint"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type Issue struct { @@ -135,6 +267,17 @@ type Issue struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Job struct { @@ -162,6 +305,7 @@ type LlmEndpoint struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type LlmModel struct { @@ -252,6 +396,50 @@ type Notification struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + type Project struct { ID pgtype.UUID `json:"id"` Slug string `json:"slug"` @@ -264,6 +452,30 @@ type Project struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type PromptTemplate struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` @@ -299,6 +511,16 @@ type RequirementArtifact struct { SourceMessageID *int64 `json:"source_message_id"` CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type User struct { @@ -354,6 +576,7 @@ type WorkspaceRunProfile struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type WorkspaceWorktree struct { diff --git a/internal/run/sqlc/run_profiles.sql.go b/internal/run/sqlc/run_profiles.sql.go index b706ac2..1e4ea55 100644 --- a/internal/run/sqlc/run_profiles.sql.go +++ b/internal/run/sqlc/run_profiles.sql.go @@ -15,7 +15,7 @@ const createRunProfile = `-- name: CreateRunProfile :one INSERT INTO workspace_run_profiles ( id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, created_by ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10) -RETURNING id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at +RETURNING id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at, key_version ` type CreateRunProfileParams struct { @@ -61,6 +61,7 @@ func (q *Queries) CreateRunProfile(ctx context.Context, arg CreateRunProfilePara &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.KeyVersion, ) return i, err } @@ -75,7 +76,7 @@ func (q *Queries) DeleteRunProfile(ctx context.Context, id pgtype.UUID) error { } const getRunProfileByID = `-- name: GetRunProfileByID :one -SELECT id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at FROM workspace_run_profiles WHERE id = $1 +SELECT id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at, key_version FROM workspace_run_profiles WHERE id = $1 ` func (q *Queries) GetRunProfileByID(ctx context.Context, id pgtype.UUID) (WorkspaceRunProfile, error) { @@ -97,12 +98,13 @@ func (q *Queries) GetRunProfileByID(ctx context.Context, id pgtype.UUID) (Worksp &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.KeyVersion, ) return i, err } const listRunProfilesByWorkspace = `-- name: ListRunProfilesByWorkspace :many -SELECT id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at FROM workspace_run_profiles +SELECT id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at, key_version FROM workspace_run_profiles WHERE workspace_id = $1 ORDER BY created_at ` @@ -132,6 +134,7 @@ func (q *Queries) ListRunProfilesByWorkspace(ctx context.Context, workspaceID pg &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.KeyVersion, ); err != nil { return nil, err } @@ -150,7 +153,7 @@ SET last_started_at = now(), last_error = '', updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at +RETURNING id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at, key_version ` func (q *Queries) MarkRunProfileStarted(ctx context.Context, id pgtype.UUID) (WorkspaceRunProfile, error) { @@ -172,6 +175,7 @@ func (q *Queries) MarkRunProfileStarted(ctx context.Context, id pgtype.UUID) (Wo &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.KeyVersion, ) return i, err } @@ -187,7 +191,7 @@ SET slug = $2, enabled = $8, updated_at = now() WHERE id = $1 -RETURNING id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at +RETURNING id, workspace_id, slug, name, description, command, args, encrypted_env, enabled, last_started_at, last_exit_code, last_error, created_by, created_at, updated_at, key_version ` type UpdateRunProfileParams struct { @@ -229,6 +233,7 @@ func (q *Queries) UpdateRunProfile(ctx context.Context, arg UpdateRunProfilePara &i.CreatedBy, &i.CreatedAt, &i.UpdatedAt, + &i.KeyVersion, ) return i, err } diff --git a/internal/run/testdata/gotest.jsonl b/internal/run/testdata/gotest.jsonl new file mode 100644 index 0000000..1d55105 --- /dev/null +++ b/internal/run/testdata/gotest.jsonl @@ -0,0 +1,9 @@ +{"Time":"2026-06-20T10:00:00Z","Action":"run","Package":"example/pkg","Test":"TestPass"} +{"Time":"2026-06-20T10:00:00Z","Action":"output","Package":"example/pkg","Test":"TestPass","Output":"=== RUN TestPass\n"} +{"Time":"2026-06-20T10:00:00Z","Action":"pass","Package":"example/pkg","Test":"TestPass","Elapsed":0.01} +{"Time":"2026-06-20T10:00:00Z","Action":"run","Package":"example/pkg","Test":"TestFail"} +{"Time":"2026-06-20T10:00:00Z","Action":"output","Package":"example/pkg","Test":"TestFail","Output":" foo_test.go:10: want 1 got 2\n"} +{"Time":"2026-06-20T10:00:00Z","Action":"fail","Package":"example/pkg","Test":"TestFail","Elapsed":0.02} +{"Time":"2026-06-20T10:00:00Z","Action":"run","Package":"example/pkg","Test":"TestSkip"} +{"Time":"2026-06-20T10:00:00Z","Action":"skip","Package":"example/pkg","Test":"TestSkip","Elapsed":0} +{"Time":"2026-06-20T10:00:00Z","Action":"fail","Package":"example/pkg","Elapsed":0.05} diff --git a/internal/run/testdata/junit.xml b/internal/run/testdata/junit.xml new file mode 100644 index 0000000..4964423 --- /dev/null +++ b/internal/run/testdata/junit.xml @@ -0,0 +1,12 @@ + + + + + + AssertionError at line 42 + + + + + + diff --git a/internal/run/testdata/vitest.json b/internal/run/testdata/vitest.json new file mode 100644 index 0000000..b335eb3 --- /dev/null +++ b/internal/run/testdata/vitest.json @@ -0,0 +1,17 @@ +{ + "numTotalTests": 3, + "numPassedTests": 1, + "numFailedTests": 1, + "numPendingTests": 1, + "startTime": 1718877600000, + "testResults": [ + { + "name": "/repo/src/sum.test.ts", + "assertionResults": [ + { "title": "adds", "fullName": "sum > adds", "status": "passed", "duration": 5, "failureMessages": [] }, + { "title": "subtracts", "fullName": "sum > subtracts", "status": "failed", "duration": 8, "failureMessages": ["expected 1 to be 2"] }, + { "title": "multiplies", "fullName": "sum > multiplies", "status": "skipped", "duration": 0, "failureMessages": [] } + ] + } + ] +} diff --git a/internal/security/handler.go b/internal/security/handler.go new file mode 100644 index 0000000..0b85e6d --- /dev/null +++ b/internal/security/handler.go @@ -0,0 +1,189 @@ +// Package security exposes admin-only HTTP endpoints for secret/key management: +// rotating the master key (bumping crypto_keys active version + enqueuing the +// re-encrypt job) and reporting re-encryption progress. All routes sit behind +// middleware.Auth + an explicit is_admin gate (reusing the userAdminAdapter +// pattern shared across the codebase), so an agent / non-admin token cannot +// trigger a rotation. +package security + +import ( + "context" + "encoding/json" + "net/http" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + + "github.com/yan1h/agent-coding-workflow/internal/audit" + "github.com/yan1h/agent-coding-workflow/internal/infra/errs" + "github.com/yan1h/agent-coding-workflow/internal/jobs" + "github.com/yan1h/agent-coding-workflow/internal/jobs/handlers" + httpx "github.com/yan1h/agent-coding-workflow/internal/transport/http" + "github.com/yan1h/agent-coding-workflow/internal/transport/http/middleware" +) + +// AdminLookup resolves is_admin for a user (same narrow interface used across +// handlers; the shared userAdminAdapter satisfies it). +type AdminLookup interface { + IsAdmin(ctx context.Context, id uuid.UUID) (bool, error) +} + +// KeyRotator bumps the active crypto key version. The Postgres key store +// satisfies this. +type KeyRotator interface { + RotateActive(ctx context.Context, provider, keyRef string) (int, error) + ActiveVersion(ctx context.Context) (int, error) +} + +// JobEnqueuer enqueues background jobs. jobs.Repository satisfies it. +type JobEnqueuer interface { + Enqueue(ctx context.Context, typ jobs.JobType, payload json.RawMessage, scheduledAt time.Time, maxAttempts int32) (*jobs.Job, error) +} + +// StatusReporter reports re-encryption progress per table. The Postgres +// re-encrypt store satisfies it. +type StatusReporter interface { + Tables() []string + StaleCount(ctx context.Context, table string, activeVersion int) (int, error) +} + +// Handler wires the security admin endpoints. +type Handler struct { + rotator KeyRotator + enqueuer JobEnqueuer + status StatusReporter + resolver middleware.SessionResolver + users AdminLookup + audit audit.Recorder +} + +// NewHandler constructs the security Handler. audit may be nil (best-effort). +func NewHandler(rotator KeyRotator, enqueuer JobEnqueuer, status StatusReporter, + resolver middleware.SessionResolver, users AdminLookup, rec audit.Recorder) *Handler { + return &Handler{rotator: rotator, enqueuer: enqueuer, status: status, resolver: resolver, users: users, audit: rec} +} + +// Mount registers the admin security routes under middleware.Auth. +func (h *Handler) Mount(r chi.Router) { + auth := middleware.Auth(h.resolver, middleware.AuthOptions{}) + r.With(auth).Route("/api/v1/admin/security", func(r chi.Router) { + r.Post("/rotate-key", h.rotateKey) + r.Get("/reencrypt-status", h.reencryptStatus) + }) +} + +// requireAdmin resolves the actor and enforces is_admin. +func (h *Handler) requireAdmin(r *http.Request) (uuid.UUID, error) { + uid, ok := middleware.UserIDFromContext(r.Context()) + if !ok { + return uuid.Nil, errs.New(errs.CodeUnauthorized, "unauthenticated") + } + isAdmin, err := h.users.IsAdmin(r.Context(), uid) + if err != nil { + return uuid.Nil, err + } + if !isAdmin { + return uuid.Nil, errs.New(errs.CodeForbidden, "需要管理员权限") + } + return uid, nil +} + +// rotateKeyReq is the optional request body for POST /rotate-key. +type rotateKeyReq struct { + Provider string `json:"provider"` // 默认 env + KeyRef string `json:"key_ref"` // KMS/Vault 引用,env provider 留空 +} + +// rotateKeyResp reports the new active version and the enqueued job. +type rotateKeyResp struct { + ActiveVersion int `json:"active_version"` + JobID string `json:"job_id"` +} + +// rotateKey bumps the active key version and enqueues the re-encrypt job. The +// new version's key material must already be loadable by the provider (e.g. +// APP_MASTER_KEY_V staged) — otherwise the re-encrypt job's seals will fail +// and the job retries until the operator stages the key. The OLD version stays +// loadable so existing blobs decrypt during the rotation window. +func (h *Handler) rotateKey(w http.ResponseWriter, r *http.Request) { + actor, err := h.requireAdmin(r) + if err != nil { + h.writeErr(w, r, err) + return + } + var req rotateKeyReq + if r.Body != nil { + _ = json.NewDecoder(r.Body).Decode(&req) // body optional + } + provider := req.Provider + if provider == "" { + provider = "env" + } + + newVer, err := h.rotator.RotateActive(r.Context(), provider, req.KeyRef) + if err != nil { + h.writeErr(w, r, errs.Wrap(err, errs.CodeInternal, "rotate key")) + return + } + + payload, _ := json.Marshal(handlers.SecretReencryptPayload{ActiveVersion: newVer}) + job, err := h.enqueuer.Enqueue(r.Context(), jobs.TypeSecretReencrypt, payload, time.Now(), 5) + if err != nil { + h.writeErr(w, r, errs.Wrap(err, errs.CodeInternal, "enqueue reencrypt")) + return + } + + if h.audit != nil { + a := actor + _ = h.audit.Record(context.WithoutCancel(r.Context()), audit.Entry{ + UserID: &a, + Action: "security.rotate_key", + TargetType: "crypto_key", + TargetID: job.ID.String(), + Metadata: map[string]any{"active_version": newVer, "job_id": job.ID.String()}, + }) + } + + httpx.WriteJSON(w, http.StatusAccepted, rotateKeyResp{ActiveVersion: newVer, JobID: job.ID.String()}) +} + +// reencryptStatusResp reports rows remaining on a stale key version per table. +type reencryptStatusResp struct { + ActiveVersion int `json:"active_version"` + Stale map[string]int `json:"stale"` + Complete bool `json:"complete"` +} + +// reencryptStatus reports per-table counts of rows still on an old key version. +func (h *Handler) reencryptStatus(w http.ResponseWriter, r *http.Request) { + if _, err := h.requireAdmin(r); err != nil { + h.writeErr(w, r, err) + return + } + active, err := h.rotator.ActiveVersion(r.Context()) + if err != nil { + h.writeErr(w, r, errs.Wrap(err, errs.CodeInternal, "active version")) + return + } + stale := map[string]int{} + total := 0 + for _, t := range h.status.Tables() { + n, err := h.status.StaleCount(r.Context(), t, active) + if err != nil { + h.writeErr(w, r, errs.Wrap(err, errs.CodeInternal, "stale count")) + return + } + stale[t] = n + total += n + } + httpx.WriteJSON(w, http.StatusOK, reencryptStatusResp{ + ActiveVersion: active, + Stale: stale, + Complete: total == 0, + }) +} + +func (h *Handler) writeErr(w http.ResponseWriter, r *http.Request, err error) { + httpx.WriteError(w, middleware.RequestIDFromContext(r.Context()), err) +} diff --git a/internal/security/handler_test.go b/internal/security/handler_test.go new file mode 100644 index 0000000..10a2a8d --- /dev/null +++ b/internal/security/handler_test.go @@ -0,0 +1,122 @@ +package security + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/go-chi/chi/v5" + "github.com/google/uuid" + "github.com/stretchr/testify/require" + + "github.com/yan1h/agent-coding-workflow/internal/jobs" +) + +type fakeRotator struct { + active int + rotated bool +} + +func (f *fakeRotator) RotateActive(_ context.Context, _, _ string) (int, error) { + f.active++ + f.rotated = true + return f.active, nil +} +func (f *fakeRotator) ActiveVersion(context.Context) (int, error) { return f.active, nil } + +type fakeEnqueuer struct{ enqueued jobs.JobType } + +func (f *fakeEnqueuer) Enqueue(_ context.Context, typ jobs.JobType, _ json.RawMessage, _ time.Time, _ int32) (*jobs.Job, error) { + f.enqueued = typ + return &jobs.Job{ID: uuid.New(), Type: typ}, nil +} + +type fakeStatus struct{ stale map[string]int } + +func (f *fakeStatus) Tables() []string { return []string{"workspaces"} } +func (f *fakeStatus) StaleCount(_ context.Context, t string, _ int) (int, error) { + return f.stale[t], nil +} + +// fakeResolver maps a fixed token to a fixed user id. +type fakeResolver struct{ uid uuid.UUID } + +func (f fakeResolver) ResolveSession(_ context.Context, token string) (uuid.UUID, bool, error) { + if token == "good" { + return f.uid, true, nil + } + return uuid.Nil, false, nil +} + +type fakeAdmin struct{ admins map[uuid.UUID]bool } + +func (f fakeAdmin) IsAdmin(_ context.Context, id uuid.UUID) (bool, error) { + return f.admins[id], nil +} + +func mountHandler(t *testing.T, admin bool) (*chi.Mux, *fakeRotator, *fakeEnqueuer) { + t.Helper() + uid := uuid.New() + rot := &fakeRotator{active: 1} + enq := &fakeEnqueuer{} + st := &fakeStatus{stale: map[string]int{"workspaces": 2}} + h := NewHandler(rot, enq, st, fakeResolver{uid: uid}, fakeAdmin{admins: map[uuid.UUID]bool{uid: admin}}, nil) + r := chi.NewRouter() + h.Mount(r) + return r, rot, enq +} + +func TestRotateKey_AdminEnqueuesReencrypt(t *testing.T) { + r, rot, enq := mountHandler(t, true) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/security/rotate-key", nil) + req.Header.Set("Authorization", "Bearer good") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusAccepted, rr.Code) + require.True(t, rot.rotated) + require.Equal(t, jobs.TypeSecretReencrypt, enq.enqueued) + + var resp rotateKeyResp + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + require.Equal(t, 2, resp.ActiveVersion) + require.NotEmpty(t, resp.JobID) +} + +func TestRotateKey_NonAdminForbidden(t *testing.T) { + r, rot, enq := mountHandler(t, false) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/security/rotate-key", nil) + req.Header.Set("Authorization", "Bearer good") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusForbidden, rr.Code) + require.False(t, rot.rotated) + require.Empty(t, enq.enqueued) +} + +func TestRotateKey_Unauthenticated(t *testing.T) { + r, _, _ := mountHandler(t, true) + req := httptest.NewRequest(http.MethodPost, "/api/v1/admin/security/rotate-key", nil) + req.Header.Set("Authorization", "Bearer bad") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + require.Equal(t, http.StatusUnauthorized, rr.Code) +} + +func TestReencryptStatus_ReportsStale(t *testing.T) { + r, _, _ := mountHandler(t, true) + req := httptest.NewRequest(http.MethodGet, "/api/v1/admin/security/reencrypt-status", nil) + req.Header.Set("Authorization", "Bearer good") + rr := httptest.NewRecorder() + r.ServeHTTP(rr, req) + + require.Equal(t, http.StatusOK, rr.Code) + var resp reencryptStatusResp + require.NoError(t, json.Unmarshal(rr.Body.Bytes(), &resp)) + require.Equal(t, 2, resp.Stale["workspaces"]) + require.False(t, resp.Complete) +} diff --git a/internal/transport/http/middleware/cors.go b/internal/transport/http/middleware/cors.go new file mode 100644 index 0000000..9c5e87f --- /dev/null +++ b/internal/transport/http/middleware/cors.go @@ -0,0 +1,91 @@ +package middleware + +import ( + "net/http" + "strconv" + "strings" +) + +// CORSConfig configures the CORS middleware. AllowedOrigins is an explicit +// allowlist (no "*" with credentials — that combination is rejected by +// browsers); an entry of "*" disables credentials and echoes "*". When the +// request Origin is not in the allowlist no CORS headers are emitted, so the +// browser blocks the cross-origin read (same as having no CORS support). +type CORSConfig struct { + Enabled bool + AllowedOrigins []string + AllowedMethods []string // empty → GET,POST,PUT,PATCH,DELETE,OPTIONS + AllowedHeaders []string // empty → Authorization,Content-Type,X-Client-Request-ID + AllowCredentials bool + MaxAgeSeconds int // preflight cache; 0 → 600 +} + +// CORS returns middleware that handles cross-origin requests against an explicit +// origin allowlist and short-circuits OPTIONS preflight with 204. When +// cfg.Enabled is false it is a transparent pass-through. +func CORS(cfg CORSConfig) func(http.Handler) http.Handler { + methods := strings.Join(orDefault(cfg.AllowedMethods, + []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"}), ", ") + headers := strings.Join(orDefault(cfg.AllowedHeaders, + []string{"Authorization", "Content-Type", "X-Client-Request-ID"}), ", ") + maxAge := cfg.MaxAgeSeconds + if maxAge <= 0 { + maxAge = 600 + } + allowAll := false + allow := map[string]struct{}{} + for _, o := range cfg.AllowedOrigins { + if o == "*" { + allowAll = true + } + allow[o] = struct{}{} + } + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !cfg.Enabled { + next.ServeHTTP(w, r) + return + } + origin := r.Header.Get("Origin") + if origin != "" && originAllowed(origin, allow, allowAll) { + h := w.Header() + // Echo the specific origin (not "*") so credentials can flow; + // vary on Origin so caches don't serve the wrong ACAO. + if allowAll && !cfg.AllowCredentials { + h.Set("Access-Control-Allow-Origin", "*") + } else { + h.Set("Access-Control-Allow-Origin", origin) + h.Add("Vary", "Origin") + } + if cfg.AllowCredentials { + h.Set("Access-Control-Allow-Credentials", "true") + } + h.Set("Access-Control-Allow-Methods", methods) + h.Set("Access-Control-Allow-Headers", headers) + h.Set("Access-Control-Max-Age", strconv.Itoa(maxAge)) + } + // Preflight: answer and stop. + if r.Method == http.MethodOptions && r.Header.Get("Access-Control-Request-Method") != "" { + w.WriteHeader(http.StatusNoContent) + return + } + next.ServeHTTP(w, r) + }) + } +} + +func originAllowed(origin string, allow map[string]struct{}, allowAll bool) bool { + if allowAll { + return true + } + _, ok := allow[origin] + return ok +} + +func orDefault(v, def []string) []string { + if len(v) == 0 { + return def + } + return v +} diff --git a/internal/transport/http/middleware/cors_test.go b/internal/transport/http/middleware/cors_test.go new file mode 100644 index 0000000..09471e8 --- /dev/null +++ b/internal/transport/http/middleware/cors_test.go @@ -0,0 +1,63 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCORS_AllowedOriginGetsHeaders(t *testing.T) { + h := CORS(CORSConfig{Enabled: true, AllowedOrigins: []string{"https://app.example.com"}, AllowCredentials: true})(okHandler()) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "https://app.example.com") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + require.Equal(t, "https://app.example.com", rr.Header().Get("Access-Control-Allow-Origin")) + require.Equal(t, "true", rr.Header().Get("Access-Control-Allow-Credentials")) + require.Equal(t, http.StatusOK, rr.Code) +} + +func TestCORS_DisallowedOriginGetsNoHeaders(t *testing.T) { + h := CORS(CORSConfig{Enabled: true, AllowedOrigins: []string{"https://app.example.com"}})(okHandler()) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "https://evil.example.com") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin")) +} + +func TestCORS_PreflightReturns204(t *testing.T) { + h := CORS(CORSConfig{Enabled: true, AllowedOrigins: []string{"https://app.example.com"}})(okHandler()) + req := httptest.NewRequest(http.MethodOptions, "/api/v1/x", nil) + req.Header.Set("Origin", "https://app.example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + + require.Equal(t, http.StatusNoContent, rr.Code) + require.Equal(t, "https://app.example.com", rr.Header().Get("Access-Control-Allow-Origin")) + require.Contains(t, rr.Header().Get("Access-Control-Allow-Methods"), "POST") +} + +func TestCORS_WildcardWithoutCredentials(t *testing.T) { + h := CORS(CORSConfig{Enabled: true, AllowedOrigins: []string{"*"}})(okHandler()) + req := httptest.NewRequest(http.MethodGet, "/", nil) + req.Header.Set("Origin", "https://anything.example.com") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + require.Equal(t, "*", rr.Header().Get("Access-Control-Allow-Origin")) +} + +func TestCORS_DisabledIsPassthrough(t *testing.T) { + h := CORS(CORSConfig{Enabled: false})(okHandler()) + req := httptest.NewRequest(http.MethodOptions, "/", nil) + req.Header.Set("Origin", "https://app.example.com") + req.Header.Set("Access-Control-Request-Method", "POST") + rr := httptest.NewRecorder() + h.ServeHTTP(rr, req) + require.Equal(t, http.StatusOK, rr.Code, "disabled CORS must not short-circuit OPTIONS") + require.Empty(t, rr.Header().Get("Access-Control-Allow-Origin")) +} diff --git a/internal/transport/http/middleware/security_headers.go b/internal/transport/http/middleware/security_headers.go new file mode 100644 index 0000000..20de83e --- /dev/null +++ b/internal/transport/http/middleware/security_headers.go @@ -0,0 +1,78 @@ +package middleware + +import "net/http" + +// SecurityHeadersConfig configures the SecurityHeaders middleware. All headers +// are opt-in via cfg so they can be tuned (or disabled) without code changes; +// the defaults are safe for an SPA + WebSocket app. HSTS is gated on TLS — it is +// only emitted when TLSEnabled is true, since advertising HSTS over plain HTTP +// either does nothing or (once cached) breaks local http access. +type SecurityHeadersConfig struct { + Enabled bool + TLSEnabled bool + // HSTSMaxAgeSeconds is the Strict-Transport-Security max-age. 0 → default + // (1 year) when TLSEnabled. + HSTSMaxAgeSeconds int + // FrameOptions sets X-Frame-Options; empty → "DENY". + FrameOptions string + // ReferrerPolicy sets Referrer-Policy; empty → "no-referrer". + ReferrerPolicy string + // ContentSecurityPolicy sets CSP; empty → header omitted (CSP can break the + // SPA + WS handshake if mis-set, so it is off by default and opt-in). + ContentSecurityPolicy string +} + +// SecurityHeaders returns middleware that stamps standard security response +// headers. When cfg.Enabled is false it is a transparent pass-through, so it can +// be wired unconditionally and toggled by config. +func SecurityHeaders(cfg SecurityHeadersConfig) func(http.Handler) http.Handler { + frame := cfg.FrameOptions + if frame == "" { + frame = "DENY" + } + referrer := cfg.ReferrerPolicy + if referrer == "" { + referrer = "no-referrer" + } + hstsAge := cfg.HSTSMaxAgeSeconds + if hstsAge <= 0 { + hstsAge = 31536000 // 1 year + } + hstsValue := "max-age=" + itoa(hstsAge) + "; includeSubDomains" + + return func(next http.Handler) http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !cfg.Enabled { + next.ServeHTTP(w, r) + return + } + h := w.Header() + h.Set("X-Content-Type-Options", "nosniff") + h.Set("X-Frame-Options", frame) + h.Set("Referrer-Policy", referrer) + if cfg.ContentSecurityPolicy != "" { + h.Set("Content-Security-Policy", cfg.ContentSecurityPolicy) + } + if cfg.TLSEnabled { + h.Set("Strict-Transport-Security", hstsValue) + } + next.ServeHTTP(w, r) + }) + } +} + +// itoa is a tiny non-allocating-ish int->string for positive values, avoiding a +// strconv import for a single use. +func itoa(n int) string { + if n == 0 { + return "0" + } + var buf [20]byte + i := len(buf) + for n > 0 { + i-- + buf[i] = byte('0' + n%10) + n /= 10 + } + return string(buf[i:]) +} diff --git a/internal/transport/http/middleware/security_headers_test.go b/internal/transport/http/middleware/security_headers_test.go new file mode 100644 index 0000000..2f1976f --- /dev/null +++ b/internal/transport/http/middleware/security_headers_test.go @@ -0,0 +1,49 @@ +package middleware + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/require" +) + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("ok")) + }) +} + +func TestSecurityHeaders_PresentWhenEnabled(t *testing.T) { + h := SecurityHeaders(SecurityHeadersConfig{Enabled: true})(okHandler()) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + + require.Equal(t, "nosniff", rr.Header().Get("X-Content-Type-Options")) + require.Equal(t, "DENY", rr.Header().Get("X-Frame-Options")) + require.Equal(t, "no-referrer", rr.Header().Get("Referrer-Policy")) + require.Empty(t, rr.Header().Get("Strict-Transport-Security"), "HSTS only under TLS") +} + +func TestSecurityHeaders_HSTSOnlyUnderTLS(t *testing.T) { + h := SecurityHeaders(SecurityHeadersConfig{Enabled: true, TLSEnabled: true})(okHandler()) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Contains(t, rr.Header().Get("Strict-Transport-Security"), "max-age=31536000") +} + +func TestSecurityHeaders_DisabledIsPassthrough(t *testing.T) { + h := SecurityHeaders(SecurityHeadersConfig{Enabled: false})(okHandler()) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Empty(t, rr.Header().Get("X-Content-Type-Options")) + require.Equal(t, http.StatusOK, rr.Code) +} + +func TestSecurityHeaders_CSPOptIn(t *testing.T) { + h := SecurityHeaders(SecurityHeadersConfig{Enabled: true, ContentSecurityPolicy: "default-src 'self'"})(okHandler()) + rr := httptest.NewRecorder() + h.ServeHTTP(rr, httptest.NewRequest(http.MethodGet, "/", nil)) + require.Equal(t, "default-src 'self'", rr.Header().Get("Content-Security-Policy")) +} diff --git a/internal/transport/http/router.go b/internal/transport/http/router.go index 3246532..847e41b 100644 --- a/internal/transport/http/router.go +++ b/internal/transport/http/router.go @@ -25,6 +25,18 @@ type RouterDeps struct { SPAHandler http.Handler // Logger 用于 Recover 和 Logger 中间件。为 nil 时跳过日志相关中间件。 Logger *slog.Logger + // Security 是安全响应头中间件配置(cfg-gated;Enabled=false 时透传)。 + Security middleware.SecurityHeadersConfig + // CORS 是跨域中间件配置(cfg-gated;Enabled=false 时透传)。默认 off, + // 避免误配破坏 SPA + WS 握手;需要时显式开启并配置 origin 白名单。 + CORS middleware.CORSConfig + // MetricsHandler 为 nil 时不挂 /metrics;非 nil 时挂到 GET /metrics。 + // 暴露成本/会话数,必须经 MetricsAuthToken 门禁(见 NewRouter)。 + MetricsHandler http.Handler + // MetricsAuthToken 非空时,/metrics 要求 Authorization: Bearer + // 或 ?token= 匹配,否则 401。为空时 /metrics 不做令牌校验(仅 + // 适用于绑定内网 / 由反代鉴权的部署)。 + MetricsAuthToken string } // NewRouter builds the application's chi.Router with the standard @@ -36,6 +48,10 @@ func NewRouter(deps RouterDeps) chi.Router { // 所有中间件必须在注册路由之前添加(chi 的强制要求)。 r.Use(middleware.RequestID) + // 安全头与 CORS 紧随 RequestID、在路由挂载之前;二者均 cfg-gated, + // Enabled=false 时为透传,默认行为与历史一致。 + r.Use(middleware.SecurityHeaders(deps.Security)) + r.Use(middleware.CORS(deps.CORS)) if deps.Logger != nil { r.Use(middleware.Recover(deps.Logger)) r.Use(middleware.Logger(deps.Logger)) @@ -61,9 +77,35 @@ func NewRouter(deps RouterDeps) chi.Router { _, _ = w.Write([]byte("ok")) }) + // /metrics(Prometheus):admin-gated via static bearer token. 挂在 SPA + // NotFound 之前,确保未匹配请求不会落到 SPA fallback。 + if deps.MetricsHandler != nil { + token := deps.MetricsAuthToken + mh := deps.MetricsHandler + r.Get("/metrics", func(w http.ResponseWriter, req *http.Request) { + if token != "" && !metricsTokenOK(req, token) { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + mh.ServeHTTP(w, req) + }) + } + if deps.SPAHandler != nil { r.NotFound(deps.SPAHandler.ServeHTTP) } return r } + +// metricsTokenOK 校验 /metrics 请求是否携带匹配的令牌(Authorization: Bearer +// 或 ?token=)。 +func metricsTokenOK(req *http.Request, token string) bool { + const prefix = "Bearer " + if h := req.Header.Get("Authorization"); len(h) > len(prefix) && h[:len(prefix)] == prefix { + if h[len(prefix):] == token { + return true + } + } + return req.URL.Query().Get("token") == token +} diff --git a/internal/transport/http/router_metrics_test.go b/internal/transport/http/router_metrics_test.go new file mode 100644 index 0000000..db67629 --- /dev/null +++ b/internal/transport/http/router_metrics_test.go @@ -0,0 +1,71 @@ +package httpx + +import ( + "net/http" + "net/http/httptest" + "testing" +) + +func okHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("metric_x 1\n")) + }) +} + +func TestMetricsNotMountedWhenNil(t *testing.T) { + r := NewRouter(RouterDeps{}) + req := httptest.NewRequest("GET", "/metrics", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + // No /metrics route and no SPA fallback -> 404. + if rec.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 (no metrics handler)", rec.Code) + } +} + +func TestMetricsOpenWhenNoToken(t *testing.T) { + r := NewRouter(RouterDeps{MetricsHandler: okHandler()}) + req := httptest.NewRequest("GET", "/metrics", nil) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } +} + +func TestMetricsTokenGated(t *testing.T) { + r := NewRouter(RouterDeps{MetricsHandler: okHandler(), MetricsAuthToken: "secret"}) + + // no token -> 401 + rec := httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest("GET", "/metrics", nil)) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("no-token status = %d, want 401", rec.Code) + } + + // wrong token -> 401 + rec = httptest.NewRecorder() + req := httptest.NewRequest("GET", "/metrics", nil) + req.Header.Set("Authorization", "Bearer nope") + r.ServeHTTP(rec, req) + if rec.Code != http.StatusUnauthorized { + t.Fatalf("wrong-token status = %d, want 401", rec.Code) + } + + // correct bearer -> 200 + rec = httptest.NewRecorder() + req = httptest.NewRequest("GET", "/metrics", nil) + req.Header.Set("Authorization", "Bearer secret") + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("bearer status = %d, want 200", rec.Code) + } + + // correct query token -> 200 + rec = httptest.NewRecorder() + r.ServeHTTP(rec, httptest.NewRequest("GET", "/metrics?token=secret", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("query-token status = %d, want 200", rec.Code) + } +} diff --git a/internal/user/login_throttle.go b/internal/user/login_throttle.go new file mode 100644 index 0000000..acc381e --- /dev/null +++ b/internal/user/login_throttle.go @@ -0,0 +1,120 @@ +// login_throttle.go throttles login brute-force by counting consecutive failed +// attempts per (ip, email) key in a fixed time window — the same fixed-window +// token-bucket algorithm proven in internal/mcp.RateLimiter, kept local here to +// avoid an import cycle (user must not depend on mcp). On too many failures +// within the window the next attempt is rejected with retry-after BEFORE any +// password hash is computed, so an attacker cannot keep spending CPU on bcrypt. +// +// Keying by ip+email (not ip alone) means one attacker cannot lock a victim out +// by hammering the victim's email from arbitrary IPs, and a shared NAT IP cannot +// lock every user behind it; it does mean a botnet rotating IPs is not fully +// stopped — that is the documented limit of a per-(ip,email) counter. +package user + +import ( + "strings" + "sync" + "time" +) + +// LoginThrottleConfig controls the failure window. MaxFailures failed attempts +// within Window blocks further attempts until the window rolls over. +type LoginThrottleConfig struct { + Enabled bool + MaxFailures int + Window time.Duration +} + +// attemptBucket is one (ip,email) key's failure counter for the current window. +type attemptBucket struct { + count int + windowAt time.Time +} + +// LoginThrottle is a concurrency-safe per-key fixed-window failure limiter. +type LoginThrottle struct { + cfg LoginThrottleConfig + mu sync.Mutex + m map[string]*attemptBucket + now func() time.Time // injectable for tests +} + +// NewLoginThrottle builds a throttle. now=nil uses time.Now. A disabled config +// (Enabled=false or MaxFailures<=0) yields a throttle that always allows. +func NewLoginThrottle(cfg LoginThrottleConfig, now func() time.Time) *LoginThrottle { + if now == nil { + now = time.Now + } + if cfg.Window <= 0 { + cfg.Window = 15 * time.Minute + } + return &LoginThrottle{cfg: cfg, m: map[string]*attemptBucket{}, now: now} +} + +// key normalizes (ip, email) into a single bucket key. Email is lowercased so +// case variants share one counter; IP is taken as-is. +func throttleKey(ip, email string) string { + return ip + ":" + strings.ToLower(strings.TrimSpace(email)) +} + +// disabled reports whether the throttle is a no-op. +func (t *LoginThrottle) disabled() bool { + return t == nil || !t.cfg.Enabled || t.cfg.MaxFailures <= 0 +} + +// Allow reports whether a login attempt for (ip,email) may proceed. When +// blocked it returns the duration until the current window rolls over. Allow +// does NOT itself record an attempt — call RecordFailure on bad credentials and +// Reset on success. +func (t *LoginThrottle) Allow(ip, email string) (bool, time.Duration) { + if t.disabled() { + return true, 0 + } + t.mu.Lock() + defer t.mu.Unlock() + now := t.now() + b := t.m[throttleKey(ip, email)] + if b == nil { + return true, 0 + } + if now.Sub(b.windowAt) >= t.cfg.Window { + // Window expired: stale counter, allow (RecordFailure will reset it). + return true, 0 + } + if b.count >= t.cfg.MaxFailures { + retry := t.cfg.Window - now.Sub(b.windowAt) + if retry < 0 { + retry = 0 + } + return false, retry + } + return true, 0 +} + +// RecordFailure increments the failure counter for (ip,email), starting a new +// window if the previous one has expired. +func (t *LoginThrottle) RecordFailure(ip, email string) { + if t.disabled() { + return + } + t.mu.Lock() + defer t.mu.Unlock() + now := t.now() + k := throttleKey(ip, email) + b := t.m[k] + if b == nil || now.Sub(b.windowAt) >= t.cfg.Window { + b = &attemptBucket{windowAt: now} + t.m[k] = b + } + b.count++ +} + +// Reset clears the failure counter for (ip,email) after a successful login. +func (t *LoginThrottle) Reset(ip, email string) { + if t.disabled() { + return + } + t.mu.Lock() + defer t.mu.Unlock() + delete(t.m, throttleKey(ip, email)) +} diff --git a/internal/user/login_throttle_test.go b/internal/user/login_throttle_test.go new file mode 100644 index 0000000..e5c8ca4 --- /dev/null +++ b/internal/user/login_throttle_test.go @@ -0,0 +1,105 @@ +package user + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func newThrottle(now *time.Time) *LoginThrottle { + return NewLoginThrottle( + LoginThrottleConfig{Enabled: true, MaxFailures: 3, Window: 10 * time.Minute}, + func() time.Time { return *now }, + ) +} + +func TestLoginThrottle_LockoutAfterN(t *testing.T) { + now := time.Now() + th := newThrottle(&now) + + ip, email := "1.2.3.4", "user@example.com" + for i := 0; i < 3; i++ { + allowed, _ := th.Allow(ip, email) + require.True(t, allowed, "attempt %d should be allowed", i+1) + th.RecordFailure(ip, email) + } + // 4th attempt blocked. + allowed, retry := th.Allow(ip, email) + require.False(t, allowed) + require.Greater(t, retry, time.Duration(0)) +} + +func TestLoginThrottle_WindowExpiryResets(t *testing.T) { + now := time.Now() + th := newThrottle(&now) + ip, email := "1.2.3.4", "user@example.com" + + for i := 0; i < 3; i++ { + th.RecordFailure(ip, email) + } + blocked, _ := th.Allow(ip, email) + require.False(t, blocked) + + // Advance past the window: counter is stale -> allowed again. + now = now.Add(11 * time.Minute) + allowed, _ := th.Allow(ip, email) + require.True(t, allowed) +} + +func TestLoginThrottle_SuccessReset(t *testing.T) { + now := time.Now() + th := newThrottle(&now) + ip, email := "1.2.3.4", "user@example.com" + + th.RecordFailure(ip, email) + th.RecordFailure(ip, email) + th.Reset(ip, email) // simulate successful login + + // Counter cleared: can fail 3 more times before lockout. + for i := 0; i < 3; i++ { + allowed, _ := th.Allow(ip, email) + require.True(t, allowed) + th.RecordFailure(ip, email) + } + blocked, _ := th.Allow(ip, email) + require.False(t, blocked) +} + +func TestLoginThrottle_PerIPEmailIsolation(t *testing.T) { + now := time.Now() + th := newThrottle(&now) + victim := "victim@example.com" + + // Attacker hammers victim's email from one IP and trips the lock for that IP. + for i := 0; i < 3; i++ { + th.RecordFailure("9.9.9.9", victim) + } + blocked, _ := th.Allow("9.9.9.9", victim) + require.False(t, blocked) + + // The real victim, on a DIFFERENT IP, is NOT locked out. + allowed, _ := th.Allow("5.5.5.5", victim) + require.True(t, allowed, "victim must not be locked out by an attacker on another IP") +} + +func TestLoginThrottle_CaseInsensitiveEmail(t *testing.T) { + now := time.Now() + th := newThrottle(&now) + ip := "1.2.3.4" + for i := 0; i < 3; i++ { + th.RecordFailure(ip, "User@Example.com") + } + // Same email different case shares the counter. + blocked, _ := th.Allow(ip, "user@example.com") + require.False(t, blocked) +} + +func TestLoginThrottle_DisabledAlwaysAllows(t *testing.T) { + th := NewLoginThrottle(LoginThrottleConfig{Enabled: false, MaxFailures: 1}, nil) + for i := 0; i < 100; i++ { + th.RecordFailure("1.1.1.1", "a@b.c") + } + allowed, _ := th.Allow("1.1.1.1", "a@b.c") + require.True(t, allowed) +} diff --git a/internal/user/service.go b/internal/user/service.go index 77e8409..abe18a6 100644 --- a/internal/user/service.go +++ b/internal/user/service.go @@ -32,17 +32,38 @@ const sessionTTL = 7 * 24 * time.Hour // service 是 Service 接口的默认实现。字段保持小写以禁止外部直接构造, // 调用方必须通过 NewService 注入 Repository。 type service struct { - repo Repository - audit audit.Recorder - now func() time.Time + repo Repository + audit audit.Recorder + now func() time.Time + throttle *LoginThrottle // 登录暴力破解节流;默认 disabled,装配期可回填 } // NewService 用给定的 Repository 和 audit.Recorder 构造 Service 实现。 // recorder 可为 nil(审计是 best-effort,nil 时直接跳过写审计)。 // 返回 Service 接口而非 *service,以便日后扩展实现(如增加缓存层装饰器) -// 时不破坏调用方代码。 +// 时不破坏调用方代码。默认装一个 disabled 的 LoginThrottle(无节流), +// 装配期通过 SetLoginThrottle 注入启用的实例。 func NewService(repo Repository, recorder audit.Recorder) Service { - return &service{repo: repo, audit: recorder, now: time.Now} + return &service{ + repo: repo, + audit: recorder, + now: time.Now, + throttle: NewLoginThrottle(LoginThrottleConfig{}, nil), + } +} + +// SetLoginThrottle 注入登录节流器(装配期回填)。传 nil 时退回 disabled。 +// 因 Service 是接口,调用方需类型断言到该 setter(与项目其它装配期回填一致)。 +func (s *service) SetLoginThrottle(t *LoginThrottle) { + if t == nil { + t = NewLoginThrottle(LoginThrottleConfig{}, nil) + } + s.throttle = t +} + +// LoginThrottleSetter 是装配期回填登录节流器的窄接口(app.go 用类型断言取得)。 +type LoginThrottleSetter interface { + SetLoginThrottle(t *LoginThrottle) } // Login 校验邮箱与密码,成功后签发新的会话 token。 @@ -50,9 +71,17 @@ func NewService(repo Repository, recorder audit.Recorder) Service { // 不向调用方泄露 "邮箱是否存在" 这一侧信道;只有底层故障(哈希解析、 // token 生成、写库失败等)会返回带详细 cause 的非授权类错误。 func (s *service) Login(ctx context.Context, email, password, ip string) (string, *User, error) { + // 暴力破解节流:在任何 DB 查询 / 密码哈希之前判定,超限直接 429,避免攻击者 + // 持续消耗 bcrypt CPU。key = ip + lowercase(email),未知邮箱与已知邮箱走同一 + // 计数路径,不引入邮箱枚举侧信道。 + if allowed, _ := s.throttle.Allow(ip, email); !allowed { + return "", nil, errs.New(errs.CodeRateLimited, "登录尝试过于频繁,请稍后再试") + } + u, err := s.repo.GetUserByEmail(ctx, email) if err != nil { - // 不区分 "邮箱不存在" 与 "密码错误",统一报 unauthorized。 + // 不区分 "邮箱不存在" 与 "密码错误",统一报 unauthorized。失败计数。 + s.throttle.RecordFailure(ip, email) return "", nil, errs.New(errs.CodeUnauthorized, "邮箱或密码错误") } ok, err := VerifyPassword(password, u.PasswordHash) @@ -60,8 +89,11 @@ func (s *service) Login(ctx context.Context, email, password, ip string) (string return "", nil, errs.Wrap(err, errs.CodeInternal, "verify password") } if !ok { + s.throttle.RecordFailure(ip, email) return "", nil, errs.New(errs.CodeUnauthorized, "邮箱或密码错误") } + // 凭据正确:清零该 key 的失败计数。 + s.throttle.Reset(ip, email) tok, hash, err := NewSessionToken() if err != nil { diff --git a/internal/user/sqlc/models.go b/internal/user/sqlc/models.go index ea1c55a..0c00bab 100644 --- a/internal/user/sqlc/models.go +++ b/internal/user/sqlc/models.go @@ -8,6 +8,7 @@ import ( "net/netip" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type AcpAgentKind struct { @@ -25,6 +26,11 @@ type AcpAgentKind struct { ToolAllowlist []string `json:"tool_allowlist"` ClientType string `json:"client_type"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` } type AcpAgentKindConfigFile struct { @@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct { UpdatedBy pgtype.UUID `json:"updated_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type AcpEvent struct { @@ -64,23 +71,64 @@ type AcpPermissionRequest struct { } type AcpSession struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ProjectID pgtype.UUID `json:"project_id"` - AgentKindID pgtype.UUID `json:"agent_kind_id"` - UserID pgtype.UUID `json:"user_id"` - IssueID pgtype.UUID `json:"issue_id"` - RequirementID pgtype.UUID `json:"requirement_id"` - AgentSessionID *string `json:"agent_session_id"` - Branch string `json:"branch"` - CwdPath string `json:"cwd_path"` - IsMainWorktree bool `json:"is_main_worktree"` - Status string `json:"status"` - Pid *int32 `json:"pid"` - ExitCode *int32 `json:"exit_code"` - LastError *string `json:"last_error"` - StartedAt pgtype.Timestamptz `json:"started_at"` - EndedAt pgtype.Timestamptz `json:"ended_at"` + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` } type AuditLog struct { @@ -94,6 +142,81 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Conversation struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` @@ -110,6 +233,14 @@ type Conversation struct { RequirementID pgtype.UUID `json:"requirement_id"` } +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type GitCredential struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -119,6 +250,7 @@ type GitCredential struct { Fingerprint *string `json:"fingerprint"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type Issue struct { @@ -135,6 +267,17 @@ type Issue struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Job struct { @@ -162,6 +305,7 @@ type LlmEndpoint struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type LlmModel struct { @@ -252,6 +396,50 @@ type Notification struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + type Project struct { ID pgtype.UUID `json:"id"` Slug string `json:"slug"` @@ -264,6 +452,30 @@ type Project struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type PromptTemplate struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` @@ -299,6 +511,16 @@ type RequirementArtifact struct { SourceMessageID *int64 `json:"source_message_id"` CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type User struct { @@ -354,6 +576,7 @@ type WorkspaceRunProfile struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type WorkspaceWorktree struct { diff --git a/internal/workspace/domain.go b/internal/workspace/domain.go index a50bf75..2e28f41 100644 --- a/internal/workspace/domain.go +++ b/internal/workspace/domain.go @@ -168,11 +168,13 @@ type GitOpsService interface { CommitOnMain(ctx context.Context, c Caller, wsID uuid.UUID, in CommitInput) (string, error) PushOnMain(ctx context.Context, c Caller, wsID uuid.UUID) error LogOnMain(ctx context.Context, c Caller, wsID uuid.UUID, n int) ([]git.Commit, error) + DiffOnMain(ctx context.Context, c Caller, wsID uuid.UUID, opts git.DiffOptions) (git.DiffResult, error) StatusOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) ([]git.FileStatus, error) CommitOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID, in CommitInput) (string, error) PushOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) error LogOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID, n int) ([]git.Commit, error) + DiffOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID, opts git.DiffOptions) (git.DiffResult, error) } // ===== Input DTO ===== diff --git a/internal/workspace/dto.go b/internal/workspace/dto.go index be02e5d..61aada7 100644 --- a/internal/workspace/dto.go +++ b/internal/workspace/dto.go @@ -131,3 +131,10 @@ type commitLogResp struct { func toCommitLogResp(c git.Commit) commitLogResp { return commitLogResp{Hash: c.Hash, Author: c.Author, Email: c.Email, Date: c.Date, Subject: c.Subject} } + +// diffResp 是 GET /diff 的响应体:原始 unified-diff 文本 + 截断标志。 +// 不在后端解析 hunk,由前端 DiffViewer 负责。 +type diffResp struct { + Diff string `json:"diff"` + Truncated bool `json:"truncated"` +} diff --git a/internal/workspace/gitops_service.go b/internal/workspace/gitops_service.go index 723ac52..7ad0ff4 100644 --- a/internal/workspace/gitops_service.go +++ b/internal/workspace/gitops_service.go @@ -13,12 +13,18 @@ import ( // CredentialResolver 是 service 共享的解密回调,避免 GitOpsService 直接持有 *crypto.Encryptor。 type CredentialResolver func(ctx context.Context, wsID uuid.UUID) (*git.Credential, error) +// CommitSummaryHook is a best-effort callback fired after a successful commit to +// enqueue auto-doc/PR-summary generation. Injected from app.go via +// SetCommitSummaryHook to avoid a workspace→docs import cycle. May be nil. +type CommitSummaryHook func(ctx context.Context, wsID uuid.UUID, worktreeID *uuid.UUID, branch, commitSHA string) error + type gitOpsService struct { - repo Repository - rec audit.Recorder - gitr git.Runner - pa ProjectAccess - cred CredentialResolver + repo Repository + rec audit.Recorder + gitr git.Runner + pa ProjectAccess + cred CredentialResolver + summaryHook CommitSummaryHook } // NewGitOpsService 构造 GitOpsService。cred 由 WorkspaceService.loadDecryptedCredential 适配过来。 @@ -26,6 +32,9 @@ func NewGitOpsService(repo Repository, rec audit.Recorder, gitr git.Runner, pa P return &gitOpsService{repo: repo, rec: rec, gitr: gitr, pa: pa, cred: cred} } +// SetCommitSummaryHook injects the auto-doc enqueue callback (app wiring). +func (s *gitOpsService) SetCommitSummaryHook(h CommitSummaryHook) { s.summaryHook = h } + // ===== Main ===== func (s *gitOpsService) StatusOnMain(ctx context.Context, c Caller, wsID uuid.UUID) ([]git.FileStatus, error) { @@ -50,6 +59,7 @@ func (s *gitOpsService) CommitOnMain(ctx context.Context, c Caller, wsID uuid.UU return "", errs.Wrap(err, errs.CodeGitCmdFailed, "git commit") } s.auditCommit(ctx, &c.UserID, wsID.String(), "main", in.Message, sha) + s.fireCommitSummary(ctx, &c.UserID, wsID, nil, ws.DefaultBranch, sha) if in.Push { cred, err := s.cred(ctx, wsID) if err != nil { @@ -63,6 +73,20 @@ func (s *gitOpsService) CommitOnMain(ctx context.Context, c Caller, wsID uuid.UU return sha, nil } +// fireCommitSummary invokes the auto-doc hook best-effort; failures are audited +// but never surfaced to the commit caller. +func (s *gitOpsService) fireCommitSummary(ctx context.Context, userID *uuid.UUID, wsID uuid.UUID, worktreeID *uuid.UUID, branch, sha string) { + if s.summaryHook == nil || sha == "" { + return + } + if herr := s.summaryHook(ctx, wsID, worktreeID, branch, sha); herr != nil { + _ = s.rec.Record(ctx, audit.Entry{ + UserID: userID, Action: "docs.summary_enqueue_failed", TargetType: "workspace", + TargetID: wsID.String(), Metadata: map[string]any{"err": herr.Error()}, + }) + } +} + func (s *gitOpsService) PushOnMain(ctx context.Context, c Caller, wsID uuid.UUID) error { ws, err := s.guardWorkspaceWrite(ctx, c, wsID) if err != nil { @@ -91,6 +115,18 @@ func (s *gitOpsService) LogOnMain(ctx context.Context, c Caller, wsID uuid.UUID, return commits, nil } +func (s *gitOpsService) DiffOnMain(ctx context.Context, c Caller, wsID uuid.UUID, opts git.DiffOptions) (git.DiffResult, error) { + ws, err := s.guardWorkspaceRead(ctx, c, wsID) + if err != nil { + return git.DiffResult{}, err + } + res, err := s.gitr.Diff(ctx, ws.MainPath, opts) + if err != nil { + return git.DiffResult{}, errs.Wrap(err, errs.CodeGitCmdFailed, "git diff") + } + return res, nil +} + // ===== Worktree ===== func (s *gitOpsService) StatusOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID) ([]git.FileStatus, error) { @@ -116,6 +152,8 @@ func (s *gitOpsService) CommitOnWorktree(ctx context.Context, c Caller, wtID uui return "", errs.Wrap(err, errs.CodeGitCmdFailed, "git commit") } s.auditCommit(ctx, &c.UserID, ws.ID.String(), wt.Branch, in.Message, sha) + wtIDCopy := wt.ID + s.fireCommitSummary(ctx, &c.UserID, ws.ID, &wtIDCopy, wt.Branch, sha) if in.Push { cred, err := s.cred(ctx, ws.ID) if err != nil { @@ -157,6 +195,18 @@ func (s *gitOpsService) LogOnWorktree(ctx context.Context, c Caller, wtID uuid.U return commits, nil } +func (s *gitOpsService) DiffOnWorktree(ctx context.Context, c Caller, wtID uuid.UUID, opts git.DiffOptions) (git.DiffResult, error) { + wt, _, err := s.guardWorktreeRead(ctx, c, wtID) + if err != nil { + return git.DiffResult{}, err + } + res, err := s.gitr.Diff(ctx, wt.Path, opts) + if err != nil { + return git.DiffResult{}, errs.Wrap(err, errs.CodeGitCmdFailed, "git diff") + } + return res, nil +} + // ===== guards ===== func (s *gitOpsService) guardWorkspaceRead(ctx context.Context, c Caller, wsID uuid.UUID) (*Workspace, error) { diff --git a/internal/workspace/handler.go b/internal/workspace/handler.go index aea93ea..bcee2e2 100644 --- a/internal/workspace/handler.go +++ b/internal/workspace/handler.go @@ -11,6 +11,7 @@ import ( "errors" "net/http" "strconv" + "strings" "github.com/go-chi/chi/v5" "github.com/google/uuid" @@ -61,6 +62,7 @@ func (h *Handler) Mount(r chi.Router) { r.Post("/main/commit", h.commitOnMain) r.Post("/main/push", h.pushOnMain) r.Get("/main/log", h.logOnMain) + r.Get("/diff", h.diffOnMain) r.Get("/worktrees", h.listWorktrees) r.Post("/worktrees", h.createWorktree) @@ -74,6 +76,7 @@ func (h *Handler) Mount(r chi.Router) { r.Post("/commit", h.commitOnWorktree) r.Post("/push", h.pushOnWorktree) r.Get("/log", h.logOnWorktree) + r.Get("/diff", h.diffOnWorktree) }) } @@ -595,3 +598,63 @@ func parseLogN(r *http.Request) int { } return n } + +func (h *Handler) diffOnMain(w http.ResponseWriter, r *http.Request) { + h.diffGeneric(w, r, true) +} + +func (h *Handler) diffOnWorktree(w http.ResponseWriter, r *http.Request) { + h.diffGeneric(w, r, false) +} + +func (h *Handler) diffGeneric(w http.ResponseWriter, r *http.Request, onMain bool) { + c, err := h.caller(r) + if err != nil { + writeErr(w, r, err) + return + } + param := "wsID" + if !onMain { + param = "wtID" + } + id, err := parseUUID(chi.URLParam(r, param)) + if err != nil { + writeErr(w, r, err) + return + } + opts := parseDiffOptions(r) + var res git.DiffResult + if onMain { + res, err = h.gop.DiffOnMain(r.Context(), c, id, opts) + } else { + res, err = h.gop.DiffOnWorktree(r.Context(), c, id, opts) + } + if err != nil { + writeErr(w, r, err) + return + } + writeJSON(w, http.StatusOK, diffResp{Diff: res.Diff, Truncated: res.Truncated}) +} + +// parseDiffOptions reads the diff query params (ref, against, staged, paths, context). +func parseDiffOptions(r *http.Request) git.DiffOptions { + q := r.URL.Query() + opts := git.DiffOptions{ + Ref: q.Get("ref"), + Against: q.Get("against"), + Staged: q.Get("staged") == "true" || q.Get("staged") == "1", + } + if raw := q.Get("paths"); raw != "" { + for _, p := range strings.Split(raw, ",") { + if p = strings.TrimSpace(p); p != "" { + opts.Paths = append(opts.Paths, p) + } + } + } + if cl := q.Get("context"); cl != "" { + if n, err := strconv.Atoi(cl); err == nil && n >= 0 { + opts.ContextLines = n + } + } + return opts +} diff --git a/internal/workspace/hooks_test.go b/internal/workspace/hooks_test.go new file mode 100644 index 0000000..076348a --- /dev/null +++ b/internal/workspace/hooks_test.go @@ -0,0 +1,62 @@ +package workspace + +import ( + "context" + "testing" + + "github.com/google/uuid" + "github.com/stretchr/testify/require" +) + +func TestWorktreeCreate_FiresIndexHookOnce(t *testing.T) { + t.Parallel() + svc, _, _, _, wsID := newWtSvc(t) + + var calls int + var gotWS uuid.UUID + var gotWT *uuid.UUID + svc.SetIndexBuildHook(func(_ context.Context, w uuid.UUID, wt *uuid.UUID, _ string) error { + calls++ + gotWS = w + gotWT = wt + return nil + }) + + wt, err := svc.Create(context.Background(), Caller{UserID: uuid.New()}, wsID, CreateWorktreeInput{Branch: "feat-idx"}) + require.NoError(t, err) + require.Equal(t, 1, calls, "index hook must fire exactly once on worktree create") + require.Equal(t, wsID, gotWS) + require.NotNil(t, gotWT) + require.Equal(t, wt.ID, *gotWT) +} + +func TestWorktreeCreate_HookErrorDoesNotFailCreate(t *testing.T) { + t.Parallel() + svc, _, _, _, wsID := newWtSvc(t) + svc.SetIndexBuildHook(func(context.Context, uuid.UUID, *uuid.UUID, string) error { + return context.DeadlineExceeded // simulate enqueue failure + }) + _, err := svc.Create(context.Background(), Caller{UserID: uuid.New()}, wsID, CreateWorktreeInput{Branch: "feat-e"}) + require.NoError(t, err, "hook failure must not fail worktree create") +} + +func TestCommitOnMain_FiresSummaryHook(t *testing.T) { + t.Parallel() + svc, _, _, wsID := newGitOpsSvc(t) + + var calls int + var gotSHA, gotBranch string + var gotWT *uuid.UUID + svc.SetCommitSummaryHook(func(_ context.Context, _ uuid.UUID, wt *uuid.UUID, branch, sha string) error { + calls++ + gotBranch, gotSHA, gotWT = branch, sha, wt + return nil + }) + + sha, err := svc.CommitOnMain(context.Background(), Caller{UserID: uuid.New()}, wsID, CommitInput{Message: "m", AddAll: true}) + require.NoError(t, err) + require.Equal(t, 1, calls) + require.Equal(t, sha, gotSHA) + require.Equal(t, "main", gotBranch) + require.Nil(t, gotWT, "main commits carry no worktree id") +} diff --git a/internal/workspace/sqlc/credentials.sql.go b/internal/workspace/sqlc/credentials.sql.go index 51eb1aa..7e1aa19 100644 --- a/internal/workspace/sqlc/credentials.sql.go +++ b/internal/workspace/sqlc/credentials.sql.go @@ -12,7 +12,7 @@ import ( ) const getCredentialByWorkspace = `-- name: GetCredentialByWorkspace :one -SELECT id, workspace_id, kind, username, encrypted_secret, fingerprint, created_at, updated_at FROM git_credentials WHERE workspace_id = $1 +SELECT id, workspace_id, kind, username, encrypted_secret, fingerprint, created_at, updated_at, key_version FROM git_credentials WHERE workspace_id = $1 ` func (q *Queries) GetCredentialByWorkspace(ctx context.Context, workspaceID pgtype.UUID) (GitCredential, error) { @@ -27,6 +27,7 @@ func (q *Queries) GetCredentialByWorkspace(ctx context.Context, workspaceID pgty &i.Fingerprint, &i.CreatedAt, &i.UpdatedAt, + &i.KeyVersion, ) return i, err } @@ -40,7 +41,7 @@ ON CONFLICT (workspace_id) DO UPDATE encrypted_secret = EXCLUDED.encrypted_secret, fingerprint = EXCLUDED.fingerprint, updated_at = now() -RETURNING id, workspace_id, kind, username, encrypted_secret, fingerprint, created_at, updated_at +RETURNING id, workspace_id, kind, username, encrypted_secret, fingerprint, created_at, updated_at, key_version ` type UpsertCredentialParams struct { @@ -71,6 +72,7 @@ func (q *Queries) UpsertCredential(ctx context.Context, arg UpsertCredentialPara &i.Fingerprint, &i.CreatedAt, &i.UpdatedAt, + &i.KeyVersion, ) return i, err } diff --git a/internal/workspace/sqlc/models.go b/internal/workspace/sqlc/models.go index 0b39e58..a61958a 100644 --- a/internal/workspace/sqlc/models.go +++ b/internal/workspace/sqlc/models.go @@ -8,6 +8,7 @@ import ( "net/netip" "github.com/jackc/pgx/v5/pgtype" + "github.com/pgvector/pgvector-go" ) type AcpAgentKind struct { @@ -25,6 +26,11 @@ type AcpAgentKind struct { ToolAllowlist []string `json:"tool_allowlist"` ClientType string `json:"client_type"` EncryptedMcpServers []byte `json:"encrypted_mcp_servers"` + ModelID pgtype.UUID `json:"model_id"` + MaxCostUsd pgtype.Numeric `json:"max_cost_usd"` + MaxTokens *int64 `json:"max_tokens"` + MaxWallClockSeconds *int32 `json:"max_wall_clock_seconds"` + KeyVersion int16 `json:"key_version"` } type AcpAgentKindConfigFile struct { @@ -35,6 +41,7 @@ type AcpAgentKindConfigFile struct { UpdatedBy pgtype.UUID `json:"updated_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type AcpEvent struct { @@ -64,23 +71,64 @@ type AcpPermissionRequest struct { } type AcpSession struct { - ID pgtype.UUID `json:"id"` - WorkspaceID pgtype.UUID `json:"workspace_id"` - ProjectID pgtype.UUID `json:"project_id"` - AgentKindID pgtype.UUID `json:"agent_kind_id"` - UserID pgtype.UUID `json:"user_id"` - IssueID pgtype.UUID `json:"issue_id"` - RequirementID pgtype.UUID `json:"requirement_id"` - AgentSessionID *string `json:"agent_session_id"` - Branch string `json:"branch"` - CwdPath string `json:"cwd_path"` - IsMainWorktree bool `json:"is_main_worktree"` - Status string `json:"status"` - Pid *int32 `json:"pid"` - ExitCode *int32 `json:"exit_code"` - LastError *string `json:"last_error"` - StartedAt pgtype.Timestamptz `json:"started_at"` - EndedAt pgtype.Timestamptz `json:"ended_at"` + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + UserID pgtype.UUID `json:"user_id"` + IssueID pgtype.UUID `json:"issue_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + AgentSessionID *string `json:"agent_session_id"` + Branch string `json:"branch"` + CwdPath string `json:"cwd_path"` + IsMainWorktree bool `json:"is_main_worktree"` + Status string `json:"status"` + Pid *int32 `json:"pid"` + ExitCode *int32 `json:"exit_code"` + LastError *string `json:"last_error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` + LastStopReason *string `json:"last_stop_reason"` + OrchestratorStepID pgtype.UUID `json:"orchestrator_step_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + TotalCostUsd pgtype.Numeric `json:"total_cost_usd"` + LastActivityAt pgtype.Timestamptz `json:"last_activity_at"` + BudgetMaxCostUsd pgtype.Numeric `json:"budget_max_cost_usd"` + BudgetMaxTokens *int64 `json:"budget_max_tokens"` + BudgetMaxWallClockSeconds *int32 `json:"budget_max_wall_clock_seconds"` + TerminatedReason *string `json:"terminated_reason"` + SandboxMode *string `json:"sandbox_mode"` + CostUsd pgtype.Numeric `json:"cost_usd"` + TokensTotal int64 `json:"tokens_total"` +} + +type AcpSessionUsage struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + UserID pgtype.UUID `json:"user_id"` + ProjectID pgtype.UUID `json:"project_id"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + ModelID pgtype.UUID `json:"model_id"` + PromptTokens int64 `json:"prompt_tokens"` + CompletionTokens int64 `json:"completion_tokens"` + ThinkingTokens int64 `json:"thinking_tokens"` + CostUsd pgtype.Numeric `json:"cost_usd"` + SourceEventID *int64 `json:"source_event_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type AcpTurn struct { + ID int64 `json:"id"` + SessionID pgtype.UUID `json:"session_id"` + TurnIndex int32 `json:"turn_index"` + PromptRequestID *string `json:"prompt_request_id"` + Status string `json:"status"` + StopReason *string `json:"stop_reason"` + UpdateCount int32 `json:"update_count"` + StartedAt pgtype.Timestamptz `json:"started_at"` + CompletedAt pgtype.Timestamptz `json:"completed_at"` } type AuditLog struct { @@ -94,6 +142,81 @@ type AuditLog struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type ChangeRequest struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + IssueID pgtype.UUID `json:"issue_id"` + Number int32 `json:"number"` + Title string `json:"title"` + Description string `json:"description"` + SourceBranch string `json:"source_branch"` + TargetBranch string `json:"target_branch"` + State string `json:"state"` + ReviewVerdict string `json:"review_verdict"` + CiState string `json:"ci_state"` + Provider string `json:"provider"` + ExternalID *int64 `json:"external_id"` + ExternalUrl string `json:"external_url"` + MergeCommitSha string `json:"merge_commit_sha"` + ReviewedBy pgtype.UUID `json:"reviewed_by"` + ReviewedAt pgtype.Timestamptz `json:"reviewed_at"` + CreatedBy pgtype.UUID `json:"created_by"` + MergedAt pgtype.Timestamptz `json:"merged_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type CodeChunk struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + CommitSha string `json:"commit_sha"` + FilePath string `json:"file_path"` + Lang *string `json:"lang"` + StartLine int32 `json:"start_line"` + EndLine int32 `json:"end_line"` + Content string `json:"content"` + ContentSha []byte `json:"content_sha"` + TokenCount *int32 `json:"token_count"` + Embedding *pgvector.Vector `json:"embedding"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CodeIndexRun struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Status string `json:"status"` + EmbeddingEndpointID pgtype.UUID `json:"embedding_endpoint_id"` + EmbeddingModel string `json:"embedding_model"` + Dims int32 `json:"dims"` + FilesIndexed int32 `json:"files_indexed"` + ChunksIndexed int32 `json:"chunks_indexed"` + Error *string `json:"error"` + StartedAt pgtype.Timestamptz `json:"started_at"` + FinishedAt pgtype.Timestamptz `json:"finished_at"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + +type CommitSummary struct { + ID pgtype.UUID `json:"id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + WorktreeID pgtype.UUID `json:"worktree_id"` + Branch string `json:"branch"` + CommitSha string `json:"commit_sha"` + Kind string `json:"kind"` + Title *string `json:"title"` + BodyMd string `json:"body_md"` + Model *string `json:"model"` + Status string `json:"status"` + Error *string `json:"error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type Conversation struct { ID pgtype.UUID `json:"id"` UserID pgtype.UUID `json:"user_id"` @@ -110,6 +233,14 @@ type Conversation struct { RequirementID pgtype.UUID `json:"requirement_id"` } +type CryptoKey struct { + Version int32 `json:"version"` + Provider string `json:"provider"` + KeyRef *string `json:"key_ref"` + Status string `json:"status"` + CreatedAt pgtype.Timestamptz `json:"created_at"` +} + type GitCredential struct { ID pgtype.UUID `json:"id"` WorkspaceID pgtype.UUID `json:"workspace_id"` @@ -119,6 +250,7 @@ type GitCredential struct { Fingerprint *string `json:"fingerprint"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type Issue struct { @@ -135,6 +267,17 @@ type Issue struct { CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` WorkspaceID pgtype.UUID `json:"workspace_id"` + ParentID pgtype.UUID `json:"parent_id"` + Priority int32 `json:"priority"` +} + +type IssueDependency struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + BlockedID pgtype.UUID `json:"blocked_id"` + BlockerID pgtype.UUID `json:"blocker_id"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` } type Job struct { @@ -162,6 +305,7 @@ type LlmEndpoint struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type LlmModel struct { @@ -252,6 +396,50 @@ type Notification struct { CreatedAt pgtype.Timestamptz `json:"created_at"` } +type NotificationPref struct { + UserID pgtype.UUID `json:"user_id"` + EmailEnabled bool `json:"email_enabled"` + ImEnabled bool `json:"im_enabled"` + ImWebhookUrlEnc []byte `json:"im_webhook_url_enc"` + MinSeverity string `json:"min_severity"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + +type OrchestratorRun struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + RequirementID pgtype.UUID `json:"requirement_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + OwnerID pgtype.UUID `json:"owner_id"` + Status string `json:"status"` + CurrentPhase string `json:"current_phase"` + Config []byte `json:"config"` + LastError *string `json:"last_error"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + +type OrchestratorStep struct { + ID pgtype.UUID `json:"id"` + RunID pgtype.UUID `json:"run_id"` + Phase string `json:"phase"` + Attempt int32 `json:"attempt"` + ParentStepID pgtype.UUID `json:"parent_step_id"` + Depth int32 `json:"depth"` + Status string `json:"status"` + AgentKindID pgtype.UUID `json:"agent_kind_id"` + AcpSessionID pgtype.UUID `json:"acp_session_id"` + Prompt string `json:"prompt"` + StopReason *string `json:"stop_reason"` + GateResult []byte `json:"gate_result"` + LastError *string `json:"last_error"` + JobID pgtype.UUID `json:"job_id"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` + EndedAt pgtype.Timestamptz `json:"ended_at"` +} + type Project struct { ID pgtype.UUID `json:"id"` Slug string `json:"slug"` @@ -264,6 +452,30 @@ type Project struct { UpdatedAt pgtype.Timestamptz `json:"updated_at"` } +type ProjectMember struct { + ProjectID pgtype.UUID `json:"project_id"` + UserID pgtype.UUID `json:"user_id"` + Role string `json:"role"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + AddedBy pgtype.UUID `json:"added_by"` +} + +type ProjectMemory struct { + ID pgtype.UUID `json:"id"` + ProjectID pgtype.UUID `json:"project_id"` + WorkspaceID pgtype.UUID `json:"workspace_id"` + Kind string `json:"kind"` + Title string `json:"title"` + Body string `json:"body"` + Tags []string `json:"tags"` + Source string `json:"source"` + Embedding *pgvector.Vector `json:"embedding"` + EmbeddingModel *string `json:"embedding_model"` + CreatedBy pgtype.UUID `json:"created_by"` + CreatedAt pgtype.Timestamptz `json:"created_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` +} + type PromptTemplate struct { ID pgtype.UUID `json:"id"` Name string `json:"name"` @@ -299,6 +511,16 @@ type RequirementArtifact struct { SourceMessageID *int64 `json:"source_message_id"` CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` + Verdict string `json:"verdict"` +} + +type RequirementPhasePointer struct { + RequirementID pgtype.UUID `json:"requirement_id"` + Phase string `json:"phase"` + ApprovedArtifactID pgtype.UUID `json:"approved_artifact_id"` + ApprovedBy pgtype.UUID `json:"approved_by"` + ApprovedAt pgtype.Timestamptz `json:"approved_at"` + UpdatedAt pgtype.Timestamptz `json:"updated_at"` } type User struct { @@ -354,6 +576,7 @@ type WorkspaceRunProfile struct { CreatedBy pgtype.UUID `json:"created_by"` CreatedAt pgtype.Timestamptz `json:"created_at"` UpdatedAt pgtype.Timestamptz `json:"updated_at"` + KeyVersion int16 `json:"key_version"` } type WorkspaceWorktree struct { diff --git a/internal/workspace/workspace_service.go b/internal/workspace/workspace_service.go index 5314aa0..1687d82 100644 --- a/internal/workspace/workspace_service.go +++ b/internal/workspace/workspace_service.go @@ -42,8 +42,13 @@ type workspaceService struct { clones CloneScheduler dataDir string fetchTimout time.Duration + indexHook IndexBuildHook } +// SetIndexBuildHook injects the code-index enqueue callback (app wiring). On Sync +// the workspace marks prior runs stale and enqueues a fresh build at new HEAD. +func (s *workspaceService) SetIndexBuildHook(h IndexBuildHook) { s.indexHook = h } + // NewWorkspaceService 构造 WorkspaceService 实现。 func NewWorkspaceService( repo Repository, @@ -281,6 +286,13 @@ func (s *workspaceService) Sync(ctx context.Context, c Caller, wsID uuid.UUID) ( return nil, err } s.audit(writeCtx, &c.UserID, "workspace.sync", "workspace", wsID.String(), nil) + // Best-effort: re-index the main worktree at the new HEAD. The hook marks + // prior runs stale then enqueues a build (coalesced on commit). Never blocks. + if s.indexHook != nil { + if herr := s.indexHook(writeCtx, wsID, nil, ""); herr != nil { + s.audit(writeCtx, &c.UserID, "workspace.index_enqueue_failed", "workspace", wsID.String(), map[string]any{"err": herr.Error()}) + } + } return updated, nil } diff --git a/internal/workspace/workspace_service_test.go b/internal/workspace/workspace_service_test.go index 209a4ae..d89b003 100644 --- a/internal/workspace/workspace_service_test.go +++ b/internal/workspace/workspace_service_test.go @@ -238,6 +238,11 @@ func (f *fakeGit) ListRemoteBranches(_ context.Context, _ string, _ *git.Credent return nil, nil } func (f *fakeGit) Log(_ context.Context, _ string, _ int) ([]git.Commit, error) { return nil, nil } +func (f *fakeGit) Diff(_ context.Context, _ string, _ git.DiffOptions) (git.DiffResult, error) { + return git.DiffResult{}, nil +} +func (f *fakeGit) ListTrackedFiles(_ context.Context, _ string) ([]string, error) { return nil, nil } +func (f *fakeGit) DiffStat(_ context.Context, _, _ string) (string, error) { return "", nil } // trackGit 包装 git.Runner,记录 FetchRemoteBranch / Checkout 调用以供断言。 // 嵌入 Runner 接口本身可避免为每个方法重复转发。 diff --git a/internal/workspace/worktree_service.go b/internal/workspace/worktree_service.go index 4b22486..19dca03 100644 --- a/internal/workspace/worktree_service.go +++ b/internal/workspace/worktree_service.go @@ -12,12 +12,18 @@ import ( "github.com/yan1h/agent-coding-workflow/internal/infra/git" ) +// IndexBuildHook is a best-effort callback fired after a worktree is created (or +// a workspace is synced) to enqueue a code-index build. Injected from app.go via +// SetIndexBuildHook to avoid a workspace→codeindex import cycle. May be nil. +type IndexBuildHook func(ctx context.Context, wsID uuid.UUID, worktreeID *uuid.UUID, commitSHA string) error + type worktreeService struct { - repo Repository - rec audit.Recorder - gitr git.Runner - pa ProjectAccess - dataDir string + repo Repository + rec audit.Recorder + gitr git.Runner + pa ProjectAccess + dataDir string + indexHook IndexBuildHook } // NewWorktreeService 构造 WorktreeService。 @@ -25,6 +31,10 @@ func NewWorktreeService(repo Repository, rec audit.Recorder, gitr git.Runner, pa return &worktreeService{repo: repo, rec: rec, gitr: gitr, pa: pa, dataDir: dataDir} } +// SetIndexBuildHook injects the code-index enqueue callback (app wiring). Safe to +// call once at construction; nil disables index builds on worktree create. +func (s *worktreeService) SetIndexBuildHook(h IndexBuildHook) { s.indexHook = h } + func (s *worktreeService) Create(ctx context.Context, c Caller, wsID uuid.UUID, in CreateWorktreeInput) (*Worktree, error) { ws, err := s.repo.GetWorkspaceByID(ctx, wsID) if err != nil { @@ -71,6 +81,14 @@ func (s *worktreeService) Create(ctx context.Context, c Caller, wsID uuid.UUID, return nil, err } s.audit(ctx, &c.UserID, "worktree.create", "worktree", saved.ID.String(), map[string]any{"branch": in.Branch}) + // Best-effort: enqueue a code-index build for the new worktree at its HEAD. + // Failures are logged via audit but never block worktree creation. + if s.indexHook != nil { + wtID := saved.ID + if herr := s.indexHook(ctx, wsID, &wtID, ""); herr != nil { + s.audit(ctx, &c.UserID, "worktree.index_enqueue_failed", "worktree", saved.ID.String(), map[string]any{"err": herr.Error()}) + } + } return saved, nil } diff --git a/migrations/0016_acp_turns.down.sql b/migrations/0016_acp_turns.down.sql new file mode 100644 index 0000000..a9bda70 --- /dev/null +++ b/migrations/0016_acp_turns.down.sql @@ -0,0 +1,2 @@ +DROP TABLE IF EXISTS acp_turns; +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS last_stop_reason; diff --git a/migrations/0016_acp_turns.up.sql b/migrations/0016_acp_turns.up.sql new file mode 100644 index 0000000..9d1b5e0 --- /dev/null +++ b/migrations/0016_acp_turns.up.sql @@ -0,0 +1,19 @@ +-- 回合完成检测:per-turn 审计记录 + session 上记录最后一次 stop reason。 +-- last_stop_reason 不加 CHECK:agent 可能返回 vendor-specific 值,由 Go 层归一化但原值落库。 +ALTER TABLE acp_sessions ADD COLUMN last_stop_reason TEXT; + +CREATE TABLE acp_turns ( + id BIGSERIAL PRIMARY KEY, + session_id UUID NOT NULL REFERENCES acp_sessions(id) ON DELETE CASCADE, + turn_index INT NOT NULL, + prompt_request_id TEXT, + status TEXT NOT NULL, + stop_reason TEXT, + update_count INT NOT NULL DEFAULT 0, + started_at TIMESTAMPTZ NOT NULL DEFAULT now(), + completed_at TIMESTAMPTZ, + CONSTRAINT acp_turns_status_check CHECK (status IN ('in_progress','completed','aborted')), + CONSTRAINT acp_turns_session_index_unique UNIQUE (session_id, turn_index) +); +CREATE INDEX idx_acp_turns_session ON acp_turns(session_id, turn_index); +CREATE INDEX idx_acp_turns_created ON acp_turns(started_at); diff --git a/migrations/0017_change_requests.down.sql b/migrations/0017_change_requests.down.sql new file mode 100644 index 0000000..4cc748c --- /dev/null +++ b/migrations/0017_change_requests.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_change_requests_open; +DROP INDEX IF EXISTS idx_change_requests_workspace; +DROP TABLE IF EXISTS change_requests; diff --git a/migrations/0017_change_requests.up.sql b/migrations/0017_change_requests.up.sql new file mode 100644 index 0000000..620db16 --- /dev/null +++ b/migrations/0017_change_requests.up.sql @@ -0,0 +1,37 @@ +-- change_requests: one row links project/workspace/(requirement|issue)/source+target +-- branch with a review verdict + CI state + the external (Gitea) PR id. This is +-- the server-side merge gate: an agent opens a reviewable PR; a human approves +-- via the web before merge_pull_request can succeed. +-- +-- number is a per-project sequence (max(number)+1 within project_id), mirroring +-- requirements/issues. Columns are appended in physical order; sqlc SELECT/RETURNING +-- column order must match this order (sqlc 列序约定,见 migrations/0012 comment)。 +CREATE TABLE change_requests ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + requirement_id UUID REFERENCES requirements(id) ON DELETE SET NULL, + issue_id UUID REFERENCES issues(id) ON DELETE SET NULL, + number INT NOT NULL, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + source_branch TEXT NOT NULL, + target_branch TEXT NOT NULL, + state TEXT NOT NULL DEFAULT 'open' CHECK (state IN ('open','merged','closed')), + review_verdict TEXT NOT NULL DEFAULT 'pending' CHECK (review_verdict IN ('pending','approved','changes_requested','rejected')), + ci_state TEXT NOT NULL DEFAULT 'unknown' CHECK (ci_state IN ('unknown','pending','success','failure')), + provider TEXT NOT NULL DEFAULT 'gitea', + external_id BIGINT, + external_url TEXT NOT NULL DEFAULT '', + merge_commit_sha TEXT NOT NULL DEFAULT '', + reviewed_by UUID REFERENCES users(id) ON DELETE SET NULL, + reviewed_at TIMESTAMPTZ, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + merged_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (project_id, number) +); + +CREATE INDEX idx_change_requests_workspace ON change_requests(workspace_id); +CREATE INDEX idx_change_requests_open ON change_requests(workspace_id) WHERE state = 'open'; diff --git a/migrations/0018_phase_gates.down.sql b/migrations/0018_phase_gates.down.sql new file mode 100644 index 0000000..228beda --- /dev/null +++ b/migrations/0018_phase_gates.down.sql @@ -0,0 +1,10 @@ +-- 回滚 0018:丢弃指针表与 verdict 列,恢复三阶段 CHECK。 +-- implementing/reviewing 产物在回滚时若存在会违反旧 CHECK,先删除它们(旧 schema 无处安放)。 +DROP TABLE IF EXISTS requirement_phase_pointers; + +ALTER TABLE requirement_artifacts DROP COLUMN IF EXISTS verdict; + +DELETE FROM requirement_artifacts WHERE phase IN ('implementing','reviewing'); +ALTER TABLE requirement_artifacts DROP CONSTRAINT requirement_artifacts_phase_check; +ALTER TABLE requirement_artifacts ADD CONSTRAINT requirement_artifacts_phase_check + CHECK (phase IN ('planning','prototyping','auditing')); diff --git a/migrations/0018_phase_gates.up.sql b/migrations/0018_phase_gates.up.sql new file mode 100644 index 0000000..6ae140d --- /dev/null +++ b/migrations/0018_phase_gates.up.sql @@ -0,0 +1,24 @@ +-- Phase state machine gates: extend artifact phases to 5, add per-version +-- verdict, and a per-(requirement,phase) approved/current artifact pointer. + +-- (1) 扩展产物阶段 CHECK,纳入 implementing/reviewing(审计报告/评审记录也是文档型产物)。 +ALTER TABLE requirement_artifacts DROP CONSTRAINT requirement_artifacts_phase_check; +ALTER TABLE requirement_artifacts ADD CONSTRAINT requirement_artifacts_phase_check + CHECK (phase IN ('planning','prototyping','auditing','implementing','reviewing')); + +-- (2) 每版本评审结论。default 'none' 让现存行全部合法(无需数据迁移)。 +-- 新列放在表末尾,满足 sqlc 列序约定(见 0012 注释)。 +ALTER TABLE requirement_artifacts ADD COLUMN verdict TEXT NOT NULL DEFAULT 'none' + CHECK (verdict IN ('none','pass','fail')); + +-- (3) 每 (requirement, phase) 的"已批准/当前"产物指针。仅在首次批准后才有行。 +CREATE TABLE requirement_phase_pointers ( + requirement_id UUID NOT NULL REFERENCES requirements(id) ON DELETE CASCADE, + phase TEXT NOT NULL, + approved_artifact_id UUID REFERENCES requirement_artifacts(id) ON DELETE SET NULL, + approved_by UUID REFERENCES users(id) ON DELETE SET NULL, + approved_at TIMESTAMPTZ, + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (requirement_id, phase) +); +CREATE INDEX idx_req_phase_pointers_artifact ON requirement_phase_pointers(approved_artifact_id); diff --git a/migrations/0019_orchestrator.down.sql b/migrations/0019_orchestrator.down.sql new file mode 100644 index 0000000..0b2a964 --- /dev/null +++ b/migrations/0019_orchestrator.down.sql @@ -0,0 +1,5 @@ +DROP INDEX IF EXISTS idx_acp_sessions_orch_step; +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS orchestrator_step_id; + +DROP TABLE IF EXISTS orchestrator_steps; +DROP TABLE IF EXISTS orchestrator_runs; diff --git a/migrations/0019_orchestrator.up.sql b/migrations/0019_orchestrator.up.sql new file mode 100644 index 0000000..7b9c1b6 --- /dev/null +++ b/migrations/0019_orchestrator.up.sql @@ -0,0 +1,72 @@ +-- 编排器/规划器:per-requirement run 聚合根,驱动 6 阶段状态机。每个 step 由 +-- jobs (orchestrator.step) 驱动,可在重启后恢复。owner_id 是"特权但真实"的身份, +-- 用于创建所有子 session(不放松 checkNotSystemToken)。 +-- +-- phase CHECK 复用 requirements 的六阶段集合(planning/prototyping/auditing/ +-- implementing/reviewing/done),与 internal/project Phase 枚举一致。 + +CREATE TABLE orchestrator_runs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + requirement_id UUID NOT NULL REFERENCES requirements(id) ON DELETE CASCADE, + workspace_id UUID NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + owner_id UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + status TEXT NOT NULL, + current_phase TEXT NOT NULL, + config JSONB NOT NULL DEFAULT '{}', + last_error TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ, + CONSTRAINT orchestrator_runs_status_check + CHECK (status IN ('pending','running','paused','succeeded','failed','canceled')), + CONSTRAINT orchestrator_runs_phase_check + CHECK (current_phase IN ('planning','prototyping','auditing','implementing','reviewing','done')) +); + +-- 每个 requirement 同一时间只允许一个活跃 run。 +CREATE UNIQUE INDEX uq_orch_runs_active_requirement + ON orchestrator_runs(requirement_id) + WHERE status IN ('pending','running','paused'); + +CREATE INDEX idx_orch_runs_status ON orchestrator_runs(status); + +CREATE TABLE orchestrator_steps ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + run_id UUID NOT NULL REFERENCES orchestrator_runs(id) ON DELETE CASCADE, + phase TEXT NOT NULL, + attempt INT NOT NULL DEFAULT 1, + parent_step_id UUID REFERENCES orchestrator_steps(id) ON DELETE SET NULL, + depth INT NOT NULL DEFAULT 0, + status TEXT NOT NULL, + agent_kind_id UUID NOT NULL REFERENCES acp_agent_kinds(id) ON DELETE RESTRICT, + acp_session_id UUID REFERENCES acp_sessions(id) ON DELETE SET NULL, + prompt TEXT NOT NULL, + stop_reason TEXT, + gate_result JSONB, + last_error TEXT, + job_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + ended_at TIMESTAMPTZ, + CONSTRAINT orchestrator_steps_status_check + CHECK (status IN ('pending','spawning','running','awaiting_gate','succeeded','failed','dead')), + CONSTRAINT orchestrator_steps_phase_check + CHECK (phase IN ('planning','prototyping','auditing','implementing','reviewing','done')) +); + +CREATE INDEX idx_orch_steps_run ON orchestrator_steps(run_id, created_at); +CREATE INDEX idx_orch_steps_session ON orchestrator_steps(acp_session_id) + WHERE acp_session_id IS NOT NULL; +CREATE INDEX idx_orch_steps_active ON orchestrator_steps(status) + WHERE status IN ('spawning','running','awaiting_gate'); +CREATE INDEX idx_orch_steps_parent ON orchestrator_steps(parent_step_id) + WHERE parent_step_id IS NOT NULL; + +-- 反向链接:让 ACP supervisor 的 onExit / 启动 reaper 能把崩溃 session 映射回 step。 +-- manual session 留 NULL。 +ALTER TABLE acp_sessions + ADD COLUMN orchestrator_step_id UUID REFERENCES orchestrator_steps(id) ON DELETE SET NULL; + +CREATE INDEX idx_acp_sessions_orch_step ON acp_sessions(orchestrator_step_id) + WHERE orchestrator_step_id IS NOT NULL; diff --git a/migrations/0020_acp_cost_budgets.down.sql b/migrations/0020_acp_cost_budgets.down.sql new file mode 100644 index 0000000..62f6afa --- /dev/null +++ b/migrations/0020_acp_cost_budgets.down.sql @@ -0,0 +1,20 @@ +-- Reverse of 0020_acp_cost_budgets.up.sql (drop in reverse order). + +DROP TABLE IF EXISTS acp_session_usage; + +DROP INDEX IF EXISTS idx_acp_sessions_active_activity; + +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS terminated_reason; +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS budget_max_wall_clock_seconds; +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS budget_max_tokens; +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS budget_max_cost_usd; +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS last_activity_at; +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS total_cost_usd; +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS thinking_tokens; +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS completion_tokens; +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS prompt_tokens; + +ALTER TABLE acp_agent_kinds DROP COLUMN IF EXISTS max_wall_clock_seconds; +ALTER TABLE acp_agent_kinds DROP COLUMN IF EXISTS max_tokens; +ALTER TABLE acp_agent_kinds DROP COLUMN IF EXISTS max_cost_usd; +ALTER TABLE acp_agent_kinds DROP COLUMN IF EXISTS model_id; diff --git a/migrations/0020_acp_cost_budgets.up.sql b/migrations/0020_acp_cost_budgets.up.sql new file mode 100644 index 0000000..5e8be4c --- /dev/null +++ b/migrations/0020_acp_cost_budgets.up.sql @@ -0,0 +1,49 @@ +-- ACP session cost accounting + budgets + reaper inputs. +-- Column-order rule: ALTER ADD COLUMN appends at the table end, so every +-- SELECT/RETURNING that reuses the table model must list the new columns LAST. + +-- ============ AgentKind: model price link + default budget caps ============ +ALTER TABLE acp_agent_kinds ADD COLUMN model_id UUID REFERENCES llm_models(id) ON DELETE SET NULL; +ALTER TABLE acp_agent_kinds ADD COLUMN max_cost_usd NUMERIC(12,6); +ALTER TABLE acp_agent_kinds ADD COLUMN max_tokens BIGINT; +ALTER TABLE acp_agent_kinds ADD COLUMN max_wall_clock_seconds INT; + +-- ============ Sessions: running accumulators + budget snapshot + reaper inputs ============ +ALTER TABLE acp_sessions ADD COLUMN prompt_tokens BIGINT NOT NULL DEFAULT 0; +ALTER TABLE acp_sessions ADD COLUMN completion_tokens BIGINT NOT NULL DEFAULT 0; +ALTER TABLE acp_sessions ADD COLUMN thinking_tokens BIGINT NOT NULL DEFAULT 0; +ALTER TABLE acp_sessions ADD COLUMN total_cost_usd NUMERIC(14,6) NOT NULL DEFAULT 0; +ALTER TABLE acp_sessions ADD COLUMN last_activity_at TIMESTAMPTZ NOT NULL DEFAULT now(); +ALTER TABLE acp_sessions ADD COLUMN budget_max_cost_usd NUMERIC(12,6); +ALTER TABLE acp_sessions ADD COLUMN budget_max_tokens BIGINT; +ALTER TABLE acp_sessions ADD COLUMN budget_max_wall_clock_seconds INT; +ALTER TABLE acp_sessions ADD COLUMN terminated_reason TEXT; + +-- Index for the reaper to scan active sessions by last activity cheaply. +CREATE INDEX idx_acp_sessions_active_activity + ON acp_sessions(last_activity_at) + WHERE status IN ('starting','running'); + +-- ============ Per-turn usage ledger (mirrors llm_usage) ============ +CREATE TABLE acp_session_usage ( + id BIGSERIAL PRIMARY KEY, + session_id UUID NOT NULL REFERENCES acp_sessions(id) ON DELETE CASCADE, + user_id UUID NOT NULL, + project_id UUID NOT NULL, + agent_kind_id UUID NOT NULL, + model_id UUID, + prompt_tokens BIGINT NOT NULL DEFAULT 0, + completion_tokens BIGINT NOT NULL DEFAULT 0, + thinking_tokens BIGINT NOT NULL DEFAULT 0, + cost_usd NUMERIC(14,6) NOT NULL DEFAULT 0, + source_event_id BIGINT REFERENCES acp_events(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX idx_acp_session_usage_session ON acp_session_usage(session_id); +CREATE INDEX idx_acp_session_usage_created ON acp_session_usage(created_at); +CREATE INDEX idx_acp_session_usage_user_created ON acp_session_usage(user_id, created_at); +CREATE INDEX idx_acp_session_usage_project_created ON acp_session_usage(project_id, created_at); +-- De-dup: a given source event must produce at most one ledger row. +CREATE UNIQUE INDEX uq_acp_session_usage_source_event + ON acp_session_usage(source_event_id) + WHERE source_event_id IS NOT NULL; diff --git a/migrations/0021_secret_key_versioning.down.sql b/migrations/0021_secret_key_versioning.down.sql new file mode 100644 index 0000000..807610e --- /dev/null +++ b/migrations/0021_secret_key_versioning.down.sql @@ -0,0 +1,16 @@ +-- Reverse 0021_secret_key_versioning. + +DROP INDEX IF EXISTS llm_endpoints_keyver_idx; +DROP INDEX IF EXISTS git_credentials_keyver_idx; +DROP INDEX IF EXISTS workspace_run_profiles_keyver_idx; +DROP INDEX IF EXISTS acp_agent_kind_config_files_keyver_idx; +DROP INDEX IF EXISTS acp_agent_kinds_keyver_idx; + +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS sandbox_mode; +ALTER TABLE llm_endpoints DROP COLUMN IF EXISTS key_version; +ALTER TABLE git_credentials DROP COLUMN IF EXISTS key_version; +ALTER TABLE workspace_run_profiles DROP COLUMN IF EXISTS key_version; +ALTER TABLE acp_agent_kind_config_files DROP COLUMN IF EXISTS key_version; +ALTER TABLE acp_agent_kinds DROP COLUMN IF EXISTS key_version; + +DROP TABLE IF EXISTS crypto_keys; diff --git a/migrations/0021_secret_key_versioning.up.sql b/migrations/0021_secret_key_versioning.up.sql new file mode 100644 index 0000000..ab18be7 --- /dev/null +++ b/migrations/0021_secret_key_versioning.up.sql @@ -0,0 +1,44 @@ +-- ============ Secret key versioning (master-key rotation) ============ +-- crypto_keys tracks WHICH key versions exist and which one is the current +-- write ("active") key. The key material itself stays in env/KMS — only the +-- version number lives in the DB, both here and in each encrypted row's +-- key_version column. This lets master-key rotation re-encrypt blobs version by +-- version without a magic ciphertext prefix. +CREATE TABLE crypto_keys ( + version INT PRIMARY KEY, + provider TEXT NOT NULL DEFAULT 'env', + key_ref TEXT, + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('active', 'retiring', 'retired')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +); + +-- Seed the historical single key as version 1, active. +INSERT INTO crypto_keys (version, provider, status) VALUES (1, 'env', 'active'); + +-- Per-row key_version on every encrypted-blob owner. DEFAULT 1 backfills every +-- existing row to the historical key. The re-encrypt job finds rows on an old +-- version and Decrypt selects the right key by this column. +ALTER TABLE acp_agent_kinds ADD COLUMN key_version SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE acp_agent_kind_config_files ADD COLUMN key_version SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE workspace_run_profiles ADD COLUMN key_version SMALLINT NOT NULL DEFAULT 1; +-- 注意:git 凭据密文在 git_credentials.encrypted_secret,不在 workspaces(后者无密文列)。 +ALTER TABLE git_credentials ADD COLUMN key_version SMALLINT NOT NULL DEFAULT 1; +ALTER TABLE llm_endpoints ADD COLUMN key_version SMALLINT NOT NULL DEFAULT 1; + +-- Partial indexes to make the re-encrypt scan (WHERE key_version < active AND +-- blob IS NOT NULL) cheap. Only index rows that actually carry ciphertext. +CREATE INDEX acp_agent_kinds_keyver_idx ON acp_agent_kinds (key_version) + WHERE encrypted_env IS NOT NULL OR encrypted_mcp_servers IS NOT NULL; +CREATE INDEX acp_agent_kind_config_files_keyver_idx ON acp_agent_kind_config_files (key_version) + WHERE encrypted_content IS NOT NULL; +CREATE INDEX workspace_run_profiles_keyver_idx ON workspace_run_profiles (key_version) + WHERE encrypted_env IS NOT NULL; +CREATE INDEX git_credentials_keyver_idx ON git_credentials (key_version) + WHERE encrypted_secret IS NOT NULL; +CREATE INDEX llm_endpoints_keyver_idx ON llm_endpoints (key_version) + WHERE api_key_encrypted IS NOT NULL; + +-- Record which sandbox mode a session ran under (audit/forensics). Cheap and +-- recommended by the spec; NULL for sessions that predate this column. +ALTER TABLE acp_sessions ADD COLUMN sandbox_mode TEXT; diff --git a/migrations/0022_pgvector_code_index.down.sql b/migrations/0022_pgvector_code_index.down.sql new file mode 100644 index 0000000..2e0ce3b --- /dev/null +++ b/migrations/0022_pgvector_code_index.down.sql @@ -0,0 +1,5 @@ +DROP TABLE IF EXISTS code_chunks; +DROP TABLE IF EXISTS code_index_runs; +-- Only this migration created the extension; drop it on full rollback. Guarded +-- so it is a no-op if a later feature already depends on it. +DROP EXTENSION IF EXISTS vector; diff --git a/migrations/0022_pgvector_code_index.up.sql b/migrations/0022_pgvector_code_index.up.sql new file mode 100644 index 0000000..87b769b --- /dev/null +++ b/migrations/0022_pgvector_code_index.up.sql @@ -0,0 +1,49 @@ +-- Code index (pgvector RAG). Guarded CREATE EXTENSION so deployments without +-- superuser/owner privileges or without the pgvector image fail loudly here +-- (and the code degrades to keyword fallback when no embedding endpoint is set). +CREATE EXTENSION IF NOT EXISTS vector; + +-- code_index_runs tracks one (workspace, commit, embedding_model) build attempt. +CREATE TABLE code_index_runs ( + id uuid PRIMARY KEY, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + worktree_id uuid NULL REFERENCES workspace_worktrees(id) ON DELETE SET NULL, + branch text NOT NULL, + commit_sha text NOT NULL, + status text NOT NULL DEFAULT 'pending' + CHECK (status IN ('pending','running','completed','failed','stale')), + embedding_endpoint_id uuid REFERENCES llm_endpoints(id), + embedding_model text NOT NULL, + dims int NOT NULL, + files_indexed int NOT NULL DEFAULT 0, + chunks_indexed int NOT NULL DEFAULT 0, + error text, + started_at timestamptz, + finished_at timestamptz, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (workspace_id, commit_sha, embedding_model) +); + +CREATE INDEX idx_code_index_runs_ws_status ON code_index_runs (workspace_id, status); + +-- code_chunks stores embedded line-window chunks. The vector dimension is fixed +-- at migration time; one embedding model per deployment is enforced via config. +CREATE TABLE code_chunks ( + id uuid PRIMARY KEY, + run_id uuid NOT NULL REFERENCES code_index_runs(id) ON DELETE CASCADE, + workspace_id uuid NOT NULL, + commit_sha text NOT NULL, + file_path text NOT NULL, + lang text, + start_line int NOT NULL, + end_line int NOT NULL, + content text NOT NULL, + content_sha bytea NOT NULL, + token_count int, + embedding vector(1536) NOT NULL, + created_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_code_chunks_embedding ON code_chunks USING hnsw (embedding vector_cosine_ops); +CREATE INDEX idx_code_chunks_ws_commit ON code_chunks (workspace_id, commit_sha); +CREATE INDEX idx_code_chunks_file ON code_chunks (workspace_id, file_path); diff --git a/migrations/0023_project_memory.down.sql b/migrations/0023_project_memory.down.sql new file mode 100644 index 0000000..526ec09 --- /dev/null +++ b/migrations/0023_project_memory.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_memory; diff --git a/migrations/0023_project_memory.up.sql b/migrations/0023_project_memory.up.sql new file mode 100644 index 0000000..641e969 --- /dev/null +++ b/migrations/0023_project_memory.up.sql @@ -0,0 +1,21 @@ +-- project_memory: durable, typed project knowledge with optional embeddings. +CREATE TABLE project_memory ( + id uuid PRIMARY KEY, + project_id uuid NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + workspace_id uuid NULL REFERENCES workspaces(id) ON DELETE CASCADE, + kind text NOT NULL CHECK (kind IN ('decision','convention','file_map','gotcha')), + title text NOT NULL, + body text NOT NULL, + tags text[] NOT NULL DEFAULT '{}', + source text NOT NULL DEFAULT 'agent' CHECK (source IN ('agent','user','auto')), + embedding vector(1536) NULL, + embedding_model text NULL, + created_by uuid REFERENCES users(id), + created_at timestamptz NOT NULL DEFAULT now(), + updated_at timestamptz NOT NULL DEFAULT now() +); + +CREATE INDEX idx_project_memory_proj ON project_memory (project_id, kind); +CREATE INDEX idx_project_memory_embedding ON project_memory USING hnsw (embedding vector_cosine_ops) + WHERE embedding IS NOT NULL; +CREATE INDEX idx_project_memory_tags ON project_memory USING gin (tags); diff --git a/migrations/0024_doc_summaries.down.sql b/migrations/0024_doc_summaries.down.sql new file mode 100644 index 0000000..9a34272 --- /dev/null +++ b/migrations/0024_doc_summaries.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS commit_summaries; diff --git a/migrations/0024_doc_summaries.up.sql b/migrations/0024_doc_summaries.up.sql new file mode 100644 index 0000000..523d761 --- /dev/null +++ b/migrations/0024_doc_summaries.up.sql @@ -0,0 +1,18 @@ +-- commit_summaries: auto-generated commit / PR markdown summaries. +CREATE TABLE commit_summaries ( + id uuid PRIMARY KEY, + workspace_id uuid NOT NULL REFERENCES workspaces(id) ON DELETE CASCADE, + worktree_id uuid NULL, + branch text NOT NULL, + commit_sha text NOT NULL, + kind text NOT NULL DEFAULT 'commit' CHECK (kind IN ('commit','pr')), + title text, + body_md text NOT NULL, + model text, + status text NOT NULL DEFAULT 'pending' CHECK (status IN ('pending','completed','failed')), + error text, + created_at timestamptz NOT NULL DEFAULT now(), + UNIQUE (workspace_id, commit_sha, kind) +); + +CREATE INDEX idx_commit_summaries_ws ON commit_summaries (workspace_id, commit_sha); diff --git a/migrations/0025_issue_graph.down.sql b/migrations/0025_issue_graph.down.sql new file mode 100644 index 0000000..33f5789 --- /dev/null +++ b/migrations/0025_issue_graph.down.sql @@ -0,0 +1,7 @@ +DROP TABLE IF EXISTS issue_dependencies; + +DROP INDEX IF EXISTS idx_issues_parent; +ALTER TABLE issues DROP CONSTRAINT IF EXISTS issues_priority_check; +ALTER TABLE issues DROP CONSTRAINT IF EXISTS issues_parent_not_self; +ALTER TABLE issues DROP COLUMN IF EXISTS priority; +ALTER TABLE issues DROP COLUMN IF EXISTS parent_id; diff --git a/migrations/0025_issue_graph.up.sql b/migrations/0025_issue_graph.up.sql new file mode 100644 index 0000000..6ffaa4b --- /dev/null +++ b/migrations/0025_issue_graph.up.sql @@ -0,0 +1,34 @@ +-- Task decomposition + dependency graph (autonomy roadmap §10). +-- Additive only (follows the existing convention where 0003/0015 patched issues). + +-- ============ issues: parent_id + priority ============ +-- parent_id: self-FK for subtask hierarchy. ON DELETE SET NULL so deleting a +-- parent orphans (not cascades) its subtasks. CHECK forbids self-parent. +ALTER TABLE issues + ADD COLUMN parent_id UUID REFERENCES issues(id) ON DELETE SET NULL; +ALTER TABLE issues + ADD CONSTRAINT issues_parent_not_self CHECK (parent_id IS NULL OR parent_id <> id); + +-- priority: higher = scheduled sooner. 0..3 (0 default = normal). +ALTER TABLE issues + ADD COLUMN priority INT NOT NULL DEFAULT 0; +ALTER TABLE issues + ADD CONSTRAINT issues_priority_check CHECK (priority BETWEEN 0 AND 3); + +CREATE INDEX idx_issues_parent ON issues(parent_id) WHERE parent_id IS NOT NULL; + +-- ============ issue_dependencies ============ +-- Semantics: blocked_id is blocked-by blocker_id; blocked_id becomes ready only +-- when blocker_id.status='closed'. project_id denormalized for scope/cycle scoping. +CREATE TABLE issue_dependencies ( + id UUID PRIMARY KEY, + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + blocked_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + blocker_id UUID NOT NULL REFERENCES issues(id) ON DELETE CASCADE, + created_by UUID NOT NULL REFERENCES users(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + UNIQUE (blocked_id, blocker_id), + CONSTRAINT issue_deps_not_self CHECK (blocked_id <> blocker_id) +); +CREATE INDEX idx_issue_deps_blocked ON issue_dependencies(blocked_id); +CREATE INDEX idx_issue_deps_blocker ON issue_dependencies(blocker_id); diff --git a/migrations/0026_observability_hitl.down.sql b/migrations/0026_observability_hitl.down.sql new file mode 100644 index 0000000..ec490b3 --- /dev/null +++ b/migrations/0026_observability_hitl.down.sql @@ -0,0 +1,12 @@ +-- Down migration for 0026_observability_hitl (reverse order). + +DROP TABLE IF EXISTS notification_prefs; + +DROP INDEX IF EXISTS idx_project_members_user; +DROP TABLE IF EXISTS project_members; + +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS tokens_total; +ALTER TABLE acp_sessions DROP COLUMN IF EXISTS cost_usd; + +DROP INDEX IF EXISTS idx_audit_target; +DROP INDEX IF EXISTS idx_audit_created; diff --git a/migrations/0026_observability_hitl.up.sql b/migrations/0026_observability_hitl.up.sql new file mode 100644 index 0000000..f90ec45 --- /dev/null +++ b/migrations/0026_observability_hitl.up.sql @@ -0,0 +1,45 @@ +-- 0026_observability_hitl: Observability + HITL data model (autonomy roadmap §11). +-- +-- 1) audit_logs read/search/retention indexes (idx_audit_action_created already +-- exists from 0001; add the time-only and target indexes). +-- 2) acp_sessions cost_usd / tokens_total columns for the run-history dashboard. +-- 3) project_members team/role model (backfill owner -> 'admin'). +-- 4) notification_prefs per-user channel routing (im_webhook_url crypto-encrypted). + +-- ============ 1. audit read/search/retention indexes ============ +CREATE INDEX IF NOT EXISTS idx_audit_created ON audit_logs (created_at DESC); +CREATE INDEX IF NOT EXISTS idx_audit_target ON audit_logs (target_type, target_id); + +-- ============ 2. acp_sessions cost rollup columns ============ +-- ADD COLUMN puts new columns LAST physically; SELECT/RETURNING order must match. +ALTER TABLE acp_sessions ADD COLUMN IF NOT EXISTS cost_usd NUMERIC NOT NULL DEFAULT 0; +ALTER TABLE acp_sessions ADD COLUMN IF NOT EXISTS tokens_total BIGINT NOT NULL DEFAULT 0; + +-- ============ 3. project_members team/role model ============ +CREATE TABLE IF NOT EXISTS project_members ( + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role TEXT NOT NULL CHECK (role IN ('viewer','member','admin')), + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + added_by UUID REFERENCES users(id) ON DELETE SET NULL, + PRIMARY KEY (project_id, user_id) +); + +CREATE INDEX IF NOT EXISTS idx_project_members_user ON project_members (user_id); + +-- Backfill: every existing project's owner becomes a project 'admin' so the +-- current owner-or-global-admin behavior is preserved under the role model. +INSERT INTO project_members (project_id, user_id, role, added_by) +SELECT p.id, p.owner_id, 'admin', p.owner_id +FROM projects p +ON CONFLICT (project_id, user_id) DO NOTHING; + +-- ============ 4. notification_prefs per-user routing ============ +CREATE TABLE IF NOT EXISTS notification_prefs ( + user_id UUID PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE, + email_enabled BOOLEAN NOT NULL DEFAULT false, + im_enabled BOOLEAN NOT NULL DEFAULT false, + im_webhook_url_enc BYTEA, + min_severity TEXT NOT NULL DEFAULT 'warning' CHECK (min_severity IN ('info','warning','error')), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); diff --git a/sqlc.yaml b/sqlc.yaml index 5964d11..46d7fc8 100644 --- a/sqlc.yaml +++ b/sqlc.yaml @@ -50,6 +50,16 @@ sql: sql_package: pgx/v5 emit_pointers_for_null_types: true emit_json_tags: true + - engine: postgresql + queries: internal/changerequest/queries + schema: migrations + gen: + go: + package: changerequestsqlc + out: internal/changerequest/sqlc + sql_package: pgx/v5 + emit_pointers_for_null_types: true + emit_json_tags: true - engine: postgresql queries: internal/chat/queries schema: migrations @@ -100,3 +110,43 @@ sql: sql_package: pgx/v5 emit_pointers_for_null_types: true emit_json_tags: true + - engine: postgresql + queries: internal/orchestrator/queries + schema: migrations + gen: + go: + package: orchestratorsqlc + out: internal/orchestrator/sqlc + sql_package: pgx/v5 + emit_pointers_for_null_types: true + emit_json_tags: true + - engine: postgresql + queries: internal/codeindex/queries + schema: migrations + gen: + go: + package: codeindexsqlc + out: internal/codeindex/sqlc + sql_package: pgx/v5 + emit_pointers_for_null_types: true + emit_json_tags: true + - engine: postgresql + queries: internal/projectmemory/queries + schema: migrations + gen: + go: + package: projectmemorysqlc + out: internal/projectmemory/sqlc + sql_package: pgx/v5 + emit_pointers_for_null_types: true + emit_json_tags: true + - engine: postgresql + queries: internal/docs/queries + schema: migrations + gen: + go: + package: docssqlc + out: internal/docs/sqlc + sql_package: pgx/v5 + emit_pointers_for_null_types: true + emit_json_tags: true diff --git a/web/src/api/changeRequests.ts b/web/src/api/changeRequests.ts new file mode 100644 index 0000000..3a75a9f --- /dev/null +++ b/web/src/api/changeRequests.ts @@ -0,0 +1,32 @@ +import { request } from './client' +import type { + ChangeRequest, + CreateChangeRequestBody, + ReviewChangeRequestBody, + MergeChangeRequestBody, +} from './types' + +const enc = (v: string) => encodeURIComponent(v) + +export const changeRequestsApi = { + list: (wsID: string) => + request(`/api/v1/workspaces/${enc(wsID)}/change-requests`), + create: (wsID: string, body: CreateChangeRequestBody) => + request(`/api/v1/workspaces/${enc(wsID)}/change-requests`, { + method: 'POST', + body, + }), + get: (id: string) => request(`/api/v1/change-requests/${enc(id)}`), + review: (id: string, body: ReviewChangeRequestBody) => + request(`/api/v1/change-requests/${enc(id)}/review`, { + method: 'POST', + body, + }), + merge: (id: string, body?: MergeChangeRequestBody) => + request(`/api/v1/change-requests/${enc(id)}/merge`, { + method: 'POST', + body: body ?? {}, + }), + sync: (id: string) => + request(`/api/v1/change-requests/${enc(id)}/sync`, { method: 'POST' }), +} diff --git a/web/src/api/orchestrator.ts b/web/src/api/orchestrator.ts new file mode 100644 index 0000000..5d25525 --- /dev/null +++ b/web/src/api/orchestrator.ts @@ -0,0 +1,57 @@ +import { request } from './client' +import type { Phase } from './projects' + +export type OrchestratorRunStatus = + | 'pending' + | 'running' + | 'paused' + | 'succeeded' + | 'failed' + | 'canceled' + +export interface OrchestratorRun { + id: string + project_id: string + requirement_id: string + workspace_id: string + owner_id: string + status: OrchestratorRunStatus + current_phase: Phase + last_error?: string + created_at: string + updated_at: string + ended_at?: string | null +} + +export interface StartOrchestratorRunBody { + project_slug: string + requirement_number: number + start_phase?: Phase + phase_agent_kinds?: Record + max_attempts_per_phase?: number + max_depth?: number + fan_out_limit?: number +} + +const enc = (v: string) => encodeURIComponent(v) + +export const orchestratorApi = { + listRuns: ( + params: { project_id?: string; requirement_id?: string; status?: OrchestratorRunStatus } = {}, + ) => { + const q = new URLSearchParams() + if (params.project_id) q.set('project_id', params.project_id) + if (params.requirement_id) q.set('requirement_id', params.requirement_id) + if (params.status) q.set('status', params.status) + const qs = q.toString() + return request(`/api/v1/orchestrator/runs/${qs ? '?' + qs : ''}`) + }, + startRun: (body: StartOrchestratorRunBody) => + request('/api/v1/orchestrator/runs/', { method: 'POST', body }), + pauseRun: (id: string) => + request(`/api/v1/orchestrator/runs/${enc(id)}/pause`, { method: 'POST' }), + resumeRun: (id: string) => + request(`/api/v1/orchestrator/runs/${enc(id)}/resume`, { method: 'POST' }), + cancelRun: (id: string) => + request(`/api/v1/orchestrator/runs/${enc(id)}/cancel`, { method: 'POST' }), +} diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 650ef58..069c4dc 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -102,3 +102,60 @@ export interface CommitLog { date: string subject: string } + +export interface DiffResp { + diff: string + truncated: boolean +} + +export interface DiffParams { + ref?: string + against?: string + staged?: boolean + paths?: string + context?: number +} + +export interface ChangeRequest { + id: string + project_id: string + workspace_id: string + requirement_id: string | null + issue_id: string | null + number: number + title: string + description: string + source_branch: string + target_branch: string + state: 'open' | 'merged' | 'closed' + review_verdict: 'pending' | 'approved' | 'changes_requested' | 'rejected' + ci_state: 'unknown' | 'pending' | 'success' | 'failure' + provider: string + external_id: number | null + external_url: string + merge_commit_sha: string + reviewed_by: string | null + reviewed_at: string | null + created_by: string + merged_at: string | null + created_at: string + updated_at: string +} + +export interface CreateChangeRequestBody { + source_branch: string + target_branch?: string + title: string + description?: string + requirement_id?: string | null + issue_id?: string | null +} + +export interface ReviewChangeRequestBody { + verdict: 'approved' | 'changes_requested' | 'rejected' + note?: string +} + +export interface MergeChangeRequestBody { + method?: 'merge' | 'squash' | 'rebase' +} diff --git a/web/src/api/workspaces.ts b/web/src/api/workspaces.ts index e875ac7..2e12f15 100644 --- a/web/src/api/workspaces.ts +++ b/web/src/api/workspaces.ts @@ -11,10 +11,24 @@ import type { CommitBody, CommitResp, CommitLog, + DiffResp, + DiffParams, } from './types' const enc = (v: string) => encodeURIComponent(v) +function diffQuery(params?: DiffParams): string { + if (!params) return '' + const q = new URLSearchParams() + if (params.ref) q.set('ref', params.ref) + if (params.against) q.set('against', params.against) + if (params.staged) q.set('staged', 'true') + if (params.paths) q.set('paths', params.paths) + if (params.context != null) q.set('context', String(params.context)) + const s = q.toString() + return s ? `?${s}` : '' +} + export const workspacesApi = { // ===== Workspace ===== list: (projectSlug: string) => @@ -27,8 +41,7 @@ export const workspacesApi = { get: (wsID: string) => request(`/api/v1/workspaces/${enc(wsID)}`), update: (wsID: string, body: UpdateWorkspaceBody) => request(`/api/v1/workspaces/${enc(wsID)}`, { method: 'PATCH', body }), - delete: (wsID: string) => - request(`/api/v1/workspaces/${enc(wsID)}`, { method: 'DELETE' }), + delete: (wsID: string) => request(`/api/v1/workspaces/${enc(wsID)}`, { method: 'DELETE' }), sync: (wsID: string) => request(`/api/v1/workspaces/${enc(wsID)}/sync`, { method: 'POST' }), @@ -42,8 +55,7 @@ export const workspacesApi = { request(`/api/v1/workspaces/${enc(wsID)}/credential`), // ===== Worktree ===== - listWorktrees: (wsID: string) => - request(`/api/v1/workspaces/${enc(wsID)}/worktrees`), + listWorktrees: (wsID: string) => request(`/api/v1/workspaces/${enc(wsID)}/worktrees`), createWorktree: (wsID: string, body: CreateWorktreeBody) => request(`/api/v1/workspaces/${enc(wsID)}/worktrees`, { method: 'POST', body }), deleteWorktree: (wtID: string) => @@ -54,8 +66,9 @@ export const workspacesApi = { request(`/api/v1/worktrees/${enc(wtID)}/release`, { method: 'POST' }), listBranches: (wsID: string): Promise => - request<{ branches: string[] }>(`/api/v1/workspaces/${enc(wsID)}/branches`) - .then(r => r.branches), + request<{ branches: string[] }>(`/api/v1/workspaces/${enc(wsID)}/branches`).then( + (r) => r.branches, + ), // ===== Git ops ===== statusOnMain: (wsID: string) => @@ -74,4 +87,10 @@ export const workspacesApi = { request(`/api/v1/worktrees/${enc(wtID)}/push`, { method: 'POST' }), logOnWorktree: (wtID: string, n = 50) => request(`/api/v1/worktrees/${enc(wtID)}/log?n=${n}`), + + // ===== Diff ===== + diff: (wsID: string, params?: DiffParams) => + request(`/api/v1/workspaces/${enc(wsID)}/diff${diffQuery(params)}`), + diffOnWorktree: (wtID: string, params?: DiffParams) => + request(`/api/v1/worktrees/${enc(wtID)}/diff${diffQuery(params)}`), } diff --git a/web/src/components/DiffViewer.vue b/web/src/components/DiffViewer.vue new file mode 100644 index 0000000..4c91e98 --- /dev/null +++ b/web/src/components/DiffViewer.vue @@ -0,0 +1,154 @@ + + + + + diff --git a/web/src/constants/requirementPhasePrompts.test.ts b/web/src/constants/requirementPhasePrompts.test.ts index c970950..65361ad 100644 --- a/web/src/constants/requirementPhasePrompts.test.ts +++ b/web/src/constants/requirementPhasePrompts.test.ts @@ -1,58 +1,11 @@ import { describe, it, expect } from 'vitest' -import type { Artifact } from '@/api/projects' -import { - buildRequirementSystemPrompt, - INTERACTIVE_QUESTION_PROMPT, - PHASE_ROLE_PROMPTS, - PREV_ARTIFACT_PHASE, -} from './requirementPhasePrompts' +import { PREV_ARTIFACT_PHASE } from './requirementPhasePrompts' -const req = { number: 7, title: '导出报表', description: '支持导出 Excel' } - -function artifact(content: string): Artifact { - return { - id: 'a1', - requirement_id: 'r1', - phase: 'planning', - version: 2, - content, - note: '', - created_by: 'u1', - created_at: '', - } -} - -describe('buildRequirementSystemPrompt', () => { - it('planning:角色模板 + 需求信息,无上一阶段产物', () => { - const out = buildRequirementSystemPrompt('planning', req) - expect(out).toContain(PHASE_ROLE_PROMPTS.planning) - expect(out).toContain(INTERACTIVE_QUESTION_PROMPT) - expect(out).toContain('需求 #7:导出报表') - expect(out).toContain('支持导出 Excel') - expect(out).not.toContain('上一阶段') - }) - - it('prototyping:注入上一阶段(planning)产物', () => { - const out = buildRequirementSystemPrompt('prototyping', req, artifact('# 规划文档内容')) - expect(out).toContain(PHASE_ROLE_PROMPTS.prototyping) - expect(out).toContain('上一阶段(planning)产物 v2') - expect(out).toContain('# 规划文档内容') - }) - - it('描述为空时不渲染需求描述段', () => { - const out = buildRequirementSystemPrompt('planning', { ...req, description: '' }) - expect(out).not.toContain('需求描述') - }) - - it('超长产物内容截断并标注', () => { - const long = 'x'.repeat(10000) - const out = buildRequirementSystemPrompt('auditing', req, artifact(long)) - expect(out).toContain('已截断') - expect(out.length).toBeLessThan(10000 + PHASE_ROLE_PROMPTS.auditing.length) - }) - - it('PREV_ARTIFACT_PHASE 链:planning←无、prototyping←planning、auditing←prototyping', () => { +// 角色模板 / 交互协议 / system prompt 组装已迁移服务端(internal/brief),相关测试 +// 覆盖随之移至 internal/brief/composer_test.go。此处仅保留前端仍拥有的阶段链映射。 +describe('PREV_ARTIFACT_PHASE', () => { + it('链:planning←无、prototyping←planning、auditing←prototyping', () => { expect(PREV_ARTIFACT_PHASE.planning).toBeNull() expect(PREV_ARTIFACT_PHASE.prototyping).toBe('planning') expect(PREV_ARTIFACT_PHASE.auditing).toBe('prototyping') diff --git a/web/src/constants/requirementPhasePrompts.ts b/web/src/constants/requirementPhasePrompts.ts index 9f7d6ee..2d17d36 100644 --- a/web/src/constants/requirementPhasePrompts.ts +++ b/web/src/constants/requirementPhasePrompts.ts @@ -1,45 +1,9 @@ -import type { Artifact, ArtifactPhase, Requirement } from '@/api/projects' +import type { ArtifactPhase } from '@/api/projects' -// 上一阶段产物注入 system prompt 的最大字符数,超出截断并标注。 -const MAX_PREV_ARTIFACT_CHARS = 8000 - -// 三个 AI 对话阶段的角色模板。创建会话时与需求上下文拼接为 system prompt, -// 组装结果在创建表单中可见可改(前端组装方案,零后端耦合)。 -export const PHASE_ROLE_PROMPTS: Record = { - planning: `你是一名资深产品规划助手。你的任务是与用户深入讨论需求,澄清目标、范围、用户场景与约束,最终形成一份结构化的规划文档(Markdown)。 -讨论时主动提问补全缺失信息;输出规划文档时包含:背景与目标、范围(含不做什么)、用户场景、功能拆解、风险与依赖、验收标准。`, - prototyping: `你是一名资深原型设计助手。基于已确认的规划,与用户讨论并产出原型设计文档(Markdown)。 -内容应包含:页面/界面结构、交互流程、状态与边界情况、数据展示与输入约束;可用文字线框、列表与表格描述布局。`, - auditing: `你是一名严格的方案评审助手。基于规划与原型,对方案进行评审并产出评审报告(Markdown)。 -评审维度:完整性(是否覆盖规划目标)、一致性(原型与规划是否矛盾)、可行性(技术与排期风险)、边界情况遗漏、验收标准可测性。明确给出问题清单与修改建议,并给出评审结论(通过 / 有条件通过 / 不通过)。`, -} - -export const INTERACTIVE_QUESTION_PROMPT = [ - '## 交互式澄清问题协议', - '当信息不足且用户直接输入大段文字的成本较高时,优先提出一个结构化澄清问题,方便用户点选。', - '每次最多输出 1 个结构化问题;问题必须有 2-5 个选项;只有选项允许多选时 mode 才使用 multiple。', - '结构化问题必须放在 acw-question 代码块中,代码块内只能是合法 JSON,不能输出 HTML。', - '字段格式如下:', - '```acw-question', - '{', - ' "version": 1,', - ' "id": "stable_question_id",', - ' "title": "需要用户确认的问题",', - ' "description": "选择这个问题的原因,可省略",', - ' "mode": "single",', - ' "options": [', - ' {', - ' "id": "stable_option_id",', - ' "label": "给用户看的短选项",', - ' "summary": "一句话解释,可省略",', - ' "details": "更完整的影响说明,可省略"', - ' }', - ' ],', - ' "allowCustom": true', - '}', - '```', - '用户回答结构化问题后,继续基于回答推进本阶段讨论或产物输出。', -].join('\n') +// 阶段角色模板 / 交互式澄清协议 / system prompt 组装已迁移至服务端 +// (internal/brief.Composer),不再由前端拼装。前端创建会话时只需传 requirement_id / +// project_id,服务端在 composeHistory 注入 FK 上下文简报。此文件仅保留前端仍需的 +// 阶段链映射(用于展示"上一阶段产物"等纯 UI 逻辑)。 // 上一阶段产物来源:prototyping ← planning、auditing ← prototyping、planning 无。 export const PREV_ARTIFACT_PHASE: Record = { @@ -47,25 +11,3 @@ export const PREV_ARTIFACT_PHASE: Record = prototyping: 'planning', auditing: 'prototyping', } - -// buildRequirementSystemPrompt 组装阶段会话的 system prompt: -// 角色模板 + 需求标题/描述 + 上一阶段产物最新版(如有,超长截断)。 -export function buildRequirementSystemPrompt( - phase: ArtifactPhase, - requirement: Pick, - prevArtifact?: Artifact | null, -): string { - const parts: string[] = [PHASE_ROLE_PROMPTS[phase], INTERACTIVE_QUESTION_PROMPT] - parts.push(`## 当前需求\n需求 #${requirement.number}:${requirement.title}`) - if (requirement.description) { - parts.push(`### 需求描述\n${requirement.description}`) - } - if (prevArtifact) { - let content = prevArtifact.content - if (content.length > MAX_PREV_ARTIFACT_CHARS) { - content = content.slice(0, MAX_PREV_ARTIFACT_CHARS) + '\n\n……(内容过长,已截断)' - } - parts.push(`## 上一阶段(${prevArtifact.phase})产物 v${prevArtifact.version}\n${content}`) - } - return parts.join('\n\n') -} diff --git a/web/src/views/admin/AdminTerminalView.vue b/web/src/views/admin/AdminTerminalView.vue index badcdd2..cf74803 100644 --- a/web/src/views/admin/AdminTerminalView.vue +++ b/web/src/views/admin/AdminTerminalView.vue @@ -2,15 +2,9 @@
-

- 终端 -

+

终端

- + {{ statusLabel }}
- - 命令在 server 宿主进程(部署时即 runtime 容器)内执行,权限等同容器内 shell。 - 安装 ACP 客户端示例:npm i -g @anthropic-ai/claude-code, - 安装完成后将 binary_path 填入对应的 ACP 客户端配置。 + + 命令在 server 宿主进程(部署时即 runtime 容器)内执行,权限等同容器内 shell。 安装 ACP + 客户端示例:npm i -g @anthropic-ai/claude-code, 安装完成后将 + binary_path 填入对应的 ACP 客户端配置。 -
+
@@ -69,7 +56,7 @@ const sock = useTerminalSocket({ initialRows: term.rows, initialCols: term.cols, }) -term.onData((d) => sock.sendInput(d)) +term.onData((d: string) => sock.sendInput(d)) let resizeObserver: ResizeObserver | null = null diff --git a/web/src/views/projects/RequirementDetailView.vue b/web/src/views/projects/RequirementDetailView.vue index eecc141..092abaa 100644 --- a/web/src/views/projects/RequirementDetailView.vue +++ b/web/src/views/projects/RequirementDetailView.vue @@ -57,6 +57,12 @@

暂无描述

+ + + +
+
+
+
+ + {{ statusLabel(activeRun.status) }} + + 未启动 + + {{ activeRun.id.slice(0, 8) }} + +
+
+ {{ phaseLabel(activeRun.current_phase) }} · {{ formatDateTime(activeRun.updated_at) }} +
+
+ + + +
+ + + {{ activeRun.last_error }} + + +
+ + + 需保持需求开放、已关联 workspace,且没有活跃 run + + + 暂停 + + + 继续 + + + 取消 + +
+ +
历史运行 {{ runs.length }} 次
+
+
+ + + diff --git a/web/src/views/workspaces/WorkspaceHomeView.vue b/web/src/views/workspaces/WorkspaceHomeView.vue index be427c2..6c16dd3 100644 --- a/web/src/views/workspaces/WorkspaceHomeView.vue +++ b/web/src/views/workspaces/WorkspaceHomeView.vue @@ -7,59 +7,29 @@ - + - - - + + + - - + + - - + + - - + + - - + + + + + + + +
@@ -78,11 +48,15 @@ const MainTab = defineAsyncComponent(() => import('./components/MainTab.vue')) const CommitHistoryTab = defineAsyncComponent(() => import('./components/CommitHistoryTab.vue')) const SettingsTab = defineAsyncComponent(() => import('./components/SettingsTab.vue')) const RunsTab = defineAsyncComponent(() => import('./components/RunsTab.vue')) +const DiffTab = defineAsyncComponent(() => import('./components/DiffTab.vue')) +const ChangeRequestsTab = defineAsyncComponent(() => import('./components/ChangeRequestsTab.vue')) const route = useRoute() const store = useWorkspacesStore() const msg = useMessage() -const tab = ref<'worktrees' | 'main' | 'commits' | 'settings' | 'runs'>('worktrees') +const tab = ref<'worktrees' | 'main' | 'commits' | 'diff' | 'prs' | 'settings' | 'runs'>( + 'worktrees', +) const projectSlug = computed(() => route.params.slug as string) const wsSlug = computed(() => route.params.wsSlug as string) diff --git a/web/src/views/workspaces/components/ChangeRequestsTab.vue b/web/src/views/workspaces/components/ChangeRequestsTab.vue new file mode 100644 index 0000000..9f58120 --- /dev/null +++ b/web/src/views/workspaces/components/ChangeRequestsTab.vue @@ -0,0 +1,208 @@ + + + diff --git a/web/src/views/workspaces/components/DiffTab.vue b/web/src/views/workspaces/components/DiffTab.vue new file mode 100644 index 0000000..b02eab0 --- /dev/null +++ b/web/src/views/workspaces/components/DiffTab.vue @@ -0,0 +1,82 @@ + + +