@repo-os/repoos 0.5.45
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE.md +110 -0
- package/README.md +272 -0
- package/dist/.build-info.json +4 -0
- package/dist/.build-stamp.json +3 -0
- package/dist/cli/colors.d.ts +13 -0
- package/dist/cli/colors.js +37 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.js +221 -0
- package/dist/commands/check.d.ts +170 -0
- package/dist/commands/check.js +1227 -0
- package/dist/commands/docs.d.ts +2 -0
- package/dist/commands/docs.js +70 -0
- package/dist/commands/gc.d.ts +1 -0
- package/dist/commands/gc.js +57 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +699 -0
- package/dist/commands/screenshots.d.ts +7 -0
- package/dist/commands/screenshots.js +77 -0
- package/dist/commands/serve.d.ts +55 -0
- package/dist/commands/serve.js +204 -0
- package/dist/commands/status.d.ts +140 -0
- package/dist/commands/status.js +569 -0
- package/dist/commands/stop.d.ts +1 -0
- package/dist/commands/stop.js +59 -0
- package/dist/commands/tasks.d.ts +47 -0
- package/dist/commands/tasks.js +334 -0
- package/dist/commands/tunnel.d.ts +1 -0
- package/dist/commands/tunnel.js +1271 -0
- package/dist/commands/ui-harness.d.ts +53 -0
- package/dist/commands/ui-harness.js +62 -0
- package/dist/commands/ui-smoke.d.ts +6 -0
- package/dist/commands/ui-smoke.js +220 -0
- package/dist/commands/upgrade.d.ts +15 -0
- package/dist/commands/upgrade.js +188 -0
- package/dist/core/auth-store.d.ts +81 -0
- package/dist/core/auth-store.js +530 -0
- package/dist/core/auth.d.ts +75 -0
- package/dist/core/auth.js +124 -0
- package/dist/core/bootstrap.d.ts +41 -0
- package/dist/core/bootstrap.js +236 -0
- package/dist/core/build.d.ts +92 -0
- package/dist/core/build.js +249 -0
- package/dist/core/canary.d.ts +10 -0
- package/dist/core/canary.js +14 -0
- package/dist/core/check-failure-summary.d.ts +10 -0
- package/dist/core/check-failure-summary.js +34 -0
- package/dist/core/config.d.ts +160 -0
- package/dist/core/config.js +1288 -0
- package/dist/core/context-pack.d.ts +74 -0
- package/dist/core/context-pack.js +778 -0
- package/dist/core/db.d.ts +236 -0
- package/dist/core/db.js +747 -0
- package/dist/core/detect.d.ts +69 -0
- package/dist/core/detect.js +251 -0
- package/dist/core/docs.d.ts +76 -0
- package/dist/core/docs.js +225 -0
- package/dist/core/frontmatter.d.ts +29 -0
- package/dist/core/frontmatter.js +215 -0
- package/dist/core/git.d.ts +505 -0
- package/dist/core/git.js +1453 -0
- package/dist/core/indexer.d.ts +33 -0
- package/dist/core/indexer.js +282 -0
- package/dist/core/input.d.ts +66 -0
- package/dist/core/input.js +222 -0
- package/dist/core/logger.d.ts +70 -0
- package/dist/core/logger.js +138 -0
- package/dist/core/models.d.ts +45 -0
- package/dist/core/models.js +291 -0
- package/dist/core/net-probe.d.ts +4 -0
- package/dist/core/net-probe.js +20 -0
- package/dist/core/providers/deepinfra.d.ts +23 -0
- package/dist/core/providers/deepinfra.js +44 -0
- package/dist/core/providers/index.d.ts +24 -0
- package/dist/core/providers/index.js +59 -0
- package/dist/core/providers/openrouter.d.ts +28 -0
- package/dist/core/providers/openrouter.js +82 -0
- package/dist/core/providers/spend.d.ts +86 -0
- package/dist/core/providers/spend.js +286 -0
- package/dist/core/providers/types.d.ts +56 -0
- package/dist/core/providers/types.js +39 -0
- package/dist/core/repoos.d.ts +59 -0
- package/dist/core/repoos.js +221 -0
- package/dist/core/runtime.d.ts +35 -0
- package/dist/core/runtime.js +167 -0
- package/dist/core/skills-registry.d.ts +39 -0
- package/dist/core/skills-registry.js +148 -0
- package/dist/core/skills-registry.test.d.ts +1 -0
- package/dist/core/skills-registry.test.js +52 -0
- package/dist/core/sysmem.d.ts +7 -0
- package/dist/core/sysmem.js +55 -0
- package/dist/core/tailscale.d.ts +2 -0
- package/dist/core/tailscale.js +19 -0
- package/dist/core/task.d.ts +53 -0
- package/dist/core/task.js +310 -0
- package/dist/core/toml-line.d.ts +20 -0
- package/dist/core/toml-line.js +52 -0
- package/dist/core/toml-validate.d.ts +44 -0
- package/dist/core/toml-validate.js +335 -0
- package/dist/core/tunnel-assistant.d.ts +30 -0
- package/dist/core/tunnel-assistant.js +65 -0
- package/dist/core/tunnel-registry.d.ts +96 -0
- package/dist/core/tunnel-registry.js +267 -0
- package/dist/core/tunnel-registry.test.d.ts +1 -0
- package/dist/core/tunnel-registry.test.js +309 -0
- package/dist/core/tunnel.d.ts +103 -0
- package/dist/core/tunnel.js +320 -0
- package/dist/core/types.d.ts +811 -0
- package/dist/core/types.js +27 -0
- package/dist/core/worktree-gc.d.ts +57 -0
- package/dist/core/worktree-gc.js +194 -0
- package/dist/server/__tests__/integration-job.test.d.ts +1 -0
- package/dist/server/__tests__/integration-job.test.js +129 -0
- package/dist/server/agents.d.ts +1020 -0
- package/dist/server/agents.js +4070 -0
- package/dist/server/attachments.d.ts +51 -0
- package/dist/server/attachments.js +130 -0
- package/dist/server/auto-engineering.d.ts +45 -0
- package/dist/server/auto-engineering.js +275 -0
- package/dist/server/branchless-release.d.ts +22 -0
- package/dist/server/branchless-release.js +67 -0
- package/dist/server/built-in-agents.d.ts +305 -0
- package/dist/server/built-in-agents.js +1838 -0
- package/dist/server/cto-monitor.d.ts +35 -0
- package/dist/server/cto-monitor.js +281 -0
- package/dist/server/cto.d.ts +125 -0
- package/dist/server/cto.js +590 -0
- package/dist/server/deployments.d.ts +119 -0
- package/dist/server/deployments.js +325 -0
- package/dist/server/diff-snapshot.d.ts +20 -0
- package/dist/server/diff-snapshot.js +73 -0
- package/dist/server/done.d.ts +160 -0
- package/dist/server/done.js +488 -0
- package/dist/server/freeform.d.ts +55 -0
- package/dist/server/freeform.js +179 -0
- package/dist/server/handoff.d.ts +99 -0
- package/dist/server/handoff.js +575 -0
- package/dist/server/hetzner.d.ts +45 -0
- package/dist/server/hetzner.js +117 -0
- package/dist/server/icons.d.ts +8 -0
- package/dist/server/icons.js +195 -0
- package/dist/server/integration-job.d.ts +75 -0
- package/dist/server/integration-job.js +144 -0
- package/dist/server/integration-orchestrator.d.ts +211 -0
- package/dist/server/integration-orchestrator.js +1299 -0
- package/dist/server/integration-status.d.ts +48 -0
- package/dist/server/integration-status.js +71 -0
- package/dist/server/live-index.d.ts +295 -0
- package/dist/server/live-index.js +452 -0
- package/dist/server/model-test.d.ts +20 -0
- package/dist/server/model-test.js +100 -0
- package/dist/server/ntfy.d.ts +57 -0
- package/dist/server/ntfy.js +112 -0
- package/dist/server/pm-attachments.d.ts +35 -0
- package/dist/server/pm-attachments.js +181 -0
- package/dist/server/pm-runs.d.ts +58 -0
- package/dist/server/pm-runs.js +80 -0
- package/dist/server/preview.d.ts +174 -0
- package/dist/server/preview.js +641 -0
- package/dist/server/release.d.ts +88 -0
- package/dist/server/release.js +449 -0
- package/dist/server/reload.d.ts +194 -0
- package/dist/server/reload.js +586 -0
- package/dist/server/remote-validation.d.ts +132 -0
- package/dist/server/remote-validation.js +478 -0
- package/dist/server/repo-lock.d.ts +36 -0
- package/dist/server/repo-lock.js +141 -0
- package/dist/server/review-guard.d.ts +14 -0
- package/dist/server/review-guard.js +159 -0
- package/dist/server/review.d.ts +243 -0
- package/dist/server/review.js +1211 -0
- package/dist/server/routes/agents.d.ts +6 -0
- package/dist/server/routes/agents.js +28 -0
- package/dist/server/routes/auth.d.ts +60 -0
- package/dist/server/routes/auth.js +755 -0
- package/dist/server/routes/config.d.ts +20 -0
- package/dist/server/routes/config.js +347 -0
- package/dist/server/routes/debugger.d.ts +33 -0
- package/dist/server/routes/debugger.js +360 -0
- package/dist/server/routes/deployments.d.ts +3 -0
- package/dist/server/routes/deployments.js +16 -0
- package/dist/server/routes/docs.d.ts +8 -0
- package/dist/server/routes/docs.js +135 -0
- package/dist/server/routes/helpers.d.ts +21 -0
- package/dist/server/routes/helpers.js +171 -0
- package/dist/server/routes/index.d.ts +19 -0
- package/dist/server/routes/index.js +18 -0
- package/dist/server/routes/info.d.ts +17 -0
- package/dist/server/routes/info.js +177 -0
- package/dist/server/routes/inputs.d.ts +7 -0
- package/dist/server/routes/inputs.js +125 -0
- package/dist/server/routes/model-providers.d.ts +27 -0
- package/dist/server/routes/model-providers.js +94 -0
- package/dist/server/routes/models.d.ts +3 -0
- package/dist/server/routes/models.js +36 -0
- package/dist/server/routes/notify.d.ts +2 -0
- package/dist/server/routes/notify.js +17 -0
- package/dist/server/routes/playground.d.ts +29 -0
- package/dist/server/routes/playground.js +81 -0
- package/dist/server/routes/release.d.ts +20 -0
- package/dist/server/routes/release.js +86 -0
- package/dist/server/routes/router.d.ts +12 -0
- package/dist/server/routes/router.js +32 -0
- package/dist/server/routes/skill-registry.d.ts +5 -0
- package/dist/server/routes/skill-registry.js +51 -0
- package/dist/server/routes/tasks.d.ts +58 -0
- package/dist/server/routes/tasks.js +1442 -0
- package/dist/server/routes/transcribe.d.ts +2 -0
- package/dist/server/routes/transcribe.js +124 -0
- package/dist/server/routes/types.d.ts +68 -0
- package/dist/server/routes/types.js +1 -0
- package/dist/server/routes/ui.d.ts +5 -0
- package/dist/server/routes/ui.js +81 -0
- package/dist/server/routes/utils.d.ts +3 -0
- package/dist/server/routes/utils.js +23 -0
- package/dist/server/serve-reaper.d.ts +135 -0
- package/dist/server/serve-reaper.js +491 -0
- package/dist/server/server.d.ts +49 -0
- package/dist/server/server.js +2171 -0
- package/dist/server/supervisor.d.ts +89 -0
- package/dist/server/supervisor.js +231 -0
- package/dist/server/system.d.ts +198 -0
- package/dist/server/system.js +480 -0
- package/dist/server/task-check.d.ts +45 -0
- package/dist/server/task-check.js +71 -0
- package/dist/server/task-transitions.d.ts +51 -0
- package/dist/server/task-transitions.js +78 -0
- package/dist/server/task-watchdog.d.ts +158 -0
- package/dist/server/task-watchdog.js +681 -0
- package/dist/server/test-run.d.ts +28 -0
- package/dist/server/test-run.js +81 -0
- package/dist/server/watcher.d.ts +21 -0
- package/dist/server/watcher.js +194 -0
- package/dist/server/write.d.ts +94 -0
- package/dist/server/write.js +345 -0
- package/dist/ui/assets/AgentsView-CF4wRvHh.css +1 -0
- package/dist/ui/assets/AgentsView-D-myL-kA.js +1 -0
- package/dist/ui/assets/ContextView-D3TC-RSp.css +1 -0
- package/dist/ui/assets/ContextView-DsOqAdyE.js +4 -0
- package/dist/ui/assets/DashboardView-BOhsIJqO.js +1 -0
- package/dist/ui/assets/DashboardView-Ck9o0RP2.css +1 -0
- package/dist/ui/assets/DeploymentsView--iIUVfEH.css +1 -0
- package/dist/ui/assets/DeploymentsView-DXu_FYR2.js +1 -0
- package/dist/ui/assets/InputsView-24HVbVPk.js +1 -0
- package/dist/ui/assets/InputsView-BLb7GHtx.css +1 -0
- package/dist/ui/assets/LoginView-6QlGg6cx.css +1 -0
- package/dist/ui/assets/LoginView-BqupI0Lt.js +1 -0
- package/dist/ui/assets/ReleasesView-BEG1A0YH.css +1 -0
- package/dist/ui/assets/ReleasesView-CAgMuxpe.js +3 -0
- package/dist/ui/assets/SettingsView-CA-NYSMK.css +1 -0
- package/dist/ui/assets/SettingsView-O3iZSnjg.js +3 -0
- package/dist/ui/assets/WorkView-D8Tl6qwy.js +1 -0
- package/dist/ui/assets/WorkView-DMYL2s3W.css +1 -0
- package/dist/ui/assets/abnfDiagram-VCTEODGH-J5lkSnEE.js +1 -0
- package/dist/ui/assets/arc-BHZnmhhM.js +1 -0
- package/dist/ui/assets/architecture-7GRP2DOG-pLdFt-xS.js +1 -0
- package/dist/ui/assets/architectureDiagram-5GKGNRK7-C3k89uKX.js +36 -0
- package/dist/ui/assets/array-BifhSqXX.js +1 -0
- package/dist/ui/assets/blockDiagram-I7D4REHJ-AWNoBzop.js +129 -0
- package/dist/ui/assets/button-lbikqSph.js +1 -0
- package/dist/ui/assets/c4Diagram-7LVT6UL2-DKG1D5gd.js +38 -0
- package/dist/ui/assets/channel-gA4qBv3A.js +1 -0
- package/dist/ui/assets/chunk-2Q5K7J3B-C1jixKkw.js +1 -0
- package/dist/ui/assets/chunk-4HAMMTFA-D1YR8lF_.js +62 -0
- package/dist/ui/assets/chunk-5VM5RSS4-ZNzvKenW.js +15 -0
- package/dist/ui/assets/chunk-75Z2AOVW-5GuBxfsL.js +2 -0
- package/dist/ui/assets/chunk-DU6HZSFF-DfdNNqwa.js +127 -0
- package/dist/ui/assets/chunk-F27PBJKO-BPZzinQ4.js +1 -0
- package/dist/ui/assets/chunk-FOHPRMQF-l2z4K2jD.js +161 -0
- package/dist/ui/assets/chunk-GMAD6QVW-DGWvSKj5.js +72 -0
- package/dist/ui/assets/chunk-GVQU2GXP-D6Aw75wJ.js +1 -0
- package/dist/ui/assets/chunk-IMKFNOWR-B8mMBKCW.js +231 -0
- package/dist/ui/assets/chunk-JWPE2WC7-DVXcaiue.js +1 -0
- package/dist/ui/assets/chunk-L3NEJ4N5-D5OJuXYE.js +1 -0
- package/dist/ui/assets/chunk-OSK3NFVY-B7r9w2UW.js +10 -0
- package/dist/ui/assets/chunk-P2QGCYS3-C74X4AQX.js +1 -0
- package/dist/ui/assets/chunk-POPQ4Y6H-C5_u1gUS.js +1 -0
- package/dist/ui/assets/chunk-PWAF6VOD-DfoTLtxn.js +1 -0
- package/dist/ui/assets/chunk-SHT3W25Y-D40m2eqz.js +168 -0
- package/dist/ui/assets/chunk-SVP7TREG-euxi0Kap.js +88 -0
- package/dist/ui/assets/chunk-TICWLB2K-fkdxngzN.js +206 -0
- package/dist/ui/assets/chunk-XXDRQBXY-DFBUG-OT.js +1 -0
- package/dist/ui/assets/chunk-Y2CYZVJY-DsF7k-Jl.js +1 -0
- package/dist/ui/assets/classDiagram-ZZMXUADV-BzW8bq2P.js +1 -0
- package/dist/ui/assets/classDiagram-v2-VYDZK3BY-BzW8bq2P.js +1 -0
- package/dist/ui/assets/cose-bilkent-JH36ORCC-BTcgMon1.js +1 -0
- package/dist/ui/assets/cynefin-OW5HDTMX-B5m_COsS.js +1 -0
- package/dist/ui/assets/cynefinDiagram-5FMLGOSQ-C-AG6CGe.js +62 -0
- package/dist/ui/assets/cytoscape.esm-FBntH-9t.js +321 -0
- package/dist/ui/assets/dagre-GXQ25YYZ-DDfDnp8L.js +4 -0
- package/dist/ui/assets/dagre-PrKaheQc.js +1 -0
- package/dist/ui/assets/defaultLocale-BFoDCU3G.js +1 -0
- package/dist/ui/assets/diagram-S7CK7UJ4-DjQZAlEY.js +30 -0
- package/dist/ui/assets/diagram-UQ7AKVKN-DAZ9yp1B.js +41 -0
- package/dist/ui/assets/diagram-VSXAHHWV-BWepUKJD.js +3 -0
- package/dist/ui/assets/diagram-VX7I27RA-EcV1J81b.js +24 -0
- package/dist/ui/assets/diagram-Z3DM3KII-Dbz7XU_K.js +24 -0
- package/dist/ui/assets/dist-DTg6UBE_.js +1 -0
- package/dist/ui/assets/ebnfDiagram-PWID7BFC-BUyQw8Mm.js +1 -0
- package/dist/ui/assets/erDiagram-RLTQ6QDP-WVf8ukKt.js +99 -0
- package/dist/ui/assets/eventmodeling-NTZA5JFV-DHqyLOAn.js +1 -0
- package/dist/ui/assets/flowDiagram-HODETNUW-CYk_ze5K.js +1 -0
- package/dist/ui/assets/ganttDiagram-EL5Y4UJY-rB4MZuB-.js +292 -0
- package/dist/ui/assets/gitGraph-4MIJSDKK-BOTWM8dY.js +1 -0
- package/dist/ui/assets/gitGraphDiagram-WWUBYQGX-eYscuhtP.js +106 -0
- package/dist/ui/assets/graphlib-DS17s2tU.js +1 -0
- package/dist/ui/assets/index-BACBfnga.css +1 -0
- package/dist/ui/assets/index-BOQ-ust6.js +26 -0
- package/dist/ui/assets/info-A6RAGUB7-_67wOORe.js +1 -0
- package/dist/ui/assets/infoDiagram-27XIBGKW-XVXbddTA.js +2 -0
- package/dist/ui/assets/init-C-OQMol4.js +1 -0
- package/dist/ui/assets/ishikawaDiagram-5VMMS53U-ClLNiEru.js +70 -0
- package/dist/ui/assets/journeyDiagram-3NMN7TZE-NRawjR6i.js +139 -0
- package/dist/ui/assets/kanban-definition-UXKFOSKX-BgSzBfIx.js +89 -0
- package/dist/ui/assets/katex-DolUETbr.js +257 -0
- package/dist/ui/assets/line-KAyfuul4.js +1 -0
- package/dist/ui/assets/linear-cCCPOpu7.js +1 -0
- package/dist/ui/assets/mermaid-parser.core-CgXZk1iN.js +7 -0
- package/dist/ui/assets/mermaid.core-BUUkChHW.js +44 -0
- package/dist/ui/assets/mindmap-definition-YA3MSWOX-Cpn7vW4r.js +96 -0
- package/dist/ui/assets/ordinal-BDEzSJ7C.js +1 -0
- package/dist/ui/assets/packet-AYTQ26CC-0lZwM-d6.js +1 -0
- package/dist/ui/assets/path-COt_16Va.js +1 -0
- package/dist/ui/assets/pegDiagram-XKGWAZYB-impy0inn.js +1 -0
- package/dist/ui/assets/pie-WAS4IAKB-CqfdGHYg.js +1 -0
- package/dist/ui/assets/pieDiagram-E7YTZNPT-CxjL4I1h.js +39 -0
- package/dist/ui/assets/quadrantDiagram-AXDQQJYC-Cjd1QMIe.js +7 -0
- package/dist/ui/assets/radar-RG4KPBEZ-CRkQIwiv.js +1 -0
- package/dist/ui/assets/railroad-74A4TZTK-i67XSWTB.js +1 -0
- package/dist/ui/assets/railroad-abnf-HS5TGJTU-Cv9m5aS3.js +1 -0
- package/dist/ui/assets/railroad-ebnf-LZEXJU2U-C5ZIn-Jh.js +1 -0
- package/dist/ui/assets/railroad-peg-WCYAUIDC-CGra9Oq-.js +1 -0
- package/dist/ui/assets/railroadDiagram-O6MQD6OU-_pK0qyaY.js +1 -0
- package/dist/ui/assets/releases-B0WNV-SY.js +1 -0
- package/dist/ui/assets/repoos-cto-square.webp +0 -0
- package/dist/ui/assets/repoos-helpful-pm-square.webp +0 -0
- package/dist/ui/assets/repoos-orchestrator-square.webp +0 -0
- package/dist/ui/assets/repoos-performance-agent-square.webp +0 -0
- package/dist/ui/assets/repoos-ross-from-friends-square.webp +0 -0
- package/dist/ui/assets/repoos-tech-debt-agent-square.webp +0 -0
- package/dist/ui/assets/requirementDiagram-BXWQKSXE-CRrFX-yw.js +84 -0
- package/dist/ui/assets/rough.esm-By172zw-.js +1 -0
- package/dist/ui/assets/sankeyDiagram-P5KCCOFB-X_xFRj9E.js +40 -0
- package/dist/ui/assets/sequenceDiagram-WJ2MYXX4-Byb49lKf.js +162 -0
- package/dist/ui/assets/sizeCapture-INFHLROL-B0uUizjq.js +1 -0
- package/dist/ui/assets/src-BH-TyZbA.js +1 -0
- package/dist/ui/assets/stateDiagram-D77RDMKH-CzaPrqYF.js +1 -0
- package/dist/ui/assets/stateDiagram-v2-MP3YSRHH-BCXckz7K.js +1 -0
- package/dist/ui/assets/swimlanes-42K2YHIH-Cx_1lnqr.js +1 -0
- package/dist/ui/assets/swimlanesDiagram-VR7AAH4N-54qsKi6A.js +8 -0
- package/dist/ui/assets/timeline-definition-24CTP7MA-B1IbZPQ-.js +120 -0
- package/dist/ui/assets/treeView-Q6P3EWNA-DFWhVE3d.js +1 -0
- package/dist/ui/assets/treemap-WGGIJYW6-BmbYPThn.js +1 -0
- package/dist/ui/assets/vendor-ui-DPm7zLxi.js +3 -0
- package/dist/ui/assets/vendor-vue-CobgyEJp.js +1 -0
- package/dist/ui/assets/vennDiagram-4TSXK5OY-Ae2AtRpt.js +34 -0
- package/dist/ui/assets/wardley-WFR3VGLG-BtD4X4tL.js +1 -0
- package/dist/ui/assets/wardleyDiagram-VM6X3IG4-B3YN2fTd.js +78 -0
- package/dist/ui/assets/xychartDiagram-S5SC5T6Z-CIzJfMKA.js +7 -0
- package/dist/ui/favicon.svg +11 -0
- package/dist/ui/index.html +49 -0
- package/dist/ui/sw.js +48 -0
- package/package.json +91 -0
|
@@ -0,0 +1,1442 @@
|
|
|
1
|
+
import { json, readBody } from "./utils.js";
|
|
2
|
+
import { agentsForConfig } from "../../core/config.js";
|
|
3
|
+
import { patchTaskFile, deleteTaskFile, PathGuardError, } from "../write.js";
|
|
4
|
+
import { resolveAgentForTask, resolvePmAgent, taskPmPrompt, runPrompt, deriveBranch, isModelOverridePinned, recordOneShotSession, pmCommand, parseOneShotLine, extractOneShotReportText, } from "../agents.js";
|
|
5
|
+
import { markPmWorking, clearPmWorking, withPmWorking, markPmChatSession, isPmWorking, } from "../pm-runs.js";
|
|
6
|
+
import { queuePmImages, dropPmImages } from "../pm-attachments.js";
|
|
7
|
+
import { parseGeneratedTask, pmPrompt, explanationTitle } from "../freeform.js";
|
|
8
|
+
import { getCurrentUser } from "./auth.js";
|
|
9
|
+
import { withOriginalPromptSection } from "../../core/repoos.js";
|
|
10
|
+
import { listInputs } from "../../core/input.js";
|
|
11
|
+
import { commitTaskFile, commitDirtyFiles, dirtyFiles, worktreePathForBranch, ensureWorktree, resetWorktree, getDiffStatsAsync, getDiff, GitDirtyCheckError, ensureHotfix, } from "../../core/git.js";
|
|
12
|
+
import { guardReviewTransition } from "../review-guard.js";
|
|
13
|
+
import { checkGenericStatusPatch } from "../task-transitions.js";
|
|
14
|
+
import { readFileSync, existsSync, statSync, unlinkSync } from "node:fs";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import { releaseBranchless, isBranchlessReleaseEligible } from "../branchless-release.js";
|
|
17
|
+
import { bootstrap } from "../../core/bootstrap.js";
|
|
18
|
+
import { generateContextPack, resumePreamble } from "../../core/context-pack.js";
|
|
19
|
+
import { mimeForExtension, resolveScreenshot, saveScreenshot } from "../attachments.js";
|
|
20
|
+
import { STATUSES } from "../../core/types.js";
|
|
21
|
+
import { parseTask } from "../../core/task.js";
|
|
22
|
+
import { buildIntegrationSnapshot } from "../integration-status.js";
|
|
23
|
+
import { loadDiffSnapshot } from "../diff-snapshot.js";
|
|
24
|
+
import { previewTargetOptions } from "../preview.js";
|
|
25
|
+
// Helper to add review status to tasks
|
|
26
|
+
function withReviewStatus(task, reviews) {
|
|
27
|
+
return {
|
|
28
|
+
...task,
|
|
29
|
+
automaticReview: {
|
|
30
|
+
running: reviews.isRunning(task.id),
|
|
31
|
+
enabled: reviews.enabled(),
|
|
32
|
+
},
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Add the preview targets a task can be served from (#0379). The drawer reads
|
|
37
|
+
* this to show the active target's name and, when the task's area matches more
|
|
38
|
+
* than one target, to offer a picker instead of silently previewing the first.
|
|
39
|
+
*/
|
|
40
|
+
function withPreviewTargets(task, config) {
|
|
41
|
+
return { ...task, previewTargets: previewTargetOptions(config, task) };
|
|
42
|
+
}
|
|
43
|
+
export const getTasks = (ctx, req, res) => {
|
|
44
|
+
const { config, index, reviews } = ctx;
|
|
45
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
46
|
+
const status = url.searchParams.get("status");
|
|
47
|
+
if (status && !STATUSES.includes(status)) {
|
|
48
|
+
return json(res, 400, { error: `Invalid status "${status}"` });
|
|
49
|
+
}
|
|
50
|
+
const tasks = index
|
|
51
|
+
.getTasks(status ?? undefined)
|
|
52
|
+
.map((t) => withPmWorking(withReviewStatus(withPreviewTargets(t, config), reviews)));
|
|
53
|
+
return json(res, 200, tasks);
|
|
54
|
+
};
|
|
55
|
+
export const createTask = async (ctx, req, res) => {
|
|
56
|
+
const { config, repoos, index, logger } = ctx;
|
|
57
|
+
const body = (await readBody(req));
|
|
58
|
+
if (!body.title || typeof body.title !== "string") {
|
|
59
|
+
return json(res, 400, { error: "title is required" });
|
|
60
|
+
}
|
|
61
|
+
const taskBody = typeof body.body === "string" ? body.body : undefined;
|
|
62
|
+
// The client-side "Save as draft" freeform path posts the raw prompt as the
|
|
63
|
+
// body with status `draft` (no client change allowed, see #0251). Treat that
|
|
64
|
+
// as `originalPrompt` so the raw capture is stored under `## Original prompt`.
|
|
65
|
+
const originalPrompt = body.status === "draft"
|
|
66
|
+
? typeof body.originalPrompt === "string" && body.originalPrompt
|
|
67
|
+
? body.originalPrompt
|
|
68
|
+
: taskBody
|
|
69
|
+
: undefined;
|
|
70
|
+
const created = repoos.createTask({
|
|
71
|
+
title: body.title,
|
|
72
|
+
type: body.type,
|
|
73
|
+
area: body.area,
|
|
74
|
+
priority: body.priority,
|
|
75
|
+
assignedTo: body.assignedTo,
|
|
76
|
+
status: body.status,
|
|
77
|
+
body: taskBody,
|
|
78
|
+
originalPrompt,
|
|
79
|
+
createdBy: getCurrentUser(req, config)?.email,
|
|
80
|
+
});
|
|
81
|
+
logger.task(created.id, "info", "Task created", {
|
|
82
|
+
title: created.title,
|
|
83
|
+
type: created.type,
|
|
84
|
+
area: created.area,
|
|
85
|
+
});
|
|
86
|
+
index.applyFileChange(created.absPath);
|
|
87
|
+
commitTaskFile(config.root, created.absPath, `docs(${created.id}): add task`);
|
|
88
|
+
return json(res, 201, index.getTask(created.id));
|
|
89
|
+
};
|
|
90
|
+
export const createFreeformTask = async (ctx, req, res) => {
|
|
91
|
+
const { config, repoos, index, logger, emitEvent } = ctx;
|
|
92
|
+
const body = (await readBody(req));
|
|
93
|
+
const explanation = typeof body?.explanation === "string" ? body.explanation.trim() : "";
|
|
94
|
+
if (!explanation) {
|
|
95
|
+
return json(res, 400, { error: "explanation is required" });
|
|
96
|
+
}
|
|
97
|
+
const runId = typeof body?.runId === "string" && body.runId ? body.runId : null;
|
|
98
|
+
// #0382: when a freeform task is created from a resolved input, carry the
|
|
99
|
+
// input's screenshots onto the new task so they are linked in the task
|
|
100
|
+
// itself. The copy runs BEFORE the PM agent's rewrite so the agent's prompt
|
|
101
|
+
// already contains them (via `## Screenshots` in the draft body), and
|
|
102
|
+
// PROTECTED_SECTIONS preserves the section across the rewrite.
|
|
103
|
+
const sourceInputId = typeof body?.inputId === "string" && body.inputId ? body.inputId : null;
|
|
104
|
+
// #0251: create a draft task with the raw prompt preserved FIRST, then spawn
|
|
105
|
+
// the PM agent asynchronously to flesh it out. The draft survives a PM
|
|
106
|
+
// failure, a slow/unavailable agent, or a bad response — the user's capture
|
|
107
|
+
// is never lost.
|
|
108
|
+
const created = repoos.createTask({
|
|
109
|
+
title: explanationTitle(explanation),
|
|
110
|
+
body: explanation,
|
|
111
|
+
originalPrompt: explanation,
|
|
112
|
+
status: "draft",
|
|
113
|
+
createdBy: getCurrentUser(req, config)?.email,
|
|
114
|
+
});
|
|
115
|
+
logger.task(created.id, "info", "Task created as draft, PM agent will flesh it out", {
|
|
116
|
+
title: created.title,
|
|
117
|
+
});
|
|
118
|
+
index.applyFileChange(created.absPath);
|
|
119
|
+
commitTaskFile(config.root, created.absPath, `docs(${created.id}): add task`);
|
|
120
|
+
// Carry input attachments onto the new task before the PM rewrite (so the
|
|
121
|
+
// PM sees them in the draft body via `## Screenshots`). Each file is
|
|
122
|
+
// re-saved through `saveScreenshot` so it lands under the task's own
|
|
123
|
+
// `.attachments/<taskId>/` folder and gets a numbered name — the task is
|
|
124
|
+
// self-contained and unaffected by later input deletion (#0382).
|
|
125
|
+
//
|
|
126
|
+
// Audit round 2: track every skip explicitly. A missing file or an
|
|
127
|
+
// unsupported MIME on one attachment must not leave the carry silently
|
|
128
|
+
// incomplete — the resulting log line lists the dropped names so a
|
|
129
|
+
// human/agent reviewing the run can recover them rather than discover the
|
|
130
|
+
// gap after the input is gone.
|
|
131
|
+
if (sourceInputId) {
|
|
132
|
+
const input = listInputs(config).find((i) => i.id === sourceInputId);
|
|
133
|
+
if (input) {
|
|
134
|
+
const inputAttDir = join(config.root, config.inputsDir ?? "inputs", ".attachments", input.id);
|
|
135
|
+
let carried = 0;
|
|
136
|
+
const skipped = [];
|
|
137
|
+
for (const att of input.attachments) {
|
|
138
|
+
const srcPath = join(inputAttDir, att.name);
|
|
139
|
+
if (!existsSync(srcPath)) {
|
|
140
|
+
logger.task(created.id, "warn", `Skipping input attachment ${att.name}: file missing on disk`);
|
|
141
|
+
skipped.push(att.name);
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
const bytes = readFileSync(srcPath);
|
|
145
|
+
const result = saveScreenshot(config, created, {
|
|
146
|
+
name: att.name,
|
|
147
|
+
mime: att.mime,
|
|
148
|
+
data: bytes.toString("base64"),
|
|
149
|
+
});
|
|
150
|
+
if ("error" in result) {
|
|
151
|
+
logger.task(created.id, "warn", `Skipping input attachment ${att.name}: ${result.error}`);
|
|
152
|
+
skipped.push(att.name);
|
|
153
|
+
continue;
|
|
154
|
+
}
|
|
155
|
+
const updated = patchTaskFile(config, created.absPath, { addScreenshot: result });
|
|
156
|
+
index.applyFileChange(updated.absPath);
|
|
157
|
+
carried++;
|
|
158
|
+
}
|
|
159
|
+
if (carried > 0) {
|
|
160
|
+
logger.task(created.id, "info", `Carried ${carried} input attachment(s) onto task`, {
|
|
161
|
+
inputId: sourceInputId,
|
|
162
|
+
});
|
|
163
|
+
}
|
|
164
|
+
if (skipped.length > 0) {
|
|
165
|
+
logger.task(created.id, "warn", `Input carry incomplete: ${skipped.length}/${input.attachments.length} attachments dropped (${skipped.join(", ")})`, { inputId: sourceInputId });
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const freeformAgentName = typeof body?.agentOverride === "string" && body.agentOverride ? body.agentOverride : undefined;
|
|
170
|
+
const freeformCli = typeof body?.cliOverride === "string" && body.cliOverride ? body.cliOverride : undefined;
|
|
171
|
+
const freeformModel = typeof body?.modelOverride === "string" && body.modelOverride ? body.modelOverride : undefined;
|
|
172
|
+
// "default" is the sentinel for "use the configured pm agent's own
|
|
173
|
+
// model" — not a real pin (same bug class as resolveAgentForTask /
|
|
174
|
+
// resolveReviewerForTask / the PM-message override above).
|
|
175
|
+
const freeformModelPinned = isModelOverridePinned(freeformModel);
|
|
176
|
+
const hasFreeformOverride = freeformAgentName || freeformCli || freeformModelPinned;
|
|
177
|
+
let pm;
|
|
178
|
+
if (hasFreeformOverride) {
|
|
179
|
+
const list = agentsForConfig(config);
|
|
180
|
+
const baseName = freeformAgentName || "pm";
|
|
181
|
+
const base = list.find((a) => a.enabled && a.name === baseName) ?? null;
|
|
182
|
+
pm = base
|
|
183
|
+
? {
|
|
184
|
+
...base,
|
|
185
|
+
...(freeformCli ? { cli: freeformCli } : {}),
|
|
186
|
+
...(freeformModelPinned ? { model: freeformModel } : {}),
|
|
187
|
+
}
|
|
188
|
+
: null;
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
pm = resolvePmAgent(config);
|
|
192
|
+
}
|
|
193
|
+
// No PM agent configured: leave the draft exactly as created (the fallback
|
|
194
|
+
// behavior — the original prompt is already preserved under its heading).
|
|
195
|
+
if (!pm) {
|
|
196
|
+
return json(res, 201, {
|
|
197
|
+
ok: true,
|
|
198
|
+
fallback: true,
|
|
199
|
+
fallbackReason: "no-pm-agent",
|
|
200
|
+
task: withPmWorking(index.getTask(created.id)),
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
// 0335: flag the draft as being fleshed out RIGHT NOW — the server-side
|
|
204
|
+
// source of truth for the live "PM is working" indicator on the card and in
|
|
205
|
+
// the task panel. Cleared on every exit path below so a failed run can
|
|
206
|
+
// never leave the task looking like it is still being worked.
|
|
207
|
+
markPmWorking(created.id);
|
|
208
|
+
emitEvent({ type: "task.pmWorking", id: created.id, at: new Date().toISOString() });
|
|
209
|
+
// Spawn the PM agent asynchronously to replace the draft body with the
|
|
210
|
+
// structured version, keeping the `## Original prompt` section intact. The
|
|
211
|
+
// response is returned immediately so the user gets their draft right away.
|
|
212
|
+
void (async () => {
|
|
213
|
+
const prompt = pmPrompt(explanation);
|
|
214
|
+
try {
|
|
215
|
+
const result = await runPrompt(pm, prompt, {
|
|
216
|
+
cwd: config.root,
|
|
217
|
+
// Structured output flags (0335): `promptCommand`'s plain stdout
|
|
218
|
+
// carries no usage figures, which is why the initial PM run used to
|
|
219
|
+
// land in the usage tab as a blank row. `pmCommand` keeps the same
|
|
220
|
+
// authoring-only blast radius while letting runPrompt's foldUsage see
|
|
221
|
+
// real tokens/cost — the treatment the reviewer got in 0273.
|
|
222
|
+
command: pmCommand(pm, prompt, config.root),
|
|
223
|
+
onLine: runId
|
|
224
|
+
? (line) => {
|
|
225
|
+
// Forward the parsed structured event so the freeform progress
|
|
226
|
+
// view renders clean text, not raw JSONL.
|
|
227
|
+
emitEvent({
|
|
228
|
+
type: "agent.output",
|
|
229
|
+
id: runId,
|
|
230
|
+
entry: parseOneShotLine(pm.cli, line) ?? { s: "out", d: line },
|
|
231
|
+
stream: "out",
|
|
232
|
+
at: new Date().toISOString(),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
: undefined,
|
|
236
|
+
});
|
|
237
|
+
// Book the PM authoring pass under the task (0311) — it aggregates into
|
|
238
|
+
// the drawer's "by role" breakdown like per-task PM chats already do.
|
|
239
|
+
// With pmCommand's structured output, `result` now carries the real
|
|
240
|
+
// tokens/cost the CLI reported, so this session shows figures.
|
|
241
|
+
recordOneShotSession(config.root, pm, result, {
|
|
242
|
+
sessionType: "pm",
|
|
243
|
+
taskId: created.id,
|
|
244
|
+
sessionId: `pm-freeform:${created.id}:${new Date().toISOString()}`,
|
|
245
|
+
});
|
|
246
|
+
// Structured engines interleave step-by-step narration with the final
|
|
247
|
+
// answer — isolate the last text event before parsing the task fields.
|
|
248
|
+
const output = extractOneShotReportText(pm.cli, result.output ?? "");
|
|
249
|
+
if (!result.ok || !output) {
|
|
250
|
+
const reason = result.error ?? "the PM agent returned no usable output";
|
|
251
|
+
logger.task(created.id, "warn", "PM agent failed; keeping draft with original prompt", {
|
|
252
|
+
reason,
|
|
253
|
+
});
|
|
254
|
+
// 0320: nothing will promote this draft now — tell every client so
|
|
255
|
+
// its "AI creation in flight" marker is dropped and a later manual
|
|
256
|
+
// move of the stale draft cannot falsely flag the card.
|
|
257
|
+
emitEvent({
|
|
258
|
+
type: "task.aiCreateFailed",
|
|
259
|
+
id: created.id,
|
|
260
|
+
reason,
|
|
261
|
+
at: new Date().toISOString(),
|
|
262
|
+
});
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
const fields = parseGeneratedTask(output);
|
|
266
|
+
// #0345: a response with no frontmatter is not the requested file
|
|
267
|
+
// content — it's typically the agent narrating what it (claims to
|
|
268
|
+
// have) done instead of emitting the file. Treat it as a failed
|
|
269
|
+
// generation and keep the draft untouched, rather than overwriting the
|
|
270
|
+
// user's original prompt and structure with the agent's stray prose.
|
|
271
|
+
if (!fields.title || !fields.body || !fields.hadFrontmatter) {
|
|
272
|
+
logger.task(created.id, "warn", "PM agent returned unusable output; keeping draft", {
|
|
273
|
+
hadFrontmatter: fields.hadFrontmatter,
|
|
274
|
+
});
|
|
275
|
+
emitEvent({
|
|
276
|
+
type: "task.aiCreateFailed",
|
|
277
|
+
id: created.id,
|
|
278
|
+
reason: "the PM agent returned unusable output",
|
|
279
|
+
at: new Date().toISOString(),
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
// Keep the raw prompt section, then promote the fleshed-out task to the
|
|
284
|
+
// config's default status (usually "inbox") so it lands on the board like
|
|
285
|
+
// the pre-0251 flow did, instead of lingering as a draft.
|
|
286
|
+
const finalBody = withOriginalPromptSection(fields.body, explanation);
|
|
287
|
+
const updated = patchTaskFile(config, created.absPath, {
|
|
288
|
+
title: fields.title,
|
|
289
|
+
type: fields.type,
|
|
290
|
+
priority: fields.priority,
|
|
291
|
+
area: fields.area,
|
|
292
|
+
assignedTo: fields.assignedTo,
|
|
293
|
+
body: finalBody,
|
|
294
|
+
status: config.defaultStatus,
|
|
295
|
+
}, { onStatusChange: ctx.onServerStatusChange });
|
|
296
|
+
index.applyFileChange(updated.absPath);
|
|
297
|
+
logger.task(created.id, "info", "PM agent fleshed out draft task", {
|
|
298
|
+
title: updated.title,
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
303
|
+
logger.task(created.id, "warn", "PM agent update failed; keeping draft with original prompt", {
|
|
304
|
+
reason,
|
|
305
|
+
});
|
|
306
|
+
emitEvent({
|
|
307
|
+
type: "task.aiCreateFailed",
|
|
308
|
+
id: created.id,
|
|
309
|
+
reason,
|
|
310
|
+
at: new Date().toISOString(),
|
|
311
|
+
});
|
|
312
|
+
}
|
|
313
|
+
finally {
|
|
314
|
+
// 0335: cleared on EVERY exit path — success (the promotion's own
|
|
315
|
+
// task.updated event lands first, so the card swaps its indicator for
|
|
316
|
+
// its new column), failure, or a thrown error — so the indicator can
|
|
317
|
+
// never get stuck showing "working". 0381: a live PM chat session on
|
|
318
|
+
// this task keeps the flag up — only emit pmFinished when nothing
|
|
319
|
+
// else is still working it.
|
|
320
|
+
clearPmWorking(created.id);
|
|
321
|
+
if (!isPmWorking(created.id)) {
|
|
322
|
+
emitEvent({ type: "task.pmFinished", id: created.id, at: new Date().toISOString() });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
})();
|
|
326
|
+
return json(res, 201, {
|
|
327
|
+
ok: true,
|
|
328
|
+
fallback: false,
|
|
329
|
+
task: withPmWorking(index.getTask(created.id)),
|
|
330
|
+
});
|
|
331
|
+
};
|
|
332
|
+
export const getTask = (ctx, _req, res, params) => {
|
|
333
|
+
const { config, index, previews, reviews } = ctx;
|
|
334
|
+
const id = params.param1;
|
|
335
|
+
const t = index.getTask(id);
|
|
336
|
+
return t
|
|
337
|
+
? json(res, 200, {
|
|
338
|
+
...withPmWorking(withReviewStatus(withPreviewTargets(t, config), reviews)),
|
|
339
|
+
preview: previews.get(t.id) ?? null,
|
|
340
|
+
})
|
|
341
|
+
: json(res, 404, { error: `Task #${id} not found` });
|
|
342
|
+
};
|
|
343
|
+
export const patchTask = async (ctx, req, res, params) => {
|
|
344
|
+
const { config, index, reviews, runner, logger, onServerStatusChange, syncTaskBranch } = ctx;
|
|
345
|
+
const id = params.param1;
|
|
346
|
+
const existing = index.getTask(id);
|
|
347
|
+
if (!existing) {
|
|
348
|
+
return json(res, 404, { error: `Task #${id} not found` });
|
|
349
|
+
}
|
|
350
|
+
const body = (await readBody(req));
|
|
351
|
+
const prevStatus = existing.status;
|
|
352
|
+
if (body.status === "done" && prevStatus !== "done") {
|
|
353
|
+
if (reviews.isRunning(existing.id)) {
|
|
354
|
+
return json(res, 409, {
|
|
355
|
+
error: `Task #${existing.id} is waiting for automatic review to finish`,
|
|
356
|
+
});
|
|
357
|
+
}
|
|
358
|
+
return json(res, 400, {
|
|
359
|
+
error: `Use POST /api/tasks/${existing.id}/done to complete a review task`,
|
|
360
|
+
});
|
|
361
|
+
}
|
|
362
|
+
if (body.status && body.status !== prevStatus) {
|
|
363
|
+
// The lifecycle audit's transition table: a bare status write may only
|
|
364
|
+
// perform the six edges with no side effect requiring a dedicated
|
|
365
|
+
// action endpoint (start/pause/done/abandon/reopen). Rejects every other
|
|
366
|
+
// pair outright — including every skip-a-step jump and unreviewed
|
|
367
|
+
// backward move — regardless of whether the request came from the task
|
|
368
|
+
// drawer's dropdown, board drag-drop, or a direct API call.
|
|
369
|
+
const check = checkGenericStatusPatch(prevStatus, body.status);
|
|
370
|
+
if (!check.ok) {
|
|
371
|
+
return json(res, 400, {
|
|
372
|
+
error: `Cannot move task #${existing.id} from ${prevStatus} to ${body.status}: ${check.reason}`,
|
|
373
|
+
});
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (body.status === "review" && prevStatus !== "review") {
|
|
377
|
+
// #0210: any transition into `review` must pass the same commit+validate
|
|
378
|
+
// gate the trusted handoff path enforces — never silently leave an
|
|
379
|
+
// uncommitted worktree, and never allow a vacuous (zero source changes)
|
|
380
|
+
// transition unless the task opts out via no_source_change.
|
|
381
|
+
const gate = await guardReviewTransition(config, existing);
|
|
382
|
+
if (!gate.ok) {
|
|
383
|
+
return json(res, 400, {
|
|
384
|
+
error: `Cannot move task #${existing.id} to review: ${gate.detail}`,
|
|
385
|
+
});
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
const updated = patchTaskFile(config, existing.absPath, body, {
|
|
389
|
+
onStatusChange: onServerStatusChange,
|
|
390
|
+
});
|
|
391
|
+
if (body.status && body.status !== prevStatus) {
|
|
392
|
+
logger.task(id, "info", `Task status changed`, {
|
|
393
|
+
from: prevStatus,
|
|
394
|
+
to: body.status,
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
// Guarded: the #0210 gate already ran above for transitions into review.
|
|
398
|
+
index.applyFileChange(updated.absPath, { guarded: true });
|
|
399
|
+
if (prevStatus !== "review" &&
|
|
400
|
+
updated.status === "review" &&
|
|
401
|
+
updated.branch &&
|
|
402
|
+
!runner.isRunning(updated.id)) {
|
|
403
|
+
void syncTaskBranch(updated);
|
|
404
|
+
}
|
|
405
|
+
return json(res, 200, index.getTask(updated.id));
|
|
406
|
+
};
|
|
407
|
+
export const deleteTask = async (ctx, _req, res, params) => {
|
|
408
|
+
const { config, index, logger, previews } = ctx;
|
|
409
|
+
const id = params.param1;
|
|
410
|
+
const existing = index.getTask(id);
|
|
411
|
+
if (!existing) {
|
|
412
|
+
return json(res, 404, { error: `Task #${id} not found` });
|
|
413
|
+
}
|
|
414
|
+
await previews.stop(id);
|
|
415
|
+
try {
|
|
416
|
+
deleteTaskFile(config, existing.absPath);
|
|
417
|
+
}
|
|
418
|
+
catch (err) {
|
|
419
|
+
if (err instanceof PathGuardError) {
|
|
420
|
+
return json(res, 400, { error: err.message });
|
|
421
|
+
}
|
|
422
|
+
return json(res, 404, { error: `Task #${id} not found` });
|
|
423
|
+
}
|
|
424
|
+
logger.task(id, "info", "Task deleted", { title: existing.title });
|
|
425
|
+
index.applyFileDelete(existing.absPath);
|
|
426
|
+
return json(res, 200, { ok: true });
|
|
427
|
+
};
|
|
428
|
+
export const getScreenshot = (ctx, _req, res, params) => {
|
|
429
|
+
const { config } = ctx;
|
|
430
|
+
const taskId = params.param1;
|
|
431
|
+
const filename = params.param2;
|
|
432
|
+
const abs = resolveScreenshot(config, taskId, filename);
|
|
433
|
+
if (!abs) {
|
|
434
|
+
return json(res, 404, { error: "Attachment not found" });
|
|
435
|
+
}
|
|
436
|
+
const mime = mimeForExtension(abs);
|
|
437
|
+
res.writeHead(200, {
|
|
438
|
+
"Content-Type": mime ?? "application/octet-stream",
|
|
439
|
+
"Cache-Control": "no-cache",
|
|
440
|
+
"Access-Control-Allow-Origin": "*",
|
|
441
|
+
});
|
|
442
|
+
res.end(readFileSync(abs));
|
|
443
|
+
};
|
|
444
|
+
export const uploadScreenshot = async (ctx, req, res, params) => {
|
|
445
|
+
const { config, index } = ctx;
|
|
446
|
+
const taskId = params.param1;
|
|
447
|
+
const task = index.getTask(taskId);
|
|
448
|
+
if (!task) {
|
|
449
|
+
return json(res, 404, { error: `Task #${taskId} not found` });
|
|
450
|
+
}
|
|
451
|
+
const body = (await readBody(req));
|
|
452
|
+
const result = saveScreenshot(config, task, body ?? {});
|
|
453
|
+
if ("error" in result) {
|
|
454
|
+
return json(res, 400, { error: result.error });
|
|
455
|
+
}
|
|
456
|
+
const updated = patchTaskFile(config, task.absPath, { addScreenshot: result });
|
|
457
|
+
index.applyFileChange(updated.absPath);
|
|
458
|
+
return json(res, 201, { ok: true, attachment: result });
|
|
459
|
+
};
|
|
460
|
+
// Task logs
|
|
461
|
+
export const getTaskLogs = (ctx, _req, res, params) => {
|
|
462
|
+
const { logger } = ctx;
|
|
463
|
+
const id = params.param1;
|
|
464
|
+
const limit = 1000;
|
|
465
|
+
const logs = logger.getTaskLogs(id, limit);
|
|
466
|
+
return json(res, 200, { ok: true, logs });
|
|
467
|
+
};
|
|
468
|
+
// Task output
|
|
469
|
+
export const getTaskOutput = (ctx, _req, res, params) => {
|
|
470
|
+
const { runner } = ctx;
|
|
471
|
+
const id = params.param1;
|
|
472
|
+
const session = runner.output(id);
|
|
473
|
+
return json(res, 200, {
|
|
474
|
+
ok: true,
|
|
475
|
+
lines: session?.lines ?? [],
|
|
476
|
+
stats: runner.stats(id),
|
|
477
|
+
});
|
|
478
|
+
};
|
|
479
|
+
// Task actions: start, pause, message, done, sync
|
|
480
|
+
export const taskAction = async (ctx, req, res, params) => {
|
|
481
|
+
const { config, index, runner, previews, reviews, syncTaskBranch, onServerStatusChange } = ctx;
|
|
482
|
+
const id = params.param1;
|
|
483
|
+
const action = params.param2;
|
|
484
|
+
let existing = index.getTask(id);
|
|
485
|
+
if (!existing) {
|
|
486
|
+
return json(res, 404, { error: `Task #${id} not found` });
|
|
487
|
+
}
|
|
488
|
+
// Hotfix activation switches the root checkout and rewrites this task file.
|
|
489
|
+
// A rapid follow-up /start must use that on-disk state, not a snapshot that
|
|
490
|
+
// was captured before the switch and can still point at a normal worktree.
|
|
491
|
+
if (action === "start") {
|
|
492
|
+
try {
|
|
493
|
+
existing = parseTask({
|
|
494
|
+
content: readFileSync(existing.absPath, "utf8"),
|
|
495
|
+
absPath: existing.absPath,
|
|
496
|
+
root: config.root,
|
|
497
|
+
defaultStatus: config.defaultStatus,
|
|
498
|
+
defaultAssignee: config.defaultAssignee,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
catch (error) {
|
|
502
|
+
return json(res, 500, {
|
|
503
|
+
error: `Could not refresh task #${id} before starting: ${error.message}`,
|
|
504
|
+
});
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
if (action === "start") {
|
|
508
|
+
if (existing.status !== "ready" && existing.status !== "active") {
|
|
509
|
+
return json(res, 400, {
|
|
510
|
+
error: `Only ready or paused tasks can be started (#${id} is ${existing.status})`,
|
|
511
|
+
});
|
|
512
|
+
}
|
|
513
|
+
if (runner.isRunning(id)) {
|
|
514
|
+
return json(res, 400, { error: `Task #${id} is already running` });
|
|
515
|
+
}
|
|
516
|
+
const agent = resolveAgentForTask(config, existing);
|
|
517
|
+
if (!agent) {
|
|
518
|
+
return json(res, 400, {
|
|
519
|
+
error: "No enabled engineer agent is configured on the Agents page",
|
|
520
|
+
});
|
|
521
|
+
}
|
|
522
|
+
const body = (await readBody(req));
|
|
523
|
+
const clean = body?.mode === "clean" && !existing.hotfix;
|
|
524
|
+
// A task returned from review needs its repair brief in the initial
|
|
525
|
+
// resumed turn. Sending it as a follow-up would race the agent's start
|
|
526
|
+
// and commonly be rejected while the new turn is already running.
|
|
527
|
+
const instruction = typeof body?.instruction === "string" ? body.instruction.trim() : "";
|
|
528
|
+
const branch = existing.branch || deriveBranch(existing.title);
|
|
529
|
+
if (clean) {
|
|
530
|
+
if (!existing.branch) {
|
|
531
|
+
return json(res, 400, {
|
|
532
|
+
error: `Task #${id} has no worktree yet — start normally instead`,
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
if (!resetWorktree(config.root, branch)) {
|
|
536
|
+
return json(res, 400, {
|
|
537
|
+
error: `Could not reset the worktree for ${branch} — is it the main checkout?`,
|
|
538
|
+
});
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
const isHotfix = existing.hotfix === true;
|
|
542
|
+
const patch = { status: "active", needsInput: false };
|
|
543
|
+
if (!existing.branch)
|
|
544
|
+
patch.branch = branch;
|
|
545
|
+
// Patch (and commit) the task file in main BEFORE forking the worktree's
|
|
546
|
+
// branch — ensureWorktree forks from main's current committed HEAD, so
|
|
547
|
+
// doing this after would freeze the new branch's task-file copy at the
|
|
548
|
+
// pre-activation state (status: ready, branch: "") forever, since nothing
|
|
549
|
+
// later syncs main's corrected metadata back into an already-forked
|
|
550
|
+
// branch. That stale copy then fails handoff finalization's branch check.
|
|
551
|
+
const updated = patchTaskFile(config, existing.absPath, patch, {
|
|
552
|
+
onStatusChange: onServerStatusChange,
|
|
553
|
+
});
|
|
554
|
+
const wtRes = isHotfix
|
|
555
|
+
? { ok: true, path: config.root, created: false }
|
|
556
|
+
: ensureWorktree(config.root, branch);
|
|
557
|
+
index.applyFileChange(updated.absPath);
|
|
558
|
+
index.refreshBranches();
|
|
559
|
+
const cwd = wtRes.ok ? wtRes.path : config.root;
|
|
560
|
+
const taskForLaunch = index.getTask(updated.id) ?? updated;
|
|
561
|
+
const bootResult = await bootstrap(config, taskForLaunch, branch, cwd);
|
|
562
|
+
if (!bootResult.ok) {
|
|
563
|
+
return json(res, 500, {
|
|
564
|
+
ok: false,
|
|
565
|
+
error: `Bootstrap failed: ${bootResult.reason ?? "unknown error"}`,
|
|
566
|
+
bootstrap: {
|
|
567
|
+
ok: false,
|
|
568
|
+
durationMs: bootResult.durationMs,
|
|
569
|
+
steps: bootResult.steps.map((s) => ({
|
|
570
|
+
name: s.name,
|
|
571
|
+
ok: s.ok,
|
|
572
|
+
durationMs: s.durationMs,
|
|
573
|
+
detail: s.detail,
|
|
574
|
+
})),
|
|
575
|
+
},
|
|
576
|
+
});
|
|
577
|
+
}
|
|
578
|
+
const pack = generateContextPack(config, taskForLaunch, branch, cwd, bootResult);
|
|
579
|
+
const resumeContext = clean || !existing.branch ? undefined : resumePreamble(config, taskForLaunch, branch, cwd);
|
|
580
|
+
const preamble = [resumeContext, instruction].filter(Boolean).join("\n\n") || undefined;
|
|
581
|
+
const spawnRes = runner.start(taskForLaunch, branch, agent, {
|
|
582
|
+
cwd,
|
|
583
|
+
contextPack: pack.content,
|
|
584
|
+
resumePreamble: preamble,
|
|
585
|
+
});
|
|
586
|
+
return json(res, 200, {
|
|
587
|
+
ok: true,
|
|
588
|
+
task: index.getTask(updated.id),
|
|
589
|
+
branch,
|
|
590
|
+
clean,
|
|
591
|
+
git: wtRes.ok ? "ok" : (wtRes.reason ?? "unknown"),
|
|
592
|
+
worktree: wtRes.ok ? wtRes.path : undefined,
|
|
593
|
+
spawn: {
|
|
594
|
+
ok: spawnRes.ok,
|
|
595
|
+
pid: spawnRes.pid,
|
|
596
|
+
queued: spawnRes.queued,
|
|
597
|
+
reason: spawnRes.reason,
|
|
598
|
+
},
|
|
599
|
+
bootstrap: {
|
|
600
|
+
ok: bootResult.ok,
|
|
601
|
+
durationMs: bootResult.durationMs,
|
|
602
|
+
steps: bootResult.steps.map((s) => ({
|
|
603
|
+
name: s.name,
|
|
604
|
+
ok: s.ok,
|
|
605
|
+
durationMs: s.durationMs,
|
|
606
|
+
})),
|
|
607
|
+
},
|
|
608
|
+
context: {
|
|
609
|
+
cacheHit: pack.cacheHit,
|
|
610
|
+
generationMs: pack.generationMs,
|
|
611
|
+
size: pack.size,
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
}
|
|
615
|
+
if (action === "done") {
|
|
616
|
+
// Branch-less release (2026-08-15): a task fixed by a direct commit on
|
|
617
|
+
// main (a hotfix — see #0212, not yet a first-class flow) has nothing to
|
|
618
|
+
// merge. Routing it through the branch-merge close-out pipeline below
|
|
619
|
+
// just dead-ends on "no branch to merge" — that's not a rejection of the
|
|
620
|
+
// task, it's the wrong pipeline for it. This is a separate, self-contained
|
|
621
|
+
// path: verify main is currently green, then release directly. It never
|
|
622
|
+
// touches the job queue or the repo lock, since there is no merge to
|
|
623
|
+
// serialize against other close-outs.
|
|
624
|
+
if (isBranchlessReleaseEligible(existing)) {
|
|
625
|
+
if (runner.isRunning(id)) {
|
|
626
|
+
return json(res, 409, { error: `Task #${id} has an agent turn in progress` });
|
|
627
|
+
}
|
|
628
|
+
const result = await releaseBranchless(config, existing);
|
|
629
|
+
if (!result.ok) {
|
|
630
|
+
return json(res, 400, { error: result.reason });
|
|
631
|
+
}
|
|
632
|
+
index.applyFileChange(result.task.absPath);
|
|
633
|
+
return json(res, 200, index.getTask(id));
|
|
634
|
+
}
|
|
635
|
+
if (existing.status !== "review") {
|
|
636
|
+
return json(res, 400, {
|
|
637
|
+
error: `Only review tasks can be completed (#${id} is ${existing.status})`,
|
|
638
|
+
});
|
|
639
|
+
}
|
|
640
|
+
// A branch-less task in review is unreachable in practice (nothing sets
|
|
641
|
+
// status: review without a branch), but keep the guard as defense in
|
|
642
|
+
// depth — the branch-less release path above only handles non-review
|
|
643
|
+
// statuses, by design, so it must not silently fall through here.
|
|
644
|
+
if (!existing.branch) {
|
|
645
|
+
return json(res, 400, { error: `Task #${id} has no branch to merge` });
|
|
646
|
+
}
|
|
647
|
+
if (runner.isRunning(id)) {
|
|
648
|
+
return json(res, 409, {
|
|
649
|
+
error: `Task #${id} has an agent turn in progress`,
|
|
650
|
+
});
|
|
651
|
+
}
|
|
652
|
+
if (reviews.isRunning(id)) {
|
|
653
|
+
return json(res, 409, {
|
|
654
|
+
error: `Task #${id} is waiting for automatic review to finish`,
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
await previews.stop(id);
|
|
658
|
+
reviews.cancel(id);
|
|
659
|
+
void runner.stop(id);
|
|
660
|
+
// Guard against task deletion mid-close-out (0118): re-validate the task
|
|
661
|
+
// exists and still has a branch before enqueueing.
|
|
662
|
+
const taskStillExists = index.getTask(id);
|
|
663
|
+
if (!taskStillExists || !taskStillExists.branch) {
|
|
664
|
+
return json(res, 400, {
|
|
665
|
+
error: `Task #${id} was deleted or lost its branch before close-out could start`,
|
|
666
|
+
});
|
|
667
|
+
}
|
|
668
|
+
// Stale-lock guard (0204): a stale repository lock blocks all close-outs.
|
|
669
|
+
// Check before enqueueing; if stale, clear it automatically so the user
|
|
670
|
+
// doesn't hit a cryptic "could not acquire publication lock" failure.
|
|
671
|
+
const lockPath = join(config.root, ".repoos/close-out.lock");
|
|
672
|
+
if (existsSync(lockPath)) {
|
|
673
|
+
try {
|
|
674
|
+
const lockStat = statSync(lockPath);
|
|
675
|
+
const age = Date.now() - lockStat.mtime.getTime();
|
|
676
|
+
if (age > 60_000) {
|
|
677
|
+
unlinkSync(lockPath);
|
|
678
|
+
}
|
|
679
|
+
}
|
|
680
|
+
catch {
|
|
681
|
+
// Best-effort
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
// Dirty-main guard (0204): a dirty working tree on `main` aborts the
|
|
685
|
+
// close-out merge. Check before enqueueing; if dirty files exist and the
|
|
686
|
+
// user has not opted in via "Commit & continue", hand the list back so the
|
|
687
|
+
// UI can show a confirmation modal and pause the close-out. The task stays
|
|
688
|
+
// in review until the user decides.
|
|
689
|
+
//
|
|
690
|
+
// Fails closed (#0211): if the dirty check itself errors or times out we
|
|
691
|
+
// cannot assert the tree is clean, so the close-out is refused rather than
|
|
692
|
+
// enqueued against a tree that git may abort at publish time. An unknown
|
|
693
|
+
// state must never silently look clean.
|
|
694
|
+
let dirty;
|
|
695
|
+
try {
|
|
696
|
+
dirty = await dirtyFiles(config.root);
|
|
697
|
+
}
|
|
698
|
+
catch (err) {
|
|
699
|
+
if (err instanceof GitDirtyCheckError) {
|
|
700
|
+
return json(res, 409, {
|
|
701
|
+
error: `could not verify main is clean before close-out (${err.message}). Retry, or commit/stash main's working tree and try again.`,
|
|
702
|
+
needsCommit: true,
|
|
703
|
+
dirtyFiles: [],
|
|
704
|
+
dirtyCheckFailed: true,
|
|
705
|
+
causeKind: err.causeKind,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
throw err;
|
|
709
|
+
}
|
|
710
|
+
const doneBody = (await readBody(req));
|
|
711
|
+
const commitDirty = doneBody?.commitDirty === true;
|
|
712
|
+
if (dirty.length > 0 && !commitDirty) {
|
|
713
|
+
return json(res, 409, {
|
|
714
|
+
error: `main has ${dirty.length} uncommitted file${dirty.length === 1 ? "" : "s"} blocking close-out`,
|
|
715
|
+
needsCommit: true,
|
|
716
|
+
dirtyFiles: dirty,
|
|
717
|
+
});
|
|
718
|
+
}
|
|
719
|
+
if (dirty.length > 0 && commitDirty) {
|
|
720
|
+
let committed;
|
|
721
|
+
try {
|
|
722
|
+
committed = await commitDirtyFiles(config.root, `chore: checkpoint before close-out (#${id})`);
|
|
723
|
+
}
|
|
724
|
+
catch (err) {
|
|
725
|
+
if (err instanceof GitDirtyCheckError) {
|
|
726
|
+
return json(res, 500, {
|
|
727
|
+
error: `could not re-verify main while committing dirty files (${err.message}). Close-out aborted; nothing was merged.`,
|
|
728
|
+
needsCommit: true,
|
|
729
|
+
dirtyFiles: dirty,
|
|
730
|
+
dirtyCheckFailed: true,
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
throw err;
|
|
734
|
+
}
|
|
735
|
+
if (committed.length !== dirty.length) {
|
|
736
|
+
return json(res, 500, {
|
|
737
|
+
error: `auto-commit of ${dirty.length} dirty file${dirty.length === 1 ? "" : "s"} failed on main`,
|
|
738
|
+
needsCommit: true,
|
|
739
|
+
dirtyFiles: dirty,
|
|
740
|
+
});
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
// Enqueue the close-out job (idempotent per task).
|
|
744
|
+
const job = ctx.jobCoordinator.enqueue(taskStillExists);
|
|
745
|
+
if (!job) {
|
|
746
|
+
return json(res, 400, { error: `Task #${id} has no branch to merge` });
|
|
747
|
+
}
|
|
748
|
+
// Reflect the new queue entry in the pinned status bar immediately (0207),
|
|
749
|
+
// even before job processing's own snapshot emission picks it up.
|
|
750
|
+
ctx.emitEvent({
|
|
751
|
+
type: "integration",
|
|
752
|
+
pipeline: buildIntegrationSnapshot(ctx.jobCoordinator, {}),
|
|
753
|
+
});
|
|
754
|
+
// Trigger job processing to start the pipeline.
|
|
755
|
+
ctx.triggerJobProcessing();
|
|
756
|
+
// Return the job status to the client.
|
|
757
|
+
return json(res, 200, {
|
|
758
|
+
ok: true,
|
|
759
|
+
job: {
|
|
760
|
+
taskId: job.taskId,
|
|
761
|
+
phase: job.phase,
|
|
762
|
+
enqueuedAt: job.enqueuedAt,
|
|
763
|
+
startedAt: job.startedAt,
|
|
764
|
+
queuePosition: ctx.jobCoordinator.allJobs().findIndex((j) => j.taskId === job.taskId),
|
|
765
|
+
},
|
|
766
|
+
});
|
|
767
|
+
}
|
|
768
|
+
if (action === "sync") {
|
|
769
|
+
if (!existing.branch) {
|
|
770
|
+
return json(res, 400, { error: `Task #${id} has no branch to sync` });
|
|
771
|
+
}
|
|
772
|
+
if (runner.isRunning(id)) {
|
|
773
|
+
return json(res, 409, {
|
|
774
|
+
error: `Task #${id} has an agent turn in progress`,
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
if (reviews.isRunning(id)) {
|
|
778
|
+
return json(res, 409, {
|
|
779
|
+
error: `Task #${id} has a review in progress`,
|
|
780
|
+
});
|
|
781
|
+
}
|
|
782
|
+
const sync = await syncTaskBranch(existing);
|
|
783
|
+
index.refreshBranches();
|
|
784
|
+
return json(res, sync.ok ? 200 : 409, {
|
|
785
|
+
ok: sync.ok,
|
|
786
|
+
conflicts: sync.conflicts,
|
|
787
|
+
error: sync.reason,
|
|
788
|
+
});
|
|
789
|
+
}
|
|
790
|
+
if (action === "message") {
|
|
791
|
+
if (existing.status !== "active" && existing.status !== "review") {
|
|
792
|
+
return json(res, 400, {
|
|
793
|
+
error: `Only active or review tasks accept messages (#${id} is ${existing.status})`,
|
|
794
|
+
});
|
|
795
|
+
}
|
|
796
|
+
const agent = resolveAgentForTask(config, existing);
|
|
797
|
+
if (!agent) {
|
|
798
|
+
return json(res, 400, {
|
|
799
|
+
error: "No enabled engineer agent is configured on the Agents page",
|
|
800
|
+
});
|
|
801
|
+
}
|
|
802
|
+
const body = (await readBody(req));
|
|
803
|
+
const text = typeof body?.text === "string" ? body.text.trim() : "";
|
|
804
|
+
if (!text) {
|
|
805
|
+
return json(res, 400, { error: "message text is required" });
|
|
806
|
+
}
|
|
807
|
+
if (existing.needsInput) {
|
|
808
|
+
const cleared = patchTaskFile(config, existing.absPath, { needsInput: false });
|
|
809
|
+
index.applyFileChange(cleared.absPath);
|
|
810
|
+
}
|
|
811
|
+
let preamble;
|
|
812
|
+
if (existing.branch) {
|
|
813
|
+
const wtPath = worktreePathForBranch(config.root, existing.branch);
|
|
814
|
+
if (wtPath) {
|
|
815
|
+
preamble = resumePreamble(config, existing, existing.branch, wtPath) || undefined;
|
|
816
|
+
}
|
|
817
|
+
}
|
|
818
|
+
const sendRes = runner.send(id, text, agent, { resumePreamble: preamble });
|
|
819
|
+
if (!sendRes.ok && sendRes.busy) {
|
|
820
|
+
return json(res, 409, { error: sendRes.reason ?? "agent is busy" });
|
|
821
|
+
}
|
|
822
|
+
if (!sendRes.ok) {
|
|
823
|
+
return json(res, 400, { error: sendRes.reason ?? "could not send message" });
|
|
824
|
+
}
|
|
825
|
+
return json(res, 200, {
|
|
826
|
+
ok: true,
|
|
827
|
+
spawn: { ok: true, pid: sendRes.pid },
|
|
828
|
+
});
|
|
829
|
+
}
|
|
830
|
+
if (action === "pause") {
|
|
831
|
+
if (existing.status !== "active") {
|
|
832
|
+
return json(res, 400, {
|
|
833
|
+
error: `Only active tasks can be paused (#${id} is ${existing.status})`,
|
|
834
|
+
});
|
|
835
|
+
}
|
|
836
|
+
const stopRes = runner.stop(id);
|
|
837
|
+
// A human pause is legitimate: the task stays active with no process, so
|
|
838
|
+
// tell the runner — the task watchdog must never disturb it (#0180).
|
|
839
|
+
runner.markPaused(id);
|
|
840
|
+
const updated = patchTaskFile(config, existing.absPath, {
|
|
841
|
+
needsInput: false,
|
|
842
|
+
}, {
|
|
843
|
+
onStatusChange: onServerStatusChange,
|
|
844
|
+
});
|
|
845
|
+
index.applyFileChange(updated.absPath);
|
|
846
|
+
return json(res, 200, {
|
|
847
|
+
ok: true,
|
|
848
|
+
task: index.getTask(updated.id),
|
|
849
|
+
stopped: stopRes.stopped,
|
|
850
|
+
reason: stopRes.reason,
|
|
851
|
+
});
|
|
852
|
+
}
|
|
853
|
+
// Abandon (lifecycle audit, task-transitions.ts): active or review -> ready,
|
|
854
|
+
// stopping any live agent/review first. Unlike pause (which stays active,
|
|
855
|
+
// preserving the in-progress turn to resume later), abandon is a deliberate
|
|
856
|
+
// "start this task's dev pass over" — the worktree is kept, not deleted, so
|
|
857
|
+
// Start work can still resume from it, but the task no longer reads as
|
|
858
|
+
// in-flight.
|
|
859
|
+
if (action === "abandon") {
|
|
860
|
+
if (existing.status !== "active" && existing.status !== "review") {
|
|
861
|
+
return json(res, 400, {
|
|
862
|
+
error: `Only active or review tasks can be abandoned (#${id} is ${existing.status})`,
|
|
863
|
+
});
|
|
864
|
+
}
|
|
865
|
+
if (existing.status === "review") {
|
|
866
|
+
reviews.cancel(id);
|
|
867
|
+
}
|
|
868
|
+
await previews.stop(id);
|
|
869
|
+
const stopRes = runner.stop(id);
|
|
870
|
+
const updated = patchTaskFile(config, existing.absPath, { status: "ready", needsInput: false }, { onStatusChange: onServerStatusChange });
|
|
871
|
+
index.applyFileChange(updated.absPath);
|
|
872
|
+
return json(res, 200, {
|
|
873
|
+
ok: true,
|
|
874
|
+
task: index.getTask(updated.id),
|
|
875
|
+
stopped: stopRes.stopped,
|
|
876
|
+
reason: stopRes.reason,
|
|
877
|
+
});
|
|
878
|
+
}
|
|
879
|
+
// Reopen (lifecycle audit, task-transitions.ts): done -> ready. Close-out's
|
|
880
|
+
// cleanup() deletes the task's branch and worktree, so re-provisioning them
|
|
881
|
+
// here would just duplicate what Start work (ready -> active) already does
|
|
882
|
+
// — clear the now-stale branch reference and land on ready instead, so the
|
|
883
|
+
// next Start work derives a fresh branch exactly like a brand-new task.
|
|
884
|
+
if (action === "reopen") {
|
|
885
|
+
if (existing.status !== "done") {
|
|
886
|
+
return json(res, 400, {
|
|
887
|
+
error: `Only done tasks can be reopened (#${id} is ${existing.status})`,
|
|
888
|
+
});
|
|
889
|
+
}
|
|
890
|
+
const updated = patchTaskFile(config, existing.absPath, { status: "ready", branch: "", needsInput: false }, { onStatusChange: onServerStatusChange });
|
|
891
|
+
index.applyFileChange(updated.absPath);
|
|
892
|
+
return json(res, 200, { ok: true, task: index.getTask(updated.id) });
|
|
893
|
+
}
|
|
894
|
+
if (action === "hotfix") {
|
|
895
|
+
if (existing.status !== "ready" && !existing.hotfix) {
|
|
896
|
+
return json(res, 400, {
|
|
897
|
+
error: `Only ready tasks can be switched to hotfix (#${id} is ${existing.status})`,
|
|
898
|
+
});
|
|
899
|
+
}
|
|
900
|
+
if (existing.hotfix) {
|
|
901
|
+
return json(res, 400, {
|
|
902
|
+
error: `Task #${id} is already in hotfix mode`,
|
|
903
|
+
});
|
|
904
|
+
}
|
|
905
|
+
const body = (await readBody(req));
|
|
906
|
+
const hotfixTarget = body?.hotfixTarget === "main" ? "main" : "branch";
|
|
907
|
+
const branch = `hotfix/${existing.id}-${existing.title
|
|
908
|
+
.toLowerCase()
|
|
909
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
910
|
+
.replace(/^-|-$/g, "")
|
|
911
|
+
.slice(0, 40)}`;
|
|
912
|
+
const rootLock = ctx.rootLock;
|
|
913
|
+
if (!rootLock.acquire(existing.id, "hotfix")) {
|
|
914
|
+
const holder = rootLock.getHolder();
|
|
915
|
+
return json(res, 409, {
|
|
916
|
+
error: `The main checkout is held by ${holder?.kind ?? "another operation"} (task #${holder?.taskId ?? "unknown"}) — wait for it to finish`,
|
|
917
|
+
});
|
|
918
|
+
}
|
|
919
|
+
let dirty;
|
|
920
|
+
try {
|
|
921
|
+
dirty = await dirtyFiles(config.root);
|
|
922
|
+
}
|
|
923
|
+
catch (err) {
|
|
924
|
+
rootLock.release(existing.id);
|
|
925
|
+
if (err instanceof GitDirtyCheckError) {
|
|
926
|
+
return json(res, 409, {
|
|
927
|
+
error: `could not verify main is clean before hotfix (${err.message}). Commit or stash first.`,
|
|
928
|
+
needsCommit: true,
|
|
929
|
+
dirtyCheckFailed: true,
|
|
930
|
+
causeKind: err.causeKind,
|
|
931
|
+
});
|
|
932
|
+
}
|
|
933
|
+
throw err;
|
|
934
|
+
}
|
|
935
|
+
if (dirty.length > 0) {
|
|
936
|
+
rootLock.release(existing.id);
|
|
937
|
+
return json(res, 409, {
|
|
938
|
+
error: `main has ${dirty.length} uncommitted file${dirty.length === 1 ? "" : "s"} — commit or stash before starting a hotfix`,
|
|
939
|
+
needsCommit: true,
|
|
940
|
+
dirtyFiles: dirty,
|
|
941
|
+
});
|
|
942
|
+
}
|
|
943
|
+
// The lock must precede `ensureHotfix`: checking out the hotfix branch is
|
|
944
|
+
// itself a mutation of the root checkout and can race a close-out merge.
|
|
945
|
+
const hotfixRes = ensureHotfix(config.root, branch, hotfixTarget);
|
|
946
|
+
if (!hotfixRes.ok) {
|
|
947
|
+
rootLock.release(existing.id);
|
|
948
|
+
return json(res, 400, {
|
|
949
|
+
error: `Cannot switch to hotfix: ${hotfixRes.reason ?? "unknown reason"}`,
|
|
950
|
+
});
|
|
951
|
+
}
|
|
952
|
+
let updated;
|
|
953
|
+
try {
|
|
954
|
+
updated = patchTaskFile(config, existing.absPath, {
|
|
955
|
+
hotfix: true,
|
|
956
|
+
hotfixTarget,
|
|
957
|
+
branch,
|
|
958
|
+
}, {
|
|
959
|
+
onStatusChange: onServerStatusChange,
|
|
960
|
+
});
|
|
961
|
+
}
|
|
962
|
+
catch (err) {
|
|
963
|
+
rootLock.release(existing.id);
|
|
964
|
+
throw err;
|
|
965
|
+
}
|
|
966
|
+
index.applyFileChange(updated.absPath);
|
|
967
|
+
index.refreshBranches();
|
|
968
|
+
return json(res, 200, {
|
|
969
|
+
ok: true,
|
|
970
|
+
task: index.getTask(updated.id),
|
|
971
|
+
branch,
|
|
972
|
+
hotfixTarget,
|
|
973
|
+
});
|
|
974
|
+
}
|
|
975
|
+
return json(res, 400, { error: `Unknown action: ${action}` });
|
|
976
|
+
};
|
|
977
|
+
// Preview routes
|
|
978
|
+
export const startPreview = async (ctx, req, res, params) => {
|
|
979
|
+
const { index, previews } = ctx;
|
|
980
|
+
const id = params.param1;
|
|
981
|
+
const t = index.getTask(id);
|
|
982
|
+
if (!t) {
|
|
983
|
+
return json(res, 404, { error: `Task #${id} not found` });
|
|
984
|
+
}
|
|
985
|
+
// The drawer's picker passes the chosen target when the task's area matches
|
|
986
|
+
// more than one (#0379); omitting it starts the first match.
|
|
987
|
+
const body = (await readBody(req));
|
|
988
|
+
const target = typeof body?.target === "string" && body.target.trim() ? body.target.trim() : undefined;
|
|
989
|
+
const result = await previews.start(t, target);
|
|
990
|
+
if (!result.ok) {
|
|
991
|
+
return json(res, 400, { error: result.error ?? "could not start preview" });
|
|
992
|
+
}
|
|
993
|
+
return json(res, 200, { ok: true, port: result.port, url: result.url, label: result.label });
|
|
994
|
+
};
|
|
995
|
+
export const stopPreview = async (ctx, _req, res, params) => {
|
|
996
|
+
const { previews } = ctx;
|
|
997
|
+
const id = params.param1;
|
|
998
|
+
await previews.stop(id);
|
|
999
|
+
return json(res, 200, { ok: true });
|
|
1000
|
+
};
|
|
1001
|
+
// Review routes
|
|
1002
|
+
export const getTaskReview = (ctx, _req, res, params) => {
|
|
1003
|
+
const { reviews } = ctx;
|
|
1004
|
+
const id = params.param1;
|
|
1005
|
+
return json(res, 200, {
|
|
1006
|
+
ok: true,
|
|
1007
|
+
running: reviews.isRunning(id),
|
|
1008
|
+
enabled: reviews.enabled(),
|
|
1009
|
+
review: reviews.read(id),
|
|
1010
|
+
lines: reviews.session(id),
|
|
1011
|
+
});
|
|
1012
|
+
};
|
|
1013
|
+
export const reviewAgain = async (ctx, _req, res, params) => {
|
|
1014
|
+
const { index, runner, reviews } = ctx;
|
|
1015
|
+
const id = params.param1;
|
|
1016
|
+
const existing = index.getTask(id);
|
|
1017
|
+
if (!existing) {
|
|
1018
|
+
return json(res, 404, { error: `Task #${id} not found` });
|
|
1019
|
+
}
|
|
1020
|
+
if (existing.status !== "review") {
|
|
1021
|
+
return json(res, 400, {
|
|
1022
|
+
error: `Only review tasks can be re-reviewed (#${id} is ${existing.status})`,
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
if (runner.isRunning(id)) {
|
|
1026
|
+
return json(res, 409, {
|
|
1027
|
+
error: `Task #${id} has an agent turn in progress — wait for it to finish`,
|
|
1028
|
+
});
|
|
1029
|
+
}
|
|
1030
|
+
const gate = reviews.canRun(existing);
|
|
1031
|
+
if (!gate.ok) {
|
|
1032
|
+
return json(res, 400, { error: gate.reason ?? "could not start the review" });
|
|
1033
|
+
}
|
|
1034
|
+
void reviews.run(existing);
|
|
1035
|
+
return json(res, 200, { ok: true });
|
|
1036
|
+
};
|
|
1037
|
+
export const reviewMessage = async (ctx, req, res, params) => {
|
|
1038
|
+
const { index, reviews } = ctx;
|
|
1039
|
+
const id = params.param1;
|
|
1040
|
+
const existing = index.getTask(id);
|
|
1041
|
+
if (!existing) {
|
|
1042
|
+
return json(res, 404, { error: `Task #${id} not found` });
|
|
1043
|
+
}
|
|
1044
|
+
if (existing.status !== "review") {
|
|
1045
|
+
return json(res, 400, {
|
|
1046
|
+
error: `Only review tasks accept reviewer messages (#${id} is ${existing.status})`,
|
|
1047
|
+
});
|
|
1048
|
+
}
|
|
1049
|
+
const body = (await readBody(req));
|
|
1050
|
+
const text = typeof body?.text === "string" ? body.text.trim() : "";
|
|
1051
|
+
if (!text) {
|
|
1052
|
+
return json(res, 400, { error: "message text is required" });
|
|
1053
|
+
}
|
|
1054
|
+
const gate = reviews.canSend(existing);
|
|
1055
|
+
if (!gate.ok) {
|
|
1056
|
+
return json(res, 400, { error: gate.reason ?? "could not send to the reviewer" });
|
|
1057
|
+
}
|
|
1058
|
+
void reviews.send(existing, text);
|
|
1059
|
+
return json(res, 200, { ok: true });
|
|
1060
|
+
};
|
|
1061
|
+
export const getCTO = (ctx, _req, res) => {
|
|
1062
|
+
const { cto } = ctx;
|
|
1063
|
+
return json(res, 200, {
|
|
1064
|
+
ok: true,
|
|
1065
|
+
running: cto.isRunning(),
|
|
1066
|
+
enabled: cto.enabled(),
|
|
1067
|
+
report: cto.read(),
|
|
1068
|
+
lines: cto.session(),
|
|
1069
|
+
});
|
|
1070
|
+
};
|
|
1071
|
+
export const ctoMessage = async (ctx, req, res) => {
|
|
1072
|
+
const { cto } = ctx;
|
|
1073
|
+
const body = (await readBody(req));
|
|
1074
|
+
const text = typeof body?.text === "string" ? body.text.trim() : "";
|
|
1075
|
+
if (!text) {
|
|
1076
|
+
return json(res, 400, { error: "message text is required" });
|
|
1077
|
+
}
|
|
1078
|
+
if (cto.isRunning()) {
|
|
1079
|
+
return json(res, 409, { error: "a CTO run is already in progress" });
|
|
1080
|
+
}
|
|
1081
|
+
if (!cto.enabled()) {
|
|
1082
|
+
return json(res, 400, { error: "the CTO agent is disabled" });
|
|
1083
|
+
}
|
|
1084
|
+
void cto.send(text);
|
|
1085
|
+
return json(res, 200, { ok: true });
|
|
1086
|
+
};
|
|
1087
|
+
/** Interrupt a running CTO response. Idempotent. */
|
|
1088
|
+
export const ctoInterrupt = (ctx, _req, res) => {
|
|
1089
|
+
const result = ctx.cto.interrupt();
|
|
1090
|
+
return json(res, 200, { ok: true, ...result });
|
|
1091
|
+
};
|
|
1092
|
+
/**
|
|
1093
|
+
* Build the "newly attached screenshots" fragment appended to the PM's task
|
|
1094
|
+
* context (#0382). Each entry surfaces the API URL and the repo-relative
|
|
1095
|
+
* path so the PM can either link them or quote them when editing the spec.
|
|
1096
|
+
* Empty/missing url+path combos are silently dropped. Returns an empty
|
|
1097
|
+
* string when there are no usable screenshots, leaving the prompt unchanged
|
|
1098
|
+
* for plain text messages.
|
|
1099
|
+
*/
|
|
1100
|
+
export function buildPmShotContext(incoming) {
|
|
1101
|
+
const lines = [];
|
|
1102
|
+
for (const raw of incoming) {
|
|
1103
|
+
if (!raw || typeof raw !== "object")
|
|
1104
|
+
continue;
|
|
1105
|
+
const url = typeof raw.url === "string" ? raw.url.trim() : "";
|
|
1106
|
+
const path = typeof raw.path === "string"
|
|
1107
|
+
? raw.path.trim()
|
|
1108
|
+
: "";
|
|
1109
|
+
if (!url && !path)
|
|
1110
|
+
continue;
|
|
1111
|
+
const bits = [];
|
|
1112
|
+
if (url)
|
|
1113
|
+
bits.push(`url=${url}`);
|
|
1114
|
+
if (path)
|
|
1115
|
+
bits.push(`repo-path=${path}`);
|
|
1116
|
+
lines.push(`- ${bits.join(" ")}`);
|
|
1117
|
+
}
|
|
1118
|
+
if (!lines.length)
|
|
1119
|
+
return "";
|
|
1120
|
+
return `\nNewly attached screenshots (already saved to this task — link them in the spec if relevant):\n${lines.join("\n")}\n`;
|
|
1121
|
+
}
|
|
1122
|
+
export const pmMessage = async (ctx, req, res, params) => {
|
|
1123
|
+
const { config, index, runner, logger, emitEvent } = ctx;
|
|
1124
|
+
const id = params.param1;
|
|
1125
|
+
const existing = index.getTask(id);
|
|
1126
|
+
if (!existing) {
|
|
1127
|
+
return json(res, 404, { error: `Task #${id} not found` });
|
|
1128
|
+
}
|
|
1129
|
+
// v2 deliberately starts a clean PM conversation: older PM chats were
|
|
1130
|
+
// incorrectly launched with Ross's read-only mission. When auth is on,
|
|
1131
|
+
// each user gets their own PM conversation per task (0248) — otherwise
|
|
1132
|
+
// teammates sharing one instance would all read and post into the same
|
|
1133
|
+
// thread. Falls back to the unscoped id when auth is off, matching every
|
|
1134
|
+
// existing single-user setup exactly as before.
|
|
1135
|
+
const currentUserEmail = getCurrentUser(req, config)?.email;
|
|
1136
|
+
const pmSessionId = currentUserEmail
|
|
1137
|
+
? `pm-task-v2:${id}::${currentUserEmail}`
|
|
1138
|
+
: `pm-task-v2:${id}`;
|
|
1139
|
+
const body = (await readBody(req));
|
|
1140
|
+
const text = typeof body?.text === "string" ? body.text.trim() : "";
|
|
1141
|
+
if (!text) {
|
|
1142
|
+
return json(res, 400, { error: "message text is required" });
|
|
1143
|
+
}
|
|
1144
|
+
// Build a one-shot agent override for this PM request. Falls back to the
|
|
1145
|
+
// task's persisted PM overrides (set via the PM tab's selector) when the
|
|
1146
|
+
// client doesn't pass explicit values.
|
|
1147
|
+
const pmAgentName = typeof body?.agentOverride === "string" && body.agentOverride
|
|
1148
|
+
? body.agentOverride
|
|
1149
|
+
: existing.pmAgentOverride || undefined;
|
|
1150
|
+
const pmCli = typeof body?.cliOverride === "string" && body.cliOverride
|
|
1151
|
+
? body.cliOverride
|
|
1152
|
+
: existing.pmCliOverride || undefined;
|
|
1153
|
+
const pmModel = typeof body?.modelOverride === "string" && body.modelOverride
|
|
1154
|
+
? body.modelOverride
|
|
1155
|
+
: existing.pmModelOverride || undefined;
|
|
1156
|
+
// "default" is the sentinel the PM tab's model dropdown offers for "use
|
|
1157
|
+
// the configured pm agent's own model" — NOT a real pin. Treating it as
|
|
1158
|
+
// truthy here force-overwrites the base agent's actual configured model
|
|
1159
|
+
// with the literal string "default", which then skips --model entirely
|
|
1160
|
+
// and falls back to the CLI's own raw default instead of what's
|
|
1161
|
+
// configured on the Agents page (same bug fixed in resolveAgentForTask /
|
|
1162
|
+
// resolveReviewerForTask).
|
|
1163
|
+
const pmModelPinned = isModelOverridePinned(pmModel);
|
|
1164
|
+
const hasPmOverride = pmAgentName || pmCli || pmModelPinned;
|
|
1165
|
+
// Resolve the PM agent, applying any one-shot override
|
|
1166
|
+
let pm;
|
|
1167
|
+
if (hasPmOverride) {
|
|
1168
|
+
const list = agentsForConfig(config);
|
|
1169
|
+
const baseName = pmAgentName || "pm";
|
|
1170
|
+
const base = list.find((a) => a.enabled && a.name === baseName) ?? null;
|
|
1171
|
+
pm = base
|
|
1172
|
+
? {
|
|
1173
|
+
...base,
|
|
1174
|
+
...(pmCli ? { cli: pmCli } : {}),
|
|
1175
|
+
...(pmModelPinned ? { model: pmModel } : {}),
|
|
1176
|
+
}
|
|
1177
|
+
: null;
|
|
1178
|
+
}
|
|
1179
|
+
else {
|
|
1180
|
+
pm = resolvePmAgent(config);
|
|
1181
|
+
}
|
|
1182
|
+
if (!pm) {
|
|
1183
|
+
return json(res, 400, {
|
|
1184
|
+
error: "PM agent is not configured — enable it on the Agents page",
|
|
1185
|
+
});
|
|
1186
|
+
}
|
|
1187
|
+
// 0381: chat-input screenshots ride along as a pending batch keyed to this
|
|
1188
|
+
// PM session. The task the PM creates from the message doesn't exist yet,
|
|
1189
|
+
// so the batch is parked on disk now and attached to the created task by
|
|
1190
|
+
// the server's task.created hook while the session is still running.
|
|
1191
|
+
const rawImages = Array.isArray(body?.images) ? body?.images : [];
|
|
1192
|
+
let imageBatchId = null;
|
|
1193
|
+
if (rawImages.length > 0) {
|
|
1194
|
+
const queuedImages = queuePmImages(config, pmSessionId, rawImages);
|
|
1195
|
+
imageBatchId = queuedImages.batchId;
|
|
1196
|
+
if (queuedImages.errors.length > 0) {
|
|
1197
|
+
// Best-effort: a rejected image never blocks the message itself.
|
|
1198
|
+
logger.task(id, "warn", "Some PM chat attachments were rejected", {
|
|
1199
|
+
errors: queuedImages.errors,
|
|
1200
|
+
});
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1203
|
+
// Build context about the current task for the PM
|
|
1204
|
+
const taskContext = `Task #${id}: ${existing.title}
|
|
1205
|
+
Status: ${existing.status}
|
|
1206
|
+
Priority: ${existing.priority || "unset"}
|
|
1207
|
+
Area: ${existing.area || "unset"}
|
|
1208
|
+
Type: ${existing.type || "unset"}
|
|
1209
|
+
|
|
1210
|
+
Description:
|
|
1211
|
+
${existing.body || "(no description)"}`;
|
|
1212
|
+
// #0382: the PM tab's attach button lets the user upload screenshots that
|
|
1213
|
+
// arrive as a `screenshots` array of {url,path} on this message. They are
|
|
1214
|
+
// already persisted in the task's `## Screenshots` section (and on disk) by
|
|
1215
|
+
// `POST /api/tasks/:id/attachments`; we surface them here so the PM agent
|
|
1216
|
+
// knows what to reference or link into the spec when relevant. Empty/missing
|
|
1217
|
+
// keys are silently ignored — a plain text message stays a plain text
|
|
1218
|
+
// message.
|
|
1219
|
+
const shotContext = buildPmShotContext(Array.isArray(body?.screenshots) ? body.screenshots : []);
|
|
1220
|
+
const fullContext = `${taskContext}${shotContext}`;
|
|
1221
|
+
const existing_session = runner.output(pmSessionId);
|
|
1222
|
+
const result = existing_session
|
|
1223
|
+
? runner.send(pmSessionId, text, pm, {
|
|
1224
|
+
resumePreamble: `Task context:\n${fullContext}`,
|
|
1225
|
+
})
|
|
1226
|
+
: runner.startChat(pmSessionId, text, pm, fullContext, taskPmPrompt);
|
|
1227
|
+
if (!result.ok && result.busy) {
|
|
1228
|
+
dropPmImages(imageBatchId);
|
|
1229
|
+
return json(res, 409, { error: result.reason ?? "PM is busy" });
|
|
1230
|
+
}
|
|
1231
|
+
if (!result.ok) {
|
|
1232
|
+
dropPmImages(imageBatchId);
|
|
1233
|
+
return json(res, 400, { error: result.reason ?? "could not send message to PM" });
|
|
1234
|
+
}
|
|
1235
|
+
// 0381: the runner accepted the turn (running now, or queued behind
|
|
1236
|
+
// maxConcurrentAgents) — flag the task so its card and panel show "PM is
|
|
1237
|
+
// working". Cleared by the emit hook in server.ts when the runner reports
|
|
1238
|
+
// the session's exit (success, error, or user interrupt).
|
|
1239
|
+
markPmChatSession(pmSessionId, id);
|
|
1240
|
+
emitEvent({ type: "task.pmWorking", id, at: new Date().toISOString() });
|
|
1241
|
+
return json(res, 200, { ok: true, spawn: { ok: true, pid: result.pid } });
|
|
1242
|
+
};
|
|
1243
|
+
/**
|
|
1244
|
+
* Interrupt a running PM response about a task. Resolves the same per-user PM
|
|
1245
|
+
* session id as `pmMessage` and stops the in-flight agent turn. Idempotent.
|
|
1246
|
+
*/
|
|
1247
|
+
export const pmInterrupt = (ctx, req, res, params) => {
|
|
1248
|
+
const { config, index, runner } = ctx;
|
|
1249
|
+
const id = params.param1;
|
|
1250
|
+
const existing = index.getTask(id);
|
|
1251
|
+
if (!existing) {
|
|
1252
|
+
return json(res, 404, { error: `Task #${id} not found` });
|
|
1253
|
+
}
|
|
1254
|
+
const currentUserEmail = getCurrentUser(req, config)?.email;
|
|
1255
|
+
const pmSessionId = currentUserEmail
|
|
1256
|
+
? `pm-task-v2:${id}::${currentUserEmail}`
|
|
1257
|
+
: `pm-task-v2:${id}`;
|
|
1258
|
+
const result = runner.interrupt(pmSessionId);
|
|
1259
|
+
return json(res, 200, { ok: true, ...result });
|
|
1260
|
+
};
|
|
1261
|
+
export const getIntegrationJob = (ctx, _req, res, params) => {
|
|
1262
|
+
const { jobCoordinator } = ctx;
|
|
1263
|
+
const id = params.param1;
|
|
1264
|
+
const job = jobCoordinator.getJob(id);
|
|
1265
|
+
if (!job) {
|
|
1266
|
+
return json(res, 404, { error: `No integration job for task #${id}` });
|
|
1267
|
+
}
|
|
1268
|
+
const allJobs = jobCoordinator.allJobs();
|
|
1269
|
+
const queuePos = allJobs.findIndex((j) => j.taskId === job.taskId);
|
|
1270
|
+
return json(res, 200, {
|
|
1271
|
+
ok: true,
|
|
1272
|
+
job: {
|
|
1273
|
+
taskId: job.taskId,
|
|
1274
|
+
phase: job.phase,
|
|
1275
|
+
enqueuedAt: job.enqueuedAt,
|
|
1276
|
+
startedAt: job.startedAt,
|
|
1277
|
+
baseMainSha: job.baseMainSha,
|
|
1278
|
+
branchSha: job.branchSha,
|
|
1279
|
+
candidateSha: job.candidateSha,
|
|
1280
|
+
reason: job.reason,
|
|
1281
|
+
queuePosition: queuePos,
|
|
1282
|
+
queueLength: allJobs.length,
|
|
1283
|
+
},
|
|
1284
|
+
});
|
|
1285
|
+
};
|
|
1286
|
+
export const getIntegrationJobs = (ctx, _req, res) => {
|
|
1287
|
+
const allJobs = ctx.jobCoordinator.allJobs();
|
|
1288
|
+
return json(res, 200, {
|
|
1289
|
+
ok: true,
|
|
1290
|
+
jobs: allJobs.map((job, idx) => ({
|
|
1291
|
+
taskId: job.taskId,
|
|
1292
|
+
phase: job.phase,
|
|
1293
|
+
enqueuedAt: job.enqueuedAt,
|
|
1294
|
+
startedAt: job.startedAt,
|
|
1295
|
+
reason: job.reason,
|
|
1296
|
+
queuePosition: idx,
|
|
1297
|
+
})),
|
|
1298
|
+
queueLength: allJobs.length,
|
|
1299
|
+
});
|
|
1300
|
+
};
|
|
1301
|
+
/**
|
|
1302
|
+
* Full integration-pipeline snapshot for the pinned status bar (0207).
|
|
1303
|
+
* Reads the same live `reportedStages` map the SSE push path uses, so a
|
|
1304
|
+
* page refresh mid-pipeline shows the job's actual current sub-step (e.g.
|
|
1305
|
+
* "check", mid-test-run) rather than falling back to a coarse per-phase
|
|
1306
|
+
* guess (0207 follow-up — see RouteContext.reportedStages).
|
|
1307
|
+
*/
|
|
1308
|
+
export const getIntegrationPipeline = (ctx, _req, res) => {
|
|
1309
|
+
return json(res, 200, {
|
|
1310
|
+
ok: true,
|
|
1311
|
+
pipeline: buildIntegrationSnapshot(ctx.jobCoordinator, ctx.reportedStages),
|
|
1312
|
+
});
|
|
1313
|
+
};
|
|
1314
|
+
/**
|
|
1315
|
+
* Retry a failed integration job (0207). Reuses the coordinator's existing
|
|
1316
|
+
* retry path: `enqueue` re-enqueues a `failed` job as a fresh queued job
|
|
1317
|
+
* (see integration-job.ts), then processing resumes from the queue.
|
|
1318
|
+
*/
|
|
1319
|
+
export const retryIntegration = (ctx, _req, res, params) => {
|
|
1320
|
+
const { jobCoordinator } = ctx;
|
|
1321
|
+
const id = params.param1;
|
|
1322
|
+
const job = jobCoordinator.getJob(id);
|
|
1323
|
+
if (!job) {
|
|
1324
|
+
return json(res, 404, { error: `No integration job for task #${id}` });
|
|
1325
|
+
}
|
|
1326
|
+
if (job.phase !== "failed") {
|
|
1327
|
+
return json(res, 409, {
|
|
1328
|
+
error: `Task #${id} is not in a failed integration state (it is ${job.phase})`,
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
const task = ctx.index.getTask(id);
|
|
1332
|
+
if (!task) {
|
|
1333
|
+
return json(res, 404, { error: `Task #${id} not found` });
|
|
1334
|
+
}
|
|
1335
|
+
const reenqueued = jobCoordinator.enqueue(task);
|
|
1336
|
+
if (!reenqueued) {
|
|
1337
|
+
return json(res, 400, { error: `Task #${id} has no branch to integrate` });
|
|
1338
|
+
}
|
|
1339
|
+
ctx.emitEvent({ type: "integration", pipeline: buildIntegrationSnapshot(jobCoordinator, {}) });
|
|
1340
|
+
ctx.triggerJobProcessing();
|
|
1341
|
+
return json(res, 200, {
|
|
1342
|
+
ok: true,
|
|
1343
|
+
job: {
|
|
1344
|
+
taskId: reenqueued.taskId,
|
|
1345
|
+
phase: reenqueued.phase,
|
|
1346
|
+
enqueuedAt: reenqueued.enqueuedAt,
|
|
1347
|
+
},
|
|
1348
|
+
});
|
|
1349
|
+
};
|
|
1350
|
+
// Session stats endpoints
|
|
1351
|
+
export const getTaskStats = (ctx, _req, res, params) => {
|
|
1352
|
+
const { runner } = ctx;
|
|
1353
|
+
const taskId = params.param1;
|
|
1354
|
+
const stats = runner.taskStats(taskId);
|
|
1355
|
+
if (!stats) {
|
|
1356
|
+
return json(res, 404, { error: `No stats found for task #${taskId}` });
|
|
1357
|
+
}
|
|
1358
|
+
return json(res, 200, { ok: true, stats });
|
|
1359
|
+
};
|
|
1360
|
+
export const getSessionTypeStats = (ctx, _req, res) => {
|
|
1361
|
+
const { runner } = ctx;
|
|
1362
|
+
const stats = runner.sessionTypeStats();
|
|
1363
|
+
return json(res, 200, { ok: true, stats });
|
|
1364
|
+
};
|
|
1365
|
+
export const getBoardStats = (ctx, req, res) => {
|
|
1366
|
+
const { runner } = ctx;
|
|
1367
|
+
// ?range=1d|7d|30d|all (0334) — trailing window on sessions.startedAt;
|
|
1368
|
+
// omitted or "all" means the unfiltered pre-range behavior.
|
|
1369
|
+
const raw = new URL(req.url ?? "/", "http://localhost").searchParams.get("range");
|
|
1370
|
+
let range = "all";
|
|
1371
|
+
if (raw !== null) {
|
|
1372
|
+
if (raw === "1d" || raw === "7d" || raw === "30d" || raw === "all") {
|
|
1373
|
+
range = raw;
|
|
1374
|
+
}
|
|
1375
|
+
else {
|
|
1376
|
+
return json(res, 400, { error: `Invalid range '${raw}' — expected 1d, 7d, 30d, or all` });
|
|
1377
|
+
}
|
|
1378
|
+
}
|
|
1379
|
+
const stats = runner.boardStats(range);
|
|
1380
|
+
return json(res, 200, { ok: true, stats });
|
|
1381
|
+
};
|
|
1382
|
+
export const getDailyTotals = (ctx, _req, res) => {
|
|
1383
|
+
const { runner } = ctx;
|
|
1384
|
+
const stats = runner.dailyTotals();
|
|
1385
|
+
return json(res, 200, { ok: true, stats });
|
|
1386
|
+
};
|
|
1387
|
+
// Diff stats endpoint
|
|
1388
|
+
export const getDiffStatsForTask = async (ctx, _req, res, params) => {
|
|
1389
|
+
const { index, config } = ctx;
|
|
1390
|
+
const id = params.param1;
|
|
1391
|
+
const task = index.getTask(id);
|
|
1392
|
+
if (!task) {
|
|
1393
|
+
return json(res, 404, { error: `Task #${id} not found` });
|
|
1394
|
+
}
|
|
1395
|
+
if (!task.branch) {
|
|
1396
|
+
return json(res, 200, {
|
|
1397
|
+
ok: true,
|
|
1398
|
+
stats: { filesChanged: 0, additions: 0, deletions: 0 },
|
|
1399
|
+
noBranch: true,
|
|
1400
|
+
});
|
|
1401
|
+
}
|
|
1402
|
+
const worktreePath = worktreePathForBranch(config.root, task.branch);
|
|
1403
|
+
if (!worktreePath) {
|
|
1404
|
+
const snapshot = task.status === "done" ? loadDiffSnapshot(config.root, config.cacheDir, task.id) : null;
|
|
1405
|
+
if (snapshot) {
|
|
1406
|
+
return json(res, 200, { ok: true, stats: snapshot.stats, snapshot: true });
|
|
1407
|
+
}
|
|
1408
|
+
return json(res, 200, {
|
|
1409
|
+
ok: true,
|
|
1410
|
+
stats: { filesChanged: 0, additions: 0, deletions: 0 },
|
|
1411
|
+
noWorktree: true,
|
|
1412
|
+
});
|
|
1413
|
+
}
|
|
1414
|
+
// Async (spawn-based) rather than the sync/execFileSync getDiffStats: every
|
|
1415
|
+
// card on the Work board fires this on mount, and the synchronous version
|
|
1416
|
+
// blocked the whole event loop for each one serially — including whatever
|
|
1417
|
+
// GET /api/tasks/:id a drawer-opening click was waiting behind.
|
|
1418
|
+
const stats = await getDiffStatsAsync(worktreePath, "main");
|
|
1419
|
+
return json(res, 200, { ok: true, stats });
|
|
1420
|
+
};
|
|
1421
|
+
// Diff endpoint — full patch
|
|
1422
|
+
export const getDiffForTask = async (ctx, _req, res, params) => {
|
|
1423
|
+
const { index, config } = ctx;
|
|
1424
|
+
const id = params.param1;
|
|
1425
|
+
const task = index.getTask(id);
|
|
1426
|
+
if (!task) {
|
|
1427
|
+
return json(res, 404, { error: `Task #${id} not found` });
|
|
1428
|
+
}
|
|
1429
|
+
if (!task.branch) {
|
|
1430
|
+
return json(res, 200, { ok: true, diff: { patch: "", truncated: false }, noBranch: true });
|
|
1431
|
+
}
|
|
1432
|
+
const worktreePath = worktreePathForBranch(config.root, task.branch);
|
|
1433
|
+
if (!worktreePath) {
|
|
1434
|
+
const snapshot = task.status === "done" ? loadDiffSnapshot(config.root, config.cacheDir, task.id) : null;
|
|
1435
|
+
if (snapshot) {
|
|
1436
|
+
return json(res, 200, { ok: true, diff: snapshot.diff, snapshot: true });
|
|
1437
|
+
}
|
|
1438
|
+
return json(res, 200, { ok: true, diff: { patch: "", truncated: false }, noWorktree: true });
|
|
1439
|
+
}
|
|
1440
|
+
const diff = await getDiff(worktreePath, "main");
|
|
1441
|
+
return json(res, 200, { ok: true, diff });
|
|
1442
|
+
};
|