@open-mercato/cezar 0.9.1 → 0.9.2-pr710.1256
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 +21 -0
- package/README.md +157 -32
- package/dist/agent-config/account-identity.d.ts +44 -0
- package/dist/agent-config/account-identity.js +128 -0
- package/dist/agent-config/account-identity.js.map +1 -0
- package/dist/agent-config/catalog.d.ts +9 -1
- package/dist/agent-config/catalog.js +18 -0
- package/dist/agent-config/catalog.js.map +1 -1
- package/dist/agent-config/files.d.ts +1 -1
- package/dist/agent-config/model-settings/claude.d.ts +2 -0
- package/dist/agent-config/model-settings/claude.js +11 -0
- package/dist/agent-config/model-settings/claude.js.map +1 -0
- package/dist/agent-config/model-settings/codex.d.ts +2 -0
- package/dist/agent-config/model-settings/codex.js +16 -0
- package/dist/agent-config/model-settings/codex.js.map +1 -0
- package/dist/agent-config/model-settings/opencode.d.ts +2 -0
- package/dist/agent-config/model-settings/opencode.js +8 -0
- package/dist/agent-config/model-settings/opencode.js.map +1 -0
- package/dist/agent-config/model-settings/pi.d.ts +12 -0
- package/dist/agent-config/model-settings/pi.js +18 -0
- package/dist/agent-config/model-settings/pi.js.map +1 -0
- package/dist/agent-config/model-settings/shared.d.ts +9 -0
- package/dist/agent-config/model-settings/shared.js +93 -0
- package/dist/agent-config/model-settings/shared.js.map +1 -0
- package/dist/agent-config/model-settings/types.d.ts +9 -0
- package/dist/agent-config/model-settings/types.js +2 -0
- package/dist/agent-config/model-settings/types.js.map +1 -0
- package/dist/agent-config/models.d.ts +9 -0
- package/dist/agent-config/models.js +32 -0
- package/dist/agent-config/models.js.map +1 -0
- package/dist/agent-config/service.d.ts +1 -1
- package/dist/agent-config/service.js +4 -4
- package/dist/agent-config/service.js.map +1 -1
- package/dist/agent-config/validate.d.ts +1 -1
- package/dist/automations/coordinator.d.ts +26 -0
- package/dist/automations/coordinator.js +66 -0
- package/dist/automations/coordinator.js.map +1 -0
- package/dist/automations/github-poller.d.ts +74 -0
- package/dist/automations/github-poller.js +234 -0
- package/dist/automations/github-poller.js.map +1 -0
- package/dist/automations/scheduler.d.ts +42 -0
- package/dist/automations/scheduler.js +192 -0
- package/dist/automations/scheduler.js.map +1 -0
- package/dist/automations/store.d.ts +65 -0
- package/dist/automations/store.js +298 -0
- package/dist/automations/store.js.map +1 -0
- package/dist/automations/task-template.d.ts +18 -0
- package/dist/automations/task-template.js +95 -0
- package/dist/automations/task-template.js.map +1 -0
- package/dist/automations/types.d.ts +255 -0
- package/dist/automations/types.js +159 -0
- package/dist/automations/types.js.map +1 -0
- package/dist/config.d.ts +19 -112
- package/dist/config.js +48 -5
- package/dist/config.js.map +1 -1
- package/dist/contract/agent-config.d.ts +153 -0
- package/dist/contract/agent-profiles.d.ts +347 -0
- package/dist/contract/automations.d.ts +935 -0
- package/dist/contract/events.d.ts +104 -0
- package/dist/contract/github.d.ts +467 -0
- package/dist/contract/health.d.ts +102 -0
- package/dist/contract/index.d.ts +16 -0
- package/dist/contract/index.js +1830 -0
- package/dist/contract/projects.d.ts +210 -0
- package/dist/contract/repo.d.ts +234 -0
- package/dist/contract/runs.d.ts +1407 -0
- package/dist/contract/skills.d.ts +232 -0
- package/dist/contract/workflows.d.ts +218 -0
- package/dist/contract/workspace.d.ts +577 -0
- package/dist/core/agent-env.d.ts +1 -1
- package/dist/core/agent-env.js +41 -16
- package/dist/core/agent-env.js.map +1 -1
- package/dist/core/agent-model-policy.d.ts +10 -0
- package/dist/core/agent-model-policy.js +36 -0
- package/dist/core/agent-model-policy.js.map +1 -0
- package/dist/core/agent-profiles.d.ts +62 -0
- package/dist/core/agent-profiles.js +90 -0
- package/dist/core/agent-profiles.js.map +1 -0
- package/dist/core/agent-runner.d.ts +49 -7
- package/dist/core/agent-runner.js +48 -3
- package/dist/core/agent-runner.js.map +1 -1
- package/dist/core/ask.d.ts +45 -124
- package/dist/core/ask.js +103 -13
- package/dist/core/ask.js.map +1 -1
- package/dist/core/backend-detect.d.ts +5 -5
- package/dist/core/backend-detect.js +44 -7
- package/dist/core/backend-detect.js.map +1 -1
- package/dist/core/claude-cli-runner.d.ts +2 -2
- package/dist/core/claude-cli-runner.js +61 -13
- package/dist/core/claude-cli-runner.js.map +1 -1
- package/dist/core/claude-ui-mapper.d.ts +1 -1
- package/dist/core/codex-app-server-runner.d.ts +1 -1
- package/dist/core/codex-app-server-runner.js +65 -3
- package/dist/core/codex-app-server-runner.js.map +1 -1
- package/dist/core/codex-app-server-transport.d.ts +6 -1
- package/dist/core/codex-app-server-transport.js +17 -3
- package/dist/core/codex-app-server-transport.js.map +1 -1
- package/dist/core/codex-model-catalog.d.ts +1 -1
- package/dist/core/codex-ui-mapper.d.ts +5 -1
- package/dist/core/codex-ui-mapper.js +74 -17
- package/dist/core/codex-ui-mapper.js.map +1 -1
- package/dist/core/model-identity.d.ts +24 -12
- package/dist/core/model-identity.js +41 -24
- package/dist/core/model-identity.js.map +1 -1
- package/dist/core/model-presets.d.ts +10 -4
- package/dist/core/model-presets.js +52 -9
- package/dist/core/model-presets.js.map +1 -1
- package/dist/core/opencode-model-catalog.d.ts +30 -0
- package/dist/core/opencode-model-catalog.js +126 -0
- package/dist/core/opencode-model-catalog.js.map +1 -0
- package/dist/core/opencode-server-runner.d.ts +2 -2
- package/dist/core/opencode-ui-mapper.d.ts +4 -1
- package/dist/core/opencode-ui-mapper.js +45 -7
- package/dist/core/opencode-ui-mapper.js.map +1 -1
- package/dist/core/pi-runner.d.ts +24 -0
- package/dist/core/pi-runner.js +349 -0
- package/dist/core/pi-runner.js.map +1 -0
- package/dist/core/pi-ui-mapper.d.ts +34 -0
- package/dist/core/pi-ui-mapper.js +270 -0
- package/dist/core/pi-ui-mapper.js.map +1 -0
- package/dist/core/provider-auth.d.ts +154 -0
- package/dist/core/provider-auth.js +509 -0
- package/dist/core/provider-auth.js.map +1 -0
- package/dist/core/provider-availability.d.ts +3 -0
- package/dist/core/provider-availability.js +14 -0
- package/dist/core/provider-availability.js.map +1 -0
- package/dist/core/runner-factory.d.ts +1 -1
- package/dist/core/runner-factory.js +3 -0
- package/dist/core/runner-factory.js.map +1 -1
- package/dist/core/runner-model-catalog.d.ts +1 -1
- package/dist/core/shell-env.d.ts +28 -0
- package/dist/core/shell-env.js +56 -0
- package/dist/core/shell-env.js.map +1 -0
- package/dist/core/tool-display.d.ts +1 -1
- package/dist/core/ui-events.d.ts +2 -2
- package/dist/core/ui-events.js +1 -1
- package/dist/core/usage-limit.d.ts +42 -0
- package/dist/core/usage-limit.js +209 -0
- package/dist/core/usage-limit.js.map +1 -0
- package/dist/git-diff-base.d.ts +85 -0
- package/dist/git-diff-base.js +180 -0
- package/dist/git-diff-base.js.map +1 -0
- package/dist/git-worktree.d.ts +46 -6
- package/dist/git-worktree.js +46 -9
- package/dist/git-worktree.js.map +1 -1
- package/dist/handoff.d.ts +2 -2
- package/dist/handoff.js +1 -1
- package/dist/index.js +66 -8
- package/dist/index.js.map +1 -1
- package/dist/paths.d.ts +69 -6
- package/dist/paths.js +86 -9
- package/dist/paths.js.map +1 -1
- package/dist/planner.d.ts +2 -2
- package/dist/planner.js +7 -1
- package/dist/planner.js.map +1 -1
- package/dist/release/manifests.d.ts +81 -0
- package/dist/release/manifests.js +74 -0
- package/dist/release/manifests.js.map +1 -0
- package/dist/release/snapshot.d.ts +9 -23
- package/dist/release/snapshot.js +9 -22
- package/dist/release/snapshot.js.map +1 -1
- package/dist/release/stable.d.ts +15 -29
- package/dist/release/stable.js +14 -27
- package/dist/release/stable.js.map +1 -1
- package/dist/runs/agent-tmpdir.d.ts +41 -0
- package/dist/runs/agent-tmpdir.js +183 -0
- package/dist/runs/agent-tmpdir.js.map +1 -0
- package/dist/runs/auto-name.d.ts +1 -1
- package/dist/runs/auto-name.js +6 -1
- package/dist/runs/auto-name.js.map +1 -1
- package/dist/runs/event-history.d.ts +50 -0
- package/dist/runs/event-history.js +613 -0
- package/dist/runs/event-history.js.map +1 -0
- package/dist/runs/retention.d.ts +1 -1
- package/dist/runs/run-index.d.ts +19 -0
- package/dist/runs/run-index.js +43 -0
- package/dist/runs/run-index.js.map +1 -0
- package/dist/runs/store.d.ts +209 -344
- package/dist/runs/store.js +298 -39
- package/dist/runs/store.js.map +1 -1
- package/dist/runs/ui-event-sink.d.ts +1 -1
- package/dist/server/app-type.d.ts +21 -0
- package/dist/server/app-type.js +2 -0
- package/dist/server/app-type.js.map +1 -0
- package/dist/server/capabilities.d.ts +21 -6
- package/dist/server/capabilities.js +27 -1
- package/dist/server/capabilities.js.map +1 -1
- package/dist/server/forge/github.d.ts +58 -151
- package/dist/server/forge/github.js +530 -6
- package/dist/server/forge/github.js.map +1 -1
- package/dist/server/forge/index.d.ts +19 -3
- package/dist/server/forge/index.js +28 -1
- package/dist/server/forge/index.js.map +1 -1
- package/dist/server/forge/types.d.ts +86 -1
- package/dist/server/git-changes.d.ts +10 -8
- package/dist/server/git-changes.js +19 -17
- package/dist/server/git-changes.js.map +1 -1
- package/dist/server/git.d.ts +2 -0
- package/dist/server/git.js +9 -0
- package/dist/server/git.js.map +1 -1
- package/dist/server/github.d.ts +3 -3
- package/dist/server/github.js +1 -1
- package/dist/server/github.js.map +1 -1
- package/dist/server/open-in-app.d.ts +2 -1
- package/dist/server/open-in-app.js +3 -1
- package/dist/server/open-in-app.js.map +1 -1
- package/dist/server/open-in-terminal.d.ts +46 -1
- package/dist/server/open-in-terminal.js +72 -13
- package/dist/server/open-in-terminal.js.map +1 -1
- package/dist/server/pr.d.ts +2 -2
- package/dist/server/project-context.d.ts +20 -3
- package/dist/server/project-context.js +30 -1
- package/dist/server/project-context.js.map +1 -1
- package/dist/server/provider-action-gate.d.ts +8 -0
- package/dist/server/provider-action-gate.js +56 -0
- package/dist/server/provider-action-gate.js.map +1 -0
- package/dist/server/provider-auth-runtime.d.ts +21 -0
- package/dist/server/provider-auth-runtime.js +66 -0
- package/dist/server/provider-auth-runtime.js.map +1 -0
- package/dist/server/server.d.ts +12802 -47
- package/dist/server/server.js +2857 -949
- package/dist/server/server.js.map +1 -1
- package/dist/server/validators.d.ts +97 -0
- package/dist/server/validators.js +86 -0
- package/dist/server/validators.js.map +1 -0
- package/dist/server/ws.d.ts +2 -2
- package/dist/server/ws.js +2 -2
- package/dist/server/ws.js.map +1 -1
- package/dist/server-install/engine.d.ts +1 -1
- package/dist/server-install/engine.js +5 -18
- package/dist/server-install/engine.js.map +1 -1
- package/dist/server-install/platforms/macosx-ngrok.d.ts +1 -1
- package/dist/server-install/platforms/ubuntu-vps.d.ts +16 -1
- package/dist/server-install/platforms/ubuntu-vps.js +83 -1
- package/dist/server-install/platforms/ubuntu-vps.js.map +1 -1
- package/dist/server-install/state.d.ts +1 -1
- package/dist/server-install/steps.d.ts +2 -2
- package/dist/server-install/steps.js +6 -2
- package/dist/server-install/steps.js.map +1 -1
- package/dist/server-install/strategies.d.ts +1 -1
- package/dist/server-install/types.d.ts +38 -424
- package/dist/server-install/ui.d.ts +1 -1
- package/dist/skills-banner.d.ts +7 -6
- package/dist/skills-banner.js +7 -6
- package/dist/skills-banner.js.map +1 -1
- package/dist/skills-remote.d.ts +2 -2
- package/dist/skills-update.d.ts +88 -0
- package/dist/skills-update.js +392 -0
- package/dist/skills-update.js.map +1 -0
- package/dist/skills.d.ts +2 -0
- package/dist/skills.js +7 -2
- package/dist/skills.js.map +1 -1
- package/dist/todos.d.ts +2 -28
- package/dist/todos.js +1 -1
- package/dist/ui-state.d.ts +16 -2
- package/dist/ui-state.js +14 -1
- package/dist/ui-state.js.map +1 -1
- package/dist/workflows/load.d.ts +1 -1
- package/dist/workflows/run.d.ts +276 -15
- package/dist/workflows/run.js +1261 -159
- package/dist/workflows/run.js.map +1 -1
- package/dist/workflows/types.d.ts +62 -224
- package/dist/workflows/types.js +25 -2
- package/dist/workflows/types.js.map +1 -1
- package/dist/workspace/agent-accounts.d.ts +153 -0
- package/dist/workspace/agent-accounts.js +304 -0
- package/dist/workspace/agent-accounts.js.map +1 -0
- package/dist/workspace/agent-profiles.d.ts +78 -0
- package/dist/workspace/agent-profiles.js +115 -0
- package/dist/workspace/agent-profiles.js.map +1 -0
- package/dist/workspace/config.d.ts +94 -206
- package/dist/workspace/config.js +226 -14
- package/dist/workspace/config.js.map +1 -1
- package/dist/workspace/migrations.d.ts +1 -1
- package/dist/workspace/migrations.js +19 -4
- package/dist/workspace/migrations.js.map +1 -1
- package/dist/workspace/projects-cli.js +54 -4
- package/dist/workspace/projects-cli.js.map +1 -1
- package/dist/workspace/projects.d.ts +26 -2
- package/dist/workspace/projects.js +42 -3
- package/dist/workspace/projects.js.map +1 -1
- package/dist/workspace/semaphore.d.ts +51 -0
- package/dist/workspace/semaphore.js +53 -3
- package/dist/workspace/semaphore.js.map +1 -1
- package/dist/workspace/ui-state.d.ts +16 -5
- package/dist/workspace/ui-state.js +18 -7
- package/dist/workspace/ui-state.js.map +1 -1
- package/package.json +17 -39
- package/scripts/inline-contract.mjs +112 -0
- package/scripts/mock-claude.mjs +59 -1
- package/scripts/mock-pi-rpc.mjs +83 -0
- package/scripts/sync-readme.mjs +20 -0
- package/web/dist/assets/alert-dialog-BVs3QSmP.js +1 -0
- package/web/dist/assets/arrow-down-D7T7XuF6.js +1 -0
- package/web/dist/assets/arrow-left-B1dRFd8y.js +1 -0
- package/web/dist/assets/bundle-mjs-BT31bpU6.js +1 -0
- package/web/dist/assets/centered-state-DdRLYi9u.js +43 -0
- package/web/dist/assets/chevron-right-Dh0t3AL8.js +1 -0
- package/web/dist/assets/{chunk-BO2N2NFS-DtrdTCWa.js → chunk-BO2N2NFS-DE6qKn3r.js} +8 -8
- package/web/dist/assets/circle-check-4FDOlh9u.js +1 -0
- package/web/dist/assets/circle-x-CgTVbqX5.js +1 -0
- package/web/dist/assets/collapsible-BoADzTok.js +1 -0
- package/web/dist/assets/commit-list-BCcCkRbU.js +1 -0
- package/web/dist/assets/compare-variants-B7gTgGqR.js +1 -0
- package/web/dist/assets/{core-DePtBHcl.js → core-BCsw8oQw.js} +1 -1
- package/web/dist/assets/diff-ecfV3-op.js +3 -0
- package/web/dist/assets/diff-stat-C2KGLVnZ.js +1 -0
- package/web/dist/assets/{diff-view-DCeSegKk.js → diff-view-CQtonQ4L.js} +4 -4
- package/web/dist/assets/dropdown-menu-WcGoirf2.js +1 -0
- package/web/dist/assets/editable-title-SqNK-wuI.js +1 -0
- package/web/dist/assets/{ellipsis-vertical-CNnSLn5a.js → ellipsis-vertical-ClTFrwit.js} +1 -1
- package/web/dist/assets/{file-D4GBh1ao.js → file-BU0mWida.js} +1 -1
- package/web/dist/assets/folder-rsgCF0aw.js +1 -0
- package/web/dist/assets/{git-pull-request-DnR8Xh4_.js → git-pull-request-BFhIKb83.js} +1 -1
- package/web/dist/assets/git-toolbar-CF1_tzqz.js +1 -0
- package/web/dist/assets/github-CyjPz-Ks.js +1 -0
- package/web/dist/assets/highlighted-body-OFNGDK62-BL-MfFgl.js +1 -0
- package/web/dist/assets/highlighter-CdHqIwFr.js +3 -0
- package/web/dist/assets/image-preview-iEzCyEmz.js +1 -0
- package/web/dist/assets/index-BN09RpYY.css +2 -0
- package/web/dist/assets/index-CyG7aE1W.js +7 -0
- package/web/dist/assets/lib-BQXq3kEf.js +1 -0
- package/web/dist/assets/lib-BxQXEXDF.js +1 -0
- package/web/dist/assets/markdown-CNAa22LB.js +2 -0
- package/web/dist/assets/mermaid-GHXKKRXX-C4jxqUUb.js +1 -0
- package/web/dist/assets/new-task-form-CWhQZbsX.js +1 -0
- package/web/dist/assets/pill-BO5s2x3N.js +1 -0
- package/web/dist/assets/project-router-Dm4OhAPE.js +1 -0
- package/web/dist/assets/prompt-templates-CP__OCDQ.js +15 -0
- package/web/dist/assets/react-runtime-CCIEwYL0.js +9 -0
- package/web/dist/assets/{refresh-cw-BT8E96Tm.js → refresh-cw-DyiysCoP.js} +1 -1
- package/web/dist/assets/repo-git-B1WM6N8T.js +1 -0
- package/web/dist/assets/rolldown-runtime-QTnfLwEv.js +1 -0
- package/web/dist/assets/run-diff-CKx4d9Oo.js +3 -0
- package/web/dist/assets/run-header-DpbfOtNi.js +1 -0
- package/web/dist/assets/{search-x-BDjaMrzG.js → search-x-DRvk-6x4.js} +1 -1
- package/web/dist/assets/skill-empty-hint-D5vsHfMr.js +1 -0
- package/web/dist/assets/skills-BiWfHmCR.js +1 -0
- package/web/dist/assets/skills-XGltyIJ_.js +1 -0
- package/web/dist/assets/sparkles-BugkDxWO.js +1 -0
- package/web/dist/assets/{square-terminal-Ckjjn0vb.js → square-terminal-Drp2h-o8.js} +1 -1
- package/web/dist/assets/tab-link-CiRLRlnY.js +1 -0
- package/web/dist/assets/task-changes-C2hKkEtc.js +1 -0
- package/web/dist/assets/task-commits-CwGs_Opa.js +1 -0
- package/web/dist/assets/task-files-5KUgFzX3.js +2 -0
- package/web/dist/assets/task-thread-B3AFO3SS.js +9 -0
- package/web/dist/assets/textarea-Dn90Eun6.js +1 -0
- package/web/dist/assets/thread-loading-_1hPuk76.js +1 -0
- package/web/dist/assets/{trash-2-DAyZ0VSi.js → trash-2-BKEc5Sp2.js} +1 -1
- package/web/dist/assets/{triangle-alert-Uy8rokUh.js → triangle-alert-BGGPY2C2.js} +1 -1
- package/web/dist/assets/{upload-CDdyPPo_.js → upload-B0AQw-mO.js} +1 -1
- package/web/dist/assets/use-desktop-CegyTp3X.js +1 -0
- package/web/dist/assets/use-submit-shortcut-Mle-9uJp.js +1 -0
- package/web/dist/assets/utils-D9GHDNza.js +64 -0
- package/web/dist/assets/workflows-3PCz57Z6.js +11 -0
- package/web/dist/assets/zoomable-image-DTq7s8CZ.js +1 -0
- package/web/dist/index.html +33 -13
- package/scripts/dev.mjs +0 -74
- package/scripts/release-snapshot.mjs +0 -130
- package/scripts/release.mjs +0 -119
- package/web/dist/assets/arrow-left-CnSdp92h.js +0 -1
- package/web/dist/assets/bundle-mjs-BegPhL5c.js +0 -1
- package/web/dist/assets/centered-state-DOsTcJZF.js +0 -43
- package/web/dist/assets/commit-list-BrxBEzRK.js +0 -1
- package/web/dist/assets/compare-variants-Bv2c9ORF.js +0 -1
- package/web/dist/assets/dist-B6XQOhgM.js +0 -1
- package/web/dist/assets/git-toolbar-pIC1ubPM.js +0 -1
- package/web/dist/assets/github-DTTSG0ht.js +0 -1
- package/web/dist/assets/highlighted-body-OFNGDK62-BuFJwunK.js +0 -1
- package/web/dist/assets/image-preview-DEhW0TFz.js +0 -1
- package/web/dist/assets/index-Cijmlz7q.js +0 -30
- package/web/dist/assets/index-DzroxqBG.css +0 -2
- package/web/dist/assets/lib-DPEZDBUN.js +0 -1
- package/web/dist/assets/lib-uHpeUPzK.js +0 -1
- package/web/dist/assets/mermaid-GHXKKRXX-CN2zQue0.js +0 -1
- package/web/dist/assets/open-mercato-toBr6SOa.svg +0 -11
- package/web/dist/assets/project-router-BMx4e8m-.js +0 -1
- package/web/dist/assets/repo-git-BusYfa3t.js +0 -1
- package/web/dist/assets/run-diff-wOJ3odA-.js +0 -3
- package/web/dist/assets/run-header-Cg5incil.js +0 -1
- package/web/dist/assets/skill-empty-hint-DTUSp6Vl.js +0 -1
- package/web/dist/assets/skills-PpP2owbM.js +0 -1
- package/web/dist/assets/tab-link-F3uQrdm2.js +0 -1
- package/web/dist/assets/task-changes-iD9kS79I.js +0 -1
- package/web/dist/assets/task-commits-COkLaUri.js +0 -1
- package/web/dist/assets/task-files-Ckttz7XH.js +0 -2
- package/web/dist/assets/task-thread-CQD91VPt.js +0 -9
- package/web/dist/assets/textarea-Bj_LCreg.js +0 -1
- package/web/dist/assets/use-desktop-DUh0STzq.js +0 -3
- package/web/dist/assets/utils-B8s5qIcK.js +0 -1
- package/web/dist/assets/workflows-BFk8C0co.js +0 -11
- package/web/dist/assets/zoomable-image-PEDOqyLQ.js +0 -1
- /package/web/{open-mercato.svg → dist/open-mercato.svg} +0 -0
package/dist/server/server.js
CHANGED
|
@@ -1,4 +1,11 @@
|
|
|
1
1
|
import { existsSync, readFileSync, statSync } from 'node:fs';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { AutomationStore } from '../automations/store.js';
|
|
4
|
+
import { AutomationCoordinator } from '../automations/coordinator.js';
|
|
5
|
+
import { GithubPoller } from '../automations/github-poller.js';
|
|
6
|
+
import { ProjectAutomationScheduler, WorkspaceAutomationScheduler } from '../automations/scheduler.js';
|
|
7
|
+
import { launchAutomationRun, reconcileAutomationReceipts, validateAutomationPrompt } from '../automations/task-template.js';
|
|
8
|
+
import { automationEventSchema, automationFiltersSchema, automationLogResultSchema, automationTaskSchema, } from '../automations/types.js';
|
|
2
9
|
import { access, constants as fsConstants, mkdir, readFile, realpath, stat, unlink, writeFile } from 'node:fs/promises';
|
|
3
10
|
import { basename, dirname, join, resolve, sep } from 'node:path';
|
|
4
11
|
import { fileURLToPath } from 'node:url';
|
|
@@ -6,67 +13,191 @@ import { Hono } from 'hono';
|
|
|
6
13
|
import { serve } from '@hono/node-server';
|
|
7
14
|
import { bodyLimit } from 'hono/body-limit';
|
|
8
15
|
import { streamSSE } from 'hono/streaming';
|
|
16
|
+
import { jsonZodValidator, paramZodValidator, queryZodValidator } from './validators.js';
|
|
9
17
|
import { parse as parseYaml, stringify as stringifyYaml } from 'yaml';
|
|
10
18
|
import { z } from 'zod';
|
|
19
|
+
import { setWorkspaceUiStateInputSchema, } from '../contract/index.js';
|
|
20
|
+
// A contract VALUE, like `workspaceUiStateSchema` in workspace/migrations.ts — the request
|
|
21
|
+
// schema this route validates with is the same one the client compiles against.
|
|
22
|
+
import { modelDiscoveryRunnerSchema, openProjectInSchema, updateProjectInputSchema, } from '../contract/index.js';
|
|
11
23
|
import { detectEnvironment } from '../core/backend-detect.js';
|
|
24
|
+
import { RUNNER_IDS } from '../core/agent-runner.js';
|
|
25
|
+
import { AGENT_MODELS_LOCKED_ERROR, agentModelsLocked } from '../core/agent-model-policy.js';
|
|
12
26
|
import { discoverCodexModels } from '../core/codex-model-catalog.js';
|
|
27
|
+
import { discoverOpencodeModels } from '../core/opencode-model-catalog.js';
|
|
28
|
+
import { PROVIDER_IDS, ProviderAuthService, providerAuthChecksDisabled, } from '../core/provider-auth.js';
|
|
29
|
+
import { applyProviderEnablement } from '../core/provider-availability.js';
|
|
13
30
|
import { RunnerModelCatalog } from '../core/runner-model-catalog.js';
|
|
14
31
|
import { currentUsage, onUsage } from '../core/process-usage.js';
|
|
15
32
|
import { WORKFLOWS_DIR, loadWorkflows } from '../workflows/load.js';
|
|
16
33
|
import { QUICK_TASK_WORKFLOW, normalizeWorkflowDoc, skillStackOf, skillsToSteps, stepsIssue, workflowFileSchema, workflowStepSchema, } from '../workflows/types.js';
|
|
17
34
|
import { planChain, slugify } from '../planner.js';
|
|
18
35
|
import { discoverSkills } from '../skills.js';
|
|
36
|
+
import { SkillsUpdateConflictError, SkillsUpdateCoordinator, SkillsUpdateService } from '../skills-update.js';
|
|
19
37
|
import { getTeamSkillsCached, refreshTeamSkills, waitForTeamSkills } from '../skills-remote.js';
|
|
20
38
|
import { appendHandoffHeartbeat, handoffProgressExcerpt, readHandoff } from '../handoff.js';
|
|
21
39
|
import { markStarted, onTodosChanged, readTodos, removeTodo, todoTaskText } from '../todos.js';
|
|
40
|
+
import { HistoryCursorError, deriveRunContextEvents, readEventsAfterLiveCursor, readRunHistoryPage, validateLiveCursor, } from '../runs/event-history.js';
|
|
41
|
+
import { readRunIndexFromDisk } from '../runs/run-index.js';
|
|
22
42
|
import { isV2WireEventType } from '../runs/ui-event-sink.js';
|
|
43
|
+
import { runEventsQuerySchema, runHistoryQuerySchema, runIdParamSchema, } from '../contract/index.js';
|
|
23
44
|
import { removeWorktree, worktreeDiff, worktreeDiffStat, worktreeSizeBytes } from '../git-worktree.js';
|
|
24
45
|
import { isReclaimable, reclaimWorktrees } from '../runs/retention.js';
|
|
25
46
|
import { getBranches, getCommit, getDiff, getLog, getRepoInfo, getStatus } from './git.js';
|
|
26
47
|
import { collectChanges, collectCommitChanges, collectRunCommits, commitAll, createOrSwitchBranch, imageMimeType, isOsOpenableImage, pushCurrentBranch, readWorktreePath, } from './git-changes.js';
|
|
27
48
|
import { gatedSkillsRepos, loadConfig, resolveWorktreeRetention } from '../config.js';
|
|
28
49
|
import { findConfigFile } from '../agent-config/catalog.js';
|
|
29
|
-
import { readConfigFile, writeConfigFile } from '../agent-config/files.js';
|
|
50
|
+
import { readConfigFile, statConfigPath, writeConfigFile } from '../agent-config/files.js';
|
|
51
|
+
import { readAgentModelDefaults } from '../agent-config/models.js';
|
|
30
52
|
import { listAgentConfig } from '../agent-config/service.js';
|
|
31
|
-
import {
|
|
32
|
-
import {
|
|
53
|
+
import { listConfigFiles } from '../agent-config/catalog.js';
|
|
54
|
+
import { readAccountIdentity } from '../agent-config/account-identity.js';
|
|
55
|
+
import { PROJECT_ID_RE, defaultWorkspaceConfig, effectiveSkillsAutoUpdate, loadWorkspaceConfig, mergeWriteWorkspaceConfig, effectiveComposerDefault, } from '../workspace/config.js';
|
|
56
|
+
import { CONTROL_CHARS_RE, DEFAULT_AGENT_ACCOUNT_ID, defaultAgentAccountStore, isAbsoluteConfigDir, loadAgentAccounts, mergeWriteAgentAccounts, } from '../workspace/agent-accounts.js';
|
|
57
|
+
import { defaultAgentProfile, listAgentProfiles, profileDirState, resolveProfileEnvForRoot, resolveStoredProfile, sameProfileDir, } from '../workspace/agent-profiles.js';
|
|
58
|
+
import { PROFILE_CAPABLE_PROVIDERS, profileEnv, supportsProfiles } from '../core/agent-profiles.js';
|
|
59
|
+
import { withEnvPrefix } from '../core/shell-env.js';
|
|
60
|
+
import { allocateProjectSlug, listProjects, normalizeProjectTags, probeProjectStatus, registerProject, removeProject, shouldRegisterProject, } from '../workspace/projects.js';
|
|
33
61
|
import { mergeWriteWorkspaceUiState, readWorkspaceUiState } from '../workspace/ui-state.js';
|
|
34
62
|
import { checkoutRepo } from './checkout.js';
|
|
35
63
|
import { ProjectContextError, ProjectContexts } from './project-context.js';
|
|
36
64
|
import { reviewGateEnabled } from '../runs/review-gate.js';
|
|
37
65
|
import { readUiState, uiStatePath } from '../ui-state.js';
|
|
38
|
-
import { expandTilde } from '../paths.js';
|
|
66
|
+
import { agentHomePaths, expandTilde } from '../paths.js';
|
|
39
67
|
import { isLoopbackHostHeader, normalizeHostname, resolveCapabilities } from './capabilities.js';
|
|
40
68
|
import { createSocketHub } from './ws.js';
|
|
41
69
|
import { browseDirectory, isInsideBrowseRoot, isLexicallyInsideBrowseRoot, resolveBrowseRoot } from './fs-browse.js';
|
|
42
|
-
import { resolveForge } from './forge/index.js';
|
|
43
|
-
import { fetchGithub, fetchGithubComments } from './github.js';
|
|
70
|
+
import { parseRemote, resolveForge } from './forge/index.js';
|
|
71
|
+
import { fetchGithub, fetchGithubChecks, fetchGithubComments, fetchGithubPrDiff, GithubPrNotFoundError, GH_CHECKS_MAX } from './github.js';
|
|
44
72
|
import { ensureLaunchKey } from './launch-key.js';
|
|
45
73
|
import { openInTerminal } from './open-in-terminal.js';
|
|
46
74
|
import { agentCliRunner, detectOpenTargets, openFileInDefaultApp, openInApp } from './open-in-app.js';
|
|
47
75
|
import { createDraftPr } from './pr.js';
|
|
76
|
+
import { ProviderRuntimeAuthObserver } from './provider-auth-runtime.js';
|
|
77
|
+
import { providerForActiveRun, providerForExistingRun, providersRequiredByWorkflow, unavailableProviderMessage, } from './provider-action-gate.js';
|
|
48
78
|
import { ASSET_CACHE_CONTROL, BUILD_HINT_HTML, assetContentType, isSafeAssetFilename, resolveGetRequest, } from './static-ui.js';
|
|
49
79
|
/** `projectId` gate at the route boundary (spec "Project identity"): the slug
|
|
50
80
|
* shape or the reserved `default` alias — validated BEFORE touching any map
|
|
51
81
|
* or path. (`default` matches the slug regex too; the literal keeps the
|
|
52
82
|
* contract explicit.) */
|
|
53
83
|
const projectIdSchema = z.union([z.literal('default'), z.string().regex(PROJECT_ID_RE)]);
|
|
54
|
-
const
|
|
84
|
+
const providerConnectSchema = z.object({
|
|
85
|
+
provider: z.enum(PROVIDER_IDS),
|
|
86
|
+
/** Which agent account to sign in (spec 2026-07-29-agent-profiles). Absent = the discovered
|
|
87
|
+
* default, which is what every pre-profiles client sends. Without this, "Connect" on a second
|
|
88
|
+
* Claude account would open a login for the FIRST one and report success. */
|
|
89
|
+
profileId: z.string().max(64).optional(),
|
|
90
|
+
}).strict();
|
|
91
|
+
/** Agent-account bodies (spec 2026-07-29-agent-profiles). Bounds mirror `agentProfileSchema` in
|
|
92
|
+
* src/workspace/config.ts exactly, so a value these accept can never be degraded away by the
|
|
93
|
+
* next load's `.catch`. The id is allocated server-side and is never a request field. */
|
|
94
|
+
const createAgentProfileSchema = z.object({
|
|
95
|
+
provider: z.enum(PROVIDER_IDS),
|
|
96
|
+
label: z.string().trim().max(200).optional(),
|
|
97
|
+
configDir: z.string().trim().min(1).max(4096),
|
|
98
|
+
}).strict();
|
|
99
|
+
/** `POST …/agent-profiles/:id/open` — a catalog id (or `folder`) plus an optional open target. */
|
|
100
|
+
const openAgentAccountFileSchema = z.object({
|
|
101
|
+
file: z.string().min(1).max(200),
|
|
102
|
+
target: z.string().min(1).max(64).optional(),
|
|
103
|
+
}).strict();
|
|
104
|
+
const updateAgentProfileSchema = z.object({
|
|
105
|
+
label: z.string().trim().max(200).optional(),
|
|
106
|
+
configDir: z.string().trim().min(1).max(4096).optional(),
|
|
107
|
+
}).strict().refine((value) => value.label !== undefined || value.configDir !== undefined, 'send label or configDir');
|
|
108
|
+
/** `PUT …/agent-profiles/selection` — which account a project uses for one provider. `null`
|
|
109
|
+
* clears it back to the discovered account. */
|
|
110
|
+
const selectAgentProfileSchema = z.object({
|
|
111
|
+
/** `null` targets the machine-wide default rather than one repo. */
|
|
112
|
+
projectId: z.string().min(1).max(64).nullable(),
|
|
113
|
+
provider: z.enum(PROVIDER_IDS),
|
|
114
|
+
profileId: z.string().max(64).nullable(),
|
|
115
|
+
}).strict();
|
|
116
|
+
/** The hosted-mode refusal, worded like the agent-config one it mirrors. */
|
|
117
|
+
const hostedProfileRefusal = {
|
|
118
|
+
error: 'agent accounts are managed from the machine that owns the checkout (this cockpit runs in hosted mode)',
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Allocate a profile id from the label (or, with no label, the folder name).
|
|
122
|
+
*
|
|
123
|
+
* Reuses the project allocator's shape, and deliberately its reserved set too: `default` is the
|
|
124
|
+
* discovered profile here exactly as it is the boot alias there, so a folder called `default/`
|
|
125
|
+
* becomes `default-2` and can never shadow it.
|
|
126
|
+
*/
|
|
127
|
+
function allocateAgentProfileId(source, taken) {
|
|
128
|
+
// `allocateProjectSlug` already basenames its argument and enforces the shared
|
|
129
|
+
// `^[a-z0-9][a-z0-9-]{0,63}$` shape, so `~/.claude-klaudiusz` slugs to `claude-klaudiusz`.
|
|
130
|
+
return allocateProjectSlug(source, taken);
|
|
131
|
+
}
|
|
132
|
+
const providerParamSchema = z.enum(PROVIDER_IDS);
|
|
133
|
+
const providerEnabledSchema = z.object({ enabled: z.boolean() }).strict();
|
|
134
|
+
const providerRetrySchema = z.object({
|
|
135
|
+
authFailureId: z.string().min(1).max(128),
|
|
136
|
+
}).strict();
|
|
137
|
+
const automationEditableSchema = z
|
|
138
|
+
.object({
|
|
139
|
+
name: z.string().trim().min(1).max(200),
|
|
140
|
+
description: z.string().max(2_000).optional(),
|
|
141
|
+
enabled: z.boolean().optional(),
|
|
142
|
+
events: z.array(automationEventSchema).min(1).max(4),
|
|
143
|
+
intervalSeconds: z.number().int().min(60).max(86_400),
|
|
144
|
+
filters: automationFiltersSchema,
|
|
145
|
+
task: automationTaskSchema,
|
|
146
|
+
})
|
|
147
|
+
.strict();
|
|
148
|
+
const automationCreateSchema = automationEditableSchema.extend({ enable: z.boolean().optional() });
|
|
149
|
+
const automationUpdateSchema = automationEditableSchema.extend({ expectedRevision: z.number().int().positive() });
|
|
150
|
+
const automationCheckRequestSchema = z.object({ mode: z.enum(['preview', 'execute']) }).strict();
|
|
151
|
+
const automationLogQuerySchema = z.object({
|
|
152
|
+
automationId: z.string().optional(),
|
|
153
|
+
result: automationLogResultSchema.optional(),
|
|
154
|
+
event: automationEventSchema.optional(),
|
|
155
|
+
since: z.string().datetime().optional(),
|
|
156
|
+
cursor: z.coerce.number().int().positive().optional(),
|
|
157
|
+
// Optional, not `.default(100)`: `AutomationStore.logs` already clamps `limit ?? 100` into
|
|
158
|
+
// 1..100, so the default here was a second copy of it — and a defaulted key is REQUIRED in the
|
|
159
|
+
// request type `queryZodValidator` publishes (see validators.ts on why the request side falls
|
|
160
|
+
// back to the schema's output), which would have made every caller send a `?limit=` this route
|
|
161
|
+
// never needed.
|
|
162
|
+
limit: z.coerce.number().int().min(1).max(100).optional(),
|
|
163
|
+
});
|
|
164
|
+
function editableAutomation(definition) {
|
|
165
|
+
return {
|
|
166
|
+
name: definition.name,
|
|
167
|
+
description: definition.description,
|
|
168
|
+
enabled: definition.enabled,
|
|
169
|
+
events: definition.events,
|
|
170
|
+
intervalSeconds: definition.intervalSeconds,
|
|
171
|
+
filters: definition.filters,
|
|
172
|
+
task: definition.task,
|
|
173
|
+
};
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* The public API surface (spec 2026-07-23-independent-server-web-packages).
|
|
177
|
+
*
|
|
178
|
+
* Every route lives under this one prefix. The unversioned `/api/*` spelling the cockpit used
|
|
179
|
+
* to speak was removed once the whole API was reachable here — carrying two spellings meant two
|
|
180
|
+
* surfaces to keep working, and only one of them could be the typed contract. Bumping to `v2`
|
|
181
|
+
* means mounting a second table beside this one, not editing route paths.
|
|
182
|
+
*/
|
|
183
|
+
const V1_PREFIX = '/api/v1';
|
|
184
|
+
/** Project scoping inside the versioned surface. The version is the OUTER dimension, so a
|
|
185
|
+
* consumer picks its API version once and then addresses projects inside it. */
|
|
186
|
+
const V1_SCOPED_PREFIX = `${V1_PREFIX}/p/:projectId`;
|
|
55
187
|
/**
|
|
56
|
-
* The project-scoped route table of a `createApp()` app, derived from its
|
|
57
|
-
*
|
|
58
|
-
*
|
|
59
|
-
*
|
|
60
|
-
*
|
|
61
|
-
* `/api/p/default/<path>`.
|
|
188
|
+
* The project-scoped route table of a `createApp()` app, derived from its actual registrations
|
|
189
|
+
* (so it can never drift from the code): every method+path mounted under
|
|
190
|
+
* `/api/v1/p/:projectId/…`, minus the scope-resolver middleware (method ALL), deduped. The
|
|
191
|
+
* alias-parity suite iterates this to assert `/api/v1/<path>` ≡ `/api/v1/p/<boot>/<path>` ≡
|
|
192
|
+
* `/api/v1/p/default/<path>`.
|
|
62
193
|
*/
|
|
63
194
|
export function projectRouteManifest(app) {
|
|
64
195
|
const seen = new Set();
|
|
65
196
|
const manifest = [];
|
|
66
197
|
for (const route of app.routes) {
|
|
67
|
-
if (route.method === 'ALL' || !route.path.startsWith(`${
|
|
198
|
+
if (route.method === 'ALL' || !route.path.startsWith(`${V1_SCOPED_PREFIX}/`))
|
|
68
199
|
continue;
|
|
69
|
-
const path = route.path.slice(
|
|
200
|
+
const path = route.path.slice(V1_SCOPED_PREFIX.length);
|
|
70
201
|
const key = `${route.method} ${path}`;
|
|
71
202
|
if (seen.has(key))
|
|
72
203
|
continue;
|
|
@@ -77,14 +208,17 @@ export function projectRouteManifest(app) {
|
|
|
77
208
|
}
|
|
78
209
|
/** 409 body for the inbox mutators while the follow-up inbox is off (#471). */
|
|
79
210
|
const FOLLOWUPS_OFF = 'the follow-up inbox is disabled — set CEZ_FOLLOWUPS=1 to enable it';
|
|
211
|
+
/** 409 body for every automations route while GitHub automations are off (#801). */
|
|
212
|
+
const AUTOMATIONS_OFF = 'GitHub automations are disabled — set CEZ_AUTOMATIONS=1 to enable them';
|
|
80
213
|
/**
|
|
81
214
|
* The in-process bus for workspace-level SSE events. The registry-mutating
|
|
82
215
|
* routes (`POST /api/projects` — step 4.2, emits `project-added` for a
|
|
83
216
|
* genuinely new entry; `DELETE /api/projects/:projectId` — step 4.4) and the
|
|
84
|
-
* checkout flow (step 4.3) call `emit()`;
|
|
85
|
-
*
|
|
86
|
-
* event verbatim under its name. Injectable via
|
|
87
|
-
* so tests (and any out-of-createApp emitter) can
|
|
217
|
+
* checkout flow (step 4.3) call `emit()`; runtime provider auth observation
|
|
218
|
+
* emits host-wide `provider-status`; every open `/api/workspace/events` stream
|
|
219
|
+
* relays the event verbatim under its name. Injectable via
|
|
220
|
+
* `ServerDeps.workspaceEvents` so tests (and any out-of-createApp emitter) can
|
|
221
|
+
* drive the stream.
|
|
88
222
|
*/
|
|
89
223
|
export class WorkspaceEventBus {
|
|
90
224
|
listeners = new Set();
|
|
@@ -120,15 +254,19 @@ const startRunSchema = z
|
|
|
120
254
|
// other prompt fields (`systemPrompt` 20k, message `text` 100k) so an
|
|
121
255
|
// unbounded body can't be piped into a spawned process (#429). 100k chars
|
|
122
256
|
// (~25k tokens) is well past any hand-written task.
|
|
123
|
-
task: z.string().min(1).max(100_000, '
|
|
257
|
+
task: z.string().min(1).max(100_000, 'must be at most 100000 characters'),
|
|
124
258
|
model: z.string().optional(),
|
|
125
259
|
// Agent backend for this task (falls back to config `defaultRunner`).
|
|
126
|
-
runner: z.enum(
|
|
260
|
+
runner: z.enum(RUNNER_IDS).optional(),
|
|
261
|
+
// Agent account for this task (spec 2026-07-29-agent-profiles). Falls back to the project's
|
|
262
|
+
// own selection, then the discovered default. Bounded like a profile id in the workspace
|
|
263
|
+
// schema, so a value this route accepts can never be degraded away by the next load.
|
|
264
|
+
agentProfile: z.string().max(64).optional(),
|
|
127
265
|
// Parallel variants (spec 010): ×2/×3 runs the task as 2–3 competing
|
|
128
266
|
// agents in separate worktrees; the user compares diffs and picks one.
|
|
129
267
|
variants: z.number().int().min(1).max(3).optional(),
|
|
130
268
|
// Composer worktree opt-out (#worktree-toggle): false runs in the repo
|
|
131
|
-
// working tree
|
|
269
|
+
// working tree. Ignored when variants > 1.
|
|
132
270
|
worktree: z.boolean().optional(),
|
|
133
271
|
// Autonomous mode (#autonomous): the run never parks at `waiting` — it
|
|
134
272
|
// auto-continues until the agent signals done. No "needs you" is raised.
|
|
@@ -145,7 +283,7 @@ const startRunSchema = z
|
|
|
145
283
|
systemPrompt: z
|
|
146
284
|
.string()
|
|
147
285
|
.trim()
|
|
148
|
-
.max(20_000, '
|
|
286
|
+
.max(20_000, 'must be at most 20000 characters')
|
|
149
287
|
.optional()
|
|
150
288
|
.transform((s) => (s ? s : undefined)),
|
|
151
289
|
// Screenshots pasted into the new-task form — same shape and limits as a
|
|
@@ -163,7 +301,7 @@ const startRunSchema = z
|
|
|
163
301
|
// started — the same bookkeeping POST /api/todos/:id/start does, so the
|
|
164
302
|
// audit trail survives the composer detour. Bounded like every other
|
|
165
303
|
// string here; a todo id is a short generated key.
|
|
166
|
-
todoId: z.string().min(1).max(200, '
|
|
304
|
+
todoId: z.string().min(1).max(200, 'must be at most 200 characters').optional(),
|
|
167
305
|
})
|
|
168
306
|
.refine((b) => Boolean(b.workflow) !== Boolean(b.steps), {
|
|
169
307
|
message: 'provide either "workflow" or "steps", not both',
|
|
@@ -173,7 +311,7 @@ const pickSchema = z.object({
|
|
|
173
311
|
});
|
|
174
312
|
const planSchema = z.object({
|
|
175
313
|
// Same bound as `startRunSchema.task` — this flows into `planChain` (#429).
|
|
176
|
-
task: z.string().trim().min(1).max(100_000, '
|
|
314
|
+
task: z.string().trim().min(1).max(100_000, 'must be at most 100000 characters'),
|
|
177
315
|
});
|
|
178
316
|
// A saved workflow carries full `steps` OR the builder's `skills` stack
|
|
179
317
|
// (spec 012). `overwrite: true` is the builder's Save on an existing file —
|
|
@@ -183,7 +321,7 @@ const saveWorkflowSchema = z
|
|
|
183
321
|
name: z.string().trim().min(1).max(80),
|
|
184
322
|
// Written into a YAML file on disk (#429) — a workflow description is a
|
|
185
323
|
// short blurb, so a 2k cap is generous without allowing a file-bloat write.
|
|
186
|
-
description: z.string().max(2_000, '
|
|
324
|
+
description: z.string().max(2_000, 'must be at most 2000 characters').optional(),
|
|
187
325
|
steps: z.array(workflowStepSchema).min(1).max(8).optional(),
|
|
188
326
|
skills: z.array(z.string().trim().min(1)).min(1).max(8).optional(),
|
|
189
327
|
overwrite: z.boolean().optional(),
|
|
@@ -206,51 +344,25 @@ const SKILL_USAGE_MAX_ENTRIES = 200;
|
|
|
206
344
|
// generous for GUI prefs; over-limit is a 400, never a silent strip. Shared by
|
|
207
345
|
// BOTH ui-state routes (per-repo and workspace) via `parseUiStateBody`.
|
|
208
346
|
const UI_STATE_MAX_KEYS = 200;
|
|
209
|
-
/** Settings → Appearance (redesign R6): accent + density
|
|
210
|
-
* ui-state files — per-repo (the legacy home, kept so an older
|
|
211
|
-
* same repo still honours it) and workspace
|
|
212
|
-
* post-migration home — multi-project spec,
|
|
347
|
+
/** Settings → Appearance (redesign R6): accent + density + reading width. ONE
|
|
348
|
+
* schema for both ui-state files — per-repo (the legacy home, kept so an older
|
|
349
|
+
* cezar in the same repo still honours it) and workspace
|
|
350
|
+
* (`~/.cezar/ui-state.json`, its post-migration home — multi-project spec,
|
|
351
|
+
* Data Model).
|
|
352
|
+
*
|
|
353
|
+
* Every key is `.optional()` so an older ui-state.json parses unchanged, but
|
|
354
|
+
* each one must be listed HERE: the enclosing `workspaceUiStateSchema` is
|
|
355
|
+
* `.passthrough()` at the top level only, so an unlisted key inside
|
|
356
|
+
* `appearance` is stripped by zod and then wiped from the file by the shallow
|
|
357
|
+
* merge-on-write. The cockpit adopts the PUT response as authoritative, so a
|
|
358
|
+
* stripped key does not merely fail to persist — it visibly reverts the
|
|
359
|
+
* control the user just touched. Adding an appearance preference means adding
|
|
360
|
+
* it here in the same change. */
|
|
213
361
|
const appearanceSchema = z.object({
|
|
214
362
|
accent: z.enum(['lime', 'violet']).optional(),
|
|
215
363
|
density: z.enum(['comfortable', 'compact', 'ultra']).optional(),
|
|
364
|
+
width: z.enum(['narrow', 'wide']).optional(),
|
|
216
365
|
});
|
|
217
|
-
/** Global GUI state (`~/.cezar/ui-state.json`, step 2.7) — the workspace twin
|
|
218
|
-
* of `uiStateSchema` below, sharing its `.passthrough()` + key-cap + shallow
|
|
219
|
-
* merge-on-write semantics via `parseUiStateBody`. Known keys are the
|
|
220
|
-
* cross-project prefs from the spec's Data Model; everything project-scoped
|
|
221
|
-
* (githubView, prompt templates, dismissed banners…) stays per-repo. */
|
|
222
|
-
const workspaceUiStateSchema = z
|
|
223
|
-
.object({
|
|
224
|
-
appearance: appearanceSchema.optional(),
|
|
225
|
-
notifications: z.object({ enabled: z.boolean().optional() }).passthrough().optional(),
|
|
226
|
-
// Sidebar per-project collapse map, keyed by project id (slug ≤ 64 chars).
|
|
227
|
-
// Entry-capped like `skillUsage`: the map is written straight to a file the
|
|
228
|
-
// cockpit GETs on every load, so it must stay bounded on every axis.
|
|
229
|
-
sidebar: z
|
|
230
|
-
.object({
|
|
231
|
-
collapsed: z
|
|
232
|
-
.record(z.string().min(1).max(64), z.boolean())
|
|
233
|
-
.refine((map) => Object.keys(map).length <= UI_STATE_MAX_KEYS, {
|
|
234
|
-
message: `sidebar.collapsed must have at most ${UI_STATE_MAX_KEYS} entries`,
|
|
235
|
-
})
|
|
236
|
-
.optional(),
|
|
237
|
-
})
|
|
238
|
-
.passthrough()
|
|
239
|
-
.optional(),
|
|
240
|
-
// The user's curated selection of default (vendor) skills — `open-mercato/skills` — so the
|
|
241
|
-
// catalog is no longer forced in full. GLOBAL (here, not per-repo) because "which skills I
|
|
242
|
-
// want" describes the person, not a checkout, and must not depend on where cezar was launched
|
|
243
|
-
// (multi-project workspace). Tri-state, enforced in `discoverSkills`: an ABSENT key means "not
|
|
244
|
-
// curated" and every default skill still shows (opt-out default — no silent break on upgrade);
|
|
245
|
-
// a PRESENT array (even `[]`) shows only those names. Bounded like the `skillUsage` map: the
|
|
246
|
-
// file is GET/PUT wholesale, so an unbounded array is an unbounded write. Names match
|
|
247
|
-
// `lastTask.ref` (`.min(1).max(200)`). The client PUTs the whole array (shallow top-level merge).
|
|
248
|
-
importedSkills: z
|
|
249
|
-
.array(z.string().min(1).max(200))
|
|
250
|
-
.max(SKILL_USAGE_MAX_ENTRIES)
|
|
251
|
-
.optional(),
|
|
252
|
-
})
|
|
253
|
-
.passthrough();
|
|
254
366
|
const uiStateSchema = z
|
|
255
367
|
.object({
|
|
256
368
|
lastTask: z
|
|
@@ -330,7 +442,7 @@ const patchRunSchema = z.object({
|
|
|
330
442
|
});
|
|
331
443
|
// Session commit (redesign R5 — §"Git/session API additions").
|
|
332
444
|
const gitCommitSchema = z.object({
|
|
333
|
-
message: z.string().trim().min(1, '
|
|
445
|
+
message: z.string().trim().min(1, 'must not be empty').max(5_000),
|
|
334
446
|
});
|
|
335
447
|
// "Open in…" (#open-in / #365): `target` selects the app; `path` (optional, worktree-relative)
|
|
336
448
|
// narrows the target's own worktree/repo-root default to one file — used by the diff pane's
|
|
@@ -387,9 +499,9 @@ function foldedLength(task, stack) {
|
|
|
387
499
|
// follow-up composer is a full composer, so a screenshot pasted into it must reach the reopened
|
|
388
500
|
// session rather than being silently dropped.
|
|
389
501
|
const continueSchema = z.object({
|
|
390
|
-
text: z.string().max(100_000, '
|
|
502
|
+
text: z.string().max(100_000, 'must be at most 100000 characters').optional(),
|
|
391
503
|
images: z.array(imageInputSchema).max(4).optional(),
|
|
392
|
-
runner: z.enum(
|
|
504
|
+
runner: z.enum(RUNNER_IDS).optional(),
|
|
393
505
|
model: z.string().max(200).optional(),
|
|
394
506
|
});
|
|
395
507
|
// Inbox "▶ Run" body (spec 007 / #401 / #413): every field optional, and the whole body is
|
|
@@ -401,12 +513,12 @@ const continueSchema = z.object({
|
|
|
401
513
|
// absent so it never touches `task`.
|
|
402
514
|
const startTodoSchema = z
|
|
403
515
|
.object({
|
|
404
|
-
runner: z.enum(
|
|
516
|
+
runner: z.enum(RUNNER_IDS).optional(),
|
|
405
517
|
model: z.string().max(200).optional(),
|
|
406
518
|
prompt: z
|
|
407
519
|
.string()
|
|
408
520
|
.trim()
|
|
409
|
-
.max(20_000, '
|
|
521
|
+
.max(20_000, 'must be at most 20000 characters')
|
|
410
522
|
.optional()
|
|
411
523
|
.transform((s) => (s ? s : undefined)),
|
|
412
524
|
})
|
|
@@ -432,21 +544,99 @@ function stripHostPort(host) {
|
|
|
432
544
|
return bracketed[1];
|
|
433
545
|
return host.replace(/:\d+$/, '');
|
|
434
546
|
}
|
|
435
|
-
/** The shared write-side half of BOTH ui-state routes (per-repo `/api/ui-state`
|
|
436
|
-
* and workspace `/api/workspace/ui-state`) — the factored split the
|
|
437
|
-
* multi-project spec calls for instead of a copy:
|
|
438
|
-
*
|
|
439
|
-
*
|
|
440
|
-
*
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
547
|
+
/** The shared write-side half of BOTH ui-state routes (per-repo `/api/v1/ui-state`
|
|
548
|
+
* and workspace `/api/v1/workspace/ui-state`) — the factored split the
|
|
549
|
+
* multi-project spec calls for instead of a copy: the route's own schema, plus a
|
|
550
|
+
* cap on the top-level key count so a `.passthrough()` schema can't accumulate an
|
|
551
|
+
* unbounded key set (#429). The cap rides as a refinement so the whole thing is one
|
|
552
|
+
* schema and can go through `jsonBody` like every other mutating route. The
|
|
553
|
+
* merge-on-write stays with each route (they write different files) but is shallow
|
|
554
|
+
* in both. */
|
|
555
|
+
function capUiStateKeys(data, ctx) {
|
|
556
|
+
if (Object.keys(data).length > UI_STATE_MAX_KEYS) {
|
|
557
|
+
ctx.addIssue({ code: z.ZodIssueCode.custom, message: `ui-state has too many keys (max ${UI_STATE_MAX_KEYS})` });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
// Derived once per route instead of by a generic `wrap(schema)` helper called at the route: a
|
|
561
|
+
// generic wrapper leaves the schema type unresolved where `jsonBody` needs it, and Hono answers
|
|
562
|
+
// that by dropping the whole PUT from the route schema rather than erroring — both ui-state PUTs
|
|
563
|
+
// silently vanished from `AppType`. Concrete consts keep them visible to `hc`.
|
|
564
|
+
const uiStateBody = uiStateSchema.superRefine(capUiStateKeys);
|
|
565
|
+
/**
|
|
566
|
+
* Query schemas for the READ routes — deliberately permissive.
|
|
567
|
+
*
|
|
568
|
+
* These exist to make a query key VISIBLE to the route type (`hc` refuses a `query` argument for
|
|
569
|
+
* a key no validator declares), NOT to narrow what the route accepts. Every one of these handlers
|
|
570
|
+
* compares `=== '1'` and treats everything else as false, so `?refresh=0` is a successful request
|
|
571
|
+
* today; a literal schema would silently turn it into a 400. The comparison stays in the handler
|
|
572
|
+
* and the validator stays out of its way.
|
|
573
|
+
*
|
|
574
|
+
* The one route that really is strict on the wire — `GET /github/prs/:number/changes`, which 400s
|
|
575
|
+
* on `?refresh=true` — keeps its own `z.enum(['1'])` schema next to the route.
|
|
576
|
+
*/
|
|
577
|
+
/**
|
|
578
|
+
* One query value, matching what `c.req.query('k')` did before these routes were validated.
|
|
579
|
+
*
|
|
580
|
+
* Hono's query validator hands a REPEATED key as an array (`?wait=1&wait=1` → `['1','1']`), where
|
|
581
|
+
* `c.req.query()` silently took the first. A plain `z.string()` therefore turns a request that
|
|
582
|
+
* used to answer 200 into a 400 — a wire change no caller asked for. Collapsing to the first
|
|
583
|
+
* value keeps the old behaviour, and the client still sees a plain `key?: string`.
|
|
584
|
+
*/
|
|
585
|
+
const queryValue = z.union([z.string(), z.array(z.string()).transform((v) => v[0])]).optional();
|
|
586
|
+
const refreshQuery = z.object({ refresh: queryValue });
|
|
587
|
+
const waitQuery = z.object({ wait: queryValue });
|
|
588
|
+
function parseAccept(header) {
|
|
589
|
+
const ranges = [];
|
|
590
|
+
for (const part of header.split(',')) {
|
|
591
|
+
const [range = '', ...params] = part.split(';');
|
|
592
|
+
const [type = '', subtype = ''] = range.trim().toLowerCase().split('/');
|
|
593
|
+
if (type === '' || subtype === '')
|
|
594
|
+
continue;
|
|
595
|
+
const weight = params.map((p) => p.trim().toLowerCase()).find((p) => p.startsWith('q='));
|
|
596
|
+
const parsed = weight === undefined ? 1 : Number.parseFloat(weight.slice(2));
|
|
597
|
+
const q = Number.isFinite(parsed) ? Math.min(Math.max(parsed, 0), 1) : 0;
|
|
598
|
+
if (q > 0)
|
|
599
|
+
ranges.push({ type, subtype, q });
|
|
600
|
+
}
|
|
601
|
+
return ranges;
|
|
602
|
+
}
|
|
603
|
+
/**
|
|
604
|
+
* The best of `offers` for this `Accept`, or `null` for "no preference expressed" — which is what
|
|
605
|
+
* an absent header, a `*<slash>*`-only header and a header naming nothing on offer all mean, and
|
|
606
|
+
* what leaves the caller on its route's default.
|
|
607
|
+
*
|
|
608
|
+
* `offers` is the server's own preference order: an offer may itself be a wildcard (`image/*`,
|
|
609
|
+
* since the concrete image type is not known until the file is read), and equal q-values go to the
|
|
610
|
+
* EARLIER offer, so each route lists its default representation first.
|
|
611
|
+
*/
|
|
612
|
+
function negotiate(accept, offers) {
|
|
613
|
+
if (accept === undefined)
|
|
614
|
+
return null;
|
|
615
|
+
const ranges = parseAccept(accept);
|
|
616
|
+
let best = null;
|
|
617
|
+
for (const offer of offers) {
|
|
618
|
+
const [type = '', subtype = ''] = offer.split('/');
|
|
619
|
+
let q = 0;
|
|
620
|
+
for (const range of ranges) {
|
|
621
|
+
if (range.type === '*' && range.subtype === '*')
|
|
622
|
+
continue; // "anything" is not a preference
|
|
623
|
+
const typeMatches = range.type === type || range.type === '*' || type === '*';
|
|
624
|
+
const subtypeMatches = range.subtype === subtype || range.subtype === '*' || subtype === '*';
|
|
625
|
+
if (typeMatches && subtypeMatches && range.q > q)
|
|
626
|
+
q = range.q;
|
|
627
|
+
}
|
|
628
|
+
if (q > 0 && (best === null || q > best.q))
|
|
629
|
+
best = { offer, q };
|
|
447
630
|
}
|
|
448
|
-
return
|
|
631
|
+
return best?.offer ?? null;
|
|
449
632
|
}
|
|
633
|
+
/** What `GET /repo/commit/:sha` offers, DEFAULT FIRST: the legacy `text/plain` blob is what a
|
|
634
|
+
* request with no opinion has always received (§2), so it also wins an Accept tie. */
|
|
635
|
+
const COMMIT_FORMATS = ['text/plain', 'application/json'];
|
|
636
|
+
/** What `GET /runs/:id/files` offers, DEFAULT FIRST. `image/*` rather than a concrete type: which
|
|
637
|
+
* image type the bytes are is only known once the path resolves, and the raw branch refuses
|
|
638
|
+
* everything that is not an image anyway. */
|
|
639
|
+
const FILE_FORMATS = ['application/json', 'image/*'];
|
|
450
640
|
/** Workspace-root writability probe (multi-project spec, "API Contracts"):
|
|
451
641
|
* optional `mkdir -p`, `access W_OK`, then a real create/delete round-trip — W_OK alone
|
|
452
642
|
* can lie (e.g. a read-only mount still reports writable permission bits).
|
|
@@ -469,6 +659,10 @@ async function probeWritableDir(dir, create) {
|
|
|
469
659
|
return err instanceof Error ? err.message : String(err);
|
|
470
660
|
}
|
|
471
661
|
}
|
|
662
|
+
// The return type is INFERRED on purpose: it is the chained app type built at the bottom of
|
|
663
|
+
// this function, and `AppType` (src/server/app-type.ts) is `ReturnType<typeof createApp>`.
|
|
664
|
+
// Annotating it `Hono` here would erase every route from the type and leave the typed client
|
|
665
|
+
// with nothing to offer. See the `routed` assembly at the end of the function.
|
|
472
666
|
export function createApp(deps) {
|
|
473
667
|
const { version, update, bindHost, bootProjectId } = deps;
|
|
474
668
|
// Boot singletons keep DELIBERATELY distinct names (`boot*`): every
|
|
@@ -479,8 +673,60 @@ export function createApp(deps) {
|
|
|
479
673
|
const bootRoot = deps.repoRoot;
|
|
480
674
|
const bootDataDir = join(bootRoot, '.ai/cezar');
|
|
481
675
|
const modelCatalog = deps.modelCatalog ?? new RunnerModelCatalog({
|
|
482
|
-
adapters: {
|
|
676
|
+
adapters: {
|
|
677
|
+
codex: { discover: () => discoverCodexModels({ cwd: bootRoot }) },
|
|
678
|
+
opencode: { discover: () => discoverOpencodeModels({ cwd: bootRoot }) },
|
|
679
|
+
},
|
|
483
680
|
});
|
|
681
|
+
const providerAuth = deps.providerAuth ?? new ProviderAuthService();
|
|
682
|
+
const workspaceConfig = deps.workspaceConfig ?? {
|
|
683
|
+
load: loadWorkspaceConfig,
|
|
684
|
+
mergeWrite: mergeWriteWorkspaceConfig,
|
|
685
|
+
};
|
|
686
|
+
const providerStatus = async (options) => {
|
|
687
|
+
if (providerAuthChecksDisabled()) {
|
|
688
|
+
return applyProviderEnablement(await providerAuth.status(options?.refresh ? { refresh: true } : undefined), []);
|
|
689
|
+
}
|
|
690
|
+
const [discovered, workspace] = await Promise.all([
|
|
691
|
+
providerAuth.status(options?.refresh ? { refresh: true } : undefined),
|
|
692
|
+
workspaceConfig.load(),
|
|
693
|
+
]);
|
|
694
|
+
return applyProviderEnablement(discovered, workspace.disabledProviders);
|
|
695
|
+
};
|
|
696
|
+
/**
|
|
697
|
+
* The gate: why a run cannot start against `required`, or null when it can.
|
|
698
|
+
*
|
|
699
|
+
* VERIFY BEFORE YOU REFUSE. Auth state is served stale-while-revalidate (see
|
|
700
|
+
* `ProviderAuthService.status`), so a cached "disconnected" may predate a login cezar could not
|
|
701
|
+
* observe — someone running `claude auth login` in a terminal. Refusing on that would lock a user
|
|
702
|
+
* out of their own cockpit with no way back but waiting. So a believed-unavailable provider is
|
|
703
|
+
* re-probed, and only a refusal that survives the fresh answer is returned.
|
|
704
|
+
*
|
|
705
|
+
* The cost lands where it belongs: the common path (connected, warm) pays nothing at all, and the
|
|
706
|
+
* probe is only spawned when cezar is about to say no — a rare, interactive moment. This is also
|
|
707
|
+
* what lets the cache hold a negative for a minute instead of five seconds, which is what made
|
|
708
|
+
* every reader of `GET /providers/status` periodically pay for a CLI spawn.
|
|
709
|
+
*
|
|
710
|
+
* A runtime auth latch is deliberately NOT escaped by this: `withRuntimeFailures` keeps forcing
|
|
711
|
+
* the row disconnected until the user acknowledges that exact incident, so the re-probe cannot
|
|
712
|
+
* talk cezar out of a rejection it actually observed.
|
|
713
|
+
*/
|
|
714
|
+
const providerActionError = async (required) => {
|
|
715
|
+
const known = await providerStatus();
|
|
716
|
+
const message = unavailableProviderMessage(required, known);
|
|
717
|
+
if (message === null)
|
|
718
|
+
return null;
|
|
719
|
+
// A DISABLED provider is a settings fact, not a probe result — re-probing it learns nothing and
|
|
720
|
+
// would spawn a CLI to re-read something the user typed.
|
|
721
|
+
const disabled = required.some((provider) => known.providers.find((row) => row.provider === provider)?.enabled === false);
|
|
722
|
+
if (disabled)
|
|
723
|
+
return message;
|
|
724
|
+
return unavailableProviderMessage(required, await providerStatus({ refresh: true }));
|
|
725
|
+
};
|
|
726
|
+
const openTerminal = deps.openTerminal ?? openInTerminal;
|
|
727
|
+
const openFile = deps.openFile ?? openFileInDefaultApp;
|
|
728
|
+
const openApp = deps.openApp ?? openInApp;
|
|
729
|
+
const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService();
|
|
484
730
|
// ---- workspace boot-project identity (multi-project spec) ----------------
|
|
485
731
|
// The boot flow (`initWorkspace` in src/index.ts) registers the boot repo
|
|
486
732
|
// and plumbs its registry id in via `deps.bootProjectId`. Legacy callers and
|
|
@@ -551,6 +797,7 @@ export function createApp(deps) {
|
|
|
551
797
|
dataDir: bootDataDir,
|
|
552
798
|
store: deps.store,
|
|
553
799
|
manager: deps.manager,
|
|
800
|
+
automationStore: deps.automationStore ?? AutomationStore.open(bootDataDir),
|
|
554
801
|
launchKey: ensureLaunchKey(bootDataDir), // bookmarklet auto-start secret (spec 011)
|
|
555
802
|
};
|
|
556
803
|
// Non-boot projects build lazily on first scoped request; their managers
|
|
@@ -567,6 +814,25 @@ export function createApp(deps) {
|
|
|
567
814
|
// Workspace-level SSE bus (step 2.8) — the registry mutators and the
|
|
568
815
|
// checkout flow (Phase 4) emit here; /api/workspace/events relays.
|
|
569
816
|
const workspaceEvents = deps.workspaceEvents ?? new WorkspaceEventBus();
|
|
817
|
+
const emitAutomationChange = (project, automationId, revision, deleted = false) => workspaceEvents.emit('automation-change', {
|
|
818
|
+
project: project.id,
|
|
819
|
+
automationId,
|
|
820
|
+
revision,
|
|
821
|
+
...(deleted ? { deleted: true } : {}),
|
|
822
|
+
});
|
|
823
|
+
const automationsChanged = () => deps.automationsChanged?.();
|
|
824
|
+
const providerRuntimeAuth = deps.providerRuntimeAuth
|
|
825
|
+
?? new ProviderRuntimeAuthObserver(providerAuth, (status) => {
|
|
826
|
+
workspaceEvents.emit('provider-status', status);
|
|
827
|
+
});
|
|
828
|
+
providerRuntimeAuth.watch(bootContext.store);
|
|
829
|
+
for (const id of contexts.ids()) {
|
|
830
|
+
const ctx = contexts.peek(id);
|
|
831
|
+
if (ctx)
|
|
832
|
+
providerRuntimeAuth.watch(ctx.store);
|
|
833
|
+
}
|
|
834
|
+
contexts.onStoreCreated((store) => providerRuntimeAuth.watch(store));
|
|
835
|
+
contexts.onContextBuilt((ctx) => providerRuntimeAuth.watch(ctx.store));
|
|
570
836
|
const app = new Hono();
|
|
571
837
|
// Reject oversized request bodies before they reach any handler (#429). GETs
|
|
572
838
|
// and SSE carry no body, so this only ever gates the mutating routes.
|
|
@@ -577,7 +843,7 @@ export function createApp(deps) {
|
|
|
577
843
|
// own: any page the user visits can still POST to us (CSRF), and DNS
|
|
578
844
|
// rebinding can point a foreign domain at loopback and read our responses.
|
|
579
845
|
// Two zero-config checks close both holes on every /api route EXCEPT
|
|
580
|
-
// /api/health (the intentional cross-origin discovery endpoint, spec 011 —
|
|
846
|
+
// /api/v1/health (the intentional cross-origin discovery endpoint, spec 011 —
|
|
581
847
|
// it exposes nothing sensitive, see #431):
|
|
582
848
|
// 1. Host allowlist (loopback deployments only) — a request whose Host is
|
|
583
849
|
// not a loopback name did not really originate from this machine. A
|
|
@@ -600,7 +866,7 @@ export function createApp(deps) {
|
|
|
600
866
|
// Scope note: check 2 covers writes only. A cross-origin GET from any site
|
|
601
867
|
// still reaches the read routes — but its Host is ours, so it is a *forced
|
|
602
868
|
// request*, not a read: the same-origin policy stops the attacker seeing any
|
|
603
|
-
// response body (we send CORS headers on /api/health alone), and no GET
|
|
869
|
+
// response body (we send CORS headers on /api/v1/health alone), and no GET
|
|
604
870
|
// handler mutates state. Rebinding, which WOULD make those reads legible, is
|
|
605
871
|
// what check 1 stops.
|
|
606
872
|
const MUTATING_METHODS = new Set(['POST', 'PUT', 'PATCH', 'DELETE']);
|
|
@@ -615,9 +881,12 @@ export function createApp(deps) {
|
|
|
615
881
|
error: 'forbidden: unexpected Host header — this request did not originate from this machine (see #426)',
|
|
616
882
|
}, 403);
|
|
617
883
|
}
|
|
618
|
-
// /api/health stays CORS-open for
|
|
619
|
-
// still checked above: cross-origin is legitimate, DNS rebinding is not.
|
|
620
|
-
|
|
884
|
+
// /api/v1/health stays CORS-open for cross-origin discovery, but its Host is
|
|
885
|
+
// still checked above: cross-origin is legitimate, DNS rebinding is not. The
|
|
886
|
+
// path is spelled through V1_PREFIX rather than inline — an unversioned
|
|
887
|
+
// literal here silently stopped matching when the API moved and left health
|
|
888
|
+
// relying on the mutating-methods gate below to let its GETs through.
|
|
889
|
+
if (c.req.path === `${V1_PREFIX}/health`)
|
|
621
890
|
return next();
|
|
622
891
|
if (MUTATING_METHODS.has(c.req.method)) {
|
|
623
892
|
const origin = c.req.header('origin');
|
|
@@ -661,15 +930,16 @@ export function createApp(deps) {
|
|
|
661
930
|
});
|
|
662
931
|
// The mirrored project-route table (spec "API Contracts → Project-scoped").
|
|
663
932
|
// Every route below registers ONCE on this sub-app; `createApp` mounts it
|
|
664
|
-
// twice — under `/api/p/:projectId` (scoped) and under `/api` (
|
|
665
|
-
//
|
|
666
|
-
//
|
|
667
|
-
// no `projectId` param (
|
|
933
|
+
// twice — under `/api/v1/p/:projectId` (scoped) and under `/api/v1` (bound to
|
|
934
|
+
// the boot project) — so both spellings share one handler and can never
|
|
935
|
+
// drift. The resolver middleware binds `c.get('project')`:
|
|
936
|
+
// no `projectId` param (unscoped mount) → the boot context, byte-identical to
|
|
668
937
|
// the pre-workspace closures; `default` or the boot project's own id → the
|
|
669
938
|
// boot context too; anything else → the lazy context map, with
|
|
670
939
|
// `ProjectContextError` mapped to 404 (unknown) / 409 (missing root).
|
|
671
|
-
|
|
672
|
-
|
|
940
|
+
// Named rather than inlined because both mounts share it — one function, so
|
|
941
|
+
// the two spellings can never disagree about what `default` means.
|
|
942
|
+
const resolveProjectScope = async (c, next) => {
|
|
673
943
|
const raw = c.req.param('projectId');
|
|
674
944
|
if (raw === undefined) {
|
|
675
945
|
c.set('project', bootContext);
|
|
@@ -694,15 +964,23 @@ export function createApp(deps) {
|
|
|
694
964
|
throw err;
|
|
695
965
|
}
|
|
696
966
|
return next();
|
|
697
|
-
}
|
|
967
|
+
};
|
|
698
968
|
// ---- static GUI ----------------------------------------------------------
|
|
699
969
|
const webDir = resolveWebDir();
|
|
700
970
|
const distDir = join(webDir, 'dist');
|
|
701
971
|
const HTML_TYPE = 'text/html; charset=utf-8';
|
|
702
|
-
const staticFile = (name, type) => () => {
|
|
972
|
+
const staticFile = (name, type) => (c) => {
|
|
703
973
|
// Read per request — the files are tiny and this keeps dev iteration live.
|
|
704
|
-
|
|
705
|
-
|
|
974
|
+
//
|
|
975
|
+
// Served out of the Vite build: the file is a `public/` asset of the web package, which the
|
|
976
|
+
// build copies verbatim into `web/dist`. One home, one URL — the same bytes this route
|
|
977
|
+
// hands out are what the bundle's own `<img src="/open-mercato.svg">` asks for.
|
|
978
|
+
// Without a build there is nothing to serve, which is a 404 rather than a crash (the shell
|
|
979
|
+
// route answers the same dev-only state with its build hint).
|
|
980
|
+
const path = join(distDir, name);
|
|
981
|
+
if (!existsSync(path))
|
|
982
|
+
return c.json({ error: 'not found' }, 404);
|
|
983
|
+
return new Response(readFileSync(path), { headers: { 'content-type': type } });
|
|
706
984
|
};
|
|
707
985
|
let hintLogged = false;
|
|
708
986
|
const serveShell = (c) => {
|
|
@@ -749,14 +1027,14 @@ export function createApp(deps) {
|
|
|
749
1027
|
},
|
|
750
1028
|
});
|
|
751
1029
|
});
|
|
752
|
-
// The favicon web/
|
|
1030
|
+
// The favicon packages/web/index.html points at (`/open-mercato.svg`).
|
|
753
1031
|
app.get('/open-mercato.svg', staticFile('open-mercato.svg', 'image/svg+xml'));
|
|
754
1032
|
// ---- meta ----------------------------------------------------------------
|
|
755
1033
|
// CORS — deliberately for /api/health ONLY (spec 011): the bookmarklets
|
|
756
1034
|
// fetch it cross-origin from github.com to discover which local ports run a
|
|
757
1035
|
// cockpit and which repo each serves. Health exposes no secrets beyond the
|
|
758
1036
|
// repo path/remote; every other endpoint stays same-origin.
|
|
759
|
-
|
|
1037
|
+
const healthCors = async (c, next) => {
|
|
760
1038
|
c.header('access-control-allow-origin', '*');
|
|
761
1039
|
if (c.req.method === 'OPTIONS') {
|
|
762
1040
|
// Preflight (e.g. Chrome Private Network Access) — allow the plain GET.
|
|
@@ -765,10 +1043,16 @@ export function createApp(deps) {
|
|
|
765
1043
|
return c.body(null, 204);
|
|
766
1044
|
}
|
|
767
1045
|
await next();
|
|
768
|
-
}
|
|
1046
|
+
};
|
|
1047
|
+
app.use(`${V1_PREFIX}/health`, healthCors);
|
|
769
1048
|
// One builder for both transports: `GET /api/health` (the authoritative,
|
|
770
|
-
// CORS-open discovery endpoint) and the `health` topic on `/api/ws` below
|
|
1049
|
+
// CORS-open discovery endpoint) and the `health` topic on `/api/v1/ws` below
|
|
771
1050
|
// push the byte-identical shape, so the two can never drift.
|
|
1051
|
+
// Deliberately UNANNOTATED: this literal is the source of the `/health` shape. Annotating it
|
|
1052
|
+
// with the api-client's `HealthResponse` made the contract circular — the DTO was declared by
|
|
1053
|
+
// hand, the handler was checked against it, and `AppType` then reported the hand-written type
|
|
1054
|
+
// back as if the server had proven it. Inferring here means the route says what it actually
|
|
1055
|
+
// sends, which is what lets the DTO be derived instead of maintained.
|
|
772
1056
|
const healthSnapshot = async () => {
|
|
773
1057
|
const [checks, repo, config, workspace] = await Promise.all([
|
|
774
1058
|
detectEnvironment(),
|
|
@@ -782,7 +1066,11 @@ export function createApp(deps) {
|
|
|
782
1066
|
const caps = capabilities();
|
|
783
1067
|
return {
|
|
784
1068
|
version,
|
|
785
|
-
latestVersion: update?.latest
|
|
1069
|
+
// Spread rather than `latestVersion: update?.latest`: an `undefined` VALUE is dropped by
|
|
1070
|
+
// JSON.stringify, so the key is absent on the wire — but writing it unconditionally types
|
|
1071
|
+
// the key as always-present, which is a shape no client ever receives. The contract schema
|
|
1072
|
+
// says `.optional()`, and contract-parity.test.ts holds the two together.
|
|
1073
|
+
...(update?.latest !== undefined ? { latestVersion: update.latest } : {}),
|
|
786
1074
|
// Health is CORS-open and, in hosted mode, reachable off the loopback —
|
|
787
1075
|
// so any site/host that reads it would learn the developer's absolute
|
|
788
1076
|
// checkout path and username (#431). Local mode keeps the full path (the
|
|
@@ -874,7 +1162,13 @@ export function createApp(deps) {
|
|
|
874
1162
|
void refreshHealth(); // stale: refresh, don't wait on it
|
|
875
1163
|
return healthCache.payload;
|
|
876
1164
|
};
|
|
877
|
-
|
|
1165
|
+
// ---- chained family: health (workspace-level) ----------------------------
|
|
1166
|
+
// Written as ONE chained expression rather than a loose `app.get(...)`
|
|
1167
|
+
// statement because Hono accumulates its route types through the chain: a
|
|
1168
|
+
// statement's return value is discarded, so `typeof app` would record nothing
|
|
1169
|
+
// and `hc<AppType>` would have no endpoint to offer. `createApp` mounts this
|
|
1170
|
+
// under both `/api` (the frozen legacy spelling) and `/api/v1`.
|
|
1171
|
+
const healthRoutes = new Hono().get('/health', async (c) => c.json(await readHealth()));
|
|
878
1172
|
// The push twin of the poll it replaced (#369): while at least one cockpit
|
|
879
1173
|
// holds the `health` topic the server re-reads the snapshot on the old 5 s
|
|
880
1174
|
// cadence and broadcasts ONLY when it changed — a `git checkout` in a
|
|
@@ -906,14 +1200,679 @@ export function createApp(deps) {
|
|
|
906
1200
|
// `GET /api/health` reads a warm value instead of the cold ~1 s compute.
|
|
907
1201
|
if (deps.socketHub)
|
|
908
1202
|
void refreshHealth();
|
|
909
|
-
|
|
910
|
-
|
|
911
|
-
|
|
912
|
-
|
|
913
|
-
|
|
914
|
-
|
|
1203
|
+
/**
|
|
1204
|
+
* Warm the whole of cezar's agent knowledge — the three discovered defaults AND every extra
|
|
1205
|
+
* account — so no reader ever pays the first shell-out.
|
|
1206
|
+
*
|
|
1207
|
+
* Which login each agent is signed into is operating knowledge, not a settings-page detail: the
|
|
1208
|
+
* composer, the action gate, the accounts pane and every run resolution ask for it. So the server
|
|
1209
|
+
* learns it once, at boot, and keeps it (see the asymmetric cache lifetime in
|
|
1210
|
+
* `core/provider-auth.ts`); on-demand refresh rides on `?refresh=1` and on the explicit
|
|
1211
|
+
* invalidations — connect, repoint, remove, runtime rejection.
|
|
1212
|
+
*
|
|
1213
|
+
* Extra accounts are warmed ONE AT A TIME, after the defaults. Each is a CLI spawn, and a machine
|
|
1214
|
+
* with several accounts would otherwise fan out a spawn storm at exactly the moment the browser is
|
|
1215
|
+
* fetching the bundle; nothing is waiting on this, so sequential costs nothing that matters.
|
|
1216
|
+
*
|
|
1217
|
+
* Hosted mode warms only the defaults: the agent-profiles family is refused there, so there are
|
|
1218
|
+
* no accounts to learn about.
|
|
1219
|
+
*/
|
|
1220
|
+
const warmAgentKnowledge = async () => {
|
|
1221
|
+
await providerAuth.status().catch(() => { });
|
|
1222
|
+
if (!capabilities().localHandoff)
|
|
1223
|
+
return;
|
|
1224
|
+
const store = await loadAgentAccounts().catch(() => defaultAgentAccountStore());
|
|
1225
|
+
for (const account of listAgentProfiles(store, PROVIDER_IDS)) {
|
|
1226
|
+
if (account.isDefault)
|
|
1227
|
+
continue; // covered by `status()` above
|
|
1228
|
+
await providerAuth
|
|
1229
|
+
.profileStatus(account.provider, { id: account.id, configDir: account.path })
|
|
1230
|
+
.catch(() => { });
|
|
1231
|
+
}
|
|
1232
|
+
};
|
|
1233
|
+
// Same gate as `refreshHealth`, same reason (startServer injects the hub; a bare app in tests does
|
|
1234
|
+
// not, so tests never spawn probes here). Fire-and-forget: a probe that fails leaves that row
|
|
1235
|
+
// cold, which is exactly the state every reader already handles.
|
|
1236
|
+
if (deps.socketHub)
|
|
1237
|
+
void warmAgentKnowledge();
|
|
1238
|
+
// ---- chained family: host model catalog (workspace-level) ----
|
|
1239
|
+
const modelsRoutes = new Hono()
|
|
1240
|
+
// `modelDiscoveryRunnerSchema` is the contract's own list of the runners with an
|
|
1241
|
+
// authoritative host-local catalog (#794), so the client compiles against exactly what this
|
|
1242
|
+
// validates. Claude has no such source: its picker stays on static presets and this 400s.
|
|
1243
|
+
.get('/models', queryZodValidator(z.object({ runner: z.union([z.string(), z.array(z.string()).transform((v) => v[0])]).pipe(modelDiscoveryRunnerSchema) }), { message: 'runner must be codex or opencode' }), async (c) => {
|
|
1244
|
+
const query = { data: c.req.valid('query') };
|
|
915
1245
|
return c.json(await modelCatalog.get(query.data.runner));
|
|
916
1246
|
});
|
|
1247
|
+
/**
|
|
1248
|
+
* Resolve `profileId` (absent = the discovered default) into a concrete account for `provider`.
|
|
1249
|
+
*
|
|
1250
|
+
* A dangling id is an ERROR here rather than the silent fall-back to the default that run
|
|
1251
|
+
* resolution performs. The difference is who is asking: a run is replaying a stored reference
|
|
1252
|
+
* and the default is the only safe answer it can act on, whereas a route is answering a user
|
|
1253
|
+
* who just named an account — telling them "unknown account" is honest, and quietly connecting
|
|
1254
|
+
* or reporting on a different one is not.
|
|
1255
|
+
*/
|
|
1256
|
+
const resolveWorkspaceProfile = async (provider, profileId) => {
|
|
1257
|
+
if (profileId === undefined || profileId === DEFAULT_AGENT_ACCOUNT_ID) {
|
|
1258
|
+
return { profile: defaultAgentProfile(provider) };
|
|
1259
|
+
}
|
|
1260
|
+
let accounts;
|
|
1261
|
+
try {
|
|
1262
|
+
accounts = (await loadAgentAccounts()).accounts;
|
|
1263
|
+
}
|
|
1264
|
+
catch {
|
|
1265
|
+
return { error: `unknown ${provider} account: ${profileId}` };
|
|
1266
|
+
}
|
|
1267
|
+
const stored = accounts.find((a) => a.id === profileId && a.provider === provider);
|
|
1268
|
+
if (!stored)
|
|
1269
|
+
return { error: `unknown ${provider} account: ${profileId}` };
|
|
1270
|
+
return { profile: resolveStoredProfile(stored) };
|
|
1271
|
+
};
|
|
1272
|
+
/**
|
|
1273
|
+
* The environment a terminal handoff must carry so the CLI lands on the right account.
|
|
1274
|
+
*
|
|
1275
|
+
* `profileId` is the one RECORDED on the step that owns the session, never the project's
|
|
1276
|
+
* current selection: `claude --resume <id>` reads `<configDir>/sessions`, so a handoff for a
|
|
1277
|
+
* run started on the work account and resumed after the project was switched back would find
|
|
1278
|
+
* nothing and silently open a fresh conversation.
|
|
1279
|
+
*/
|
|
1280
|
+
const handoffEnv = async (provider, profileId) => {
|
|
1281
|
+
const resolved = await resolveWorkspaceProfile(provider, profileId);
|
|
1282
|
+
if ('error' in resolved) {
|
|
1283
|
+
return { error: `this session belongs to an account that no longer exists (${profileId})` };
|
|
1284
|
+
}
|
|
1285
|
+
const { profile } = resolved;
|
|
1286
|
+
return { env: profile.isDefault ? {} : profileEnv(provider, profile.path) };
|
|
1287
|
+
};
|
|
1288
|
+
/** The copy-paste fallback shown when no terminal could be opened — same account, spelled for
|
|
1289
|
+
* this platform's shell. `null` when the dir cannot be embedded safely, which is a refusal. */
|
|
1290
|
+
const handoffFallbackCommand = (cwd, command, env) => {
|
|
1291
|
+
const prefixed = withEnvPrefix(command, env, process.platform);
|
|
1292
|
+
return prefixed === null ? null : `cd '${cwd}' && ${prefixed}`;
|
|
1293
|
+
};
|
|
1294
|
+
/** A registered project's realpath'd root, or null when the id is unknown. */
|
|
1295
|
+
const projectRootFor = async (projectId) => {
|
|
1296
|
+
try {
|
|
1297
|
+
return (await loadWorkspaceConfig()).projects.find((p) => p.id === projectId)?.root ?? null;
|
|
1298
|
+
}
|
|
1299
|
+
catch {
|
|
1300
|
+
return null;
|
|
1301
|
+
}
|
|
1302
|
+
};
|
|
1303
|
+
// ---- chained family: agent providers (workspace-level) ----
|
|
1304
|
+
const providersRoutes = new Hono()
|
|
1305
|
+
.get('/providers/status', queryZodValidator(z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') }), { message: 'refresh must be 1 when provided' }), async (c) => {
|
|
1306
|
+
const query = { data: c.req.valid('query') };
|
|
1307
|
+
return c.json(await providerStatus({ refresh: query.data.refresh === '1' }));
|
|
1308
|
+
})
|
|
1309
|
+
.put('/providers/:provider/enabled', paramZodValidator(z.object({ provider: providerParamSchema }), { message: 'provider and enabled boolean are required' }), jsonZodValidator(providerEnabledSchema, { message: 'provider and enabled boolean are required' }), async (c) => {
|
|
1310
|
+
const provider = { data: c.req.valid('param').provider };
|
|
1311
|
+
const body = { data: c.req.valid('json') };
|
|
1312
|
+
let workspace;
|
|
1313
|
+
try {
|
|
1314
|
+
workspace = await workspaceConfig.mergeWrite((config) => {
|
|
1315
|
+
const disabled = new Set(config.disabledProviders);
|
|
1316
|
+
if (body.data.enabled)
|
|
1317
|
+
disabled.delete(provider.data);
|
|
1318
|
+
else
|
|
1319
|
+
disabled.add(provider.data);
|
|
1320
|
+
config.disabledProviders = PROVIDER_IDS.filter((id) => disabled.has(id));
|
|
1321
|
+
});
|
|
1322
|
+
}
|
|
1323
|
+
catch {
|
|
1324
|
+
return c.json({ error: 'Provider preference could not be saved.' }, 500);
|
|
1325
|
+
}
|
|
1326
|
+
const result = applyProviderEnablement(await providerAuth.status(), workspace.disabledProviders);
|
|
1327
|
+
const row = result.providers.find(({ provider: id }) => id === provider.data);
|
|
1328
|
+
if (row)
|
|
1329
|
+
workspaceEvents.emit('provider-status', row);
|
|
1330
|
+
return c.json(result);
|
|
1331
|
+
})
|
|
1332
|
+
.post('/providers/:provider/retry', paramZodValidator(z.object({ provider: providerParamSchema }), { message: 'provider and current authFailureId are required' }), jsonZodValidator(providerRetrySchema, { message: 'provider and current authFailureId are required' }), async (c) => {
|
|
1333
|
+
const provider = { data: c.req.valid('param').provider };
|
|
1334
|
+
const body = { data: c.req.valid('json') };
|
|
1335
|
+
if (!providerAuth.clearRuntimeAuthFailure(provider.data, body.data.authFailureId)) {
|
|
1336
|
+
return c.json({ error: 'Authentication incident changed. Refresh and try again.' }, 409);
|
|
1337
|
+
}
|
|
1338
|
+
const result = await providerStatus({ refresh: true });
|
|
1339
|
+
const row = result.providers.find(({ provider: id }) => id === provider.data);
|
|
1340
|
+
if (row)
|
|
1341
|
+
workspaceEvents.emit('provider-status', row);
|
|
1342
|
+
return c.json(result);
|
|
1343
|
+
})
|
|
1344
|
+
.post('/providers/connect', jsonZodValidator(providerConnectSchema, { message: 'provider must be claude, codex, opencode, or pi' }), async (c) => {
|
|
1345
|
+
const body = { data: c.req.valid('json') };
|
|
1346
|
+
const provider = body.data.provider;
|
|
1347
|
+
// A NAMED account is refused in hosted mode before anything is resolved, exactly like every
|
|
1348
|
+
// sibling route in the agent-profiles family. Checking later would already have read
|
|
1349
|
+
// `~/.cezar/agent-accounts.json`, built a command carrying the account's absolute path (which
|
|
1350
|
+
// both the success body and the hosted 409 echo), and — for a stored account — spawned a
|
|
1351
|
+
// probe. It would also answer `unknown account: <id>` for a wrong id, which is an enumeration
|
|
1352
|
+
// oracle for the very ids the hosted listing withholds. The bare-provider spelling keeps its
|
|
1353
|
+
// existing behaviour: it names no host path and is how the Providers card has always worked.
|
|
1354
|
+
if (body.data.profileId !== undefined
|
|
1355
|
+
&& body.data.profileId !== DEFAULT_AGENT_ACCOUNT_ID
|
|
1356
|
+
&& !capabilities().localHandoff) {
|
|
1357
|
+
return c.json(hostedProfileRefusal, 409);
|
|
1358
|
+
}
|
|
1359
|
+
// Resolve the account BEFORE anything else: both the command and the status probe below
|
|
1360
|
+
// must describe the same one, or the pane reports on the personal login while the terminal
|
|
1361
|
+
// signs into the work login.
|
|
1362
|
+
const resolved = await resolveWorkspaceProfile(provider, body.data.profileId);
|
|
1363
|
+
if ('error' in resolved)
|
|
1364
|
+
return c.json({ error: resolved.error }, 400);
|
|
1365
|
+
const { profile } = resolved;
|
|
1366
|
+
const command = providerAuth.loginCommand(provider, profile.isDefault ? null : profile.path);
|
|
1367
|
+
// Fail closed: a config dir that cannot be embedded safely in this platform's shell has no
|
|
1368
|
+
// safe degradation. Running the bare command would sign the user into a DIFFERENT account
|
|
1369
|
+
// than the one they clicked, and nothing in the terminal would say so.
|
|
1370
|
+
if (command === null) {
|
|
1371
|
+
return c.json({ error: `This account's folder cannot be used in a terminal command: ${profile.configDir}` }, 409);
|
|
1372
|
+
}
|
|
1373
|
+
// BOTH branches must mean "is this account signed in NOW". The default branch refreshes; the
|
|
1374
|
+
// account branch has to evict first, because `profileStatus` serves the per-account cache and
|
|
1375
|
+
// a connected answer there stands for CONNECTED_TTL_MS. Without this, Connect after a
|
|
1376
|
+
// `claude /logout` answers "already connected", opens nothing, and the user is stuck — and it
|
|
1377
|
+
// would contradict this module's own invariant that opening a login is one of the things
|
|
1378
|
+
// cezar CAN observe and therefore invalidates explicitly rather than waiting out a window.
|
|
1379
|
+
if (!profile.isDefault)
|
|
1380
|
+
providerAuth.forgetProfileStatus(provider, profile.id);
|
|
1381
|
+
const row = profile.isDefault
|
|
1382
|
+
? (await providerAuth.status({ refresh: true })).providers.find((candidate) => candidate.provider === provider)
|
|
1383
|
+
: await providerAuth.profileStatus(provider, { id: profile.id, configDir: profile.path });
|
|
1384
|
+
if (!row) {
|
|
1385
|
+
return c.json({ error: 'Authentication could not be verified. Try again.' }, 500);
|
|
1386
|
+
}
|
|
1387
|
+
if (row.status === 'connected') {
|
|
1388
|
+
return c.json({ opened: false, connected: true, command });
|
|
1389
|
+
}
|
|
1390
|
+
if (row.status === 'not-installed') {
|
|
1391
|
+
return c.json({ error: row.hint ?? providerAuth.installHint(provider), command }, 409);
|
|
1392
|
+
}
|
|
1393
|
+
if (row.status === 'unknown') {
|
|
1394
|
+
return c.json({ error: row.hint ?? 'Authentication could not be verified. Try again.', command }, 409);
|
|
1395
|
+
}
|
|
1396
|
+
if (!capabilities().localHandoff) {
|
|
1397
|
+
return c.json({ error: 'Run this command on the machine hosting cezar.', command }, 409);
|
|
1398
|
+
}
|
|
1399
|
+
let opened = false;
|
|
1400
|
+
try {
|
|
1401
|
+
// No `env` argument: `loginCommand` already rendered the account's config dir INTO the
|
|
1402
|
+
// command, because this string is also the copy-paste fallback the pane shows. Passing
|
|
1403
|
+
// it again here would set the variable twice.
|
|
1404
|
+
opened = await openTerminal(bootRoot, command);
|
|
1405
|
+
}
|
|
1406
|
+
catch {
|
|
1407
|
+
// Terminal handoff is best-effort; the exact command remains the safe fallback.
|
|
1408
|
+
}
|
|
1409
|
+
if (!opened) {
|
|
1410
|
+
return c.json({ error: 'No terminal emulator could be opened. Run this command manually.', command }, 409);
|
|
1411
|
+
}
|
|
1412
|
+
return c.json({ opened: true, command });
|
|
1413
|
+
});
|
|
1414
|
+
// ---- chained family: agent profiles / accounts (workspace-level) ----------
|
|
1415
|
+
// Extra config dirs for a SECOND login of the same agent CLI (spec
|
|
1416
|
+
// 2026-07-29-agent-profiles). Workspace-level and therefore SINGLE-MOUNT: an account belongs to
|
|
1417
|
+
// the person and the machine, never to a repo, and a project-scoped spelling would be a second
|
|
1418
|
+
// surface to protect with no consumer. Which account a project uses is a field on
|
|
1419
|
+
// `PATCH /api/v1/projects/:projectId` instead.
|
|
1420
|
+
//
|
|
1421
|
+
// Writing is a LOCAL-MACHINE capability, exactly like `PUT /api/v1/agent-config/:id`: a profile
|
|
1422
|
+
// points an agent at a directory on the host, and the listing echoes absolute paths carrying
|
|
1423
|
+
// the username — the same disclosure `/api/v1/health` trims in hosted mode (#431).
|
|
1424
|
+
/**
|
|
1425
|
+
* This agent's own USER-scope config files, resolved inside ONE account's folder.
|
|
1426
|
+
*
|
|
1427
|
+
* Straight from the catalog — the single home of config-file vendor knowledge — with an
|
|
1428
|
+
* `AgentHomePaths` whose slot for this provider is the account's dir. That is what makes a second
|
|
1429
|
+
* login's `settings.json` the file you open rather than the default account's, and it keeps the
|
|
1430
|
+
* ids opaque and stable so the open route below never takes a path from the client.
|
|
1431
|
+
*/
|
|
1432
|
+
const accountFiles = async (profile) => {
|
|
1433
|
+
const home = {
|
|
1434
|
+
...agentHomePaths(),
|
|
1435
|
+
...(profile.provider === 'claude' ? { claude: profile.path } : {}),
|
|
1436
|
+
...(profile.provider === 'codex' ? { codex: profile.path } : {}),
|
|
1437
|
+
...(profile.provider === 'opencode' ? { opencodeConfig: profile.path } : {}),
|
|
1438
|
+
};
|
|
1439
|
+
const defs = listConfigFiles().filter((def) => def.scope === 'user' && def.runners.includes(profile.provider));
|
|
1440
|
+
return Promise.all(defs.map(async (def) => {
|
|
1441
|
+
const path = def.resolve(bootRoot, home);
|
|
1442
|
+
return { id: def.id, label: basename(path), path, exists: (await statConfigPath(path)).exists };
|
|
1443
|
+
}));
|
|
1444
|
+
};
|
|
1445
|
+
/** Build the wire row for one resolved profile: its dir state plus whatever auth is cached. */
|
|
1446
|
+
const agentProfileBody = async (profile) => ({
|
|
1447
|
+
id: profile.id,
|
|
1448
|
+
provider: profile.provider,
|
|
1449
|
+
label: profile.label,
|
|
1450
|
+
configDir: profile.configDir,
|
|
1451
|
+
path: profile.path,
|
|
1452
|
+
...(await profileDirState(profile.provider, profile.path)),
|
|
1453
|
+
isDefault: profile.isDefault,
|
|
1454
|
+
// CACHED auth only — this listing must never pay a CLI spawn.
|
|
1455
|
+
//
|
|
1456
|
+
// Probing here cost a shell-out per provider PLUS one per extra account: ~0.7s with no extra
|
|
1457
|
+
// accounts and over 2s with a few, on every cold load, for a route whose real job (what
|
|
1458
|
+
// accounts exist) is a JSON read and a handful of stats. `GET /api/v1/health` already
|
|
1459
|
+
// established the rule — it serves whatever the cache holds and never pays a `gh` shell-out.
|
|
1460
|
+
// An absent `status` means "not determined yet", which the cockpit renders as Checking… and
|
|
1461
|
+
// then fills in from the per-account status route below.
|
|
1462
|
+
//
|
|
1463
|
+
// SPREAD, not `status: maybeUndefined`: hono would type the key as always-present while
|
|
1464
|
+
// `JSON.stringify` drops it, which is exactly the drift `contract-parity` catches (AGENTS.md
|
|
1465
|
+
// names this as one of the two recurring mismatches).
|
|
1466
|
+
...(() => {
|
|
1467
|
+
const cached = profile.isDefault
|
|
1468
|
+
? providerAuth.peekStatus()?.providers.find((row) => row.provider === profile.provider)
|
|
1469
|
+
: providerAuth.peekProfileStatus(profile.provider, profile.id);
|
|
1470
|
+
return cached ? { status: cached } : {};
|
|
1471
|
+
})(),
|
|
1472
|
+
files: await accountFiles(profile),
|
|
1473
|
+
});
|
|
1474
|
+
/**
|
|
1475
|
+
* Resolve `:id` to an account for the per-account reads below. Unlike `resolveWorkspaceProfile`
|
|
1476
|
+
* this accepts the reserved `default` for a NAMED provider, which these routes cannot infer — so
|
|
1477
|
+
* the id may be `default:<provider>` as well as a stored account id.
|
|
1478
|
+
*/
|
|
1479
|
+
const accountById = async (id) => {
|
|
1480
|
+
const [head, tail] = id.split(':');
|
|
1481
|
+
if (head === DEFAULT_AGENT_ACCOUNT_ID) {
|
|
1482
|
+
const provider = PROVIDER_IDS.find((p) => p === tail);
|
|
1483
|
+
return provider ? defaultAgentProfile(provider) : null;
|
|
1484
|
+
}
|
|
1485
|
+
try {
|
|
1486
|
+
const stored = (await loadAgentAccounts()).accounts.find((a) => a.id === id);
|
|
1487
|
+
return stored ? resolveStoredProfile(stored) : null;
|
|
1488
|
+
}
|
|
1489
|
+
catch {
|
|
1490
|
+
return null;
|
|
1491
|
+
}
|
|
1492
|
+
};
|
|
1493
|
+
/** Validate a client-supplied config dir. Returns the error text, or null when it is usable. */
|
|
1494
|
+
const checkProfileDir = (configDir) => {
|
|
1495
|
+
if (CONTROL_CHARS_RE.test(configDir))
|
|
1496
|
+
return 'folder must not contain control characters';
|
|
1497
|
+
const expanded = expandTilde(configDir);
|
|
1498
|
+
// Absolute after expansion: a relative dir would resolve against whatever cwd the agent
|
|
1499
|
+
// happens to be spawned in, which for a task is a throwaway worktree. Through
|
|
1500
|
+
// `isAbsoluteConfigDir`, never a leading-`/` test — see its note: a string test refuses every
|
|
1501
|
+
// real Windows path, and this is the only gate the Add-account dialog has.
|
|
1502
|
+
if (!isAbsoluteConfigDir(expanded))
|
|
1503
|
+
return `folder must be an absolute path: ${configDir}`;
|
|
1504
|
+
return null;
|
|
1505
|
+
};
|
|
1506
|
+
/** Refuse a dir that is already some other account's (or the default's), compared through
|
|
1507
|
+
* `realpath` — two spellings of one directory would be two accounts silently sharing one
|
|
1508
|
+
* session store, and "which one am I logged into?" would stop having an answer. */
|
|
1509
|
+
const conflictingProfile = async (profiles, provider, path, exceptId) => {
|
|
1510
|
+
if (await sameProfileDir(path, defaultAgentProfile(provider).path)) {
|
|
1511
|
+
return 'that is already this agent\'s default folder';
|
|
1512
|
+
}
|
|
1513
|
+
for (const candidate of profiles) {
|
|
1514
|
+
if (candidate.provider !== provider || candidate.id === exceptId)
|
|
1515
|
+
continue;
|
|
1516
|
+
if (await sameProfileDir(path, expandTilde(candidate.configDir))) {
|
|
1517
|
+
return `that folder is already used by "${candidate.label || candidate.id}"`;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
return null;
|
|
1521
|
+
};
|
|
1522
|
+
const agentProfilesRoutes = new Hono()
|
|
1523
|
+
.get('/workspace/agent-profiles', async (c) => {
|
|
1524
|
+
const editable = capabilities().localHandoff;
|
|
1525
|
+
// Hosted mode withholds the listing entirely rather than serving it read-only: the paths
|
|
1526
|
+
// are the host disclosure, so an empty list is the only honest hosted answer.
|
|
1527
|
+
//
|
|
1528
|
+
// ONE body object, never a hosted `return` and a local `return`: two returns let hono
|
|
1529
|
+
// narrow `editable` to the literal `false`/`true` of each branch, and the contract's
|
|
1530
|
+
// honest `z.boolean()` then reads as wider than the route. Same shape as
|
|
1531
|
+
// `listAgentConfig`, which carries the same flag for the same reason.
|
|
1532
|
+
let store = defaultAgentAccountStore();
|
|
1533
|
+
if (editable) {
|
|
1534
|
+
try {
|
|
1535
|
+
store = await loadAgentAccounts();
|
|
1536
|
+
}
|
|
1537
|
+
catch {
|
|
1538
|
+
// an unreadable home degrades to "no extra accounts", never a failed request
|
|
1539
|
+
}
|
|
1540
|
+
}
|
|
1541
|
+
const profiles = editable
|
|
1542
|
+
? await Promise.all(listAgentProfiles(store, PROVIDER_IDS).map(agentProfileBody))
|
|
1543
|
+
: [];
|
|
1544
|
+
return c.json({
|
|
1545
|
+
editable,
|
|
1546
|
+
profiles,
|
|
1547
|
+
profileCapableProviders: [...PROFILE_CAPABLE_PROVIDERS],
|
|
1548
|
+
// Which account each project uses, keyed by repo root. Served here rather than on the
|
|
1549
|
+
// project registry because it lives in the same file as the accounts it names.
|
|
1550
|
+
selections: editable ? store.selections : {},
|
|
1551
|
+
/** The machine-wide fallback, for repos that have chosen nothing. Withheld in hosted mode
|
|
1552
|
+
* on the same terms as the rest of this family. */
|
|
1553
|
+
defaults: editable ? store.defaults : {},
|
|
1554
|
+
});
|
|
1555
|
+
})
|
|
1556
|
+
.post('/workspace/agent-profiles', jsonZodValidator(() => createAgentProfileSchema), async (c) => {
|
|
1557
|
+
if (!capabilities().localHandoff)
|
|
1558
|
+
return c.json(hostedProfileRefusal, 409);
|
|
1559
|
+
const { provider, configDir, label } = c.req.valid('json');
|
|
1560
|
+
if (!supportsProfiles(provider)) {
|
|
1561
|
+
return c.json({ error: `${provider} cannot carry more than one account` }, 400);
|
|
1562
|
+
}
|
|
1563
|
+
const dirError = checkProfileDir(configDir);
|
|
1564
|
+
if (dirError)
|
|
1565
|
+
return c.json({ error: dirError }, 400);
|
|
1566
|
+
// Read-first, exactly like `POST /projects`: the duplicate check needs `realpath`, and the
|
|
1567
|
+
// merge-write mutator is deliberately SYNCHRONOUS so the read→rename window stays as small
|
|
1568
|
+
// as it is for every other writer of this file. Two processes adding the same folder in
|
|
1569
|
+
// that window would both win — a cosmetic duplicate, not a correctness problem, since the
|
|
1570
|
+
// schema's id dedupe is what keeps resolution deterministic.
|
|
1571
|
+
let existing = [];
|
|
1572
|
+
try {
|
|
1573
|
+
existing = (await loadAgentAccounts()).accounts;
|
|
1574
|
+
}
|
|
1575
|
+
catch {
|
|
1576
|
+
// unreadable store — the merge-write below reports the real failure
|
|
1577
|
+
}
|
|
1578
|
+
const conflict = await conflictingProfile(existing, provider, expandTilde(configDir));
|
|
1579
|
+
if (conflict !== null)
|
|
1580
|
+
return c.json({ error: conflict }, 409);
|
|
1581
|
+
let created;
|
|
1582
|
+
try {
|
|
1583
|
+
await mergeWriteAgentAccounts((store) => {
|
|
1584
|
+
const id = allocateAgentProfileId(label ?? configDir, store.accounts.map((a) => a.id));
|
|
1585
|
+
created = {
|
|
1586
|
+
id,
|
|
1587
|
+
provider,
|
|
1588
|
+
configDir,
|
|
1589
|
+
label: label?.trim() || id,
|
|
1590
|
+
addedAt: new Date().toISOString(),
|
|
1591
|
+
};
|
|
1592
|
+
store.accounts.push(created);
|
|
1593
|
+
});
|
|
1594
|
+
}
|
|
1595
|
+
catch (err) {
|
|
1596
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
1597
|
+
}
|
|
1598
|
+
if (!created)
|
|
1599
|
+
return c.json({ error: 'account could not be saved' }, 500);
|
|
1600
|
+
// A brand-new account is the one thing the boot warm could not have known about, so learn it
|
|
1601
|
+
// now rather than only when something asks. Still off the response: the row is returned with
|
|
1602
|
+
// `status` absent (the listing's rule), the pane shows Checking…, and its follow-up request
|
|
1603
|
+
// joins this same in-flight probe.
|
|
1604
|
+
const account = resolveStoredProfile(created);
|
|
1605
|
+
const created201 = await agentProfileBody(account);
|
|
1606
|
+
void providerAuth
|
|
1607
|
+
.profileStatus(account.provider, { id: account.id, configDir: account.path })
|
|
1608
|
+
.catch(() => { });
|
|
1609
|
+
return c.json({ profile: created201 }, 201);
|
|
1610
|
+
})
|
|
1611
|
+
.patch('/workspace/agent-profiles/:id', paramZodValidator(z.object({ id: z.string() })), jsonZodValidator(() => updateAgentProfileSchema), async (c) => {
|
|
1612
|
+
if (!capabilities().localHandoff)
|
|
1613
|
+
return c.json(hostedProfileRefusal, 409);
|
|
1614
|
+
const id = c.req.param('id');
|
|
1615
|
+
const { label, configDir } = c.req.valid('json');
|
|
1616
|
+
if (configDir !== undefined) {
|
|
1617
|
+
const dirError = checkProfileDir(configDir);
|
|
1618
|
+
if (dirError)
|
|
1619
|
+
return c.json({ error: dirError }, 400);
|
|
1620
|
+
}
|
|
1621
|
+
// Read-first (see POST above, and `PATCH /projects`): a well-formed but unknown id must
|
|
1622
|
+
// 404 WITHOUT rewriting the config, and the duplicate check needs async `realpath`.
|
|
1623
|
+
let existing = [];
|
|
1624
|
+
try {
|
|
1625
|
+
existing = (await loadAgentAccounts()).accounts;
|
|
1626
|
+
}
|
|
1627
|
+
catch {
|
|
1628
|
+
// unreadable store — treated as unknown, like DELETE and PATCH /projects
|
|
1629
|
+
}
|
|
1630
|
+
const current = existing.find((a) => a.id === id);
|
|
1631
|
+
if (!current)
|
|
1632
|
+
return c.json({ error: `unknown account: ${id}` }, 404);
|
|
1633
|
+
if (configDir !== undefined) {
|
|
1634
|
+
const conflict = await conflictingProfile(existing, current.provider, expandTilde(configDir), id);
|
|
1635
|
+
if (conflict !== null)
|
|
1636
|
+
return c.json({ error: conflict }, 409);
|
|
1637
|
+
}
|
|
1638
|
+
let updated;
|
|
1639
|
+
try {
|
|
1640
|
+
await mergeWriteAgentAccounts((store) => {
|
|
1641
|
+
const entry = store.accounts.find((a) => a.id === id);
|
|
1642
|
+
if (!entry)
|
|
1643
|
+
return; // lost a race with a concurrent delete — answered below
|
|
1644
|
+
// Mutated in place so `.passthrough()` keys on the row survive.
|
|
1645
|
+
if (label !== undefined)
|
|
1646
|
+
entry.label = label.trim() || entry.id;
|
|
1647
|
+
if (configDir !== undefined)
|
|
1648
|
+
entry.configDir = configDir;
|
|
1649
|
+
updated = entry;
|
|
1650
|
+
});
|
|
1651
|
+
}
|
|
1652
|
+
catch (err) {
|
|
1653
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
1654
|
+
}
|
|
1655
|
+
if (!updated)
|
|
1656
|
+
return c.json({ error: `unknown account: ${id}` }, 404);
|
|
1657
|
+
// The dir may have moved under a cached probe — drop THIS account's answer so the response
|
|
1658
|
+
// reports the new folder's state rather than the old one's, and leave every other account's
|
|
1659
|
+
// warm answer alone (it is still true). Then re-learn it in the background, so the server's
|
|
1660
|
+
// knowledge is complete again whether or not a cockpit is open to ask; the pane's own
|
|
1661
|
+
// request for this row joins the same in-flight probe rather than spawning a second.
|
|
1662
|
+
const repointed = resolveStoredProfile(updated);
|
|
1663
|
+
providerAuth.forgetProfileStatus(repointed.provider, repointed.id);
|
|
1664
|
+
const body = await agentProfileBody(repointed);
|
|
1665
|
+
void providerAuth
|
|
1666
|
+
.profileStatus(repointed.provider, { id: repointed.id, configDir: repointed.path })
|
|
1667
|
+
.catch(() => { });
|
|
1668
|
+
return c.json({ profile: body });
|
|
1669
|
+
})
|
|
1670
|
+
/**
|
|
1671
|
+
* One account's authentication state, probed for real.
|
|
1672
|
+
*
|
|
1673
|
+
* Separate from the listing because a probe shells out to an agent CLI: keeping it here lets
|
|
1674
|
+
* the pane paint immediately and fill each row in as its answer lands, instead of every cold
|
|
1675
|
+
* load blocking on N spawns. `refresh=1` drops this account's cached answer and re-probes, for
|
|
1676
|
+
* the "Check again" affordance — the cache itself holds a connected answer for minutes and an
|
|
1677
|
+
* unsettled one for a minute (`cacheTtlFor` in `core/provider-auth.ts`).
|
|
1678
|
+
*/
|
|
1679
|
+
.get('/workspace/agent-profiles/:id/status', paramZodValidator(z.object({ id: z.string() })), queryZodValidator(z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') }), { message: 'refresh must be 1 when provided' }), async (c) => {
|
|
1680
|
+
if (!capabilities().localHandoff)
|
|
1681
|
+
return c.json(hostedProfileRefusal, 409);
|
|
1682
|
+
const account = await accountById(c.req.param('id'));
|
|
1683
|
+
if (!account)
|
|
1684
|
+
return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
|
|
1685
|
+
const refresh = c.req.valid('query').refresh === '1';
|
|
1686
|
+
// The discovered account's row is the one `GET /api/v1/providers/status` owns, so it comes
|
|
1687
|
+
// from there — enablement included, which a bare probe does not know about.
|
|
1688
|
+
if (account.isDefault) {
|
|
1689
|
+
const all = await providerStatus(refresh ? { refresh: true } : undefined);
|
|
1690
|
+
const row = all.providers.find((candidate) => candidate.provider === account.provider);
|
|
1691
|
+
return c.json({ status: row ?? { provider: account.provider, status: 'unknown' } });
|
|
1692
|
+
}
|
|
1693
|
+
// "Check again" re-probes THIS account only — the other accounts' warm answers are not
|
|
1694
|
+
// invalidated by asking about this one.
|
|
1695
|
+
if (refresh)
|
|
1696
|
+
providerAuth.forgetProfileStatus(account.provider, account.id);
|
|
1697
|
+
return c.json({
|
|
1698
|
+
status: await providerAuth.profileStatus(account.provider, {
|
|
1699
|
+
id: account.id,
|
|
1700
|
+
configDir: account.path,
|
|
1701
|
+
}),
|
|
1702
|
+
});
|
|
1703
|
+
})
|
|
1704
|
+
/**
|
|
1705
|
+
* Who this account is signed in AS — the pane's "Show details".
|
|
1706
|
+
*
|
|
1707
|
+
* A separate, on-demand route rather than a field on the listing, and that is what makes
|
|
1708
|
+
* "hidden by default" real: an email carried by the listing would already be in the response,
|
|
1709
|
+
* the query cache and devtools, whatever the UI chose to render. `provider-auth.ts` keeps
|
|
1710
|
+
* identity out of ITS boundary on purpose; this is the deliberate, local-only, opt-in
|
|
1711
|
+
* exception — not a widening of that rule. Never logged, never persisted.
|
|
1712
|
+
*/
|
|
1713
|
+
.get('/workspace/agent-profiles/:id/details', paramZodValidator(z.object({ id: z.string() })), async (c) => {
|
|
1714
|
+
if (!capabilities().localHandoff)
|
|
1715
|
+
return c.json(hostedProfileRefusal, 409);
|
|
1716
|
+
const account = await accountById(c.req.param('id'));
|
|
1717
|
+
if (!account)
|
|
1718
|
+
return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
|
|
1719
|
+
return c.json(await readAccountIdentity(account.provider, account.path));
|
|
1720
|
+
})
|
|
1721
|
+
/**
|
|
1722
|
+
* Open one of this account's own config files — or its folder — in a local app.
|
|
1723
|
+
*
|
|
1724
|
+
* `file` is a catalog ID from the account's own `files`, never a path: the client cannot name a
|
|
1725
|
+
* location, so there is no traversal surface here at all (the same rule
|
|
1726
|
+
* `/api/v1/agent-config/:id` follows). `folder` is the one extra keyword, and it resolves to
|
|
1727
|
+
* the account's dir rather than anything the caller spelled.
|
|
1728
|
+
*/
|
|
1729
|
+
.post('/workspace/agent-profiles/:id/open', paramZodValidator(z.object({ id: z.string() })), jsonZodValidator(() => openAgentAccountFileSchema), async (c) => {
|
|
1730
|
+
if (!capabilities().localHandoff)
|
|
1731
|
+
return c.json(hostedProfileRefusal, 409);
|
|
1732
|
+
const account = await accountById(c.req.param('id'));
|
|
1733
|
+
if (!account)
|
|
1734
|
+
return c.json({ error: `unknown account: ${c.req.param('id')}` }, 404);
|
|
1735
|
+
const { file, target } = c.req.valid('json');
|
|
1736
|
+
let path;
|
|
1737
|
+
if (file === 'folder') {
|
|
1738
|
+
path = account.path;
|
|
1739
|
+
}
|
|
1740
|
+
else {
|
|
1741
|
+
const match = (await accountFiles(account)).find((f) => f.id === file);
|
|
1742
|
+
if (!match)
|
|
1743
|
+
return c.json({ error: `unknown file: ${file}` }, 404);
|
|
1744
|
+
path = match.path;
|
|
1745
|
+
}
|
|
1746
|
+
// A file the agent has not written yet has nothing to open; say so rather than hand the OS
|
|
1747
|
+
// a missing path and report success.
|
|
1748
|
+
if (!(await statConfigPath(path)).exists && file !== 'folder') {
|
|
1749
|
+
return c.json({ error: `this account has no ${basename(path)} yet` }, 409);
|
|
1750
|
+
}
|
|
1751
|
+
// `target` names a detected app; absent means the OS default handler. Either way the PATH
|
|
1752
|
+
// came from the catalog, so an editor is only ever pointed inside this account's folder.
|
|
1753
|
+
//
|
|
1754
|
+
// Which targets APPLY is checked here rather than left to the UI, because two of them are
|
|
1755
|
+
// actively wrong rather than merely useless: `terminal` runs `cd <path>`, which fails on a
|
|
1756
|
+
// file, and a `cli:<runner>` handoff would start an agent session inside the config folder.
|
|
1757
|
+
// A route is a surface of its own; it refuses what it cannot do correctly.
|
|
1758
|
+
if (target !== undefined) {
|
|
1759
|
+
if (agentCliRunner(target) !== null) {
|
|
1760
|
+
return c.json({ error: 'agent CLIs open a task worktree, not a config folder' }, 400);
|
|
1761
|
+
}
|
|
1762
|
+
if (target === 'terminal' && file !== 'folder') {
|
|
1763
|
+
return c.json({ error: 'a terminal opens a folder, not a file' }, 400);
|
|
1764
|
+
}
|
|
1765
|
+
if (!detectOpenTargets().some((candidate) => candidate.id === target)) {
|
|
1766
|
+
return c.json({ error: `no such app on this machine: ${target}` }, 400);
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
const opened = target === undefined
|
|
1770
|
+
? await openFile(path)
|
|
1771
|
+
: await openApp(target, path);
|
|
1772
|
+
if (!opened)
|
|
1773
|
+
return c.json({ error: `could not open ${basename(path)}`, path }, 409);
|
|
1774
|
+
return c.json({ opened: true, path });
|
|
1775
|
+
})
|
|
1776
|
+
// Which account a PROJECT uses. On the accounts family rather than `PATCH /api/v1/projects`
|
|
1777
|
+
// because the selection is stored beside the accounts it names — one file, one atomic write,
|
|
1778
|
+
// and nothing about it can be dropped by a cezar version that never heard of accounts.
|
|
1779
|
+
.put('/workspace/agent-profiles/selection', jsonZodValidator(() => selectAgentProfileSchema), async (c) => {
|
|
1780
|
+
if (!capabilities().localHandoff)
|
|
1781
|
+
return c.json(hostedProfileRefusal, 409);
|
|
1782
|
+
const { projectId, provider, profileId } = c.req.valid('json');
|
|
1783
|
+
// `null` writes the MACHINE-WIDE default instead of one repo's selection: the account any
|
|
1784
|
+
// repo that has chosen nothing uses, so a second login is set up once rather than per
|
|
1785
|
+
// checkout. No project to resolve, and therefore no 404 path.
|
|
1786
|
+
let root = null;
|
|
1787
|
+
if (projectId !== null) {
|
|
1788
|
+
const resolvedId = projectId === 'default' ? await resolveBootProject() : projectId;
|
|
1789
|
+
// Keyed by repo ROOT, so the selection survives the registry being rebuilt and needs no
|
|
1790
|
+
// cross-reference into config.json. An unknown project is a 404 rather than an orphan
|
|
1791
|
+
// entry nobody will ever read.
|
|
1792
|
+
root = await projectRootFor(resolvedId);
|
|
1793
|
+
if (root === null)
|
|
1794
|
+
return c.json({ error: `unknown project: ${projectId}` }, 404);
|
|
1795
|
+
}
|
|
1796
|
+
// A user naming an account that does not exist gets told so — the opposite of how RUN
|
|
1797
|
+
// resolution treats a dangling stored id, and deliberately: a run has no better answer
|
|
1798
|
+
// than the default, a person does.
|
|
1799
|
+
if (profileId !== null && profileId !== DEFAULT_AGENT_ACCOUNT_ID) {
|
|
1800
|
+
const account = await resolveWorkspaceProfile(provider, profileId);
|
|
1801
|
+
if ('error' in account)
|
|
1802
|
+
return c.json({ error: account.error }, 400);
|
|
1803
|
+
}
|
|
1804
|
+
let store;
|
|
1805
|
+
try {
|
|
1806
|
+
store = await mergeWriteAgentAccounts((current) => {
|
|
1807
|
+
// One rule, two targets: the machine default is the same per-provider shape as a repo's
|
|
1808
|
+
// selection, so it clears the same way rather than growing its own spelling.
|
|
1809
|
+
const selection = root === null ? current.defaults : current.selections[root] ?? {};
|
|
1810
|
+
// `null` and the reserved `default` both mean "back to the discovered account", which
|
|
1811
|
+
// is stored as ABSENCE — the default id is never written to the file.
|
|
1812
|
+
if (profileId === null || profileId === DEFAULT_AGENT_ACCOUNT_ID)
|
|
1813
|
+
delete selection[provider];
|
|
1814
|
+
else
|
|
1815
|
+
selection[provider] = profileId;
|
|
1816
|
+
// The machine default keeps an emptied object where a repo selection is deleted, and
|
|
1817
|
+
// that asymmetry is the schema's, not an oversight: `defaults` is one FIXED field with
|
|
1818
|
+
// `.default(() => ({}))`, so `mergeWriteAgentAccounts` re-materializes it on the next
|
|
1819
|
+
// write no matter what this one omits (verified). `selections` is a growing MAP, where
|
|
1820
|
+
// an emptied entry is a row per repo ever touched that says nothing. The rule stated
|
|
1821
|
+
// for `agentDefaults.models` — "absence is the same answer" — applies there because
|
|
1822
|
+
// that key is `.optional()`, so deleting it actually sticks.
|
|
1823
|
+
if (root === null)
|
|
1824
|
+
current.defaults = selection;
|
|
1825
|
+
else if (Object.keys(selection).length === 0)
|
|
1826
|
+
delete current.selections[root];
|
|
1827
|
+
else
|
|
1828
|
+
current.selections[root] = selection;
|
|
1829
|
+
});
|
|
1830
|
+
}
|
|
1831
|
+
catch (err) {
|
|
1832
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
1833
|
+
}
|
|
1834
|
+
return c.json({ selections: store.selections, defaults: store.defaults });
|
|
1835
|
+
})
|
|
1836
|
+
.delete('/workspace/agent-profiles/:id', paramZodValidator(z.object({ id: z.string() })), async (c) => {
|
|
1837
|
+
if (!capabilities().localHandoff)
|
|
1838
|
+
return c.json(hostedProfileRefusal, 409);
|
|
1839
|
+
const id = c.req.param('id');
|
|
1840
|
+
let removed = false;
|
|
1841
|
+
// Captured inside the mutator, because after the write there is nothing left to ask which
|
|
1842
|
+
// provider this account belonged to — and the eviction below is keyed by it.
|
|
1843
|
+
let removedProvider;
|
|
1844
|
+
try {
|
|
1845
|
+
await mergeWriteAgentAccounts((store) => {
|
|
1846
|
+
const before = store.accounts.length;
|
|
1847
|
+
removedProvider = store.accounts.find((a) => a.id === id)?.provider;
|
|
1848
|
+
store.accounts = store.accounts.filter((a) => a.id !== id);
|
|
1849
|
+
removed = store.accounts.length < before;
|
|
1850
|
+
if (!removed)
|
|
1851
|
+
return;
|
|
1852
|
+
// Scrub every reference IN THE SAME MUTATOR — the reason selections share this file.
|
|
1853
|
+
// A two-call delete-then-scrub can be observed mid-way by another cezar process on
|
|
1854
|
+
// this machine, which would then resolve a dangling id; harmless today (it degrades
|
|
1855
|
+
// to the default) but only by luck.
|
|
1856
|
+
for (const [root, selection] of Object.entries(store.selections)) {
|
|
1857
|
+
for (const key of Object.keys(selection)) {
|
|
1858
|
+
if (selection[key] === id)
|
|
1859
|
+
delete selection[key];
|
|
1860
|
+
}
|
|
1861
|
+
if (Object.keys(selection).length === 0)
|
|
1862
|
+
delete store.selections[root];
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
}
|
|
1866
|
+
catch (err) {
|
|
1867
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
1868
|
+
}
|
|
1869
|
+
if (!removed)
|
|
1870
|
+
return c.json({ error: `unknown account: ${id}` }, 404);
|
|
1871
|
+
// Only this account's answer: it is about to stop existing, and holding it would let a
|
|
1872
|
+
// re-added account with the same id read the deleted one's state.
|
|
1873
|
+
providerAuth.forgetProfileStatus(removedProvider, id);
|
|
1874
|
+
return c.json({ removed: true, id });
|
|
1875
|
+
});
|
|
917
1876
|
// ---- workspace projects (multi-project spec) -----------------------------
|
|
918
1877
|
// The registered-project list for the cockpit sidebar. Same-origin (unlike
|
|
919
1878
|
// health), so absolute `root`s are fine here. `listProjects()` TTL-caches
|
|
@@ -939,7 +1898,9 @@ export function createApp(deps) {
|
|
|
939
1898
|
return defaultWorkspaceConfig().browseRoot;
|
|
940
1899
|
}
|
|
941
1900
|
};
|
|
942
|
-
|
|
1901
|
+
// ---- chained family: project registry (workspace-level) ----
|
|
1902
|
+
const projectsRoutes = new Hono()
|
|
1903
|
+
.get('/projects', async (c) => {
|
|
943
1904
|
let projects = [];
|
|
944
1905
|
let projectsDir = defaultWorkspaceConfig().projectsDir;
|
|
945
1906
|
try {
|
|
@@ -958,36 +1919,223 @@ export function createApp(deps) {
|
|
|
958
1919
|
projectsDir,
|
|
959
1920
|
};
|
|
960
1921
|
return c.json(body);
|
|
961
|
-
})
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
})
|
|
969
|
-
|
|
970
|
-
* The register-a-folder half of `POST /api/projects`, factored out so the
|
|
971
|
-
* checkout route (step 4.3) commits its fresh clone through the SAME guards
|
|
972
|
-
* and the same `project-added` emission rather than a second copy of them.
|
|
973
|
-
* Returns the status + body for the caller to answer with.
|
|
974
|
-
*/
|
|
975
|
-
const registerFolder = async (spelled, source) => {
|
|
1922
|
+
})
|
|
1923
|
+
.post('/projects', jsonZodValidator(() => registerProjectSchema, { message: 'root must be a non-empty path' }), async (c) => {
|
|
1924
|
+
const parsed = { data: c.req.valid('json') };
|
|
1925
|
+
const registered = await registerFolder(parsed.data.root, 'local');
|
|
1926
|
+
if (registered.status !== 200)
|
|
1927
|
+
return c.json(registered.body, registered.status);
|
|
1928
|
+
return c.json(registered.body, 200);
|
|
1929
|
+
})
|
|
1930
|
+
.delete('/projects/:projectId', async (c) => {
|
|
976
1931
|
if (capabilities().singleProject) {
|
|
977
|
-
return
|
|
978
|
-
}
|
|
979
|
-
// `~` is expanded for the same reason `/api/fs/browse` expands it: the
|
|
980
|
-
// dialog hands back absolute paths, but a hand-written body (curl, a
|
|
981
|
-
// future CLI) spells home the way a shell does.
|
|
982
|
-
const requested = expandTilde(spelled);
|
|
983
|
-
if (!requested.startsWith('/')) {
|
|
984
|
-
return {
|
|
985
|
-
status: 400,
|
|
986
|
-
body: { error: `not a folder: ${spelled} is not an absolute path` },
|
|
987
|
-
};
|
|
1932
|
+
return c.json(singleProjectRefusal('removing projects'), 409);
|
|
988
1933
|
}
|
|
989
|
-
|
|
990
|
-
//
|
|
1934
|
+
const raw = c.req.param('projectId');
|
|
1935
|
+
// Same gate the scoped-route resolver applies, and the same 404 wording —
|
|
1936
|
+
// a malformed id is an unknown project, not a validation essay.
|
|
1937
|
+
if (!projectIdSchema.safeParse(raw).success) {
|
|
1938
|
+
return c.json({ error: `unknown project: ${raw}` }, 404);
|
|
1939
|
+
}
|
|
1940
|
+
const bootId = await resolveBootProject();
|
|
1941
|
+
// `default` is the boot alias everywhere else in the API; honour it here
|
|
1942
|
+
// too rather than 404ing a spelling the cockpit is allowed to use.
|
|
1943
|
+
const id = raw === 'default' ? bootId : raw;
|
|
1944
|
+
let entry;
|
|
1945
|
+
try {
|
|
1946
|
+
entry = (await loadWorkspaceConfig()).projects.find((p) => p.id === id);
|
|
1947
|
+
}
|
|
1948
|
+
catch {
|
|
1949
|
+
// unreadable workspace — there is nothing to remove, and saying so is
|
|
1950
|
+
// more useful than a 500 the user cannot act on
|
|
1951
|
+
}
|
|
1952
|
+
if (!entry)
|
|
1953
|
+
return c.json({ error: `unknown project: ${id}` }, 404);
|
|
1954
|
+
// The boot project is refused, not removed: `cezar serve` re-registers the
|
|
1955
|
+
// repo it was started in on every boot, so "removing" it would undo itself
|
|
1956
|
+
// at the next restart while breaking this session's sidebar in the
|
|
1957
|
+
// meantime. The pane disables the button and says the same thing.
|
|
1958
|
+
if (id === bootId) {
|
|
1959
|
+
return c.json({
|
|
1960
|
+
error: `cezar is serving ${entry.name} right now — it re-registers itself at every start, so it cannot be removed from here`,
|
|
1961
|
+
}, 409);
|
|
1962
|
+
}
|
|
1963
|
+
const active = activeRunCount(id);
|
|
1964
|
+
if (active > 0) {
|
|
1965
|
+
return c.json({
|
|
1966
|
+
error: `${entry.name} has ${active} running task${active === 1 ? '' : 's'} — cancel or finish ${active === 1 ? 'it' : 'them'} before removing the project`,
|
|
1967
|
+
runningTasks: active,
|
|
1968
|
+
}, 409);
|
|
1969
|
+
}
|
|
1970
|
+
let removed;
|
|
1971
|
+
try {
|
|
1972
|
+
removed = await removeProject(id);
|
|
1973
|
+
}
|
|
1974
|
+
catch (err) {
|
|
1975
|
+
// e.g. a read-only home — nothing was persisted (atomic tmp+rename).
|
|
1976
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
1977
|
+
}
|
|
1978
|
+
// Lost a race with another writer (or another cezar process): the entry is
|
|
1979
|
+
// gone, which is what the caller wanted, but say it honestly.
|
|
1980
|
+
if (!removed)
|
|
1981
|
+
return c.json({ error: `unknown project: ${id}` }, 404);
|
|
1982
|
+
// In-process handles for a project no route can reach any more: store
|
|
1983
|
+
// closed (index flushed), manager's timers and usage subscription dropped.
|
|
1984
|
+
contexts.dispose(id);
|
|
1985
|
+
workspaceEvents.emit('project-removed', { id });
|
|
1986
|
+
const body = { removed: true, id };
|
|
1987
|
+
return c.json(body);
|
|
1988
|
+
})
|
|
1989
|
+
// Edit the per-project registry fields the cockpit owns: the concurrency ceiling (spec
|
|
1990
|
+
// 2026-07-22-per-project-concurrency) and the grouping tags (spec
|
|
1991
|
+
// 2026-08-10-global-tasks-and-project-tags). A PATCH (not PUT) because it touches named
|
|
1992
|
+
// fields and leaves the rest of the entry alone, and a distinct route from POST
|
|
1993
|
+
// (register-a-folder) to keep register vs. edit semantics clear.
|
|
1994
|
+
//
|
|
1995
|
+
// The body schema is the CONTRACT's, passed directly rather than restated behind a thunk:
|
|
1996
|
+
// it is already in scope, and a second copy is a second thing to keep in step. Its bounds
|
|
1997
|
+
// mirror `workspaceProjectSchema` (config.ts) exactly, so a value this route accepts can
|
|
1998
|
+
// never be degraded away by the next load's `.catch`.
|
|
1999
|
+
//
|
|
2000
|
+
// Deliberately NOT the home of the agent-account selection: that lives in
|
|
2001
|
+
// `~/.cezar/agent-accounts.json` beside the accounts it names, so a cezar version that has
|
|
2002
|
+
// never heard of accounts cannot drop it (see workspace/agent-accounts.ts).
|
|
2003
|
+
.patch('/projects/:projectId', jsonZodValidator(updateProjectInputSchema), async (c) => {
|
|
2004
|
+
if (capabilities().singleProject) {
|
|
2005
|
+
return c.json(singleProjectRefusal('editing projects'), 409);
|
|
2006
|
+
}
|
|
2007
|
+
const raw = c.req.param('projectId');
|
|
2008
|
+
// Same gate + 404 wording as DELETE: a malformed id is an unknown project.
|
|
2009
|
+
if (!projectIdSchema.safeParse(raw).success) {
|
|
2010
|
+
return c.json({ error: `unknown project: ${raw}` }, 404);
|
|
2011
|
+
}
|
|
2012
|
+
const parsed = { data: c.req.valid('json') };
|
|
2013
|
+
// `default` is the boot alias the cockpit is allowed to use everywhere else.
|
|
2014
|
+
const id = raw === 'default' ? await resolveBootProject() : raw;
|
|
2015
|
+
const { maxParallel, tags } = parsed.data;
|
|
2016
|
+
// Read-first (mirroring DELETE, server.ts:1252-1258): a well-formed but
|
|
2017
|
+
// unknown id must 404 WITHOUT rewriting the config — otherwise it would both
|
|
2018
|
+
// do a needless full-config tmp+rename and, on a read-only home, surface the
|
|
2019
|
+
// write failure as a 500 where the honest answer is 404.
|
|
2020
|
+
let known = false;
|
|
2021
|
+
try {
|
|
2022
|
+
known = (await loadWorkspaceConfig()).projects.some((p) => p.id === id);
|
|
2023
|
+
}
|
|
2024
|
+
catch {
|
|
2025
|
+
// unreadable workspace — treat as unknown; the read-only case answers 404,
|
|
2026
|
+
// not a 500 the caller cannot act on (same reasoning as DELETE).
|
|
2027
|
+
}
|
|
2028
|
+
if (!known)
|
|
2029
|
+
return c.json({ error: `unknown project: ${id}` }, 404);
|
|
2030
|
+
let updated;
|
|
2031
|
+
try {
|
|
2032
|
+
await mergeWriteWorkspaceConfig((config) => {
|
|
2033
|
+
const entry = config.projects.find((p) => p.id === id);
|
|
2034
|
+
if (!entry)
|
|
2035
|
+
return; // lost a race with a concurrent remove — answered below
|
|
2036
|
+
// Each key is applied only when the body NAMED it: a PATCH that says
|
|
2037
|
+
// nothing about a field must leave it exactly as it was, which is what
|
|
2038
|
+
// keeps the tags editor from clearing a concurrency ceiling (and the
|
|
2039
|
+
// pre-tags `{ maxParallel }` body from clearing tags). null clears;
|
|
2040
|
+
// a value sets. Mutated in place so `.passthrough()` keys survive.
|
|
2041
|
+
if (maxParallel !== undefined) {
|
|
2042
|
+
if (maxParallel === null)
|
|
2043
|
+
delete entry.maxParallel;
|
|
2044
|
+
else
|
|
2045
|
+
entry.maxParallel = maxParallel;
|
|
2046
|
+
}
|
|
2047
|
+
if (tags !== undefined) {
|
|
2048
|
+
// Normalized on the way IN, so every reader — this API, the CLI, the
|
|
2049
|
+
// global Tasks page — sees one spelling per tag and never has to
|
|
2050
|
+
// fold case itself.
|
|
2051
|
+
const normalized = normalizeProjectTags(tags);
|
|
2052
|
+
if (normalized === undefined)
|
|
2053
|
+
delete entry.tags;
|
|
2054
|
+
else
|
|
2055
|
+
entry.tags = normalized;
|
|
2056
|
+
}
|
|
2057
|
+
updated = entry;
|
|
2058
|
+
});
|
|
2059
|
+
}
|
|
2060
|
+
catch (err) {
|
|
2061
|
+
// e.g. a read-only home — nothing was persisted (atomic tmp+rename).
|
|
2062
|
+
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
2063
|
+
}
|
|
2064
|
+
// Raced with a concurrent removal between the read and the write.
|
|
2065
|
+
if (!updated)
|
|
2066
|
+
return c.json({ error: `unknown project: ${id}` }, 404);
|
|
2067
|
+
// The new ceiling takes effect WITHOUT a restart: refresh the shared
|
|
2068
|
+
// semaphore's snapshot and pump every manager — the same live-apply hook
|
|
2069
|
+
// `PUT /api/workspace/config` fires for a workspace-cap change.
|
|
2070
|
+
await deps.semaphore?.refresh();
|
|
2071
|
+
const body = {
|
|
2072
|
+
project: { ...updated, ...(await probeProjectStatus(updated.root)) },
|
|
2073
|
+
};
|
|
2074
|
+
return c.json(body);
|
|
2075
|
+
})
|
|
2076
|
+
.post('/projects/checkout', jsonZodValidator(() => checkoutSchema, { message: 'url must be a GitHub repository' }), async (c) => {
|
|
2077
|
+
if (capabilities().singleProject) {
|
|
2078
|
+
return c.json(singleProjectRefusal('adding projects'), 409);
|
|
2079
|
+
}
|
|
2080
|
+
const parsed = { data: c.req.valid('json') };
|
|
2081
|
+
const { url, name, checkoutId } = parsed.data;
|
|
2082
|
+
const result = await checkoutRepo({
|
|
2083
|
+
url,
|
|
2084
|
+
name,
|
|
2085
|
+
checkoutId,
|
|
2086
|
+
projectsDir: expandTilde(await workspaceProjectsDir()),
|
|
2087
|
+
onProgress: (event) => workspaceEvents.emit('checkout-progress', event),
|
|
2088
|
+
// A closed dialog / navigated-away tab aborts the request; the clone is
|
|
2089
|
+
// killed and its partial directory removed rather than left running.
|
|
2090
|
+
signal: c.req.raw.signal,
|
|
2091
|
+
...(deps.cloneRunner ? { run: deps.cloneRunner } : {}),
|
|
2092
|
+
});
|
|
2093
|
+
if (!result.ok) {
|
|
2094
|
+
// `reason` rides along on the 503 (`gh` unavailable) — the spec's
|
|
2095
|
+
// `{ error, reason }` degradation, mirroring the GitHub pane.
|
|
2096
|
+
return c.json('reason' in result ? { error: result.error, reason: result.reason } : { error: result.error }, result.status);
|
|
2097
|
+
}
|
|
2098
|
+
const registered = await registerFolder(result.target, 'checkout');
|
|
2099
|
+
if (registered.status !== 200) {
|
|
2100
|
+
// The clone SUCCEEDED and its files are legitimately the user's, so this
|
|
2101
|
+
// path deliberately does NOT clean up — an unregisterable checkout is a
|
|
2102
|
+
// registry problem, not a reason to delete a repo we just fetched. Say
|
|
2103
|
+
// where it is so they can register it by hand.
|
|
2104
|
+
const { body } = registered;
|
|
2105
|
+
const error = 'error' in body && body.error ? body.error : 'could not register the checkout';
|
|
2106
|
+
return c.json({ error: `${error} (the clone is at ${result.target})` }, registered.status);
|
|
2107
|
+
}
|
|
2108
|
+
return c.json(registered.body, 200);
|
|
2109
|
+
});
|
|
2110
|
+
// Register an existing folder (multi-project spec, "Add project" — the
|
|
2111
|
+
// folder-browser dialog's commit step, step 4.2). Workspace-level like its
|
|
2112
|
+
// GET twin. Everything here is a guard; the registry write itself is one
|
|
2113
|
+
// idempotent `registerProject` call.
|
|
2114
|
+
const registerProjectSchema = z.object({
|
|
2115
|
+
root: z.string().trim().min(1).max(4096),
|
|
2116
|
+
});
|
|
2117
|
+
/**
|
|
2118
|
+
* The register-a-folder half of `POST /api/projects`, factored out so the
|
|
2119
|
+
* checkout route (step 4.3) commits its fresh clone through the SAME guards
|
|
2120
|
+
* and the same `project-added` emission rather than a second copy of them.
|
|
2121
|
+
* Returns the status + body for the caller to answer with.
|
|
2122
|
+
*/
|
|
2123
|
+
const registerFolder = async (spelled, source) => {
|
|
2124
|
+
if (capabilities().singleProject) {
|
|
2125
|
+
return { status: 409, body: singleProjectRefusal('adding projects') };
|
|
2126
|
+
}
|
|
2127
|
+
// `~` is expanded for the same reason `/api/fs/browse` expands it: the
|
|
2128
|
+
// dialog hands back absolute paths, but a hand-written body (curl, a
|
|
2129
|
+
// future CLI) spells home the way a shell does.
|
|
2130
|
+
const requested = expandTilde(spelled);
|
|
2131
|
+
if (!requested.startsWith('/')) {
|
|
2132
|
+
return {
|
|
2133
|
+
status: 400,
|
|
2134
|
+
body: { error: `not a folder: ${spelled} is not an absolute path` },
|
|
2135
|
+
};
|
|
2136
|
+
}
|
|
2137
|
+
// Hosted mode: the same root the picker is narrowed to, re-checked — see
|
|
2138
|
+
// `isInsideBrowseRoot`. Local mode deliberately has NO containment: a
|
|
991
2139
|
// project under `/srv/code` is a normal local setup and `cezar serve`
|
|
992
2140
|
// registers it today.
|
|
993
2141
|
//
|
|
@@ -1079,13 +2227,6 @@ export function createApp(deps) {
|
|
|
1079
2227
|
workspaceEvents.emit('project-added', { project });
|
|
1080
2228
|
return { status: 200, body: { project } };
|
|
1081
2229
|
};
|
|
1082
|
-
app.post('/api/projects', async (c) => {
|
|
1083
|
-
const parsed = registerProjectSchema.safeParse(await c.req.json().catch(() => null));
|
|
1084
|
-
if (!parsed.success)
|
|
1085
|
-
return c.json({ error: 'root must be a non-empty path' }, 400);
|
|
1086
|
-
const { status, body } = await registerFolder(parsed.data.root, 'local');
|
|
1087
|
-
return c.json(body, status);
|
|
1088
|
-
});
|
|
1089
2230
|
// Deregister a project (multi-project spec, step 4.4 — Settings → Projects,
|
|
1090
2231
|
// the per-row "Remove"). READ THIS BEFORE TOUCHING THE HANDLER: the ONLY
|
|
1091
2232
|
// durable effect allowed here is dropping one entry from
|
|
@@ -1120,136 +2261,59 @@ export function createApp(deps) {
|
|
|
1120
2261
|
return 0;
|
|
1121
2262
|
return ctx.store.listRuns().filter((run) => ACTIVE_RUN_STATUSES.has(run.status)).length;
|
|
1122
2263
|
};
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
// a malformed id is an unknown project, not a validation essay.
|
|
1130
|
-
if (!projectIdSchema.safeParse(raw).success) {
|
|
1131
|
-
return c.json({ error: `unknown project: ${raw}` }, 404);
|
|
1132
|
-
}
|
|
2264
|
+
// Workspace-level by design: update state spans project and global installs,
|
|
2265
|
+
// but the selected registered project supplies the safe, server-owned cwd.
|
|
2266
|
+
const skillsUpdateInputSchema = z.object({ projectId: projectIdSchema }).strict();
|
|
2267
|
+
const resolveSkillsUpdateRoot = async (raw) => {
|
|
2268
|
+
if (!projectIdSchema.safeParse(raw).success)
|
|
2269
|
+
return { status: 404, error: `unknown project: ${raw}` };
|
|
1133
2270
|
const bootId = await resolveBootProject();
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
const
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
|
|
1141
|
-
catch {
|
|
1142
|
-
// unreadable workspace — there is nothing to remove, and saying so is
|
|
1143
|
-
// more useful than a 500 the user cannot act on
|
|
1144
|
-
}
|
|
1145
|
-
if (!entry)
|
|
1146
|
-
return c.json({ error: `unknown project: ${id}` }, 404);
|
|
1147
|
-
// The boot project is refused, not removed: `cezar serve` re-registers the
|
|
1148
|
-
// repo it was started in on every boot, so "removing" it would undo itself
|
|
1149
|
-
// at the next restart while breaking this session's sidebar in the
|
|
1150
|
-
// meantime. The pane disables the button and says the same thing.
|
|
1151
|
-
if (id === bootId) {
|
|
1152
|
-
return c.json({
|
|
1153
|
-
error: `cezar is serving ${entry.name} right now — it re-registers itself at every start, so it cannot be removed from here`,
|
|
1154
|
-
}, 409);
|
|
1155
|
-
}
|
|
1156
|
-
const active = activeRunCount(id);
|
|
1157
|
-
if (active > 0) {
|
|
1158
|
-
return c.json({
|
|
1159
|
-
error: `${entry.name} has ${active} running task${active === 1 ? '' : 's'} — cancel or finish ${active === 1 ? 'it' : 'them'} before removing the project`,
|
|
1160
|
-
runningTasks: active,
|
|
1161
|
-
}, 409);
|
|
1162
|
-
}
|
|
1163
|
-
let removed;
|
|
1164
|
-
try {
|
|
1165
|
-
removed = await removeProject(id);
|
|
2271
|
+
if (raw === 'default' || raw === bootId)
|
|
2272
|
+
return { root: bootRoot };
|
|
2273
|
+
const project = (await loadWorkspaceConfig()).projects.find((entry) => entry.id === raw);
|
|
2274
|
+
if (!project)
|
|
2275
|
+
return { status: 404, error: `unknown project: ${raw}` };
|
|
2276
|
+
if ((await probeProjectStatus(project.root)).status === 'missing') {
|
|
2277
|
+
return { status: 409, error: `project folder not found: ${raw}` };
|
|
1166
2278
|
}
|
|
1167
|
-
|
|
1168
|
-
|
|
1169
|
-
|
|
1170
|
-
|
|
1171
|
-
|
|
1172
|
-
|
|
1173
|
-
|
|
1174
|
-
|
|
1175
|
-
|
|
1176
|
-
|
|
1177
|
-
|
|
1178
|
-
|
|
1179
|
-
|
|
1180
|
-
|
|
1181
|
-
|
|
1182
|
-
|
|
1183
|
-
|
|
1184
|
-
|
|
1185
|
-
|
|
1186
|
-
|
|
1187
|
-
|
|
1188
|
-
|
|
1189
|
-
|
|
1190
|
-
|
|
1191
|
-
|
|
1192
|
-
|
|
1193
|
-
|
|
1194
|
-
if (
|
|
1195
|
-
return c.json(
|
|
1196
|
-
}
|
|
1197
|
-
const raw = c.req.param('projectId');
|
|
1198
|
-
// Same gate + 404 wording as DELETE: a malformed id is an unknown project.
|
|
1199
|
-
if (!projectIdSchema.safeParse(raw).success) {
|
|
1200
|
-
return c.json({ error: `unknown project: ${raw}` }, 404);
|
|
1201
|
-
}
|
|
1202
|
-
const parsed = updateProjectSchema.safeParse(await c.req.json().catch(() => null));
|
|
1203
|
-
if (!parsed.success) {
|
|
1204
|
-
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
1205
|
-
}
|
|
1206
|
-
// `default` is the boot alias the cockpit is allowed to use everywhere else.
|
|
1207
|
-
const id = raw === 'default' ? await resolveBootProject() : raw;
|
|
1208
|
-
const { maxParallel } = parsed.data;
|
|
1209
|
-
// Read-first (mirroring DELETE, server.ts:1252-1258): a well-formed but
|
|
1210
|
-
// unknown id must 404 WITHOUT rewriting the config — otherwise it would both
|
|
1211
|
-
// do a needless full-config tmp+rename and, on a read-only home, surface the
|
|
1212
|
-
// write failure as a 500 where the honest answer is 404.
|
|
1213
|
-
let known = false;
|
|
1214
|
-
try {
|
|
1215
|
-
known = (await loadWorkspaceConfig()).projects.some((p) => p.id === id);
|
|
1216
|
-
}
|
|
1217
|
-
catch {
|
|
1218
|
-
// unreadable workspace — treat as unknown; the read-only case answers 404,
|
|
1219
|
-
// not a 500 the caller cannot act on (same reasoning as DELETE).
|
|
1220
|
-
}
|
|
1221
|
-
if (!known)
|
|
1222
|
-
return c.json({ error: `unknown project: ${id}` }, 404);
|
|
1223
|
-
let updated;
|
|
2279
|
+
return { root: project.root };
|
|
2280
|
+
};
|
|
2281
|
+
const skillsUpdateResponse = async (state) => {
|
|
2282
|
+
const config = await loadWorkspaceConfig();
|
|
2283
|
+
return { ...state, autoUpdateEnabled: effectiveSkillsAutoUpdate(config), inherited: config.skillsAutoUpdate === undefined };
|
|
2284
|
+
};
|
|
2285
|
+
// ---- chained family: skills updates (workspace-level) ----
|
|
2286
|
+
const skillsUpdateRoutes = new Hono()
|
|
2287
|
+
.get('/workspace/skills-update', queryZodValidator(skillsUpdateInputSchema, { message: 'projectId is required' }), async (c) => {
|
|
2288
|
+
const parsed = { data: c.req.valid('query') };
|
|
2289
|
+
const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
|
|
2290
|
+
if ('error' in resolved)
|
|
2291
|
+
return c.json({ error: resolved.error }, resolved.status);
|
|
2292
|
+
const state = skillsUpdate.snapshot(resolved.root);
|
|
2293
|
+
void skillsUpdate.check(resolved.root).catch(() => { });
|
|
2294
|
+
return c.json(await skillsUpdateResponse(state));
|
|
2295
|
+
})
|
|
2296
|
+
.post('/workspace/skills-update/check', jsonZodValidator(skillsUpdateInputSchema, { message: 'body must contain only projectId' }), async (c) => {
|
|
2297
|
+
const parsed = { data: c.req.valid('json') };
|
|
2298
|
+
const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
|
|
2299
|
+
if ('error' in resolved)
|
|
2300
|
+
return c.json({ error: resolved.error }, resolved.status);
|
|
2301
|
+
return c.json(await skillsUpdateResponse(await skillsUpdate.check(resolved.root, true)));
|
|
2302
|
+
})
|
|
2303
|
+
.post('/workspace/skills-update/apply', jsonZodValidator(skillsUpdateInputSchema, { message: 'body must contain only projectId' }), async (c) => {
|
|
2304
|
+
const parsed = { data: c.req.valid('json') };
|
|
2305
|
+
const resolved = await resolveSkillsUpdateRoot(parsed.data.projectId);
|
|
2306
|
+
if ('error' in resolved)
|
|
2307
|
+
return c.json({ error: resolved.error }, resolved.status);
|
|
1224
2308
|
try {
|
|
1225
|
-
await
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
delete entry.maxParallel;
|
|
1233
|
-
else
|
|
1234
|
-
entry.maxParallel = maxParallel;
|
|
1235
|
-
updated = entry;
|
|
1236
|
-
});
|
|
1237
|
-
}
|
|
1238
|
-
catch (err) {
|
|
1239
|
-
// e.g. a read-only home — nothing was persisted (atomic tmp+rename).
|
|
1240
|
-
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
2309
|
+
return c.json(await skillsUpdateResponse(await skillsUpdate.update(resolved.root, true)));
|
|
2310
|
+
}
|
|
2311
|
+
catch (error) {
|
|
2312
|
+
if (error instanceof SkillsUpdateConflictError) {
|
|
2313
|
+
return c.json({ error: 'another skills update operation is running', state: await skillsUpdateResponse(skillsUpdate.snapshot(resolved.root)) }, 409);
|
|
2314
|
+
}
|
|
2315
|
+
throw error;
|
|
1241
2316
|
}
|
|
1242
|
-
// Raced with a concurrent removal between the read and the write.
|
|
1243
|
-
if (!updated)
|
|
1244
|
-
return c.json({ error: `unknown project: ${id}` }, 404);
|
|
1245
|
-
// The new ceiling takes effect WITHOUT a restart: refresh the shared
|
|
1246
|
-
// semaphore's snapshot and pump every manager — the same live-apply hook
|
|
1247
|
-
// `PUT /api/workspace/config` fires for a workspace-cap change.
|
|
1248
|
-
await deps.semaphore?.refresh();
|
|
1249
|
-
const body = {
|
|
1250
|
-
project: { ...updated, ...(await probeProjectStatus(updated.root)) },
|
|
1251
|
-
};
|
|
1252
|
-
return c.json(body);
|
|
1253
2317
|
});
|
|
1254
2318
|
// ---- GUI clone (multi-project spec, step 4.3) ----------------------------
|
|
1255
2319
|
// "Add project → Clone from GitHub": clone into the checkout root, then
|
|
@@ -1267,41 +2331,6 @@ export function createApp(deps) {
|
|
|
1267
2331
|
name: z.string().trim().max(128).optional(),
|
|
1268
2332
|
checkoutId: z.string().trim().max(128).optional(),
|
|
1269
2333
|
});
|
|
1270
|
-
app.post('/api/projects/checkout', async (c) => {
|
|
1271
|
-
if (capabilities().singleProject) {
|
|
1272
|
-
return c.json(singleProjectRefusal('adding projects'), 409);
|
|
1273
|
-
}
|
|
1274
|
-
const parsed = checkoutSchema.safeParse(await c.req.json().catch(() => null));
|
|
1275
|
-
if (!parsed.success)
|
|
1276
|
-
return c.json({ error: 'url must be a GitHub repository' }, 400);
|
|
1277
|
-
const { url, name, checkoutId } = parsed.data;
|
|
1278
|
-
const result = await checkoutRepo({
|
|
1279
|
-
url,
|
|
1280
|
-
name,
|
|
1281
|
-
checkoutId,
|
|
1282
|
-
projectsDir: expandTilde(await workspaceProjectsDir()),
|
|
1283
|
-
onProgress: (event) => workspaceEvents.emit('checkout-progress', event),
|
|
1284
|
-
// A closed dialog / navigated-away tab aborts the request; the clone is
|
|
1285
|
-
// killed and its partial directory removed rather than left running.
|
|
1286
|
-
signal: c.req.raw.signal,
|
|
1287
|
-
...(deps.cloneRunner ? { run: deps.cloneRunner } : {}),
|
|
1288
|
-
});
|
|
1289
|
-
if (!result.ok) {
|
|
1290
|
-
// `reason` rides along on the 503 (`gh` unavailable) — the spec's
|
|
1291
|
-
// `{ error, reason }` degradation, mirroring the GitHub pane.
|
|
1292
|
-
return c.json('reason' in result ? { error: result.error, reason: result.reason } : { error: result.error }, result.status);
|
|
1293
|
-
}
|
|
1294
|
-
const { status, body } = await registerFolder(result.target, 'checkout');
|
|
1295
|
-
if (status !== 200) {
|
|
1296
|
-
// The clone SUCCEEDED and its files are legitimately the user's, so this
|
|
1297
|
-
// path deliberately does NOT clean up — an unregisterable checkout is a
|
|
1298
|
-
// registry problem, not a reason to delete a repo we just fetched. Say
|
|
1299
|
-
// where it is so they can register it by hand.
|
|
1300
|
-
const error = 'error' in body && body.error ? body.error : 'could not register the checkout';
|
|
1301
|
-
return c.json({ error: `${error} (the clone is at ${result.target})` }, status);
|
|
1302
|
-
}
|
|
1303
|
-
return c.json(body, 200);
|
|
1304
|
-
});
|
|
1305
2334
|
// ---- workspace settings (multi-project spec, step 2.7) -------------------
|
|
1306
2335
|
// WORKSPACE-level routes: single-mount (never mirrored under /api/p/),
|
|
1307
2336
|
// same-origin. The config routes carry the settings UI's slice of
|
|
@@ -1311,33 +2340,40 @@ export function createApp(deps) {
|
|
|
1311
2340
|
const workspaceConfigBody = (config) => ({
|
|
1312
2341
|
browseRoot: config.browseRoot,
|
|
1313
2342
|
projectsDir: config.projectsDir,
|
|
2343
|
+
skillsAutoUpdate: config.skillsAutoUpdate ?? null,
|
|
2344
|
+
effectiveSkillsAutoUpdate: effectiveSkillsAutoUpdate(config),
|
|
2345
|
+
composerDefaults: {
|
|
2346
|
+
autonomous: config.composerDefaults.autonomous ?? null,
|
|
2347
|
+
worktree: config.composerDefaults.worktree ?? null,
|
|
2348
|
+
inheritedAutonomous: process.env.CEZ_AUTONOMOUS_DEFAULT === '0'
|
|
2349
|
+
? false
|
|
2350
|
+
: process.env.CEZ_AUTONOMOUS_DEFAULT === '1'
|
|
2351
|
+
? true
|
|
2352
|
+
: 'source-dependent',
|
|
2353
|
+
inheritedWorktree: effectiveComposerDefault(undefined, process.env.CEZ_WORKTREE_DEFAULT, true),
|
|
2354
|
+
},
|
|
1314
2355
|
resources: {
|
|
1315
2356
|
maxParallel: config.resources.maxParallel,
|
|
2357
|
+
maxMonitoringSessions: config.resources.maxMonitoringSessions,
|
|
2358
|
+
monitoringWakeIntervalMinutes: config.resources.monitoringWakeIntervalMinutes,
|
|
2359
|
+
autoResumeOnUsageLimit: config.resources.autoResumeOnUsageLimit,
|
|
1316
2360
|
memoryLimitMb: config.resources.memoryLimitMb,
|
|
1317
2361
|
worktreeRetentionDefault: config.resources.worktreeRetentionDefault,
|
|
1318
2362
|
},
|
|
2363
|
+
// SPREAD, never `runner: maybeUndefined`: hono would type the key as always-present while
|
|
2364
|
+
// `JSON.stringify` drops it, which is the exact drift `contract-parity` catches. And absent has
|
|
2365
|
+
// to keep meaning "no opinion" here, or the fallback collapses into "always claude".
|
|
2366
|
+
agentDefaults: {
|
|
2367
|
+
...(config.agentDefaults.runner !== undefined ? { runner: config.agentDefaults.runner } : {}),
|
|
2368
|
+
...(config.agentDefaults.models !== undefined ? { models: config.agentDefaults.models } : {}),
|
|
2369
|
+
},
|
|
1319
2370
|
});
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
|
|
1323
|
-
|
|
1324
|
-
|
|
1325
|
-
browseRoot
|
|
1326
|
-
projectsDir: z.string().trim().min(1).max(4096).optional(),
|
|
1327
|
-
resources: z
|
|
1328
|
-
.object({
|
|
1329
|
-
maxParallel: z.number().int().min(1).max(16).optional(),
|
|
1330
|
-
memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
|
|
1331
|
-
worktreeRetentionDefault: z.number().int().min(0).max(1000).optional(),
|
|
1332
|
-
})
|
|
1333
|
-
.optional(),
|
|
1334
|
-
});
|
|
1335
|
-
app.put('/api/workspace/config', async (c) => {
|
|
1336
|
-
const parsed = workspaceConfigUpdateSchema.safeParse(await c.req.json().catch(() => null));
|
|
1337
|
-
if (!parsed.success) {
|
|
1338
|
-
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
1339
|
-
}
|
|
1340
|
-
const { browseRoot, projectsDir, resources } = parsed.data;
|
|
2371
|
+
// ---- chained family: workspace settings + GUI prefs (workspace-level) ----
|
|
2372
|
+
const workspaceConfigRoutes = new Hono()
|
|
2373
|
+
.get('/workspace/config', async (c) => c.json(workspaceConfigBody(await loadWorkspaceConfig())))
|
|
2374
|
+
.put('/workspace/config', jsonZodValidator(() => workspaceConfigUpdateSchema), async (c) => {
|
|
2375
|
+
const parsed = { data: c.req.valid('json') };
|
|
2376
|
+
const { browseRoot, projectsDir, skillsAutoUpdate, composerDefaults, resources, agentDefaults } = parsed.data;
|
|
1341
2377
|
for (const [configuredRoot, create] of [
|
|
1342
2378
|
[browseRoot, false],
|
|
1343
2379
|
[projectsDir, true],
|
|
@@ -1376,13 +2412,57 @@ export function createApp(deps) {
|
|
|
1376
2412
|
config.browseRoot = browseRoot;
|
|
1377
2413
|
if (projectsDir !== undefined)
|
|
1378
2414
|
config.projectsDir = projectsDir;
|
|
2415
|
+
if (skillsAutoUpdate === null)
|
|
2416
|
+
delete config.skillsAutoUpdate;
|
|
2417
|
+
else if (skillsAutoUpdate !== undefined)
|
|
2418
|
+
config.skillsAutoUpdate = skillsAutoUpdate;
|
|
2419
|
+
if (composerDefaults?.autonomous === null)
|
|
2420
|
+
delete config.composerDefaults.autonomous;
|
|
2421
|
+
else if (composerDefaults?.autonomous !== undefined) {
|
|
2422
|
+
config.composerDefaults.autonomous = composerDefaults.autonomous;
|
|
2423
|
+
}
|
|
2424
|
+
if (composerDefaults?.worktree === null)
|
|
2425
|
+
delete config.composerDefaults.worktree;
|
|
2426
|
+
else if (composerDefaults?.worktree !== undefined) {
|
|
2427
|
+
config.composerDefaults.worktree = composerDefaults.worktree;
|
|
2428
|
+
}
|
|
1379
2429
|
if (resources?.maxParallel !== undefined)
|
|
1380
2430
|
config.resources.maxParallel = resources.maxParallel;
|
|
2431
|
+
if (resources?.maxMonitoringSessions !== undefined) {
|
|
2432
|
+
config.resources.maxMonitoringSessions = resources.maxMonitoringSessions;
|
|
2433
|
+
}
|
|
2434
|
+
if (resources?.monitoringWakeIntervalMinutes !== undefined) {
|
|
2435
|
+
config.resources.monitoringWakeIntervalMinutes = resources.monitoringWakeIntervalMinutes;
|
|
2436
|
+
}
|
|
2437
|
+
if (resources?.autoResumeOnUsageLimit !== undefined) {
|
|
2438
|
+
config.resources.autoResumeOnUsageLimit = resources.autoResumeOnUsageLimit;
|
|
2439
|
+
}
|
|
1381
2440
|
if (resources?.memoryLimitMb !== undefined)
|
|
1382
2441
|
config.resources.memoryLimitMb = resources.memoryLimitMb;
|
|
1383
2442
|
if (resources?.worktreeRetentionDefault !== undefined) {
|
|
1384
2443
|
config.resources.worktreeRetentionDefault = resources.worktreeRetentionDefault;
|
|
1385
2444
|
}
|
|
2445
|
+
// `null` CLEARS back to "no opinion" — a partial patch cannot say that by omission,
|
|
2446
|
+
// and leaving a stale runner behind would keep overriding repos that never chose.
|
|
2447
|
+
if (agentDefaults?.runner === null)
|
|
2448
|
+
delete config.agentDefaults.runner;
|
|
2449
|
+
else if (agentDefaults?.runner !== undefined)
|
|
2450
|
+
config.agentDefaults.runner = agentDefaults.runner;
|
|
2451
|
+
for (const runner of PROVIDER_IDS) {
|
|
2452
|
+
const model = agentDefaults?.models?.[runner];
|
|
2453
|
+
if (model === undefined)
|
|
2454
|
+
continue;
|
|
2455
|
+
const models = config.agentDefaults.models ?? {};
|
|
2456
|
+
if (model === null)
|
|
2457
|
+
delete models[runner];
|
|
2458
|
+
else
|
|
2459
|
+
models[runner] = model;
|
|
2460
|
+
// An empty object would persist a key that says nothing; absence is the same answer.
|
|
2461
|
+
if (Object.keys(models).length === 0)
|
|
2462
|
+
delete config.agentDefaults.models;
|
|
2463
|
+
else
|
|
2464
|
+
config.agentDefaults.models = models;
|
|
2465
|
+
}
|
|
1386
2466
|
});
|
|
1387
2467
|
}
|
|
1388
2468
|
catch (err) {
|
|
@@ -1394,15 +2474,19 @@ export function createApp(deps) {
|
|
|
1394
2474
|
if (resources !== undefined)
|
|
1395
2475
|
await deps.semaphore?.refresh();
|
|
1396
2476
|
return c.json(workspaceConfigBody(written));
|
|
1397
|
-
})
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
2477
|
+
})
|
|
2478
|
+
// Global GUI state (`~/.cezar/ui-state.json`) — same parse/key-cap/shallow-
|
|
2479
|
+
// merge semantics as the per-repo /api/v1/ui-state route below (the shared half
|
|
2480
|
+
// is `uiStateBodySchema`), but backed by the workspace file.
|
|
2481
|
+
.get('/workspace/ui-state', async (c) => c.json(await readWorkspaceUiState()))
|
|
2482
|
+
// The tighter body cap rides on `use` rather than inline on the route: `bodyLimit` is typed
|
|
2483
|
+
// as a bare MiddlewareHandler, and passing one to `.put()` collapses the route's schema, so
|
|
2484
|
+
// the PUT went missing from `AppType` and `hc` could not see its body at all. `use` runs at
|
|
2485
|
+
// the same point (before the handler, so the cap still precedes any read) and leaves the
|
|
2486
|
+
// chain's type accumulation alone. Method-agnostic here, which the GET does not mind.
|
|
2487
|
+
.use('/workspace/ui-state', bodyLimit({ maxSize: UI_STATE_BODY_LIMIT }))
|
|
2488
|
+
.put('/workspace/ui-state', jsonZodValidator(setWorkspaceUiStateInputSchema), async (c) => {
|
|
2489
|
+
const parsed = { data: c.req.valid('json') };
|
|
1406
2490
|
try {
|
|
1407
2491
|
return c.json(await mergeWriteWorkspaceUiState((state) => ({
|
|
1408
2492
|
...state,
|
|
@@ -1414,67 +2498,119 @@ export function createApp(deps) {
|
|
|
1414
2498
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
1415
2499
|
}
|
|
1416
2500
|
});
|
|
1417
|
-
//
|
|
1418
|
-
//
|
|
1419
|
-
//
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
2501
|
+
// Partial updates only — absent keys stay untouched. Bounds mirror the
|
|
2502
|
+
// workspace schema (src/workspace/config.ts, step 1.2) exactly, so a value
|
|
2503
|
+
// this route accepts can never be degraded away by the next load's `.catch`.
|
|
2504
|
+
const workspaceConfigUpdateSchema = z.object({
|
|
2505
|
+
browseRoot: z.string().trim().min(1).max(4096).optional(),
|
|
2506
|
+
projectsDir: z.string().trim().min(1).max(4096).optional(),
|
|
2507
|
+
skillsAutoUpdate: z.boolean().nullable().optional(),
|
|
2508
|
+
composerDefaults: z
|
|
2509
|
+
.object({
|
|
2510
|
+
autonomous: z.boolean().nullable().optional(),
|
|
2511
|
+
worktree: z.boolean().nullable().optional(),
|
|
2512
|
+
})
|
|
2513
|
+
.optional(),
|
|
2514
|
+
resources: z
|
|
2515
|
+
.object({
|
|
2516
|
+
maxParallel: z.number().int().min(1).max(16).optional(),
|
|
2517
|
+
maxMonitoringSessions: z.number().int().min(0).max(16).optional(),
|
|
2518
|
+
monitoringWakeIntervalMinutes: z.number().int().min(1).max(60).nullable().optional(),
|
|
2519
|
+
autoResumeOnUsageLimit: z.boolean().optional(),
|
|
2520
|
+
memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
|
|
2521
|
+
worktreeRetentionDefault: z.number().int().min(0).max(1000).optional(),
|
|
2522
|
+
})
|
|
2523
|
+
.optional(),
|
|
2524
|
+
// Bounds mirror `src/workspace/config.ts`, so a value this accepts is never degraded away by
|
|
2525
|
+
// the next load's `.catch`. `null` clears a key back to "no opinion".
|
|
2526
|
+
agentDefaults: z
|
|
2527
|
+
.object({
|
|
2528
|
+
runner: z.enum(PROVIDER_IDS).nullable().optional(),
|
|
2529
|
+
models: z
|
|
2530
|
+
.object({
|
|
2531
|
+
claude: z.string().trim().min(1).max(200).nullable().optional(),
|
|
2532
|
+
codex: z.string().trim().min(1).max(200).nullable().optional(),
|
|
2533
|
+
opencode: z.string().trim().min(1).max(200).nullable().optional(),
|
|
2534
|
+
pi: z.string().trim().min(1).max(200).nullable().optional(),
|
|
2535
|
+
})
|
|
2536
|
+
.optional(),
|
|
2537
|
+
})
|
|
2538
|
+
.optional(),
|
|
2539
|
+
});
|
|
2540
|
+
// ---- chained family: filesystem browse (workspace-level) ----
|
|
2541
|
+
const fsBrowseRoutes = new Hono()
|
|
2542
|
+
.get('/fs/browse', queryZodValidator(z.object({ path: queryValue, showHidden: queryValue })), async (c) => {
|
|
1424
2543
|
if (capabilities().singleProject) {
|
|
1425
2544
|
return c.json(singleProjectRefusal('folder browsing'), 409);
|
|
1426
2545
|
}
|
|
2546
|
+
const query = c.req.valid('query');
|
|
1427
2547
|
const root = resolveBrowseRoot(await workspaceBrowseRoot());
|
|
1428
2548
|
const result = await browseDirectory({
|
|
1429
2549
|
root,
|
|
1430
|
-
path:
|
|
1431
|
-
showHidden:
|
|
2550
|
+
path: query.path,
|
|
2551
|
+
showHidden: query.showHidden === '1',
|
|
1432
2552
|
});
|
|
1433
2553
|
if (!result.ok)
|
|
1434
2554
|
return c.json({ error: result.error }, result.status);
|
|
1435
2555
|
return c.json(result.body);
|
|
1436
2556
|
});
|
|
1437
|
-
//
|
|
1438
|
-
|
|
1439
|
-
|
|
1440
|
-
|
|
2557
|
+
// ---- chained family: launch-key (project-scoped) ----
|
|
2558
|
+
const launchKeyRoutes = new Hono()
|
|
2559
|
+
.get('/launch-key', (c) => c.json({ key: c.get('project').launchKey }));
|
|
2560
|
+
// ---- chained family: skills (project-scoped) ----
|
|
2561
|
+
const skillsRoutes = new Hono()
|
|
2562
|
+
.get('/skills', queryZodValidator(waitQuery), async (c) => {
|
|
1441
2563
|
const repoRoot = c.get('project').root;
|
|
1442
2564
|
// The default read stays fast and starts the team load in the background.
|
|
1443
2565
|
// The cockpit follows it with `wait=1`, off the render path, so a cold
|
|
1444
2566
|
// cache converges without polling or a manual reload (spec 005 / #555).
|
|
1445
|
-
if (c.req.
|
|
2567
|
+
if (c.req.valid('query').wait === '1')
|
|
1446
2568
|
await waitForTeamSkills(repoRoot);
|
|
1447
2569
|
return c.json(await discoverSkills(repoRoot));
|
|
1448
|
-
})
|
|
1449
|
-
|
|
1450
|
-
|
|
1451
|
-
|
|
1452
|
-
|
|
1453
|
-
|
|
1454
|
-
|
|
2570
|
+
})
|
|
2571
|
+
// The opt-in catalog for the "Import skills" panel: every skill a default
|
|
2572
|
+
// (vendor) repo offers — `open-mercato/skills` — regardless of import state,
|
|
2573
|
+
// so the panel can present them all with a per-skill toggle. Empty once a repo
|
|
2574
|
+
// configures its own `skillsRepos` (nothing is gated then). `wait=1` lets the
|
|
2575
|
+
// panel wait out a cold team-skill cache, same as `GET /skills` (spec 005).
|
|
2576
|
+
.get('/skills/importable', queryZodValidator(waitQuery), async (c) => {
|
|
1455
2577
|
const repoRoot = c.get('project').root;
|
|
1456
2578
|
const gated = await gatedSkillsRepos(repoRoot);
|
|
1457
2579
|
if (gated.size === 0)
|
|
1458
2580
|
return c.json([]);
|
|
1459
|
-
if (c.req.
|
|
2581
|
+
if (c.req.valid('query').wait === '1')
|
|
1460
2582
|
await waitForTeamSkills(repoRoot);
|
|
1461
2583
|
const importable = getTeamSkillsCached(repoRoot)
|
|
1462
2584
|
.filter((skill) => skill.team && gated.has(skill.team.repo))
|
|
1463
|
-
|
|
2585
|
+
// Spread `description` rather than writing it unconditionally: an undefined VALUE is
|
|
2586
|
+
// dropped by JSON.stringify, so the key is absent on the wire, and writing it always
|
|
2587
|
+
// typed the route as sending a key it does not. contract/skills.ts says `.optional()`,
|
|
2588
|
+
// which is what the client actually receives.
|
|
2589
|
+
.map((skill) => ({
|
|
2590
|
+
name: skill.name,
|
|
2591
|
+
...(skill.description !== undefined ? { description: skill.description } : {}),
|
|
2592
|
+
}));
|
|
1464
2593
|
return c.json(importable);
|
|
2594
|
+
})
|
|
2595
|
+
// Refresh team skills (spec 005): clone/fetch the configured skills repos,
|
|
2596
|
+
// then return the merged catalog. Degrades quietly — offline just means the
|
|
2597
|
+
// team entries stay as they were (or absent).
|
|
2598
|
+
.post('/skills/refresh', async (c) => {
|
|
2599
|
+
const { root: repoRoot } = c.get('project');
|
|
2600
|
+
await refreshTeamSkills(repoRoot);
|
|
2601
|
+
return c.json(await discoverSkills(repoRoot));
|
|
1465
2602
|
});
|
|
1466
|
-
// ---- GUI prefs
|
|
1467
|
-
|
|
1468
|
-
|
|
1469
|
-
|
|
1470
|
-
|
|
2603
|
+
// ---- chained family: GUI prefs / ui-state (project-scoped) ----
|
|
2604
|
+
const uiStateRoutes = new Hono()
|
|
2605
|
+
.get('/ui-state', async (c) => c.json(await readUiState(c.get('project').root)))
|
|
2606
|
+
// On `use`, not inline on the route — see the workspace ui-state PUT above.
|
|
2607
|
+
.use('/ui-state', bodyLimit({ maxSize: UI_STATE_BODY_LIMIT }))
|
|
2608
|
+
.put('/ui-state', jsonZodValidator(uiStateBody), async (c) => {
|
|
1471
2609
|
const { root: repoRoot, dataDir } = c.get('project');
|
|
1472
2610
|
// `.passthrough()` keeps unknown prefs (BACKWARD_COMPATIBILITY §3), but a
|
|
1473
2611
|
// single request may not stuff an unbounded key set (#429) — the shared
|
|
1474
|
-
//
|
|
1475
|
-
const parsed =
|
|
1476
|
-
if ('error' in parsed)
|
|
1477
|
-
return c.json({ error: parsed.error }, 400);
|
|
2612
|
+
// schema+cap half of both ui-state routes lives in `uiStateBody`.
|
|
2613
|
+
const parsed = { data: c.req.valid('json') };
|
|
1478
2614
|
const merged = { ...(await readUiState(repoRoot)), ...parsed.data };
|
|
1479
2615
|
try {
|
|
1480
2616
|
await mkdir(dataDir, { recursive: true });
|
|
@@ -1485,24 +2621,18 @@ export function createApp(deps) {
|
|
|
1485
2621
|
}
|
|
1486
2622
|
return c.json(merged);
|
|
1487
2623
|
});
|
|
1488
|
-
//
|
|
1489
|
-
//
|
|
1490
|
-
//
|
|
1491
|
-
|
|
1492
|
-
|
|
1493
|
-
await
|
|
1494
|
-
|
|
1495
|
-
|
|
1496
|
-
|
|
1497
|
-
|
|
1498
|
-
// `.ai/cezar/workflows/<slug>.yaml` — from then on it's in the dropdown
|
|
1499
|
-
// like any other workflow.
|
|
1500
|
-
api.post('/workflows', async (c) => {
|
|
2624
|
+
// ---- chained family: workflows (project-scoped) --------------------------
|
|
2625
|
+
// One chained expression, mounted by `createApp` into BOTH the legacy `api`
|
|
2626
|
+
// table and the versioned `v1` one — see `healthRoutes` for why the chain
|
|
2627
|
+
// shape (not the statement shape) is what carries the types.
|
|
2628
|
+
const workflowsRoutes = new Hono()
|
|
2629
|
+
.get('/workflows', async (c) => c.json(await loadWorkflows(c.get('project').root)))
|
|
2630
|
+
// Save an approved plan as a reusable chain (spec 008): YAML in
|
|
2631
|
+
// `.ai/cezar/workflows/<slug>.yaml` — from then on it's in the dropdown
|
|
2632
|
+
// like any other workflow.
|
|
2633
|
+
.post('/workflows', jsonZodValidator(saveWorkflowSchema), async (c) => {
|
|
1501
2634
|
const { root: repoRoot } = c.get('project');
|
|
1502
|
-
const parsed =
|
|
1503
|
-
if (!parsed.success) {
|
|
1504
|
-
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
1505
|
-
}
|
|
2635
|
+
const parsed = { data: c.req.valid('json') };
|
|
1506
2636
|
const steps = parsed.data.steps ?? skillsToSteps(parsed.data.skills ?? []);
|
|
1507
2637
|
const issue = stepsIssue(steps);
|
|
1508
2638
|
if (issue)
|
|
@@ -1534,10 +2664,10 @@ export function createApp(deps) {
|
|
|
1534
2664
|
return c.json({ error: message }, 500);
|
|
1535
2665
|
}
|
|
1536
2666
|
return c.json({ path, name: parsed.data.name }, 201);
|
|
1537
|
-
})
|
|
1538
|
-
|
|
1539
|
-
|
|
1540
|
-
|
|
2667
|
+
})
|
|
2668
|
+
// Delete a saved workflow (spec 012 follow-up): file workflows only —
|
|
2669
|
+
// built-ins have no file and always come back.
|
|
2670
|
+
.delete('/workflows/:name', async (c) => {
|
|
1541
2671
|
const { root: repoRoot } = c.get('project');
|
|
1542
2672
|
const name = c.req.param('name');
|
|
1543
2673
|
const { workflows } = await loadWorkflows(repoRoot);
|
|
@@ -1559,15 +2689,12 @@ export function createApp(deps) {
|
|
|
1559
2689
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
1560
2690
|
}
|
|
1561
2691
|
return c.json({ ok: true, path: target });
|
|
1562
|
-
})
|
|
1563
|
-
|
|
1564
|
-
|
|
1565
|
-
|
|
1566
|
-
|
|
1567
|
-
const parsed =
|
|
1568
|
-
if (!parsed.success) {
|
|
1569
|
-
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
1570
|
-
}
|
|
2692
|
+
})
|
|
2693
|
+
// Import support for the builder (spec 012): parse + validate a pasted
|
|
2694
|
+
// workflow YAML (either form) and hand back the normalized definition. The
|
|
2695
|
+
// server owns YAML parsing — the GUI stays dependency-free.
|
|
2696
|
+
.post('/workflows/parse', jsonZodValidator(parseWorkflowSchema), async (c) => {
|
|
2697
|
+
const parsed = { data: c.req.valid('json') };
|
|
1571
2698
|
let raw;
|
|
1572
2699
|
try {
|
|
1573
2700
|
raw = parseYaml(parsed.data.yaml);
|
|
@@ -1586,17 +2713,274 @@ export function createApp(deps) {
|
|
|
1586
2713
|
return c.json({ error: issue }, 400);
|
|
1587
2714
|
return c.json(normalized);
|
|
1588
2715
|
});
|
|
1589
|
-
//
|
|
1590
|
-
|
|
1591
|
-
|
|
1592
|
-
api.post('/plan', async (c) => {
|
|
2716
|
+
// ---- chained family: plan (project-scoped) ----
|
|
2717
|
+
const planRoutes = new Hono()
|
|
2718
|
+
.post('/plan', jsonZodValidator(planSchema), async (c) => {
|
|
1593
2719
|
const { root: repoRoot } = c.get('project');
|
|
1594
|
-
const parsed =
|
|
1595
|
-
|
|
1596
|
-
|
|
1597
|
-
|
|
2720
|
+
const parsed = { data: c.req.valid('json') };
|
|
2721
|
+
const blocked = await providerActionError([(await loadConfig(repoRoot)).defaultRunner]);
|
|
2722
|
+
if (blocked)
|
|
2723
|
+
return c.json({ error: blocked }, 409);
|
|
1598
2724
|
return c.json(await planChain(repoRoot, parsed.data.task));
|
|
1599
2725
|
});
|
|
2726
|
+
const manualChecks = new Map();
|
|
2727
|
+
/**
|
|
2728
|
+
* The automations gate (#801): with `CEZ_AUTOMATIONS` unset, every route of the feature
|
|
2729
|
+
* answers 409 before touching a store, a lease or GitHub.
|
|
2730
|
+
*
|
|
2731
|
+
* Written as MIDDLEWARE rather than a line in each handler so the family cannot drift: a route
|
|
2732
|
+
* added to either chain below inherits the gate from its path, where a per-handler check is one
|
|
2733
|
+
* omission away from an ungated endpoint.
|
|
2734
|
+
*
|
|
2735
|
+
* Registered against EXPLICIT paths, never `use('*')`. Both chains are mounted with
|
|
2736
|
+
* `.route('/', …)` alongside a dozen unrelated sub-apps, and `route()` re-registers a sub-app's
|
|
2737
|
+
* middleware under the mount prefix — so a `'*'` here would gate the entire `/api/v1` surface,
|
|
2738
|
+
* including `/health`. The two-line pairing (`/automations` and `/automations/*`) is what makes
|
|
2739
|
+
* a path match both the collection and everything under it.
|
|
2740
|
+
*/
|
|
2741
|
+
const requireAutomations = async (c, next) => {
|
|
2742
|
+
if (!capabilities().automations)
|
|
2743
|
+
return c.json({ error: AUTOMATIONS_OFF }, 409);
|
|
2744
|
+
await next();
|
|
2745
|
+
};
|
|
2746
|
+
// ---- chained family: GitHub automations (project-scoped) ----
|
|
2747
|
+
// Every handler below reads `c.get('project')` — the definitions, their runtime state and the
|
|
2748
|
+
// execution log are per-project files — so the family is project-scoped and mounted with the
|
|
2749
|
+
// rest of the mirrored table. The one exception is the manual-check read, which touches no
|
|
2750
|
+
// project at all; it is its own workspace-level family below.
|
|
2751
|
+
const automationsRoutes = new Hono()
|
|
2752
|
+
.use('/automations', requireAutomations)
|
|
2753
|
+
.use('/automations/*', requireAutomations)
|
|
2754
|
+
.use('/automation-log', requireAutomations)
|
|
2755
|
+
.use('/automation-log/*', requireAutomations)
|
|
2756
|
+
.get('/automations', async (c) => {
|
|
2757
|
+
const { root, automationStore } = c.get('project');
|
|
2758
|
+
const forge = resolveForge(await getRepoInfo(root));
|
|
2759
|
+
// Annotated, so the two branches are ONE shape rather than a union of two: the fallback
|
|
2760
|
+
// literal always carries `reason`, the cached answer only sometimes does, and the route
|
|
2761
|
+
// type is what `contract/src/automations.ts` has to describe.
|
|
2762
|
+
const availability = forge?.detectCached() ?? {
|
|
2763
|
+
available: false,
|
|
2764
|
+
reason: forge ? 'GitHub availability is still being checked' : 'No GitHub remote is configured',
|
|
2765
|
+
};
|
|
2766
|
+
const automations = automationStore.list().map((automation) => {
|
|
2767
|
+
const logs = automationStore.logs({ automationId: automation.id, limit: 100 });
|
|
2768
|
+
const state = automationStore.state(automation.id);
|
|
2769
|
+
const latestLog = logs[0];
|
|
2770
|
+
return {
|
|
2771
|
+
...automation,
|
|
2772
|
+
// Spread conditionally, never `state: maybeUndefined`: the latter types the key as
|
|
2773
|
+
// always-present while `JSON.stringify` drops it from the wire, so the contract would
|
|
2774
|
+
// have to describe a key consumers never receive.
|
|
2775
|
+
...(state ? { state } : {}),
|
|
2776
|
+
...(latestLog ? { latestLog } : {}),
|
|
2777
|
+
counts: {
|
|
2778
|
+
matches: logs.filter((row) => row.result === 'launched' || row.result === 'duplicate').length,
|
|
2779
|
+
launched: logs.filter((row) => row.result === 'launched').length,
|
|
2780
|
+
duplicates: logs.filter((row) => row.result === 'duplicate').length,
|
|
2781
|
+
errors: logs.filter((row) => row.result === 'error' || row.result === 'rate-limited').length,
|
|
2782
|
+
},
|
|
2783
|
+
};
|
|
2784
|
+
});
|
|
2785
|
+
const nextDue = automations.map((item) => item.state?.nextCheckAt).filter(Boolean).sort()[0];
|
|
2786
|
+
return c.json({
|
|
2787
|
+
...availability,
|
|
2788
|
+
scheduler: {
|
|
2789
|
+
// `as const` on both arms: in an object literal a conditional of two string literals
|
|
2790
|
+
// widens to `string`, which would erase the two states this key can hold.
|
|
2791
|
+
state: automations.some((item) => item.enabled) ? 'scheduled' : 'idle',
|
|
2792
|
+
...(nextDue ? { nextDue } : {}),
|
|
2793
|
+
},
|
|
2794
|
+
automations,
|
|
2795
|
+
});
|
|
2796
|
+
})
|
|
2797
|
+
.post('/automations', jsonZodValidator(() => automationCreateSchema), async (c) => {
|
|
2798
|
+
const { automationStore } = c.get('project');
|
|
2799
|
+
const parsed = { data: c.req.valid('json') };
|
|
2800
|
+
const promptIssue = validateAutomationPrompt(parsed.data.task.prompt);
|
|
2801
|
+
if (promptIssue)
|
|
2802
|
+
return c.json({ error: promptIssue }, 400);
|
|
2803
|
+
const { enable, ...input } = parsed.data;
|
|
2804
|
+
try {
|
|
2805
|
+
const automation = automationStore.create({ ...input, enabled: enable === true });
|
|
2806
|
+
if (enable) {
|
|
2807
|
+
const baselineAt = new Date().toISOString();
|
|
2808
|
+
automationStore.setState(automation.id, {
|
|
2809
|
+
revision: automation.revision,
|
|
2810
|
+
baselineAt,
|
|
2811
|
+
cursor: { timestamp: baselineAt },
|
|
2812
|
+
nextCheckAt: new Date(Date.now() + automation.intervalSeconds * 1_000).toISOString(),
|
|
2813
|
+
});
|
|
2814
|
+
}
|
|
2815
|
+
emitAutomationChange(c.get('project'), automation.id, automation.revision);
|
|
2816
|
+
automationsChanged();
|
|
2817
|
+
return c.json({ automation }, 201);
|
|
2818
|
+
}
|
|
2819
|
+
catch (error) {
|
|
2820
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 400);
|
|
2821
|
+
}
|
|
2822
|
+
})
|
|
2823
|
+
.get('/automations/:id', (c) => {
|
|
2824
|
+
const { automationStore } = c.get('project');
|
|
2825
|
+
const automation = automationStore.get(c.req.param('id'));
|
|
2826
|
+
if (!automation)
|
|
2827
|
+
return c.json({ error: 'not found' }, 404);
|
|
2828
|
+
const state = automationStore.state(automation.id);
|
|
2829
|
+
const latestLog = automationStore.logs({ automationId: automation.id, limit: 1 })[0];
|
|
2830
|
+
return c.json({
|
|
2831
|
+
automation,
|
|
2832
|
+
...(state ? { state } : {}),
|
|
2833
|
+
...(latestLog ? { latestLog } : {}),
|
|
2834
|
+
});
|
|
2835
|
+
})
|
|
2836
|
+
.put('/automations/:id', jsonZodValidator(() => automationUpdateSchema), async (c) => {
|
|
2837
|
+
const { automationStore } = c.get('project');
|
|
2838
|
+
const parsed = { data: c.req.valid('json') };
|
|
2839
|
+
const promptIssue = validateAutomationPrompt(parsed.data.task.prompt);
|
|
2840
|
+
if (promptIssue)
|
|
2841
|
+
return c.json({ error: promptIssue }, 400);
|
|
2842
|
+
const { expectedRevision, ...input } = parsed.data;
|
|
2843
|
+
if (!automationStore.get(c.req.param('id')))
|
|
2844
|
+
return c.json({ error: 'not found' }, 404);
|
|
2845
|
+
try {
|
|
2846
|
+
const automation = automationStore.update(c.req.param('id'), expectedRevision, { ...input, enabled: input.enabled ?? false });
|
|
2847
|
+
emitAutomationChange(c.get('project'), automation.id, automation.revision);
|
|
2848
|
+
automationsChanged();
|
|
2849
|
+
return c.json({ automation });
|
|
2850
|
+
}
|
|
2851
|
+
catch (error) {
|
|
2852
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
2853
|
+
return c.json({ error: message }, message.includes('conflict') ? 409 : 400);
|
|
2854
|
+
}
|
|
2855
|
+
})
|
|
2856
|
+
.delete('/automations/:id', (c) => {
|
|
2857
|
+
const id = c.req.param('id');
|
|
2858
|
+
const store = c.get('project').automationStore;
|
|
2859
|
+
const current = store.get(id);
|
|
2860
|
+
if (!current || !store.delete(id))
|
|
2861
|
+
return c.json({ error: 'not found' }, 404);
|
|
2862
|
+
emitAutomationChange(c.get('project'), id, current.revision, true);
|
|
2863
|
+
automationsChanged();
|
|
2864
|
+
return c.body(null, 204);
|
|
2865
|
+
})
|
|
2866
|
+
.post('/automations/:id/enable', (c) => {
|
|
2867
|
+
const store = c.get('project').automationStore;
|
|
2868
|
+
const current = store.get(c.req.param('id'));
|
|
2869
|
+
if (!current)
|
|
2870
|
+
return c.json({ error: 'not found' }, 404);
|
|
2871
|
+
const automation = store.update(current.id, current.revision, { ...editableAutomation(current), enabled: true });
|
|
2872
|
+
const baselineAt = new Date().toISOString();
|
|
2873
|
+
store.setState(automation.id, {
|
|
2874
|
+
...store.state(automation.id),
|
|
2875
|
+
revision: automation.revision,
|
|
2876
|
+
baselineAt,
|
|
2877
|
+
cursor: { timestamp: baselineAt },
|
|
2878
|
+
nextCheckAt: new Date(Date.now() + automation.intervalSeconds * 1_000).toISOString(),
|
|
2879
|
+
});
|
|
2880
|
+
store.appendLog({ automationId: automation.id, revision: automation.revision, result: 'baseline', reason: 'Enabled from a current-time baseline; existing records were not launched.' });
|
|
2881
|
+
emitAutomationChange(c.get('project'), automation.id, automation.revision);
|
|
2882
|
+
automationsChanged();
|
|
2883
|
+
return c.json({ automation });
|
|
2884
|
+
})
|
|
2885
|
+
.post('/automations/:id/pause', (c) => {
|
|
2886
|
+
const store = c.get('project').automationStore;
|
|
2887
|
+
const current = store.get(c.req.param('id'));
|
|
2888
|
+
if (!current)
|
|
2889
|
+
return c.json({ error: 'not found' }, 404);
|
|
2890
|
+
const automation = store.update(current.id, current.revision, { ...editableAutomation(current), enabled: false });
|
|
2891
|
+
emitAutomationChange(c.get('project'), automation.id, automation.revision);
|
|
2892
|
+
automationsChanged();
|
|
2893
|
+
return c.json({ automation });
|
|
2894
|
+
})
|
|
2895
|
+
// The body is validated as MIDDLEWARE, which is what puts it in the route type — and moves
|
|
2896
|
+
// the 400 ahead of this route's 404: `POST /automations/<unknown>/check` with a malformed
|
|
2897
|
+
// body now answers 400 rather than 404. Nothing else about either answer changed.
|
|
2898
|
+
.post('/automations/:id/check', jsonZodValidator(() => automationCheckRequestSchema), async (c) => {
|
|
2899
|
+
const project = c.get('project');
|
|
2900
|
+
const store = project.automationStore;
|
|
2901
|
+
const automation = store.get(c.req.param('id'));
|
|
2902
|
+
if (!automation)
|
|
2903
|
+
return c.json({ error: 'not found' }, 404);
|
|
2904
|
+
const parsed = { data: c.req.valid('json') };
|
|
2905
|
+
// `string`, not `randomUUID`'s template-literal type: the wire carries an opaque id, and
|
|
2906
|
+
// leaking `${string}-${string}-…` into the route type would make the contract describe the
|
|
2907
|
+
// generator rather than the answer.
|
|
2908
|
+
const id = randomUUID();
|
|
2909
|
+
const check = { id, automationId: automation.id, mode: parsed.data.mode, status: 'queued', createdAt: new Date().toISOString() };
|
|
2910
|
+
if (manualChecks.size >= 200)
|
|
2911
|
+
manualChecks.delete(manualChecks.keys().next().value);
|
|
2912
|
+
manualChecks.set(id, check);
|
|
2913
|
+
void (async () => {
|
|
2914
|
+
check.status = 'running';
|
|
2915
|
+
try {
|
|
2916
|
+
const remote = parseRemote((await getRepoInfo(project.root))?.remote ?? '');
|
|
2917
|
+
if (!remote || remote.host !== 'github.com')
|
|
2918
|
+
throw new Error('No GitHub remote is configured');
|
|
2919
|
+
const scheduler = new ProjectAutomationScheduler({
|
|
2920
|
+
projectId: project.id,
|
|
2921
|
+
owner: remote.owner,
|
|
2922
|
+
repo: remote.repo,
|
|
2923
|
+
store,
|
|
2924
|
+
poller: new GithubPoller(),
|
|
2925
|
+
launch: parsed.data.mode === 'execute'
|
|
2926
|
+
? (definition, candidate, receiptId) => launchAutomationRun({ root: project.root, manager: project.manager, store: project.store, definition, candidate, receiptId })
|
|
2927
|
+
: undefined,
|
|
2928
|
+
onChange: (automationId, revision) => emitAutomationChange(project, automationId, revision),
|
|
2929
|
+
});
|
|
2930
|
+
const result = await scheduler.check(automation, parsed.data.mode);
|
|
2931
|
+
Object.assign(check, { status: 'complete', completedAt: new Date().toISOString(), matches: result.candidates.length, truncated: result.truncated });
|
|
2932
|
+
}
|
|
2933
|
+
catch (error) {
|
|
2934
|
+
Object.assign(check, { status: 'error', completedAt: new Date().toISOString(), error: error instanceof Error ? error.message : String(error) });
|
|
2935
|
+
}
|
|
2936
|
+
})();
|
|
2937
|
+
return c.json({ checkId: id }, 202);
|
|
2938
|
+
})
|
|
2939
|
+
.get('/automation-log', queryZodValidator(automationLogQuerySchema), (c) => {
|
|
2940
|
+
return c.json({ records: c.get('project').automationStore.logs(c.req.valid('query')) });
|
|
2941
|
+
})
|
|
2942
|
+
.post('/automation-log/:receiptId/retry', async (c) => {
|
|
2943
|
+
const project = c.get('project');
|
|
2944
|
+
const store = project.automationStore;
|
|
2945
|
+
const receipt = [...store.latestReceipts().values()].find((row) => row.receiptId === c.req.param('receiptId'));
|
|
2946
|
+
if (!receipt)
|
|
2947
|
+
return c.json({ error: 'not found' }, 404);
|
|
2948
|
+
if (receipt.status !== 'launch-error' || receipt.runId)
|
|
2949
|
+
return c.json({ error: 'receipt is not retryable' }, 409);
|
|
2950
|
+
if (!receipt.candidate)
|
|
2951
|
+
return c.json({ error: 'receipt predates retry context and cannot be retried safely' }, 409);
|
|
2952
|
+
const definition = store.get(receipt.automationId);
|
|
2953
|
+
if (!definition)
|
|
2954
|
+
return c.json({ error: 'automation not found' }, 404);
|
|
2955
|
+
const lease = store.acquireLease();
|
|
2956
|
+
if (!lease)
|
|
2957
|
+
return c.json({ error: 'automation polling lease is held by another process' }, 409);
|
|
2958
|
+
const reserved = { ...receipt, status: 'reserved', error: undefined, updatedAt: new Date().toISOString() };
|
|
2959
|
+
store.appendReceipt(reserved);
|
|
2960
|
+
try {
|
|
2961
|
+
const launched = await launchAutomationRun({ root: project.root, manager: project.manager, store: project.store, definition, candidate: receipt.candidate, receiptId: receipt.receiptId });
|
|
2962
|
+
store.appendReceipt({ ...reserved, status: 'launched', runId: launched.runId, updatedAt: new Date().toISOString() });
|
|
2963
|
+
emitAutomationChange(project, definition.id, definition.revision);
|
|
2964
|
+
return c.json({ receiptId: receipt.receiptId, runId: launched.runId }, 202);
|
|
2965
|
+
}
|
|
2966
|
+
catch (error) {
|
|
2967
|
+
store.appendReceipt({ ...reserved, status: 'launch-error', error: error instanceof Error ? error.message : String(error), updatedAt: new Date().toISOString() });
|
|
2968
|
+
return c.json({ error: error instanceof Error ? error.message : String(error) }, 409);
|
|
2969
|
+
}
|
|
2970
|
+
finally {
|
|
2971
|
+
lease.release();
|
|
2972
|
+
}
|
|
2973
|
+
});
|
|
2974
|
+
// ---- chained family: manual automation checks (workspace-level) ----
|
|
2975
|
+
// Workspace-level because the handler reads no project: a check lives in the server-memory map
|
|
2976
|
+
// above, keyed by an unguessable id that the project-scoped POST hands back. Mounting it under
|
|
2977
|
+
// `/api/v1/p/:projectId` too would be a second spelling of a lookup that consults no project.
|
|
2978
|
+
const automationChecksRoutes = new Hono()
|
|
2979
|
+
.use('/automation-checks/*', requireAutomations)
|
|
2980
|
+
.get('/automation-checks/:checkId', (c) => {
|
|
2981
|
+
const check = manualChecks.get(c.req.param('checkId'));
|
|
2982
|
+
return check ? c.json(check) : c.json({ error: 'not found' }, 404);
|
|
2983
|
+
});
|
|
1600
2984
|
// ---- runs ----------------------------------------------------------------
|
|
1601
2985
|
// Additive `usage` field (#348): the latest CPU/RSS/proc-count sample of the
|
|
1602
2986
|
// run's live process tree — absent for finished runs and when `ps` yields
|
|
@@ -1625,27 +3009,56 @@ export function createApp(deps) {
|
|
|
1625
3009
|
console.warn(`[cezar] could not mark inbox entry ${todoId} started: ${String(err)}`);
|
|
1626
3010
|
}
|
|
1627
3011
|
};
|
|
1628
|
-
|
|
1629
|
-
|
|
1630
|
-
|
|
1631
|
-
|
|
1632
|
-
|
|
3012
|
+
// ---- chained family: runs lifecycle + artifacts (project-scoped) ----
|
|
3013
|
+
const runsRoutes = new Hono()
|
|
3014
|
+
.get('/runs', (c) => c.json(c.get('project').store.listRuns().map(withUsage)))
|
|
3015
|
+
// Registered before the `/:id/...` routes so "archive-finished" and "read-all"
|
|
3016
|
+
// never match as a run id.
|
|
3017
|
+
.post('/runs/archive-finished', (c) => c.json({ archived: c.get('project').store.archiveFinished() }))
|
|
3018
|
+
// The read-receipt sweep (#unread-done-items) — the mark-read twin of the archive
|
|
3019
|
+
// sweep above, and under the same registration-order guard.
|
|
3020
|
+
.post('/runs/read-all', (c) => c.json({ read: c.get('project').store.markAllRead() }))
|
|
3021
|
+
.post('/runs/:id/archive', jsonZodValidator(archiveSchema, { absent: ({}) }), async (c) => {
|
|
1633
3022
|
const { store } = c.get('project');
|
|
1634
3023
|
const id = c.req.param('id');
|
|
1635
3024
|
// An empty/absent body archives (the common case); a malformed body degrades
|
|
1636
3025
|
// to `{}` just as before, but a wrong-typed `archived` is now a 400 (#429).
|
|
1637
|
-
|
|
1638
|
-
|
|
1639
|
-
|
|
1640
|
-
}
|
|
3026
|
+
// Archiving also retires any pending usage-limit resume, but that rule belongs to
|
|
3027
|
+
// `setArchived` itself — the bulk sweep must obey it too (spec
|
|
3028
|
+
// 2026-08-03-auto-resume-after-usage-limit).
|
|
3029
|
+
const parsed = { data: c.req.valid('json') };
|
|
1641
3030
|
const run = store.setArchived(id, parsed.data.archived !== false);
|
|
1642
3031
|
return run ? c.json(run) : c.json({ error: 'not found' }, 404);
|
|
1643
|
-
})
|
|
1644
|
-
|
|
3032
|
+
})
|
|
3033
|
+
// The per-task off switch for that resume (the workspace setting is Settings → Resources).
|
|
3034
|
+
// Idempotent: a run with nothing pending answers 200 too, because "this task will not
|
|
3035
|
+
// resume itself" is equally true either way.
|
|
3036
|
+
.delete('/runs/:id/auto-resume', (c) => {
|
|
3037
|
+
const { store, manager } = c.get('project');
|
|
3038
|
+
const id = c.req.param('id');
|
|
3039
|
+
if (!store.getRun(id))
|
|
3040
|
+
return c.json({ error: 'not found' }, 404);
|
|
3041
|
+
manager.cancelAutoResume(id);
|
|
3042
|
+
return c.json({ cancelled: true });
|
|
3043
|
+
})
|
|
3044
|
+
.post('/runs/:id/read', (c) => {
|
|
3045
|
+
// No body: opening a thread marks it read, full stop. Stamps `seenAt = now` and
|
|
3046
|
+
// returns the updated record (which also rides the `run` SSE via `touch`).
|
|
3047
|
+
const run = c.get('project').store.setRead(c.req.param('id'));
|
|
3048
|
+
return run ? c.json(run) : c.json({ error: 'not found' }, 404);
|
|
3049
|
+
})
|
|
3050
|
+
.post('/runs/:id/unread', (c) => {
|
|
3051
|
+
// The mark-unread twin (#775) — bodyless like its read counterpart: clearing the
|
|
3052
|
+
// receipt is the whole action, so there is nothing to say about it. Sits under
|
|
3053
|
+
// `/runs/:id/`, so the `read-all` registration-order caveat above does not apply.
|
|
3054
|
+
const run = c.get('project').store.setUnread(c.req.param('id'));
|
|
3055
|
+
return run ? c.json(run) : c.json({ error: 'not found' }, 404);
|
|
3056
|
+
})
|
|
3057
|
+
.post('/runs', jsonZodValidator(startRunSchema), async (c) => {
|
|
1645
3058
|
const { root: repoRoot, dataDir, manager } = c.get('project');
|
|
1646
|
-
const parsed =
|
|
1647
|
-
if (
|
|
1648
|
-
return c.json({ error:
|
|
3059
|
+
const parsed = { data: c.req.valid('json') };
|
|
3060
|
+
if (agentModelsLocked(repoRoot) && parsed.data.model?.trim()) {
|
|
3061
|
+
return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
|
|
1649
3062
|
}
|
|
1650
3063
|
let workflow;
|
|
1651
3064
|
if (parsed.data.steps) {
|
|
@@ -1665,6 +3078,19 @@ export function createApp(deps) {
|
|
|
1665
3078
|
if (!workflow)
|
|
1666
3079
|
return c.json({ error: `unknown workflow: ${parsed.data.workflow}` }, 404);
|
|
1667
3080
|
}
|
|
3081
|
+
const fallback = parsed.data.runner ?? (await loadConfig(repoRoot)).defaultRunner;
|
|
3082
|
+
const blocked = await providerActionError(providersRequiredByWorkflow(workflow, fallback));
|
|
3083
|
+
if (blocked)
|
|
3084
|
+
return c.json({ error: blocked }, 409);
|
|
3085
|
+
// A composer override names an account the user just picked, so a stale id (deleted since
|
|
3086
|
+
// the page loaded) is answered honestly instead of quietly running on the default — the
|
|
3087
|
+
// opposite of how RESOLUTION treats a dangling reference, and deliberately so: a run
|
|
3088
|
+
// replaying a stored id has no better answer than the default, a user does.
|
|
3089
|
+
if (parsed.data.agentProfile !== undefined) {
|
|
3090
|
+
const account = await resolveWorkspaceProfile(fallback, parsed.data.agentProfile);
|
|
3091
|
+
if ('error' in account)
|
|
3092
|
+
return c.json({ error: account.error }, 400);
|
|
3093
|
+
}
|
|
1668
3094
|
const images = parsed.data.images?.map((img) => ({
|
|
1669
3095
|
type: 'image',
|
|
1670
3096
|
source: { type: 'base64', media_type: img.mediaType, data: img.data },
|
|
@@ -1673,146 +3099,79 @@ export function createApp(deps) {
|
|
|
1673
3099
|
task: parsed.data.task,
|
|
1674
3100
|
model: parsed.data.model,
|
|
1675
3101
|
runner: parsed.data.runner,
|
|
3102
|
+
agentProfile: parsed.data.agentProfile,
|
|
1676
3103
|
images,
|
|
1677
3104
|
systemPrompt: parsed.data.systemPrompt,
|
|
1678
3105
|
worktree: parsed.data.worktree,
|
|
1679
3106
|
autonomous: parsed.data.autonomous,
|
|
1680
3107
|
// Opt-in inbox (#471): the capability is the ceiling, so a client asking
|
|
1681
3108
|
// for follow-ups on a server that has them off gets a plain `false`
|
|
1682
|
-
// rather than an error — the run is still perfectly valid without them.
|
|
1683
|
-
// One decision here feeds the run record, the system prompt and
|
|
1684
|
-
// CEZ_TODOS_FILE alike (RunManager.agentEnv).
|
|
1685
|
-
generateFollowups: capabilities().followups ? parsed.data.generateFollowups : false,
|
|
1686
|
-
};
|
|
1687
|
-
const variants = parsed.data.variants ?? 1;
|
|
1688
|
-
if (variants > 1) {
|
|
1689
|
-
// Variants live in worktrees — without git there's nothing to isolate
|
|
1690
|
-
// them with, so this degrades to a clear 400 instead of stepping on
|
|
1691
|
-
// one shared working tree.
|
|
1692
|
-
const repo = await getRepoInfo(repoRoot);
|
|
1693
|
-
if (!repo) {
|
|
1694
|
-
return c.json({
|
|
1695
|
-
error: 'parallel variants need a git repository (each variant runs in its own worktree) — run ×1 here, or start cezar inside a git repo',
|
|
1696
|
-
}, 400);
|
|
1697
|
-
}
|
|
1698
|
-
const runs = manager.startVariants(workflow, input, variants);
|
|
1699
|
-
// The entry points at the first variant — the thread the composer navigates to.
|
|
1700
|
-
const first = runs[0];
|
|
1701
|
-
if (parsed.data.todoId && first)
|
|
1702
|
-
await noteTodoStarted(dataDir, parsed.data.todoId, first.id);
|
|
1703
|
-
return c.json({ runs }, 201);
|
|
1704
|
-
}
|
|
1705
|
-
const run = manager.startRun(workflow, input);
|
|
1706
|
-
if (parsed.data.todoId)
|
|
1707
|
-
await noteTodoStarted(dataDir, parsed.data.todoId, run.id);
|
|
1708
|
-
return c.json(run, 201);
|
|
1709
|
-
});
|
|
1710
|
-
// ---- parallel variants (spec 010) -----------------------------------------
|
|
1711
|
-
const groupRuns = (store, groupId) => store
|
|
1712
|
-
.listRuns()
|
|
1713
|
-
.filter((r) => r.groupId === groupId)
|
|
1714
|
-
.sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
|
|
1715
|
-
// Comparison data: per variant the status, cost, `git diff --stat` and the
|
|
1716
|
-
// first Progress-log lines from the handoff. The full diff is fetched per
|
|
1717
|
-
// variant via the existing GET /api/runs/:id/diff.
|
|
1718
|
-
api.get('/groups/:groupId', async (c) => {
|
|
1719
|
-
const { dataDir, store } = c.get('project');
|
|
1720
|
-
const runs = groupRuns(store, c.req.param('groupId'));
|
|
1721
|
-
if (runs.length === 0)
|
|
1722
|
-
return c.json({ error: 'not found' }, 404);
|
|
1723
|
-
const detailed = await Promise.all(runs.map(async (r) => ({
|
|
1724
|
-
id: r.id,
|
|
1725
|
-
variant: r.variant ?? '?',
|
|
1726
|
-
title: r.title,
|
|
1727
|
-
status: r.status,
|
|
1728
|
-
archived: r.archived,
|
|
1729
|
-
tokensUsed: r.tokensUsed,
|
|
1730
|
-
costUsd: r.costUsd,
|
|
1731
|
-
diffStat: r.worktreePath && existsSync(r.worktreePath)
|
|
1732
|
-
? await worktreeDiffStat(r.worktreePath, r.baseBranch ?? 'HEAD')
|
|
1733
|
-
: '',
|
|
1734
|
-
handoffExcerpt: handoffProgressExcerpt(readHandoff(dataDir, r.id)),
|
|
1735
|
-
})));
|
|
1736
|
-
return c.json({
|
|
1737
|
-
groupId: c.req.param('groupId'),
|
|
1738
|
-
runs: detailed,
|
|
1739
|
-
});
|
|
1740
|
-
});
|
|
1741
|
-
// "Pick this one": the winner rests at `review` (spec 009 takes it from
|
|
1742
|
-
// there — send back / draft PR / finish); the losers are cancelled if
|
|
1743
|
-
// alive, archived, and their worktrees + branches removed.
|
|
1744
|
-
api.post('/groups/:groupId/pick', async (c) => {
|
|
1745
|
-
const { root: repoRoot, dataDir, store, manager } = c.get('project');
|
|
1746
|
-
const runs = groupRuns(store, c.req.param('groupId'));
|
|
1747
|
-
if (runs.length === 0)
|
|
1748
|
-
return c.json({ error: 'not found' }, 404);
|
|
1749
|
-
const parsed = pickSchema.safeParse(await c.req.json().catch(() => null));
|
|
1750
|
-
if (!parsed.success) {
|
|
1751
|
-
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
1752
|
-
}
|
|
1753
|
-
const winner = runs.find((r) => r.id === parsed.data.runId);
|
|
1754
|
-
if (!winner)
|
|
1755
|
-
return c.json({ error: 'runId is not part of this group' }, 404);
|
|
1756
|
-
if (manager.isActive(winner.id)) {
|
|
1757
|
-
return c.json({ error: 'this variant is still active — wait for it to finish first' }, 409);
|
|
1758
|
-
}
|
|
1759
|
-
// Winner: a non-review terminal state with a non-empty diff flips to
|
|
1760
|
-
// `review` (the settleSuccess rule) — but only when the review gate applies
|
|
1761
|
-
// (#489): it is enabled (`reviewGateEnabled`, default off) AND the winner is
|
|
1762
|
-
// not autonomous. An autonomous / gate-off winner keeps its `done` state with
|
|
1763
|
-
// the diff left in the worktree; an empty diff (or no worktree) stays too.
|
|
1764
|
-
if (winner.status !== 'review' &&
|
|
1765
|
-
winner.worktreePath &&
|
|
1766
|
-
existsSync(winner.worktreePath) &&
|
|
1767
|
-
winner.autonomous !== true &&
|
|
1768
|
-
reviewGateEnabled(await loadConfig(repoRoot))) {
|
|
1769
|
-
const diff = await worktreeDiff(winner.worktreePath, winner.baseBranch ?? 'HEAD');
|
|
1770
|
-
if (diff.trim().length > 0 && !diff.startsWith('(diff failed')) {
|
|
1771
|
-
store.updateRun(winner.id, { status: 'review' });
|
|
1772
|
-
}
|
|
1773
|
-
}
|
|
1774
|
-
const losers = runs.filter((r) => r.id !== winner.id);
|
|
1775
|
-
store.appendEvent(winner.id, {
|
|
1776
|
-
type: 'lifecycle',
|
|
1777
|
-
message: `picked from ${runs.length} variants — ${losers.length} other variant(s) archived`,
|
|
1778
|
-
});
|
|
1779
|
-
appendHandoffHeartbeat(dataDir, winner.id, `picked from ${runs.length} variants`);
|
|
1780
|
-
for (const loser of losers) {
|
|
1781
|
-
if (manager.isActive(loser.id))
|
|
1782
|
-
manager.cancel(loser.id);
|
|
1783
|
-
if (loser.worktreePath)
|
|
1784
|
-
await removeWorktree(repoRoot, loser.worktreePath, loser.branch);
|
|
1785
|
-
store.updateRun(loser.id, { worktreePath: undefined, branch: undefined });
|
|
1786
|
-
store.setArchived(loser.id, true);
|
|
1787
|
-
store.appendEvent(loser.id, {
|
|
1788
|
-
type: 'lifecycle',
|
|
1789
|
-
message: `variant ${winner.variant ?? '?'} was picked — this variant is archived, its worktree removed`,
|
|
1790
|
-
});
|
|
3109
|
+
// rather than an error — the run is still perfectly valid without them.
|
|
3110
|
+
// One decision here feeds the run record, the system prompt and
|
|
3111
|
+
// CEZ_TODOS_FILE alike (RunManager.agentEnv).
|
|
3112
|
+
generateFollowups: capabilities().followups ? parsed.data.generateFollowups : false,
|
|
3113
|
+
};
|
|
3114
|
+
const variants = parsed.data.variants ?? 1;
|
|
3115
|
+
if (variants > 1) {
|
|
3116
|
+
// Variants live in worktrees — without git there's nothing to isolate
|
|
3117
|
+
// them with, so this degrades to a clear 400 instead of stepping on
|
|
3118
|
+
// one shared working tree.
|
|
3119
|
+
const repo = await getRepoInfo(repoRoot);
|
|
3120
|
+
if (!repo) {
|
|
3121
|
+
return c.json({
|
|
3122
|
+
error: 'parallel variants need a git repository (each variant runs in its own worktree) — run ×1 here, or start cezar inside a git repo',
|
|
3123
|
+
}, 400);
|
|
3124
|
+
}
|
|
3125
|
+
const runs = manager.startVariants(workflow, input, variants);
|
|
3126
|
+
// The entry points at the first variant — the thread the composer navigates to.
|
|
3127
|
+
const first = runs[0];
|
|
3128
|
+
if (parsed.data.todoId && first)
|
|
3129
|
+
await noteTodoStarted(dataDir, parsed.data.todoId, first.id);
|
|
3130
|
+
return c.json({ runs }, 201);
|
|
1791
3131
|
}
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
|
|
1795
|
-
|
|
1796
|
-
|
|
3132
|
+
const run = manager.startRun(workflow, input);
|
|
3133
|
+
if (parsed.data.todoId)
|
|
3134
|
+
await noteTodoStarted(dataDir, parsed.data.todoId, run.id);
|
|
3135
|
+
return c.json(run, 201);
|
|
3136
|
+
})
|
|
3137
|
+
.get('/runs/:id', (c) => {
|
|
1797
3138
|
const { store } = c.get('project');
|
|
1798
3139
|
const run = store.getRun(c.req.param('id'));
|
|
1799
3140
|
return run ? c.json(withUsage(run)) : c.json({ error: 'not found' }, 404);
|
|
1800
|
-
})
|
|
1801
|
-
|
|
1802
|
-
|
|
1803
|
-
|
|
1804
|
-
|
|
1805
|
-
|
|
1806
|
-
|
|
1807
|
-
|
|
3141
|
+
})
|
|
3142
|
+
.get('/runs/:id/history', paramZodValidator(runIdParamSchema), queryZodValidator(runHistoryQuerySchema), async (c) => {
|
|
3143
|
+
const { store, dataDir } = c.get('project');
|
|
3144
|
+
const { id } = c.req.valid('param');
|
|
3145
|
+
if (!store.getRun(id))
|
|
3146
|
+
return c.json({ error: 'not found' }, 404);
|
|
3147
|
+
try {
|
|
3148
|
+
return c.json(await readRunHistoryPage(join(dataDir, 'runs', `${id}.ndjson`), c.req.valid('query').cursor));
|
|
3149
|
+
}
|
|
3150
|
+
catch (error) {
|
|
3151
|
+
if (error instanceof HistoryCursorError)
|
|
3152
|
+
return c.json({ error: error.message }, error.status);
|
|
3153
|
+
throw error;
|
|
3154
|
+
}
|
|
3155
|
+
})
|
|
3156
|
+
.get('/runs/:id/history-context', paramZodValidator(runIdParamSchema), async (c) => {
|
|
3157
|
+
const { store, dataDir } = c.get('project');
|
|
3158
|
+
const { id } = c.req.valid('param');
|
|
3159
|
+
if (!store.getRun(id))
|
|
3160
|
+
return c.json({ error: 'not found' }, 404);
|
|
3161
|
+
return c.json(await deriveRunContextEvents(join(dataDir, 'runs', `${id}.ndjson`)));
|
|
3162
|
+
})
|
|
3163
|
+
// Editable titles (#389). The UI displays `titleSummary ?? title`, so a
|
|
3164
|
+
// user edit sets BOTH: `title` (the record's own name — the raw task stops
|
|
3165
|
+
// being it the moment the user renames the run) and `titleSummary` (what
|
|
3166
|
+
// actually displays). The auto-summarizer only ever fills an *unset*
|
|
3167
|
+
// titleSummary (RunManager.recordTurnEnd), so an edit wins over any past or
|
|
3168
|
+
// future auto-summary. Answers the updated record.
|
|
3169
|
+
.patch('/runs/:id', jsonZodValidator(patchRunSchema), async (c) => {
|
|
1808
3170
|
const { store, manager } = c.get('project');
|
|
1809
3171
|
const id = c.req.param('id');
|
|
1810
3172
|
if (!store.getRun(id))
|
|
1811
3173
|
return c.json({ error: 'not found' }, 404);
|
|
1812
|
-
const parsed =
|
|
1813
|
-
if (!parsed.success) {
|
|
1814
|
-
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
1815
|
-
}
|
|
3174
|
+
const parsed = { data: c.req.valid('json') };
|
|
1816
3175
|
// The prompt is editable only while the run is still queued (#472). Checked
|
|
1817
3176
|
// BEFORE the title write so a rejected PATCH is a no-op rather than a partial
|
|
1818
3177
|
// one. `title` itself keeps working on any status — no regression to #389.
|
|
@@ -1837,25 +3196,33 @@ export function createApp(deps) {
|
|
|
1837
3196
|
});
|
|
1838
3197
|
}
|
|
1839
3198
|
return c.json(store.getRun(id));
|
|
1840
|
-
})
|
|
1841
|
-
|
|
3199
|
+
})
|
|
3200
|
+
.post('/runs/:id/cancel', (c) => {
|
|
1842
3201
|
const { store, manager } = c.get('project');
|
|
1843
3202
|
const id = c.req.param('id');
|
|
1844
3203
|
if (!store.getRun(id))
|
|
1845
3204
|
return c.json({ error: 'not found' }, 404);
|
|
1846
3205
|
const cancelled = manager.cancel(id);
|
|
1847
3206
|
return c.json({ cancelled });
|
|
1848
|
-
})
|
|
1849
|
-
|
|
1850
|
-
|
|
1851
|
-
|
|
3207
|
+
})
|
|
3208
|
+
// Live-session participation (spec 002): deliver a user message (text +
|
|
3209
|
+
// pasted screenshots) into the run's open claude session.
|
|
3210
|
+
.post('/runs/:id/messages', jsonZodValidator(messageSchema), async (c) => {
|
|
1852
3211
|
const { store, manager } = c.get('project');
|
|
1853
3212
|
const id = c.req.param('id');
|
|
1854
|
-
|
|
3213
|
+
const run = store.getRun(id);
|
|
3214
|
+
if (!run)
|
|
1855
3215
|
return c.json({ error: 'not found' }, 404);
|
|
1856
|
-
const parsed =
|
|
1857
|
-
|
|
1858
|
-
|
|
3216
|
+
const parsed = { data: c.req.valid('json') };
|
|
3217
|
+
// Stacking onto a queued prompt mutates an existing task and invokes no provider.
|
|
3218
|
+
// Provider availability still gates live delivery after the record leaves `queued`, but
|
|
3219
|
+
// must not strand prompt authoring just because an unrelated fallback provider is
|
|
3220
|
+
// disconnected (provider-auth spec: disabling never blocks existing-task mutations).
|
|
3221
|
+
// In the dequeue race, the ladder below safely turns this into a starting-state buffer.
|
|
3222
|
+
if (run.status !== 'queued') {
|
|
3223
|
+
const blocked = await providerActionError([providerForActiveRun(run)]);
|
|
3224
|
+
if (blocked)
|
|
3225
|
+
return c.json({ error: blocked }, 409);
|
|
1859
3226
|
}
|
|
1860
3227
|
const content = [
|
|
1861
3228
|
...parsed.data.images.map((img) => ({
|
|
@@ -1871,14 +3238,14 @@ export function createApp(deps) {
|
|
|
1871
3238
|
// starting up → buffered · anything else → 409, exactly as before
|
|
1872
3239
|
if (manager.sendMessage(id, content))
|
|
1873
3240
|
return c.json({ delivered: true });
|
|
1874
|
-
const
|
|
1875
|
-
const stack =
|
|
3241
|
+
const currentRun = store.getRun(id);
|
|
3242
|
+
const stack = currentRun?.queuedMessages ?? [];
|
|
1876
3243
|
// Bounds apply only to a message that is actually about to be stacked. Without this
|
|
1877
3244
|
// gate an over-long message posted to a *finished* run would answer `400 prompt too
|
|
1878
3245
|
// long` when the truthful answer is `409 session closed`. The status read is safe
|
|
1879
3246
|
// here because it only decides whether to reject EARLY — `enqueueMessage` still
|
|
1880
3247
|
// re-checks against the engine's own queue before writing anything.
|
|
1881
|
-
if (
|
|
3248
|
+
if (currentRun?.status === 'queued') {
|
|
1882
3249
|
if (stack.length >= MAX_QUEUED_MESSAGES) {
|
|
1883
3250
|
return c.json({ error: `too many queued messages — ${MAX_QUEUED_MESSAGES} message limit` }, 400);
|
|
1884
3251
|
}
|
|
@@ -1886,7 +3253,7 @@ export function createApp(deps) {
|
|
|
1886
3253
|
if (stackedImages + parsed.data.images.length > MAX_QUEUED_IMAGES) {
|
|
1887
3254
|
return c.json({ error: `too many queued images — ${MAX_QUEUED_IMAGES} image limit across the stack` }, 400);
|
|
1888
3255
|
}
|
|
1889
|
-
const prospective = foldedLength(
|
|
3256
|
+
const prospective = foldedLength(currentRun.task, [...stack, { text: parsed.data.text }]);
|
|
1890
3257
|
if (prospective > MAX_FOLDED_TASK_CHARS) {
|
|
1891
3258
|
return c.json({
|
|
1892
3259
|
error: `prompt too long — ${MAX_FOLDED_TASK_CHARS} character limit across the task and its queued messages (would be ${prospective})`,
|
|
@@ -1899,19 +3266,16 @@ export function createApp(deps) {
|
|
|
1899
3266
|
if (manager.deferMessage(id, content))
|
|
1900
3267
|
return c.json({ deferred: true });
|
|
1901
3268
|
return c.json({ error: 'session closed' }, 409);
|
|
1902
|
-
})
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
3269
|
+
})
|
|
3270
|
+
// Edit / remove a stacked message (#472). Registered before any conflicting
|
|
3271
|
+
// `/:id` route so `queued-messages` never matches as a run id.
|
|
3272
|
+
.patch('/runs/:id/queued-messages/:msgId', jsonZodValidator(queuedMessagePatchSchema), async (c) => {
|
|
1906
3273
|
const { store, manager } = c.get('project');
|
|
1907
3274
|
const id = c.req.param('id');
|
|
1908
3275
|
const run = store.getRun(id);
|
|
1909
3276
|
if (!run)
|
|
1910
3277
|
return c.json({ error: 'not found' }, 404);
|
|
1911
|
-
const parsed =
|
|
1912
|
-
if (!parsed.success) {
|
|
1913
|
-
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
1914
|
-
}
|
|
3278
|
+
const parsed = { data: c.req.valid('json') };
|
|
1915
3279
|
const msgId = c.req.param('msgId');
|
|
1916
3280
|
const stack = run.queuedMessages ?? [];
|
|
1917
3281
|
const existing = stack.find((m) => m.id === msgId);
|
|
@@ -1944,8 +3308,8 @@ export function createApp(deps) {
|
|
|
1944
3308
|
if (!message)
|
|
1945
3309
|
return c.json({ error: 'run already started' }, 409);
|
|
1946
3310
|
return c.json({ message });
|
|
1947
|
-
})
|
|
1948
|
-
|
|
3311
|
+
})
|
|
3312
|
+
.delete('/runs/:id/queued-messages/:msgId', (c) => {
|
|
1949
3313
|
const { store, manager } = c.get('project');
|
|
1950
3314
|
const id = c.req.param('id');
|
|
1951
3315
|
const run = store.getRun(id);
|
|
@@ -1958,9 +3322,9 @@ export function createApp(deps) {
|
|
|
1958
3322
|
if (!manager.removeQueuedMessage(id, msgId))
|
|
1959
3323
|
return c.json({ error: 'run already started' }, 409);
|
|
1960
3324
|
return c.json({ removed: true });
|
|
1961
|
-
})
|
|
1962
|
-
|
|
1963
|
-
|
|
3325
|
+
})
|
|
3326
|
+
// "Finish": gracefully close a waiting session — the run completes as done.
|
|
3327
|
+
.post('/runs/:id/finish', (c) => {
|
|
1964
3328
|
const { store, manager } = c.get('project');
|
|
1965
3329
|
const id = c.req.param('id');
|
|
1966
3330
|
if (!store.getRun(id))
|
|
@@ -1969,19 +3333,23 @@ export function createApp(deps) {
|
|
|
1969
3333
|
if (!finished)
|
|
1970
3334
|
return c.json({ error: 'no open session' }, 409);
|
|
1971
3335
|
return c.json({ finished: true });
|
|
1972
|
-
})
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
const { store, manager } = c.get('project');
|
|
3336
|
+
})
|
|
3337
|
+
// "Continue" (spec 003): reopen a finished run's session in-process.
|
|
3338
|
+
.post('/runs/:id/continue', jsonZodValidator(continueSchema, { absent: ({}) }), async (c) => {
|
|
3339
|
+
const { root: repoRoot, store, manager } = c.get('project');
|
|
1976
3340
|
const id = c.req.param('id');
|
|
1977
|
-
|
|
3341
|
+
const run = store.getRun(id);
|
|
3342
|
+
if (!run)
|
|
1978
3343
|
return c.json({ error: 'not found' }, 404);
|
|
1979
3344
|
// Bounded resume text (#429); an empty/absent body still just re-runs on the
|
|
1980
3345
|
// run's current backend, and a runner/model override reopens on that engine (#401).
|
|
1981
|
-
const parsed =
|
|
1982
|
-
if (
|
|
1983
|
-
return c.json({ error:
|
|
3346
|
+
const parsed = { data: c.req.valid('json') };
|
|
3347
|
+
if (agentModelsLocked(repoRoot) && parsed.data.model?.trim()) {
|
|
3348
|
+
return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
|
|
1984
3349
|
}
|
|
3350
|
+
const blocked = await providerActionError([providerForExistingRun(run, parsed.data.runner)]);
|
|
3351
|
+
if (blocked)
|
|
3352
|
+
return c.json({ error: blocked }, 409);
|
|
1985
3353
|
const result = manager.continueRun(id, {
|
|
1986
3354
|
text: parsed.data.text,
|
|
1987
3355
|
images: parsed.data.images?.map((img) => ({
|
|
@@ -1994,10 +3362,10 @@ export function createApp(deps) {
|
|
|
1994
3362
|
if (!result.ok)
|
|
1995
3363
|
return c.json({ error: result.error }, 409);
|
|
1996
3364
|
return c.json({ continued: true });
|
|
1997
|
-
})
|
|
1998
|
-
|
|
1999
|
-
|
|
2000
|
-
|
|
3365
|
+
})
|
|
3366
|
+
// "Open in terminal" (spec 003): hand the session off to a real terminal —
|
|
3367
|
+
// in the task's worktree when it still exists (spec 006).
|
|
3368
|
+
.post('/runs/:id/open-in-cli', async (c) => {
|
|
2001
3369
|
const { root: repoRoot, store } = c.get('project');
|
|
2002
3370
|
const id = c.req.param('id');
|
|
2003
3371
|
const run = store.getRun(id);
|
|
@@ -2010,28 +3378,36 @@ export function createApp(deps) {
|
|
|
2010
3378
|
error: 'local handoff is disabled — this cockpit runs in hosted mode (CEZ_REMOTE); resume the session from a machine that has the checkout',
|
|
2011
3379
|
}, 409);
|
|
2012
3380
|
}
|
|
2013
|
-
const
|
|
3381
|
+
const sessionStep = [...run.steps].reverse().find((s) => s.sessionId);
|
|
3382
|
+
const sessionId = sessionStep?.sessionId;
|
|
2014
3383
|
if (!sessionId)
|
|
2015
3384
|
return c.json({ error: 'no agent session to resume' }, 409);
|
|
3385
|
+
const blocked = await providerActionError([providerForExistingRun(run)]);
|
|
3386
|
+
if (blocked)
|
|
3387
|
+
return c.json({ error: blocked }, 409);
|
|
2016
3388
|
const cwd = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
|
|
2017
3389
|
const command = resumeCommand(run.runner, sessionId);
|
|
2018
3390
|
// Fails closed on an id we do not recognise — see resumeCommand (#431).
|
|
2019
3391
|
if (!command)
|
|
2020
3392
|
return c.json({ error: 'the recorded session id has an unexpected shape' }, 409);
|
|
2021
|
-
|
|
3393
|
+
// The account that OWNS this session, not the project's current one (spec 2026-07-29).
|
|
3394
|
+
const account = await handoffEnv(run.runner ?? 'claude', sessionStep?.profileId);
|
|
3395
|
+
if ('error' in account)
|
|
3396
|
+
return c.json({ error: account.error }, 409);
|
|
3397
|
+
const fallback = handoffFallbackCommand(cwd, command, account.env);
|
|
3398
|
+
// Fail closed for the same reason as the session id: a terminal opened without the
|
|
3399
|
+
// account's config dir resumes nothing and says nothing about why.
|
|
3400
|
+
if (fallback === null) {
|
|
3401
|
+
return c.json({ error: 'this account\'s folder cannot be used in a terminal command' }, 409);
|
|
3402
|
+
}
|
|
3403
|
+
const opened = await openTerminal(cwd, command, account.env);
|
|
2022
3404
|
if (!opened) {
|
|
2023
|
-
return c.json({
|
|
2024
|
-
error: 'no terminal emulator found',
|
|
2025
|
-
command: `cd '${cwd}' && ${command}`,
|
|
2026
|
-
}, 409);
|
|
3405
|
+
return c.json({ error: 'no terminal emulator found', command: fallback }, 409);
|
|
2027
3406
|
}
|
|
2028
3407
|
return c.json({ opened: true, command });
|
|
2029
|
-
})
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
api.get('/open-targets', (c) => c.json({ targets: capabilities().localHandoff ? detectOpenTargets() : [] }));
|
|
2033
|
-
// Open a run's worktree (or the repo root) in the chosen local app.
|
|
2034
|
-
api.post('/runs/:id/open-in', async (c) => {
|
|
3408
|
+
})
|
|
3409
|
+
// Open a run's worktree (or the repo root) in the chosen local app.
|
|
3410
|
+
.post('/runs/:id/open-in', jsonZodValidator(openInSchema), async (c) => {
|
|
2035
3411
|
const { root: repoRoot, store } = c.get('project');
|
|
2036
3412
|
const id = c.req.param('id');
|
|
2037
3413
|
const run = store.getRun(id);
|
|
@@ -2044,10 +3420,7 @@ export function createApp(deps) {
|
|
|
2044
3420
|
}
|
|
2045
3421
|
// Follows the safeParse convention (#429); the downstream allowlist match is the real
|
|
2046
3422
|
// injection guard, this just validates the shape.
|
|
2047
|
-
const parsedBody =
|
|
2048
|
-
if (!parsedBody.success) {
|
|
2049
|
-
return c.json({ error: parsedBody.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
2050
|
-
}
|
|
3423
|
+
const parsedBody = { data: c.req.valid('json') };
|
|
2051
3424
|
const { target, path: relPath } = parsedBody.data;
|
|
2052
3425
|
const dir = run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : repoRoot;
|
|
2053
3426
|
// Diff pane "open in OS default app" (#365): one worktree file, opened with the platform's
|
|
@@ -2086,7 +3459,7 @@ export function createApp(deps) {
|
|
|
2086
3459
|
}, 409);
|
|
2087
3460
|
}
|
|
2088
3461
|
const filePath = join(run.worktreePath, result.path);
|
|
2089
|
-
const opened = await
|
|
3462
|
+
const opened = await openFile(filePath);
|
|
2090
3463
|
if (!opened)
|
|
2091
3464
|
return c.json({ error: `could not open ${result.path}`, path: filePath }, 409);
|
|
2092
3465
|
return c.json({ opened: true, path: filePath });
|
|
@@ -2105,29 +3478,43 @@ export function createApp(deps) {
|
|
|
2105
3478
|
// and what the client's cliTargetResumes now labels. Resume-after-finish is untouched.
|
|
2106
3479
|
const cliRunner = agentCliRunner(target);
|
|
2107
3480
|
if (cliRunner) {
|
|
3481
|
+
const blocked = await providerActionError([cliRunner]);
|
|
3482
|
+
if (blocked)
|
|
3483
|
+
return c.json({ error: blocked }, 409);
|
|
2108
3484
|
const engineOwnsSession = run.status === 'running' || run.status === 'queued' || run.status === 'waiting';
|
|
2109
|
-
const
|
|
3485
|
+
const sessionStep = engineOwnsSession ? undefined : [...run.steps].reverse().find((s) => s.sessionId);
|
|
3486
|
+
const sessionId = sessionStep?.sessionId;
|
|
2110
3487
|
// An id resumeCommand refuses (#431) degrades to a fresh CLI in the worktree,
|
|
2111
3488
|
// exactly like a run that never recorded a session.
|
|
2112
3489
|
const resume = sessionId && cliRunner === (run.runner ?? 'claude') ? resumeCommand(cliRunner, sessionId) : null;
|
|
2113
3490
|
const command = resume ?? cliRunner;
|
|
2114
|
-
|
|
3491
|
+
// BOTH branches carry the account (spec 2026-07-29-agent-profiles): a resume needs the
|
|
3492
|
+
// config dir that holds its session, and a FRESH CLI in this worktree should still open
|
|
3493
|
+
// on the account the project works under — otherwise "Open in → Claude CLI" quietly
|
|
3494
|
+
// hands the user a different subscription than every task in the same project uses.
|
|
3495
|
+
const account = resume
|
|
3496
|
+
? await handoffEnv(cliRunner, sessionStep?.profileId)
|
|
3497
|
+
: { env: (await resolveProfileEnvForRoot(repoRoot, cliRunner)).env };
|
|
3498
|
+
if ('error' in account)
|
|
3499
|
+
return c.json({ error: account.error }, 409);
|
|
3500
|
+
const fallback = handoffFallbackCommand(dir, command, account.env);
|
|
3501
|
+
if (fallback === null) {
|
|
3502
|
+
return c.json({ error: 'this account\'s folder cannot be used in a terminal command' }, 409);
|
|
3503
|
+
}
|
|
3504
|
+
const opened = await openTerminal(dir, command, account.env);
|
|
2115
3505
|
if (!opened) {
|
|
2116
|
-
return c.json({
|
|
2117
|
-
error: 'no terminal emulator found',
|
|
2118
|
-
command: `cd '${dir}' && ${command}`,
|
|
2119
|
-
}, 409);
|
|
3506
|
+
return c.json({ error: 'no terminal emulator found', command: fallback }, 409);
|
|
2120
3507
|
}
|
|
2121
3508
|
return c.json({ opened: true, path: dir, command });
|
|
2122
3509
|
}
|
|
2123
|
-
const opened = await
|
|
3510
|
+
const opened = await openApp(target, dir);
|
|
2124
3511
|
if (!opened)
|
|
2125
3512
|
return c.json({ error: `could not open ${target}`, path: dir }, 409);
|
|
2126
3513
|
return c.json({ opened: true, path: dir });
|
|
2127
|
-
})
|
|
2128
|
-
|
|
2129
|
-
|
|
2130
|
-
|
|
3514
|
+
})
|
|
3515
|
+
// Handoff journal (spec 007): the per-task handoff.md as markdown. 404 only
|
|
3516
|
+
// when the task is unknown; a task without a (yet) seeded file returns ''.
|
|
3517
|
+
.get('/runs/:id/handoff', (c) => {
|
|
2131
3518
|
const { dataDir, store } = c.get('project');
|
|
2132
3519
|
const run = store.getRun(c.req.param('id'));
|
|
2133
3520
|
if (!run)
|
|
@@ -2135,16 +3522,8 @@ export function createApp(deps) {
|
|
|
2135
3522
|
return c.text(readHandoff(dataDir, run.id), 200, {
|
|
2136
3523
|
'content-type': 'text/markdown; charset=utf-8',
|
|
2137
3524
|
});
|
|
2138
|
-
})
|
|
2139
|
-
|
|
2140
|
-
// results (persistImage). `basename` pins reads inside the run's own dir.
|
|
2141
|
-
const IMAGE_TYPES = {
|
|
2142
|
-
png: 'image/png',
|
|
2143
|
-
jpg: 'image/jpeg',
|
|
2144
|
-
webp: 'image/webp',
|
|
2145
|
-
gif: 'image/gif',
|
|
2146
|
-
};
|
|
2147
|
-
api.get('/runs/:id/images/:file', (c) => {
|
|
3525
|
+
})
|
|
3526
|
+
.get('/runs/:id/images/:file', (c) => {
|
|
2148
3527
|
const { dataDir, store } = c.get('project');
|
|
2149
3528
|
const run = store.getRun(c.req.param('id'));
|
|
2150
3529
|
if (!run)
|
|
@@ -2160,9 +3539,9 @@ export function createApp(deps) {
|
|
|
2160
3539
|
'cache-control': 'private, max-age=31536000, immutable',
|
|
2161
3540
|
},
|
|
2162
3541
|
});
|
|
2163
|
-
})
|
|
2164
|
-
|
|
2165
|
-
|
|
3542
|
+
})
|
|
3543
|
+
// Task diff (spec 006): what this run changed — its worktree vs its base.
|
|
3544
|
+
.get('/runs/:id/diff', async (c) => {
|
|
2166
3545
|
const { store } = c.get('project');
|
|
2167
3546
|
const run = store.getRun(c.req.param('id'));
|
|
2168
3547
|
if (!run)
|
|
@@ -2171,94 +3550,113 @@ export function createApp(deps) {
|
|
|
2171
3550
|
return c.text('(no worktree — this task ran directly in the repo working tree)');
|
|
2172
3551
|
}
|
|
2173
3552
|
return c.text(await worktreeDiff(run.worktreePath, run.baseBranch ?? 'HEAD'));
|
|
2174
|
-
})
|
|
2175
|
-
|
|
2176
|
-
|
|
2177
|
-
// protected surface). Same worktree/base resolution; every predictable git
|
|
2178
|
-
// failure degrades to 409 + human-readable reason, 404 only for unknown ids.
|
|
2179
|
-
const worktreeOf = (run) => run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : null;
|
|
2180
|
-
const NO_WORKTREE = 'no worktree — this task ran directly in the repo working tree';
|
|
2181
|
-
api.get('/runs/:id/changes', async (c) => {
|
|
2182
|
-
const { store } = c.get('project');
|
|
3553
|
+
})
|
|
3554
|
+
.get('/runs/:id/changes', async (c) => {
|
|
3555
|
+
const { root: repoRoot, store } = c.get('project');
|
|
2183
3556
|
const run = store.getRun(c.req.param('id'));
|
|
2184
3557
|
if (!run)
|
|
2185
3558
|
return c.json({ error: 'not found' }, 404);
|
|
2186
|
-
const
|
|
2187
|
-
if (!
|
|
3559
|
+
const workingDirectory = workingDirectoryOf(run, repoRoot);
|
|
3560
|
+
if (!workingDirectory)
|
|
2188
3561
|
return c.json({ error: NO_WORKTREE }, 409);
|
|
2189
|
-
const result = await collectChanges(
|
|
3562
|
+
const result = await collectChanges(workingDirectory, run.baseBranch ?? 'HEAD', {
|
|
3563
|
+
taskBranch: run.branch,
|
|
3564
|
+
// Anchors a repointed worktree at the branch as this run found it (#751).
|
|
3565
|
+
runStartedAt: run.startedAt,
|
|
3566
|
+
// A read-only GET against the user's real checkout must never modify its index.
|
|
3567
|
+
intentToAdd: run.worktreePath ? undefined : false,
|
|
3568
|
+
});
|
|
2190
3569
|
if (!result.ok)
|
|
2191
3570
|
return c.json({ error: result.error }, 409);
|
|
2192
3571
|
return c.json(result.changes);
|
|
2193
|
-
})
|
|
2194
|
-
|
|
2195
|
-
|
|
2196
|
-
const { store } = c.get('project');
|
|
3572
|
+
})
|
|
3573
|
+
// The run's own commits (<base>..HEAD on the worktree branch) — the Commits tab.
|
|
3574
|
+
.get('/runs/:id/commits', async (c) => {
|
|
3575
|
+
const { root: repoRoot, store } = c.get('project');
|
|
2197
3576
|
const run = store.getRun(c.req.param('id'));
|
|
2198
3577
|
if (!run)
|
|
2199
3578
|
return c.json({ error: 'not found' }, 404);
|
|
2200
|
-
const
|
|
2201
|
-
if (!
|
|
3579
|
+
const workingDirectory = workingDirectoryOf(run, repoRoot);
|
|
3580
|
+
if (!workingDirectory)
|
|
2202
3581
|
return c.json({ error: NO_WORKTREE }, 409);
|
|
2203
|
-
const result = await collectRunCommits(
|
|
3582
|
+
const result = await collectRunCommits(workingDirectory, run.baseBranch ?? 'HEAD');
|
|
2204
3583
|
if (!result.ok)
|
|
2205
3584
|
return c.json({ error: result.error }, 409);
|
|
2206
3585
|
return c.json({ commits: result.commits });
|
|
2207
|
-
})
|
|
2208
|
-
|
|
2209
|
-
|
|
2210
|
-
const { store } = c.get('project');
|
|
3586
|
+
})
|
|
3587
|
+
// One of the run's commits, structured like the Changes tab (reuses collectCommitChanges).
|
|
3588
|
+
.get('/runs/:id/commit/:sha', async (c) => {
|
|
3589
|
+
const { root: repoRoot, store } = c.get('project');
|
|
2211
3590
|
const run = store.getRun(c.req.param('id'));
|
|
2212
3591
|
if (!run)
|
|
2213
3592
|
return c.json({ error: 'not found' }, 404);
|
|
2214
|
-
const
|
|
2215
|
-
if (!
|
|
3593
|
+
const workingDirectory = workingDirectoryOf(run, repoRoot);
|
|
3594
|
+
if (!workingDirectory)
|
|
2216
3595
|
return c.json({ error: NO_WORKTREE }, 409);
|
|
2217
|
-
const result = await collectCommitChanges(
|
|
3596
|
+
const result = await collectCommitChanges(workingDirectory, c.req.param('sha'));
|
|
2218
3597
|
if (!result.ok)
|
|
2219
3598
|
return c.json({ error: result.error }, 409);
|
|
2220
3599
|
return c.json(result.commit);
|
|
2221
|
-
})
|
|
2222
|
-
|
|
2223
|
-
|
|
2224
|
-
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
2229
|
-
|
|
3600
|
+
})
|
|
3601
|
+
// Files tab: directory listing (path omitted or a dir) or file content
|
|
3602
|
+
// (size-capped, binary flagged). Traversal-safe — readWorktreePath rejects
|
|
3603
|
+
// anything escaping the worktree. `raw=1` (R5 Step 1.6) serves the BYTES of
|
|
3604
|
+
// image files only, for the preview's inline <img> — never HTML/JS/etc., so
|
|
3605
|
+
// no worktree file can become a same-origin document, and never past the
|
|
3606
|
+
// size cap. The no-script CSP neutralizes SVG opened as a top-level URL.
|
|
3607
|
+
//
|
|
3608
|
+
// An `Accept` that asks for images reaches the same raw branch without the flag — which is
|
|
3609
|
+
// what an `<img>` sends — while the flag still wins whenever it is present and `*<slash>*`
|
|
3610
|
+
// (every `fetch`) still gets the JSON listing. See `negotiate`.
|
|
3611
|
+
.get('/runs/:id/files', queryZodValidator(z.object({ path: queryValue, raw: queryValue })), async (c) => {
|
|
3612
|
+
const { root: repoRoot, store } = c.get('project');
|
|
3613
|
+
const query = c.req.valid('query');
|
|
3614
|
+
c.header('vary', 'Accept');
|
|
3615
|
+
const wantsRaw = query.raw !== undefined
|
|
3616
|
+
? query.raw === '1'
|
|
3617
|
+
: negotiate(c.req.header('accept'), FILE_FORMATS) === 'image/*';
|
|
2230
3618
|
const run = store.getRun(c.req.param('id'));
|
|
2231
3619
|
if (!run)
|
|
2232
3620
|
return c.json({ error: 'not found' }, 404);
|
|
2233
|
-
const
|
|
2234
|
-
if (!
|
|
3621
|
+
const workingDirectory = workingDirectoryOf(run, repoRoot);
|
|
3622
|
+
if (!workingDirectory)
|
|
2235
3623
|
return c.json({ error: NO_WORKTREE }, 409);
|
|
2236
|
-
const result = await readWorktreePath(
|
|
3624
|
+
const result = await readWorktreePath(workingDirectory, query.path ?? '');
|
|
2237
3625
|
if (result.kind === 'invalid' || result.kind === 'missing') {
|
|
2238
3626
|
return c.json({ error: result.error }, 409);
|
|
2239
3627
|
}
|
|
2240
3628
|
if (result.kind === 'dir') {
|
|
2241
3629
|
return c.json({
|
|
3630
|
+
// `as const` or the literal widens to `string` during Hono's route-type inference,
|
|
3631
|
+
// which erases the discriminant a consumer narrows on — `entry.type === 'dir'` then
|
|
3632
|
+
// leaves `never` and every field access on it fails. The wire was always 'dir'.
|
|
2242
3633
|
type: 'dir',
|
|
2243
3634
|
path: result.path,
|
|
2244
3635
|
entries: result.entries,
|
|
2245
3636
|
});
|
|
2246
3637
|
}
|
|
2247
|
-
if (
|
|
3638
|
+
if (wantsRaw) {
|
|
2248
3639
|
const mime = imageMimeType(result.path);
|
|
2249
|
-
if (
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
3640
|
+
if (mime === null || result.tooLarge) {
|
|
3641
|
+
// `?raw=1` ASKED for bytes, so it hears why it cannot have them — that 409 and its
|
|
3642
|
+
// wording are the protected surface (§2). An `Accept` is only a preference, so a
|
|
3643
|
+
// resource with no image representation falls THROUGH to the JSON answer below rather
|
|
3644
|
+
// than turning a browser's navigation to a text file into an error.
|
|
3645
|
+
if (query.raw !== undefined) {
|
|
3646
|
+
const error = mime === null
|
|
3647
|
+
? `raw serving is limited to images: ${result.path}`
|
|
3648
|
+
: `file too large to serve raw (${result.size} bytes): ${result.path}`;
|
|
3649
|
+
return c.json({ error }, 409);
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
else {
|
|
3653
|
+
const bytes = await readFile(join(workingDirectory, result.path));
|
|
3654
|
+
return c.body(new Uint8Array(bytes).buffer, 200, {
|
|
3655
|
+
'content-type': mime,
|
|
3656
|
+
'x-content-type-options': 'nosniff',
|
|
3657
|
+
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
3658
|
+
});
|
|
2255
3659
|
}
|
|
2256
|
-
const bytes = await readFile(join(worktree, result.path));
|
|
2257
|
-
return c.body(new Uint8Array(bytes).buffer, 200, {
|
|
2258
|
-
'content-type': mime,
|
|
2259
|
-
'x-content-type-options': 'nosniff',
|
|
2260
|
-
'content-security-policy': "default-src 'none'; style-src 'unsafe-inline'; sandbox",
|
|
2261
|
-
});
|
|
2262
3660
|
}
|
|
2263
3661
|
return c.json({
|
|
2264
3662
|
type: 'file',
|
|
@@ -2268,8 +3666,8 @@ export function createApp(deps) {
|
|
|
2268
3666
|
tooLarge: result.tooLarge,
|
|
2269
3667
|
...(result.content !== undefined ? { content: result.content } : {}),
|
|
2270
3668
|
});
|
|
2271
|
-
})
|
|
2272
|
-
|
|
3669
|
+
})
|
|
3670
|
+
.post('/runs/:id/git/commit', jsonZodValidator(gitCommitSchema), async (c) => {
|
|
2273
3671
|
const { store } = c.get('project');
|
|
2274
3672
|
const run = store.getRun(c.req.param('id'));
|
|
2275
3673
|
if (!run)
|
|
@@ -2277,16 +3675,13 @@ export function createApp(deps) {
|
|
|
2277
3675
|
const worktree = worktreeOf(run);
|
|
2278
3676
|
if (!worktree)
|
|
2279
3677
|
return c.json({ error: NO_WORKTREE }, 409);
|
|
2280
|
-
const parsed =
|
|
2281
|
-
if (!parsed.success) {
|
|
2282
|
-
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
2283
|
-
}
|
|
3678
|
+
const parsed = { data: c.req.valid('json') };
|
|
2284
3679
|
const result = await commitAll(worktree, parsed.data.message);
|
|
2285
3680
|
if (!result.ok)
|
|
2286
3681
|
return c.json({ error: result.error }, 409);
|
|
2287
3682
|
return c.json({ committed: true, sha: result.sha });
|
|
2288
|
-
})
|
|
2289
|
-
|
|
3683
|
+
})
|
|
3684
|
+
.post('/runs/:id/git/push', async (c) => {
|
|
2290
3685
|
const { store } = c.get('project');
|
|
2291
3686
|
const run = store.getRun(c.req.param('id'));
|
|
2292
3687
|
if (!run)
|
|
@@ -2303,12 +3698,12 @@ export function createApp(deps) {
|
|
|
2303
3698
|
remote: result.remote,
|
|
2304
3699
|
upstreamSet: result.upstreamSet,
|
|
2305
3700
|
});
|
|
2306
|
-
})
|
|
2307
|
-
|
|
2308
|
-
|
|
2309
|
-
|
|
2310
|
-
|
|
2311
|
-
|
|
3701
|
+
})
|
|
3702
|
+
// Draft PR from the review gate (spec 009): final autosave → push →
|
|
3703
|
+
// `gh pr create --draft`; on success the run completes as done with the PR
|
|
3704
|
+
// badge. Failures come back as 409 with a `manual` merge command the GUI
|
|
3705
|
+
// shows next to the toast. CEZ_DRY_RUN=1 fakes the URL (no push, no gh).
|
|
3706
|
+
.post('/runs/:id/pr', async (c) => {
|
|
2312
3707
|
const { root: repoRoot, dataDir, store, manager } = c.get('project');
|
|
2313
3708
|
const id = c.req.param('id');
|
|
2314
3709
|
const run = store.getRun(id);
|
|
@@ -2339,10 +3734,10 @@ export function createApp(deps) {
|
|
|
2339
3734
|
message: `draft PR created: ${outcome.url}${outcome.dryRun ? ' (dry run — no real PR)' : ''}`,
|
|
2340
3735
|
});
|
|
2341
3736
|
return c.json({ url: outcome.url, dryRun: outcome.dryRun }, 201);
|
|
2342
|
-
})
|
|
2343
|
-
|
|
2344
|
-
|
|
2345
|
-
|
|
3737
|
+
})
|
|
3738
|
+
// Archived tasks keep their worktree for inspection; this is the explicit
|
|
3739
|
+
// "🧹 Remove worktree" cleanup (spec 006).
|
|
3740
|
+
.post('/runs/:id/remove-worktree', async (c) => {
|
|
2346
3741
|
const { root: repoRoot, store, manager } = c.get('project');
|
|
2347
3742
|
const id = c.req.param('id');
|
|
2348
3743
|
const run = store.getRun(id);
|
|
@@ -2354,8 +3749,8 @@ export function createApp(deps) {
|
|
|
2354
3749
|
await removeWorktree(repoRoot, run.worktreePath, run.branch);
|
|
2355
3750
|
store.updateRun(id, { worktreePath: undefined, branch: undefined });
|
|
2356
3751
|
return c.json({ removed: true });
|
|
2357
|
-
})
|
|
2358
|
-
|
|
3752
|
+
})
|
|
3753
|
+
.delete('/runs/:id', async (c) => {
|
|
2359
3754
|
const { root: repoRoot, store, manager } = c.get('project');
|
|
2360
3755
|
const id = c.req.param('id');
|
|
2361
3756
|
if (manager.isActive(id))
|
|
@@ -2368,11 +3763,140 @@ export function createApp(deps) {
|
|
|
2368
3763
|
await removeWorktree(repoRoot, run.worktreePath, run.branch);
|
|
2369
3764
|
return store.deleteRun(id) ? c.json({ deleted: true }) : c.json({ error: 'not found' }, 404);
|
|
2370
3765
|
});
|
|
2371
|
-
// ----
|
|
2372
|
-
|
|
2373
|
-
|
|
2374
|
-
|
|
2375
|
-
|
|
3766
|
+
// ---- parallel variants (spec 010) -----------------------------------------
|
|
3767
|
+
const groupRuns = (store, groupId) => store
|
|
3768
|
+
.listRuns()
|
|
3769
|
+
.filter((r) => r.groupId === groupId)
|
|
3770
|
+
.sort((a, b) => (a.variant ?? '').localeCompare(b.variant ?? ''));
|
|
3771
|
+
// ---- chained family: variant groups (project-scoped) ----
|
|
3772
|
+
const groupsRoutes = new Hono()
|
|
3773
|
+
.get('/groups/:groupId', async (c) => {
|
|
3774
|
+
const { dataDir, store } = c.get('project');
|
|
3775
|
+
const runs = groupRuns(store, c.req.param('groupId'));
|
|
3776
|
+
if (runs.length === 0)
|
|
3777
|
+
return c.json({ error: 'not found' }, 404);
|
|
3778
|
+
const detailed = await Promise.all(runs.map(async (r) => ({
|
|
3779
|
+
id: r.id,
|
|
3780
|
+
variant: r.variant ?? '?',
|
|
3781
|
+
title: r.title,
|
|
3782
|
+
status: r.status,
|
|
3783
|
+
archived: r.archived,
|
|
3784
|
+
tokensUsed: r.tokensUsed,
|
|
3785
|
+
...(r.inputTokens !== undefined ? { inputTokens: r.inputTokens } : {}),
|
|
3786
|
+
...(r.outputTokens !== undefined ? { outputTokens: r.outputTokens } : {}),
|
|
3787
|
+
...(r.costUsd !== undefined ? { costUsd: r.costUsd } : {}),
|
|
3788
|
+
diffStat: r.worktreePath && existsSync(r.worktreePath)
|
|
3789
|
+
? await worktreeDiffStat(r.worktreePath, r.baseBranch ?? 'HEAD')
|
|
3790
|
+
: '',
|
|
3791
|
+
handoffExcerpt: handoffProgressExcerpt(readHandoff(dataDir, r.id)),
|
|
3792
|
+
})));
|
|
3793
|
+
return c.json({
|
|
3794
|
+
groupId: c.req.param('groupId'),
|
|
3795
|
+
runs: detailed,
|
|
3796
|
+
});
|
|
3797
|
+
})
|
|
3798
|
+
// "Pick this one": the winner rests at `review` (spec 009 takes it from
|
|
3799
|
+
// there — send back / draft PR / finish); the losers are cancelled if
|
|
3800
|
+
// alive, archived, and their worktrees + branches removed.
|
|
3801
|
+
.post('/groups/:groupId/pick', jsonZodValidator(pickSchema), async (c) => {
|
|
3802
|
+
const { root: repoRoot, dataDir, store, manager } = c.get('project');
|
|
3803
|
+
const runs = groupRuns(store, c.req.param('groupId'));
|
|
3804
|
+
if (runs.length === 0)
|
|
3805
|
+
return c.json({ error: 'not found' }, 404);
|
|
3806
|
+
const parsed = { data: c.req.valid('json') };
|
|
3807
|
+
const winner = runs.find((r) => r.id === parsed.data.runId);
|
|
3808
|
+
if (!winner)
|
|
3809
|
+
return c.json({ error: 'runId is not part of this group' }, 404);
|
|
3810
|
+
if (manager.isActive(winner.id)) {
|
|
3811
|
+
return c.json({ error: 'this variant is still active — wait for it to finish first' }, 409);
|
|
3812
|
+
}
|
|
3813
|
+
// Winner: a non-review terminal state with a non-empty diff flips to
|
|
3814
|
+
// `review` (the settleSuccess rule) — but only when the review gate applies
|
|
3815
|
+
// (#489): it is enabled (`reviewGateEnabled`, default off) AND the winner is
|
|
3816
|
+
// not autonomous. An autonomous / gate-off winner keeps its `done` state with
|
|
3817
|
+
// the diff left in the worktree; an empty diff (or no worktree) stays too.
|
|
3818
|
+
if (winner.status !== 'review' &&
|
|
3819
|
+
winner.worktreePath &&
|
|
3820
|
+
existsSync(winner.worktreePath) &&
|
|
3821
|
+
winner.autonomous !== true &&
|
|
3822
|
+
reviewGateEnabled(await loadConfig(repoRoot))) {
|
|
3823
|
+
const diff = await worktreeDiff(winner.worktreePath, winner.baseBranch ?? 'HEAD');
|
|
3824
|
+
if (diff.trim().length > 0 && !diff.startsWith('(diff failed')) {
|
|
3825
|
+
store.updateRun(winner.id, { status: 'review' });
|
|
3826
|
+
}
|
|
3827
|
+
}
|
|
3828
|
+
const losers = runs.filter((r) => r.id !== winner.id);
|
|
3829
|
+
store.appendEvent(winner.id, {
|
|
3830
|
+
type: 'lifecycle',
|
|
3831
|
+
message: `picked from ${runs.length} variants — ${losers.length} other variant(s) archived`,
|
|
3832
|
+
});
|
|
3833
|
+
appendHandoffHeartbeat(dataDir, winner.id, `picked from ${runs.length} variants`);
|
|
3834
|
+
for (const loser of losers) {
|
|
3835
|
+
if (manager.isActive(loser.id))
|
|
3836
|
+
manager.cancel(loser.id);
|
|
3837
|
+
if (loser.worktreePath)
|
|
3838
|
+
await removeWorktree(repoRoot, loser.worktreePath, loser.branch);
|
|
3839
|
+
store.updateRun(loser.id, { worktreePath: undefined, branch: undefined });
|
|
3840
|
+
store.setArchived(loser.id, true);
|
|
3841
|
+
store.appendEvent(loser.id, {
|
|
3842
|
+
type: 'lifecycle',
|
|
3843
|
+
message: `variant ${winner.variant ?? '?'} was picked — this variant is archived, its worktree removed`,
|
|
3844
|
+
});
|
|
3845
|
+
}
|
|
3846
|
+
// Spread: `getRun` may answer undefined, and an undefined VALUE is dropped by
|
|
3847
|
+
// JSON.stringify — so writing the key unconditionally typed the route as sending a key it
|
|
3848
|
+
// does not. contract/workflows.ts says `.optional()`, which is what a client receives.
|
|
3849
|
+
const picked = store.getRun(winner.id);
|
|
3850
|
+
return c.json({ ...(picked !== undefined ? { winner: picked } : {}) });
|
|
3851
|
+
});
|
|
3852
|
+
// ---- chained family: open-targets (project-scoped) ----
|
|
3853
|
+
const openTargetsRoutes = new Hono()
|
|
3854
|
+
.get('/open-targets', (c) => c.json({ targets: capabilities().localHandoff ? detectOpenTargets() : [] }))
|
|
3855
|
+
// Open the PROJECT ROOT itself (Settings → "Project folder" → Open with). The run route
|
|
3856
|
+
// above opens a task worktree and needs a run to name one; this is the repo the cockpit is
|
|
3857
|
+
// scoped to, which the scope middleware has already resolved — so no path is accepted from
|
|
3858
|
+
// the client and there is nothing to contain.
|
|
3859
|
+
.post('/open-in', jsonZodValidator(openProjectInSchema), async (c) => {
|
|
3860
|
+
const { root } = c.get('project');
|
|
3861
|
+
if (!capabilities().localHandoff) {
|
|
3862
|
+
return c.json({ error: 'local handoff is disabled — this cockpit runs in hosted mode (CEZ_REMOTE)' }, 409);
|
|
3863
|
+
}
|
|
3864
|
+
const { target } = c.req.valid('json');
|
|
3865
|
+
// Refused here rather than left to the menu, on the same principle as the accounts route:
|
|
3866
|
+
// a `cli:<runner>` handoff would START AN AGENT in the checkout everything else runs in a
|
|
3867
|
+
// worktree to protect. Which app APPLIES is a property of the route, not of one client.
|
|
3868
|
+
if (agentCliRunner(target) !== null) {
|
|
3869
|
+
return c.json({ error: 'agent CLIs open a task worktree, not the project folder' }, 400);
|
|
3870
|
+
}
|
|
3871
|
+
if (!detectOpenTargets().some((candidate) => candidate.id === target)) {
|
|
3872
|
+
return c.json({ error: `no such app on this machine: ${target}` }, 400);
|
|
3873
|
+
}
|
|
3874
|
+
const opened = await openApp(target, root);
|
|
3875
|
+
if (!opened)
|
|
3876
|
+
return c.json({ error: `could not open ${target}`, path: root }, 409);
|
|
3877
|
+
return c.json({ opened: true, path: root });
|
|
3878
|
+
});
|
|
3879
|
+
// Agent screenshots — image blocks the run manager persisted out of tool
|
|
3880
|
+
// results (persistImage). `basename` pins reads inside the run's own dir.
|
|
3881
|
+
const IMAGE_TYPES = {
|
|
3882
|
+
png: 'image/png',
|
|
3883
|
+
jpg: 'image/jpeg',
|
|
3884
|
+
webp: 'image/webp',
|
|
3885
|
+
gif: 'image/gif',
|
|
3886
|
+
};
|
|
3887
|
+
// ---- session git view (redesign R5 Step 1.2 — §"Git/session API additions").
|
|
3888
|
+
// Structured sibling of the text-blob /diff above (which stays untouched —
|
|
3889
|
+
// protected surface). Isolated runs read their worktree; worktree-off runs
|
|
3890
|
+
// read the repo checkout they executed in. Every predictable git failure
|
|
3891
|
+
// degrades to 409 + human-readable reason, 404 only for unknown ids.
|
|
3892
|
+
const worktreeOf = (run) => run.worktreePath && existsSync(run.worktreePath) ? run.worktreePath : null;
|
|
3893
|
+
const workingDirectoryOf = (run, repoRoot) => run.worktree === false
|
|
3894
|
+
? repoRoot
|
|
3895
|
+
: worktreeOf(run);
|
|
3896
|
+
const NO_WORKTREE = 'no worktree — this task ran directly in the repo working tree';
|
|
3897
|
+
// ---- chained family: worktrees (project-scoped) ----
|
|
3898
|
+
const worktreesRoutes = new Hono()
|
|
3899
|
+
.get('/worktrees', async (c) => {
|
|
2376
3900
|
const { root: repoRoot, store } = c.get('project');
|
|
2377
3901
|
// The keep-limit the panel reports is the one the enforcer will actually
|
|
2378
3902
|
// apply — inherited from the workspace default when this repo sets none.
|
|
@@ -2393,60 +3917,68 @@ export function createApp(deps) {
|
|
|
2393
3917
|
? null
|
|
2394
3918
|
: worktrees.reduce((sum, w) => sum + (w.sizeBytes ?? 0), 0);
|
|
2395
3919
|
return c.json({ worktrees, totalBytes, keep });
|
|
2396
|
-
})
|
|
2397
|
-
|
|
2398
|
-
api.post('/worktrees/reclaim', async (c) => {
|
|
3920
|
+
})
|
|
3921
|
+
.post('/worktrees/reclaim', jsonZodValidator(() => reclaimBodySchema, { absent: ({}), message: 'invalid body' }), async (c) => {
|
|
2399
3922
|
const { root: repoRoot, store } = c.get('project');
|
|
2400
|
-
//
|
|
2401
|
-
|
|
2402
|
-
if (!parsed.success)
|
|
2403
|
-
return c.json({ error: 'invalid body' }, 400);
|
|
3923
|
+
// The body is validated (an empty or `{}` one is accepted) but carries nothing this
|
|
3924
|
+
// handler reads; retention is best-effort, so 200 always.
|
|
2404
3925
|
const reclaimed = await reclaimWorktrees(repoRoot, store, await resolveWorktreeRetention(repoRoot));
|
|
2405
3926
|
return c.json({ reclaimed });
|
|
2406
3927
|
});
|
|
2407
|
-
|
|
2408
|
-
|
|
2409
|
-
|
|
2410
|
-
|
|
2411
|
-
|
|
2412
|
-
|
|
2413
|
-
|
|
2414
|
-
|
|
2415
|
-
|
|
3928
|
+
const reclaimBodySchema = z.object({}).passthrough();
|
|
3929
|
+
/**
|
|
3930
|
+
* "The inbox is on and this entry exists" — the 409/404 half of `POST /todos/:id/start`, lifted
|
|
3931
|
+
* out of the handler and in FRONT of the body validator.
|
|
3932
|
+
*
|
|
3933
|
+
* That position is the whole point. The route's contract is that an unknown id 404s before the
|
|
3934
|
+
* body is looked at, and Hono only records a body in the route type when it is validated as
|
|
3935
|
+
* MIDDLEWARE — which necessarily runs before the handler. Registering this guard first satisfies
|
|
3936
|
+
* both: the documented status order is unchanged, and `startTodoSchema` becomes visible to
|
|
3937
|
+
* `AppType` (and so to `hc`) instead of being parsed invisibly inside the handler.
|
|
3938
|
+
*
|
|
3939
|
+
* Deliberately NOT annotated with a return type: the inferred one carries the two typed
|
|
3940
|
+
* responses, which is what keeps the 409 and 404 branches in the route's schema for the client.
|
|
3941
|
+
*/
|
|
3942
|
+
const todoMustExist = async (c, next) => {
|
|
3943
|
+
if (!capabilities().followups)
|
|
3944
|
+
return c.json({ error: FOLLOWUPS_OFF }, 409);
|
|
3945
|
+
const todo = (await readTodos(c.get('project').dataDir)).find((t) => t.id === c.req.param('id'));
|
|
3946
|
+
if (!todo)
|
|
3947
|
+
return c.json({ error: 'not found' }, 404);
|
|
3948
|
+
c.set('todo', todo);
|
|
3949
|
+
await next();
|
|
3950
|
+
};
|
|
3951
|
+
// ---- chained family: follow-up inbox / todos (project-scoped) ----
|
|
3952
|
+
const todosRoutes = new Hono()
|
|
3953
|
+
.get('/todos', async (c) => c.json(capabilities().followups ? await readTodos(c.get('project').dataDir) : []))
|
|
3954
|
+
// Check off = delete the entry.
|
|
3955
|
+
.delete('/todos/:id', async (c) => {
|
|
2416
3956
|
const { dataDir } = c.get('project');
|
|
2417
3957
|
if (!capabilities().followups)
|
|
2418
3958
|
return c.json({ error: FOLLOWUPS_OFF }, 409);
|
|
2419
3959
|
const removed = await removeTodo(dataDir, c.req.param('id'));
|
|
2420
3960
|
return removed ? c.json({ removed: true }) : c.json({ error: 'not found' }, 404);
|
|
2421
|
-
})
|
|
2422
|
-
|
|
2423
|
-
|
|
2424
|
-
|
|
3961
|
+
})
|
|
3962
|
+
// "▶ Run": turn an inbox entry into a task — a one-off single-step workflow
|
|
3963
|
+
// around the suggested skill when it exists, plain quick-task otherwise.
|
|
3964
|
+
//
|
|
3965
|
+
// TWO middlewares, and their ORDER is the contract. This route's documented status order
|
|
3966
|
+
// (pinned by todos-start.test.ts) is that a disabled inbox 409s and an unknown id 404s BEFORE
|
|
3967
|
+
// the body is looked at — which is why the body used to be parsed inline, invisible to `hc`.
|
|
3968
|
+
// Hono runs route middleware in registration order, so `todoMustExist` FIRST keeps that order
|
|
3969
|
+
// exactly while `jsonZodValidator` second is what records the body in the route type.
|
|
3970
|
+
//
|
|
3971
|
+
// The two no-body cases the old inline parse distinguished are carried by the validator's
|
|
3972
|
+
// `absent`/`malformed` options: no body at all is `undefined` (the pre-#401 bodyless POST,
|
|
3973
|
+
// which the optional schema accepts → 201), a truncated payload is `null` (which it rejects
|
|
3974
|
+
// → 400, rather than passing as "no body" and silently starting a run).
|
|
3975
|
+
.post('/todos/:id/start', todoMustExist, jsonZodValidator(startTodoSchema, { absent: undefined, malformed: null }), async (c) => {
|
|
2425
3976
|
const { root: repoRoot, dataDir, manager } = c.get('project');
|
|
2426
|
-
if (!capabilities().followups)
|
|
2427
|
-
return c.json({ error: FOLLOWUPS_OFF }, 409);
|
|
2428
3977
|
const id = c.req.param('id');
|
|
2429
|
-
const todo =
|
|
2430
|
-
|
|
2431
|
-
|
|
2432
|
-
|
|
2433
|
-
// request with none at all (the pre-pills/pre-composer client) stays `undefined`, same as an
|
|
2434
|
-
// empty `{}`. A body that IS present but is not valid JSON becomes `null`, which the schema
|
|
2435
|
-
// rejects → 400 (the `.catch(() => null)` pattern every other mutating route uses); mapping
|
|
2436
|
-
// it to `undefined` too would let a broken payload pass as "no body" and silently 201.
|
|
2437
|
-
const rawBody = await c.req.text().catch(() => '');
|
|
2438
|
-
let body;
|
|
2439
|
-
if (rawBody.trim().length > 0) {
|
|
2440
|
-
try {
|
|
2441
|
-
body = JSON.parse(rawBody);
|
|
2442
|
-
}
|
|
2443
|
-
catch {
|
|
2444
|
-
body = null;
|
|
2445
|
-
}
|
|
2446
|
-
}
|
|
2447
|
-
const parsed = startTodoSchema.safeParse(body);
|
|
2448
|
-
if (!parsed.success) {
|
|
2449
|
-
return c.json({ error: parsed.error.issues.map((i) => i.message).join('; ') }, 400);
|
|
3978
|
+
const todo = c.get('todo');
|
|
3979
|
+
const parsed = { data: c.req.valid('json') };
|
|
3980
|
+
if (agentModelsLocked(repoRoot) && parsed.data?.model?.trim()) {
|
|
3981
|
+
return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
|
|
2450
3982
|
}
|
|
2451
3983
|
if (todo.startedTaskId)
|
|
2452
3984
|
return c.json({ error: 'already started' }, 409);
|
|
@@ -2476,6 +4008,10 @@ export function createApp(deps) {
|
|
|
2476
4008
|
const { workflows } = await loadWorkflows(repoRoot);
|
|
2477
4009
|
workflow = workflows.find((w) => w.name === 'quick-task') ?? QUICK_TASK_WORKFLOW;
|
|
2478
4010
|
}
|
|
4011
|
+
const fallback = parsed.data?.runner ?? (await loadConfig(repoRoot)).defaultRunner;
|
|
4012
|
+
const blocked = await providerActionError(providersRequiredByWorkflow(workflow, fallback));
|
|
4013
|
+
if (blocked)
|
|
4014
|
+
return c.json({ error: blocked }, 409);
|
|
2479
4015
|
const run = manager.startRun(workflow, {
|
|
2480
4016
|
task,
|
|
2481
4017
|
runner: parsed.data?.runner,
|
|
@@ -2483,18 +4019,31 @@ export function createApp(deps) {
|
|
|
2483
4019
|
});
|
|
2484
4020
|
await markStarted(dataDir, id, run.id);
|
|
2485
4021
|
return c.json({ run }, 201);
|
|
2486
|
-
});
|
|
2487
|
-
//
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
const {
|
|
2492
|
-
const id = c.req.param('id');
|
|
4022
|
+
});
|
|
4023
|
+
// ---- chained family: SSE streams (project-scoped) ----
|
|
4024
|
+
const sseRoutes = new Hono()
|
|
4025
|
+
.get('/runs/:id/events', paramZodValidator(runIdParamSchema), queryZodValidator(runEventsQuerySchema), async (c) => {
|
|
4026
|
+
const { store, dataDir } = c.get('project');
|
|
4027
|
+
const { id } = c.req.valid('param');
|
|
2493
4028
|
if (!store.getRun(id))
|
|
2494
4029
|
return c.json({ error: 'not found' }, 404);
|
|
4030
|
+
const query = c.req.valid('query');
|
|
4031
|
+
const eventsPath = join(dataDir, 'runs', `${id}.ndjson`);
|
|
4032
|
+
if (query.cursor) {
|
|
4033
|
+
try {
|
|
4034
|
+
await validateLiveCursor(eventsPath, query.cursor);
|
|
4035
|
+
}
|
|
4036
|
+
catch (error) {
|
|
4037
|
+
if (error instanceof HistoryCursorError)
|
|
4038
|
+
return c.json({ error: error.message }, error.status);
|
|
4039
|
+
throw error;
|
|
4040
|
+
}
|
|
4041
|
+
}
|
|
4042
|
+
const lastEventId = Number.parseInt(c.req.header('Last-Event-ID') ?? '', 10);
|
|
4043
|
+
const requestedAfter = Math.max(query.afterSeq ?? 0, Number.isSafeInteger(lastEventId) && lastEventId >= 0 ? lastEventId : 0);
|
|
2495
4044
|
return streamSSENoBuffer(c, async (stream) => {
|
|
2496
4045
|
let replaying = true;
|
|
2497
|
-
let maxSeq =
|
|
4046
|
+
let maxSeq = requestedAfter;
|
|
2498
4047
|
const buffered = [];
|
|
2499
4048
|
// One endpoint, two SSE event names: v1 lines stay `run-event` (the name
|
|
2500
4049
|
// the legacy UI listened to — its default branch JSON-dumped unknown
|
|
@@ -2504,6 +4053,7 @@ export function createApp(deps) {
|
|
|
2504
4053
|
// ride `ui-event`, which only v2-aware clients subscribe to.
|
|
2505
4054
|
// EventSource ignores names it has no listener for.
|
|
2506
4055
|
const writeEvent = (event) => stream.writeSSE({
|
|
4056
|
+
id: String(event.seq),
|
|
2507
4057
|
event: isV2WireEventType(event.type) ? 'ui-event' : 'run-event',
|
|
2508
4058
|
data: JSON.stringify(event),
|
|
2509
4059
|
});
|
|
@@ -2526,9 +4076,15 @@ export function createApp(deps) {
|
|
|
2526
4076
|
store.off('event', onEvent);
|
|
2527
4077
|
store.off('run', onRun);
|
|
2528
4078
|
});
|
|
2529
|
-
|
|
2530
|
-
|
|
4079
|
+
const replay = query.cursor
|
|
4080
|
+
? await readEventsAfterLiveCursor(eventsPath, query.cursor)
|
|
4081
|
+
: { events: store.readEvents(id), boundarySeq: 0 };
|
|
4082
|
+
maxSeq = Math.max(maxSeq, replay.boundarySeq);
|
|
4083
|
+
for (const event of replay.events) {
|
|
4084
|
+
if (event.seq <= maxSeq)
|
|
4085
|
+
continue;
|
|
2531
4086
|
await writeEvent(event);
|
|
4087
|
+
maxSeq = event.seq;
|
|
2532
4088
|
}
|
|
2533
4089
|
replaying = false;
|
|
2534
4090
|
for (const event of buffered) {
|
|
@@ -2543,13 +4099,13 @@ export function createApp(deps) {
|
|
|
2543
4099
|
await stream.sleep(15_000);
|
|
2544
4100
|
}
|
|
2545
4101
|
});
|
|
2546
|
-
})
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
4102
|
+
})
|
|
4103
|
+
// Global SSE: run-summary updates for the list view + inbox changes.
|
|
4104
|
+
// Scoped `/p/:projectId/events` carries that project's stream in today's
|
|
4105
|
+
// shape; the legacy unprefixed alias stays bound to the boot project ONLY
|
|
4106
|
+
// (spec "Legacy aliases" — widening it would be a silent behavioral break;
|
|
4107
|
+
// the all-project stream arrives as `/api/workspace/events` in step 2.8).
|
|
4108
|
+
.get('/events', (c) => {
|
|
2553
4109
|
const { dataDir, store } = c.get('project');
|
|
2554
4110
|
return streamSSENoBuffer(c, async (stream) => {
|
|
2555
4111
|
const onRun = (run) => void stream.writeSSE({ event: 'run', data: JSON.stringify(run) });
|
|
@@ -2595,22 +4151,9 @@ export function createApp(deps) {
|
|
|
2595
4151
|
}
|
|
2596
4152
|
});
|
|
2597
4153
|
});
|
|
2598
|
-
// ---- workspace SSE (
|
|
2599
|
-
|
|
2600
|
-
|
|
2601
|
-
// Same event names as the per-project stream, but every payload is stamped
|
|
2602
|
-
// with the owning `project` id — additively where the legacy payload is an
|
|
2603
|
-
// object (`run` grows a `project` key, `run-deleted` becomes
|
|
2604
|
-
// `{id, project}`), wrapped where it is not (`todos` → `{project, items}`,
|
|
2605
|
-
// `usage` → `{project, usage}` — a bare array/record has nowhere to carry a
|
|
2606
|
-
// stamp). The legacy `/api/events` alias above keeps its UN-stamped,
|
|
2607
|
-
// boot-filtered shape — that stream is a protected surface.
|
|
2608
|
-
//
|
|
2609
|
-
// Subscribing NEVER force-instantiates a project: only the boot context
|
|
2610
|
-
// (always live) and already-built lazy contexts are attached at connect;
|
|
2611
|
-
// contexts built later join via the `onContextBuilt` hook, so a project's
|
|
2612
|
-
// first API touch makes its events flow to streams already open.
|
|
2613
|
-
app.get('/api/workspace/events', (c) => {
|
|
4154
|
+
// ---- chained family: workspace SSE stream (workspace-level) ----
|
|
4155
|
+
const workspaceEventsRoutes = new Hono()
|
|
4156
|
+
.get('/workspace/events', (c) => {
|
|
2614
4157
|
return streamSSENoBuffer(c, async (stream) => {
|
|
2615
4158
|
// One detach bundle per attached project — the id guard makes a double
|
|
2616
4159
|
// attach (connect-time snapshot vs. the built hook) impossible.
|
|
@@ -2682,11 +4225,12 @@ export function createApp(deps) {
|
|
|
2682
4225
|
}
|
|
2683
4226
|
});
|
|
2684
4227
|
// Workspace-level events (project-added / project-removed /
|
|
2685
|
-
// checkout-progress
|
|
2686
|
-
// removal also drops the project's
|
|
2687
|
-
// `attach` would otherwise pin the
|
|
2688
|
-
// project removed and re-added on the
|
|
2689
|
-
// context whose events never reach this
|
|
4228
|
+
// checkout-progress plus host-wide unstamped provider-status) — relayed
|
|
4229
|
+
// verbatim under their own names. A removal also drops the project's
|
|
4230
|
+
// attach entry: the id guard in `attach` would otherwise pin the
|
|
4231
|
+
// DISPOSED context forever, so a project removed and re-added on the
|
|
4232
|
+
// same slug would rebuild a fresh context whose events never reach this
|
|
4233
|
+
// already-open stream.
|
|
2690
4234
|
const offWorkspace = workspaceEvents.on((event, data) => {
|
|
2691
4235
|
if (event === 'project-removed') {
|
|
2692
4236
|
const removed = data.id;
|
|
@@ -2711,23 +4255,25 @@ export function createApp(deps) {
|
|
|
2711
4255
|
}
|
|
2712
4256
|
});
|
|
2713
4257
|
});
|
|
2714
|
-
// ---- GitHub
|
|
2715
|
-
//
|
|
2716
|
-
//
|
|
2717
|
-
//
|
|
2718
|
-
|
|
4258
|
+
// ---- chained family: GitHub (project-scoped) ----
|
|
4259
|
+
// These sit ABOVE the routes rather than with the family's other schemas below it, because a
|
|
4260
|
+
// validator argument is evaluated when the route is REGISTERED — a schema declared further down
|
|
4261
|
+
// would be in its temporal dead zone. (The schemas below are all read inside a handler, or
|
|
4262
|
+
// passed as a thunk, which defers them past that point.)
|
|
4263
|
+
const mergeNumberParams = z.object({ number: z.coerce.number().int().positive() });
|
|
4264
|
+
const prChangesParams = z.object({ number: z.coerce.number().int().positive().safe() });
|
|
4265
|
+
const prChangesQuery = z.object({ refresh: queryValue.refine((v) => v === undefined || v === '1') });
|
|
4266
|
+
const githubRoutes = new Hono()
|
|
4267
|
+
.get('/github',
|
|
4268
|
+
// `limit` stays a bare string: the handler's `Number.parseInt`/`Number.isFinite` fallback to
|
|
4269
|
+
// 30 already accepts `?limit=banana`, and a numeric schema would 400 it instead.
|
|
4270
|
+
queryZodValidator(z.object({ limit: queryValue, refresh: queryValue })), async (c) => {
|
|
2719
4271
|
const { root: repoRoot } = c.get('project');
|
|
2720
|
-
const
|
|
2721
|
-
|
|
2722
|
-
|
|
2723
|
-
|
|
2724
|
-
|
|
2725
|
-
// same in-payload availability degrade (gh missing / offline / 404 all render as a hint).
|
|
2726
|
-
const commentsParams = z.object({
|
|
2727
|
-
kind: z.enum(['issue', 'pr']),
|
|
2728
|
-
number: z.coerce.number().int().positive(),
|
|
2729
|
-
});
|
|
2730
|
-
api.get('/github/comments/:kind/:number', async (c) => {
|
|
4272
|
+
const query = c.req.valid('query');
|
|
4273
|
+
const limit = Number.parseInt(query.limit ?? '', 10);
|
|
4274
|
+
return c.json(await fetchGithub(repoRoot, query.refresh === '1', Number.isFinite(limit) ? limit : 30));
|
|
4275
|
+
})
|
|
4276
|
+
.get('/github/comments/:kind/:number', queryZodValidator(refreshQuery), async (c) => {
|
|
2731
4277
|
const { root: repoRoot } = c.get('project');
|
|
2732
4278
|
const parsed = commentsParams.safeParse({
|
|
2733
4279
|
kind: c.req.param('kind'),
|
|
@@ -2735,10 +4281,86 @@ export function createApp(deps) {
|
|
|
2735
4281
|
});
|
|
2736
4282
|
if (!parsed.success)
|
|
2737
4283
|
return c.json({ error: 'invalid kind or number' }, 400);
|
|
2738
|
-
return c.json(await fetchGithubComments(repoRoot, parsed.data.kind, parsed.data.number, c.req.
|
|
4284
|
+
return c.json(await fetchGithubComments(repoRoot, parsed.data.kind, parsed.data.number, c.req.valid('query').refresh === '1'));
|
|
4285
|
+
})
|
|
4286
|
+
// Lazy checks glyphs for on-screen PR rows (#664). Additive sibling of /api/github — the list
|
|
4287
|
+
// call dropped `statusCheckRollup` (the dominant cost), so the glyph is hydrated here per
|
|
4288
|
+
// visible row. `prs` is a comma-separated list of positive integers, capped at GH_CHECKS_MAX;
|
|
4289
|
+
// anything malformed is a 400. Same in-payload availability degrade as the list (never a 5xx).
|
|
4290
|
+
// `prs` is the one genuinely REQUIRED query key on this server, so it is the one validated
|
|
4291
|
+
// strictly — `.min(1)` because `?prs=` answered `missing prs query` before it answered
|
|
4292
|
+
// `invalid prs query`, and both spellings must keep their own words.
|
|
4293
|
+
.get('/github/checks', queryZodValidator(z.object({ prs: z.string().min(1) }), { message: 'missing prs query' }), async (c) => {
|
|
4294
|
+
const { root: repoRoot } = c.get('project');
|
|
4295
|
+
const raw = c.req.valid('query').prs;
|
|
4296
|
+
const parts = raw.split(',').map((p) => p.trim()).filter(Boolean);
|
|
4297
|
+
if (parts.length === 0 || parts.length > GH_CHECKS_MAX)
|
|
4298
|
+
return c.json({ error: 'invalid prs query' }, 400);
|
|
4299
|
+
const numbers = [];
|
|
4300
|
+
for (const part of parts) {
|
|
4301
|
+
const n = Number(part);
|
|
4302
|
+
if (!Number.isInteger(n) || n <= 0 || String(n) !== part)
|
|
4303
|
+
return c.json({ error: 'invalid prs query' }, 400);
|
|
4304
|
+
numbers.push(n);
|
|
4305
|
+
}
|
|
4306
|
+
return c.json(await fetchGithubChecks(repoRoot, numbers));
|
|
4307
|
+
})
|
|
4308
|
+
.get('/github/prs/:number/merge-state', paramZodValidator(mergeNumberParams, { message: 'invalid pull request number' }), queryZodValidator(refreshQuery), async (c) => {
|
|
4309
|
+
const { root: repoRoot } = c.get('project');
|
|
4310
|
+
const parsed = { data: c.req.valid('param') };
|
|
4311
|
+
const forge = resolveForge(await getRepoInfo(repoRoot));
|
|
4312
|
+
if (!forge?.prMergeState)
|
|
4313
|
+
return c.json({ available: false, reason: 'GitHub merge state is unavailable' });
|
|
4314
|
+
return c.json(await forge.prMergeState(parsed.data.number, { refresh: c.req.valid('query').refresh === '1' }));
|
|
4315
|
+
})
|
|
4316
|
+
.post('/github/prs/:number/merge', paramZodValidator(mergeNumberParams, { message: 'invalid pull request number' }), jsonZodValidator(() => mergeBodySchema, { message: 'invalid merge request' }), async (c) => {
|
|
4317
|
+
const { root: repoRoot } = c.get('project');
|
|
4318
|
+
const parsedNumber = { data: c.req.valid('param') };
|
|
4319
|
+
const body = { data: c.req.valid('json') };
|
|
4320
|
+
const forge = resolveForge(await getRepoInfo(repoRoot));
|
|
4321
|
+
if (!forge?.mergePR)
|
|
4322
|
+
return c.json({ error: 'GitHub merge is unavailable' }, 409);
|
|
4323
|
+
const result = await forge.mergePR(parsedNumber.data.number, body.data);
|
|
4324
|
+
if (result.merged)
|
|
4325
|
+
return c.json(result);
|
|
4326
|
+
return c.json({
|
|
4327
|
+
error: result.error,
|
|
4328
|
+
...(result.code ? { code: result.code } : {}),
|
|
4329
|
+
...(result.current ? { current: result.current } : {}),
|
|
4330
|
+
}, result.status);
|
|
4331
|
+
})
|
|
4332
|
+
.get('/github/prs/:number/changes',
|
|
4333
|
+
// Split out of one `safeParse` over both inputs, because a path param and the query string
|
|
4334
|
+
// are separate validation targets to Hono and only a split makes each visible to the route
|
|
4335
|
+
// type. Both keep the single 400 sentence the combined parse answered. `refresh` stays
|
|
4336
|
+
// STRICT here (`?refresh=true` is a 400 today, unlike everywhere else on this server).
|
|
4337
|
+
paramZodValidator(prChangesParams, { message: 'invalid pull request number or refresh flag' }), queryZodValidator(prChangesQuery, { message: 'invalid pull request number or refresh flag' }), async (c) => {
|
|
4338
|
+
const { root: repoRoot } = c.get('project');
|
|
4339
|
+
const parsed = { data: c.req.valid('param') };
|
|
4340
|
+
try {
|
|
4341
|
+
return c.json(await fetchGithubPrDiff(repoRoot, parsed.data.number, c.req.valid('query').refresh === '1'));
|
|
4342
|
+
}
|
|
4343
|
+
catch (err) {
|
|
4344
|
+
if (err instanceof GithubPrNotFoundError)
|
|
4345
|
+
return c.json({ error: err.message }, 404);
|
|
4346
|
+
throw err;
|
|
4347
|
+
}
|
|
4348
|
+
});
|
|
4349
|
+
// The full comment thread for one issue/PR (#499). Additive sibling of /api/github — lazy
|
|
4350
|
+
// (fetched only while a detail view is open), zod-validated params, 400 on garbage, and the
|
|
4351
|
+
// same in-payload availability degrade (gh missing / offline / 404 all render as a hint).
|
|
4352
|
+
const commentsParams = z.object({
|
|
4353
|
+
kind: z.enum(['issue', 'pr']),
|
|
4354
|
+
number: z.coerce.number().int().positive(),
|
|
2739
4355
|
});
|
|
2740
|
-
|
|
2741
|
-
|
|
4356
|
+
const mergeBodySchema = z.object({
|
|
4357
|
+
method: z.enum(['merge', 'squash', 'rebase']),
|
|
4358
|
+
expectedHeadSha: z.string().regex(/^[0-9a-f]{40}$/),
|
|
4359
|
+
overrideRules: z.boolean().optional().default(false),
|
|
4360
|
+
}).strict();
|
|
4361
|
+
// ---- chained family: repo / git (project-scoped) ----
|
|
4362
|
+
const repoRoutes = new Hono()
|
|
4363
|
+
.get('/repo', async (c) => {
|
|
2742
4364
|
const { root: repoRoot } = c.get('project');
|
|
2743
4365
|
const info = await getRepoInfo(repoRoot);
|
|
2744
4366
|
if (!info)
|
|
@@ -2762,64 +4384,115 @@ export function createApp(deps) {
|
|
|
2762
4384
|
branches,
|
|
2763
4385
|
baseBranch: config.baseBranch ?? null,
|
|
2764
4386
|
});
|
|
4387
|
+
})
|
|
4388
|
+
.get('/repo/diff', async (c) => {
|
|
4389
|
+
const { root: repoRoot } = c.get('project');
|
|
4390
|
+
const info = await getRepoInfo(repoRoot);
|
|
4391
|
+
if (!info)
|
|
4392
|
+
return c.text('not a git repository');
|
|
4393
|
+
return c.text(await getDiff(info.root));
|
|
4394
|
+
})
|
|
4395
|
+
// One commit's message + stat + patch — the Repo view expands it inline.
|
|
4396
|
+
// `?structured=1` is the ADDITIVE sibling (R5 Step 1.7): the new repo view's commit-diff
|
|
4397
|
+
// shape `{sha, subject, author, when, files, stat}` with 409 + reason on failure. The
|
|
4398
|
+
// legacy text answer below is a protected surface (BACKWARD_COMPATIBILITY.md §2) — its
|
|
4399
|
+
// shape, including the in-band failure sentences, stays exactly as it was.
|
|
4400
|
+
//
|
|
4401
|
+
// `Accept: application/json` reaches the same structured answer without the flag, and
|
|
4402
|
+
// `Accept: text/plain` asks for the blob; the flag still wins whenever it is present, and a
|
|
4403
|
+
// request with no opinion still gets the blob. See `negotiate`.
|
|
4404
|
+
.get('/repo/commit/:sha', queryZodValidator(z.object({ structured: queryValue })), async (c) => {
|
|
4405
|
+
const { root: repoRoot } = c.get('project');
|
|
4406
|
+
c.header('vary', 'Accept');
|
|
4407
|
+
const { structured } = c.req.valid('query');
|
|
4408
|
+
const wantsJson = structured !== undefined
|
|
4409
|
+
? structured === '1'
|
|
4410
|
+
: negotiate(c.req.header('accept'), COMMIT_FORMATS) === 'application/json';
|
|
4411
|
+
const info = await getRepoInfo(repoRoot);
|
|
4412
|
+
if (wantsJson) {
|
|
4413
|
+
if (!info)
|
|
4414
|
+
return c.json({ error: 'not a git repository' }, 409);
|
|
4415
|
+
const result = await collectCommitChanges(info.root, c.req.param('sha'));
|
|
4416
|
+
if (!result.ok)
|
|
4417
|
+
return c.json({ error: result.error }, 409);
|
|
4418
|
+
return c.json(result.commit);
|
|
4419
|
+
}
|
|
4420
|
+
if (!info)
|
|
4421
|
+
return c.text('not a git repository');
|
|
4422
|
+
try {
|
|
4423
|
+
return c.text(await getCommit(info.root, c.req.param('sha')));
|
|
4424
|
+
}
|
|
4425
|
+
catch (err) {
|
|
4426
|
+
return c.text(`(git show failed: ${err instanceof Error ? err.message : String(err)})`);
|
|
4427
|
+
}
|
|
4428
|
+
})
|
|
4429
|
+
// Structured sibling of the text-blob /api/repo/diff above (protected
|
|
4430
|
+
// surface, untouched): the same {files, stat} shape the session /changes
|
|
4431
|
+
// route serves, here for the MAIN working tree's uncommitted changes vs
|
|
4432
|
+
// HEAD (redesign R5 Step 1.3 — §"Git/session API additions").
|
|
4433
|
+
.get('/repo/changes', async (c) => {
|
|
4434
|
+
const { root: repoRoot } = c.get('project');
|
|
4435
|
+
const info = await getRepoInfo(repoRoot);
|
|
4436
|
+
if (!info)
|
|
4437
|
+
return c.json({ error: 'not a git repository' }, 409);
|
|
4438
|
+
// The user's REAL working tree — never stage into their index (a GET must not write).
|
|
4439
|
+
const result = await collectChanges(info.root, 'HEAD', {
|
|
4440
|
+
intentToAdd: false,
|
|
4441
|
+
});
|
|
4442
|
+
if (!result.ok)
|
|
4443
|
+
return c.json({ error: result.error }, 409);
|
|
4444
|
+
return c.json(result.changes);
|
|
4445
|
+
})
|
|
4446
|
+
.post('/repo/branch', jsonZodValidator(() => repoBranchSchema), async (c) => {
|
|
4447
|
+
const { root: repoRoot } = c.get('project');
|
|
4448
|
+
const info = await getRepoInfo(repoRoot);
|
|
4449
|
+
if (!info)
|
|
4450
|
+
return c.json({ error: 'not a git repository' }, 409);
|
|
4451
|
+
const parsed = { data: c.req.valid('json') };
|
|
4452
|
+
const result = await createOrSwitchBranch(info.root, parsed.data.name, parsed.data.from);
|
|
4453
|
+
if (!result.ok)
|
|
4454
|
+
return c.json({ error: result.error }, 409);
|
|
4455
|
+
return c.json({ branch: result.branch, created: result.created });
|
|
2765
4456
|
});
|
|
2766
4457
|
// The Settings → Agents knobs in one read (R6 Step 1.5) — an ADDITIVE
|
|
2767
4458
|
// sibling of PUT /api/config below; /api/health keeps its protected shape.
|
|
2768
|
-
const configAnswer = (config) =>
|
|
2769
|
-
|
|
2770
|
-
|
|
2771
|
-
|
|
2772
|
-
|
|
2773
|
-
|
|
2774
|
-
|
|
2775
|
-
|
|
2776
|
-
|
|
2777
|
-
|
|
2778
|
-
|
|
2779
|
-
|
|
2780
|
-
|
|
2781
|
-
|
|
2782
|
-
|
|
2783
|
-
|
|
2784
|
-
|
|
2785
|
-
|
|
2786
|
-
|
|
2787
|
-
|
|
2788
|
-
|
|
2789
|
-
|
|
2790
|
-
|
|
2791
|
-
|
|
2792
|
-
|
|
2793
|
-
|
|
2794
|
-
|
|
2795
|
-
|
|
2796
|
-
|
|
2797
|
-
|
|
2798
|
-
|
|
2799
|
-
|
|
2800
|
-
|
|
2801
|
-
})
|
|
2802
|
-
.optional(),
|
|
2803
|
-
// Concurrency + memory guard (Settings → Resources). maxParallel clamps to
|
|
2804
|
-
// the schema's 1–16; memoryLimitMb null/0 clears the ceiling.
|
|
2805
|
-
maxParallel: z.number().int().min(1).max(16).optional(),
|
|
2806
|
-
memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
|
|
2807
|
-
// Worktree retention count (Settings → Resources, #483). 0 = unlimited;
|
|
2808
|
-
// null clears the key back to the schema default (10). Unlike memoryLimitMb,
|
|
2809
|
-
// 0 is a meaningful value (unlimited), so it is stored, not treated as clear.
|
|
2810
|
-
worktreeRetention: z.number().int().min(0).max(1000).nullable().optional(),
|
|
2811
|
-
// Live title updates toggle (Settings → Agents): null clears the key back
|
|
2812
|
-
// to the env-default behavior.
|
|
2813
|
-
liveTitleUpdates: z.boolean().nullable().optional(),
|
|
2814
|
-
// Optional review gate toggle (Settings → Agents, #489): null clears the key
|
|
2815
|
-
// back to the env-default behavior (OFF).
|
|
2816
|
-
reviewGate: z.boolean().nullable().optional(),
|
|
2817
|
-
});
|
|
2818
|
-
api.put('/config', async (c) => {
|
|
4459
|
+
const configAnswer = async (repoRoot, config) => {
|
|
4460
|
+
const nativeModels = await readAgentModelDefaults(repoRoot);
|
|
4461
|
+
const modelsLocked = agentModelsLocked(repoRoot);
|
|
4462
|
+
return {
|
|
4463
|
+
baseBranch: config.baseBranch ?? null,
|
|
4464
|
+
defaultRunner: config.defaultRunner,
|
|
4465
|
+
systemPrompt: config.systemPrompt ?? null,
|
|
4466
|
+
// Native defaults seed each runner independently. A Cezar preset remains
|
|
4467
|
+
// selectable unless the operator opts into the fixed-model policy.
|
|
4468
|
+
defaultModels: modelsLocked
|
|
4469
|
+
? nativeModels
|
|
4470
|
+
: { ...nativeModels, ...(config.defaultModels ?? {}) },
|
|
4471
|
+
modelsLocked,
|
|
4472
|
+
maxParallel: config.maxParallel,
|
|
4473
|
+
memoryLimitMb: config.memoryLimitMb ?? null,
|
|
4474
|
+
// Count-based worktree retention (#483): keep the last N finished worktrees
|
|
4475
|
+
// on disk. 0 = unlimited. Always materialized (schema default 10).
|
|
4476
|
+
worktreeRetention: config.worktreeRetention,
|
|
4477
|
+
// Live title updates (task auto-naming spec): tri-state — null means "no
|
|
4478
|
+
// config key, the CEZ_TITLE_UPDATES env default (ON) decides".
|
|
4479
|
+
liveTitleUpdates: config.liveTitleUpdates ?? null,
|
|
4480
|
+
// Optional review gate (#489): tri-state — null means "no config key, the
|
|
4481
|
+
// CEZ_REVIEW_GATE env default (OFF) decides".
|
|
4482
|
+
reviewGate: config.reviewGate ?? null,
|
|
4483
|
+
};
|
|
4484
|
+
};
|
|
4485
|
+
// ---- chained family: per-repo config (project-scoped) ----
|
|
4486
|
+
const configRoutes = new Hono()
|
|
4487
|
+
.get('/config', async (c) => {
|
|
4488
|
+
const repoRoot = c.get('project').root;
|
|
4489
|
+
return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
|
|
4490
|
+
})
|
|
4491
|
+
.put('/config', jsonZodValidator(() => setConfigSchema), async (c) => {
|
|
2819
4492
|
const { root: repoRoot, dataDir } = c.get('project');
|
|
2820
|
-
const parsed =
|
|
2821
|
-
if (
|
|
2822
|
-
return c.json({ error:
|
|
4493
|
+
const parsed = { data: c.req.valid('json') };
|
|
4494
|
+
if (agentModelsLocked(repoRoot) && parsed.data.defaultModels !== undefined) {
|
|
4495
|
+
return c.json({ error: AGENT_MODELS_LOCKED_ERROR }, 409);
|
|
2823
4496
|
}
|
|
2824
4497
|
const configPath = join(dataDir, 'config.json');
|
|
2825
4498
|
let raw = {};
|
|
@@ -2905,16 +4578,56 @@ export function createApp(deps) {
|
|
|
2905
4578
|
return c.json({ error: err instanceof Error ? err.message : String(err) }, 500);
|
|
2906
4579
|
}
|
|
2907
4580
|
// Pre-R6 answer shape ({baseBranch, defaultRunner}) + additive R6 fields.
|
|
2908
|
-
return c.json(configAnswer(await loadConfig(repoRoot)));
|
|
4581
|
+
return c.json(await configAnswer(repoRoot, await loadConfig(repoRoot)));
|
|
4582
|
+
});
|
|
4583
|
+
// Set/clear the agents' config knobs (Settings → Agents; the Repo tab's
|
|
4584
|
+
// base-branch picker). Merges into the RAW config.json so user keys
|
|
4585
|
+
// (skillsRepos…) survive and schema defaults are never materialized into
|
|
4586
|
+
// the file. All fields optional + additive: `null` (and `''` for the
|
|
4587
|
+
// R6 keys) clears a knob back to its default.
|
|
4588
|
+
const modelPresetSchema = z.string().trim().max(200).nullable().optional();
|
|
4589
|
+
const setConfigSchema = z.object({
|
|
4590
|
+
baseBranch: z.string().trim().min(1).max(200).nullable().optional(),
|
|
4591
|
+
defaultRunner: z.enum(RUNNER_IDS).optional(),
|
|
4592
|
+
systemPrompt: z.string().trim().max(20_000, 'must be at most 20000 characters').nullable().optional(),
|
|
4593
|
+
defaultModels: z
|
|
4594
|
+
.object({
|
|
4595
|
+
claude: modelPresetSchema,
|
|
4596
|
+
codex: modelPresetSchema,
|
|
4597
|
+
opencode: modelPresetSchema,
|
|
4598
|
+
pi: modelPresetSchema,
|
|
4599
|
+
})
|
|
4600
|
+
.optional(),
|
|
4601
|
+
// Concurrency + memory guard (Settings → Resources). maxParallel clamps to
|
|
4602
|
+
// the schema's 1–16; memoryLimitMb null/0 clears the ceiling.
|
|
4603
|
+
maxParallel: z.number().int().min(1).max(16).optional(),
|
|
4604
|
+
memoryLimitMb: z.number().int().min(0).max(1_048_576).nullable().optional(),
|
|
4605
|
+
// Worktree retention count (Settings → Resources, #483). 0 = unlimited;
|
|
4606
|
+
// null clears the key back to the schema default (10). Unlike memoryLimitMb,
|
|
4607
|
+
// 0 is a meaningful value (unlimited), so it is stored, not treated as clear.
|
|
4608
|
+
worktreeRetention: z.number().int().min(0).max(1000).nullable().optional(),
|
|
4609
|
+
// Live title updates toggle (Settings → Agents): null clears the key back
|
|
4610
|
+
// to the env-default behavior.
|
|
4611
|
+
liveTitleUpdates: z.boolean().nullable().optional(),
|
|
4612
|
+
// Optional review gate toggle (Settings → Agents, #489): null clears the key
|
|
4613
|
+
// back to the env-default behavior (OFF).
|
|
4614
|
+
reviewGate: z.boolean().nullable().optional(),
|
|
4615
|
+
});
|
|
4616
|
+
const setAgentConfigSchema = z.object({
|
|
4617
|
+
content: z.string().max(2_000_000),
|
|
4618
|
+
version: z.string().nullable(),
|
|
2909
4619
|
});
|
|
4620
|
+
// ---- chained family: agent-config (project-scoped) -----------------------
|
|
2910
4621
|
// Agent config is project-scoped (spec #404, adapted to the multi-project
|
|
2911
4622
|
// route seam from #521): handlers resolve the selected repo through the
|
|
2912
|
-
// same ProjectContext as every other mirrored route.
|
|
2913
|
-
|
|
4623
|
+
// same ProjectContext as every other mirrored route. Chained (not statements)
|
|
4624
|
+
// so `AppType` carries it — see `healthRoutes`.
|
|
4625
|
+
const agentConfigRoutes = new Hono()
|
|
4626
|
+
.get('/agent-config', async (c) => {
|
|
2914
4627
|
const editable = capabilities().localHandoff;
|
|
2915
4628
|
return c.json(await listAgentConfig(c.get('project').root, process.env, editable));
|
|
2916
|
-
})
|
|
2917
|
-
|
|
4629
|
+
})
|
|
4630
|
+
.get('/agent-config/:id', async (c) => {
|
|
2918
4631
|
const id = c.req.param('id');
|
|
2919
4632
|
const def = findConfigFile(id);
|
|
2920
4633
|
if (!def)
|
|
@@ -2930,12 +4643,8 @@ export function createApp(deps) {
|
|
|
2930
4643
|
if ('error' in read)
|
|
2931
4644
|
return c.json({ error: read.error }, 500);
|
|
2932
4645
|
return c.json(read);
|
|
2933
|
-
})
|
|
2934
|
-
|
|
2935
|
-
content: z.string().max(2_000_000),
|
|
2936
|
-
version: z.string().nullable(),
|
|
2937
|
-
});
|
|
2938
|
-
api.put('/agent-config/:id', async (c) => {
|
|
4646
|
+
})
|
|
4647
|
+
.put('/agent-config/:id', jsonZodValidator(setAgentConfigSchema), async (c) => {
|
|
2939
4648
|
// Config files may define hooks and MCP commands, so writes remain a
|
|
2940
4649
|
// local-machine capability and are re-gated on every request.
|
|
2941
4650
|
if (!capabilities().localHandoff) {
|
|
@@ -2943,10 +4652,7 @@ export function createApp(deps) {
|
|
|
2943
4652
|
error: 'editing agent config is disabled in hosted mode (CEZ_REMOTE) — edit it from the machine that owns the checkout',
|
|
2944
4653
|
}, 409);
|
|
2945
4654
|
}
|
|
2946
|
-
const parsed =
|
|
2947
|
-
if (!parsed.success) {
|
|
2948
|
-
return c.json({ error: parsed.error.issues.map((issue) => issue.message).join('; ') }, 400);
|
|
2949
|
-
}
|
|
4655
|
+
const parsed = { data: c.req.valid('json') };
|
|
2950
4656
|
const out = await writeConfigFile(c.req.param('id'), parsed.data.content, parsed.data.version, c.get('project').root);
|
|
2951
4657
|
if (out === null)
|
|
2952
4658
|
return c.json({ error: 'unknown config file' }, 404);
|
|
@@ -2954,55 +4660,6 @@ export function createApp(deps) {
|
|
|
2954
4660
|
return c.json({ error: out.error }, out.status);
|
|
2955
4661
|
return c.json(out.read);
|
|
2956
4662
|
});
|
|
2957
|
-
api.get('/repo/diff', async (c) => {
|
|
2958
|
-
const { root: repoRoot } = c.get('project');
|
|
2959
|
-
const info = await getRepoInfo(repoRoot);
|
|
2960
|
-
if (!info)
|
|
2961
|
-
return c.text('not a git repository');
|
|
2962
|
-
return c.text(await getDiff(info.root));
|
|
2963
|
-
});
|
|
2964
|
-
// One commit's message + stat + patch — the Repo view expands it inline.
|
|
2965
|
-
// `?structured=1` is the ADDITIVE sibling (R5 Step 1.7): the new repo view's commit-diff
|
|
2966
|
-
// shape `{sha, subject, author, when, files, stat}` with 409 + reason on failure. The
|
|
2967
|
-
// legacy text answer below is a protected surface (BACKWARD_COMPATIBILITY.md §2) — its
|
|
2968
|
-
// shape, including the in-band failure sentences, stays exactly as it was.
|
|
2969
|
-
api.get('/repo/commit/:sha', async (c) => {
|
|
2970
|
-
const { root: repoRoot } = c.get('project');
|
|
2971
|
-
const info = await getRepoInfo(repoRoot);
|
|
2972
|
-
if (c.req.query('structured') === '1') {
|
|
2973
|
-
if (!info)
|
|
2974
|
-
return c.json({ error: 'not a git repository' }, 409);
|
|
2975
|
-
const result = await collectCommitChanges(info.root, c.req.param('sha'));
|
|
2976
|
-
if (!result.ok)
|
|
2977
|
-
return c.json({ error: result.error }, 409);
|
|
2978
|
-
return c.json(result.commit);
|
|
2979
|
-
}
|
|
2980
|
-
if (!info)
|
|
2981
|
-
return c.text('not a git repository');
|
|
2982
|
-
try {
|
|
2983
|
-
return c.text(await getCommit(info.root, c.req.param('sha')));
|
|
2984
|
-
}
|
|
2985
|
-
catch (err) {
|
|
2986
|
-
return c.text(`(git show failed: ${err instanceof Error ? err.message : String(err)})`);
|
|
2987
|
-
}
|
|
2988
|
-
});
|
|
2989
|
-
// Structured sibling of the text-blob /api/repo/diff above (protected
|
|
2990
|
-
// surface, untouched): the same {files, stat} shape the session /changes
|
|
2991
|
-
// route serves, here for the MAIN working tree's uncommitted changes vs
|
|
2992
|
-
// HEAD (redesign R5 Step 1.3 — §"Git/session API additions").
|
|
2993
|
-
api.get('/repo/changes', async (c) => {
|
|
2994
|
-
const { root: repoRoot } = c.get('project');
|
|
2995
|
-
const info = await getRepoInfo(repoRoot);
|
|
2996
|
-
if (!info)
|
|
2997
|
-
return c.json({ error: 'not a git repository' }, 409);
|
|
2998
|
-
// The user's REAL working tree — never stage into their index (a GET must not write).
|
|
2999
|
-
const result = await collectChanges(info.root, 'HEAD', {
|
|
3000
|
-
intentToAdd: false,
|
|
3001
|
-
});
|
|
3002
|
-
if (!result.ok)
|
|
3003
|
-
return c.json({ error: result.error }, 409);
|
|
3004
|
-
return c.json(result.changes);
|
|
3005
|
-
});
|
|
3006
4663
|
// Repo view branch actions: switch to an existing branch, or create one
|
|
3007
4664
|
// (from `from` or HEAD) and switch. Predictable git failures — invalid
|
|
3008
4665
|
// name, unknown `from`, dirty-tree checkout conflict — are 409 + reason.
|
|
@@ -3010,28 +4667,165 @@ export function createApp(deps) {
|
|
|
3010
4667
|
name: z.string().trim().min(1).max(200),
|
|
3011
4668
|
from: z.string().trim().min(1).max(200).optional(),
|
|
3012
4669
|
});
|
|
3013
|
-
|
|
3014
|
-
|
|
3015
|
-
|
|
3016
|
-
|
|
3017
|
-
|
|
3018
|
-
|
|
3019
|
-
|
|
3020
|
-
|
|
4670
|
+
// ---- assemble the chained families --------------------------------------
|
|
4671
|
+
// Every chained family is registered ONCE and mounted into the versioned table. There is no
|
|
4672
|
+
// second, unversioned spelling: `/api/*` was removed once the whole API was reachable under
|
|
4673
|
+
// `/api/v1` (BACKWARD_COMPATIBILITY.md §2). One surface means one thing to keep working, and
|
|
4674
|
+
// it is the one the typed client describes.
|
|
4675
|
+
//
|
|
4676
|
+
// MOUNT ORDER IS REGISTRATION ORDER. Hono matches in the order routes were added, so each
|
|
4677
|
+
// family keeps its internal order and the families keep theirs relative to each other.
|
|
4678
|
+
//
|
|
4679
|
+
// Written as ONE chained expression because that is the only shape Hono can infer route types
|
|
4680
|
+
// from — it is what puts these routes in `AppType`, and so in the typed client.
|
|
4681
|
+
const v1 = new Hono()
|
|
4682
|
+
.use('*', resolveProjectScope)
|
|
4683
|
+
.route('/', launchKeyRoutes)
|
|
4684
|
+
.route('/', skillsRoutes)
|
|
4685
|
+
.route('/', uiStateRoutes)
|
|
4686
|
+
.route('/', workflowsRoutes)
|
|
4687
|
+
.route('/', planRoutes)
|
|
4688
|
+
.route('/', automationsRoutes)
|
|
4689
|
+
.route('/', runsRoutes)
|
|
4690
|
+
.route('/', groupsRoutes)
|
|
4691
|
+
.route('/', openTargetsRoutes)
|
|
4692
|
+
.route('/', worktreesRoutes)
|
|
4693
|
+
.route('/', todosRoutes)
|
|
4694
|
+
.route('/', sseRoutes)
|
|
4695
|
+
.route('/', githubRoutes)
|
|
4696
|
+
.route('/', repoRoutes)
|
|
4697
|
+
.route('/', configRoutes)
|
|
4698
|
+
.route('/', agentConfigRoutes);
|
|
4699
|
+
// ---- chained family: the cross-project run index (workspace-level) -------
|
|
4700
|
+
/**
|
|
4701
|
+
* How many runs each project may contribute, newest first. The index is a FINDER, not a
|
|
4702
|
+
* listing: past the newest couple of hundred per project you are looking for something the
|
|
4703
|
+
* project's own Tasks table answers better, and every extra row is a DOM node the palette's
|
|
4704
|
+
* filter walks on each keystroke. `truncated` names the projects this bit, so a consumer never
|
|
4705
|
+
* has to pretend the list is complete.
|
|
4706
|
+
*/
|
|
4707
|
+
const RUNS_INDEX_PER_PROJECT = 200;
|
|
4708
|
+
/** `RunRecord` → the wire row. Optional keys are spread CONDITIONALLY: writing
|
|
4709
|
+
* `titleSummary: run.titleSummary` types a key as always-present that `JSON.stringify` then
|
|
4710
|
+
* drops when it is undefined, which is exactly the drift the parity guard fails on. */
|
|
4711
|
+
const runIndexEntry = (projectId, run) => {
|
|
4712
|
+
const usage = currentUsage(run.id);
|
|
4713
|
+
return {
|
|
4714
|
+
projectId,
|
|
4715
|
+
id: run.id,
|
|
4716
|
+
title: run.title,
|
|
4717
|
+
...(run.titleSummary !== undefined ? { titleSummary: run.titleSummary } : {}),
|
|
4718
|
+
...(run.titleOrigin !== undefined ? { titleOrigin: run.titleOrigin } : {}),
|
|
4719
|
+
status: run.status,
|
|
4720
|
+
...(run.activity !== undefined ? { activity: run.activity } : {}),
|
|
4721
|
+
createdAt: run.createdAt,
|
|
4722
|
+
...(run.finishedAt !== undefined ? { finishedAt: run.finishedAt } : {}),
|
|
4723
|
+
...(run.seenAt !== undefined ? { seenAt: run.seenAt } : {}),
|
|
4724
|
+
archived: run.archived,
|
|
4725
|
+
...(run.autoResumeAt !== undefined ? { autoResumeAt: run.autoResumeAt } : {}),
|
|
4726
|
+
workflow: run.workflow,
|
|
4727
|
+
...(run.branch !== undefined ? { branch: run.branch } : {}),
|
|
4728
|
+
...(run.startedAt !== undefined ? { startedAt: run.startedAt } : {}),
|
|
4729
|
+
// The tracker-reference inputs, verbatim — the cockpit's `taskReference()` owns the rule
|
|
4730
|
+
// that picks between them (see the schema's note).
|
|
4731
|
+
...(run.pullRequestUrl !== undefined ? { pullRequestUrl: run.pullRequestUrl } : {}),
|
|
4732
|
+
...(run.referencedPullRequestUrl !== undefined
|
|
4733
|
+
? { referencedPullRequestUrl: run.referencedPullRequestUrl }
|
|
4734
|
+
: {}),
|
|
4735
|
+
...(run.prNumber !== undefined ? { prNumber: run.prNumber } : {}),
|
|
4736
|
+
...(run.issueNumber !== undefined ? { issueNumber: run.issueNumber } : {}),
|
|
4737
|
+
...(run.referencedIssueUrl !== undefined ? { referencedIssueUrl: run.referencedIssueUrl } : {}),
|
|
4738
|
+
...(run.markerRefs !== undefined ? { markerRefs: run.markerRefs } : {}),
|
|
4739
|
+
...(run.costUsd !== undefined ? { costUsd: run.costUsd } : {}),
|
|
4740
|
+
...(run.peakRssBytes !== undefined ? { peakRssBytes: run.peakRssBytes } : {}),
|
|
4741
|
+
...(run.peakProcCount !== undefined ? { peakProcCount: run.peakProcCount } : {}),
|
|
4742
|
+
// The live sample, on the same terms as `GET /runs`: process-wide sampler, so a
|
|
4743
|
+
// workspace-level answer can carry it for every project's runs at once.
|
|
4744
|
+
...(usage ? { usage } : {}),
|
|
4745
|
+
};
|
|
4746
|
+
};
|
|
4747
|
+
/**
|
|
4748
|
+
* `GET /workspace/runs-index` — every registered project's recent tasks in one slim answer, so
|
|
4749
|
+
* ⌘K can find a task without knowing which project it lives in.
|
|
4750
|
+
*
|
|
4751
|
+
* Workspace-level and single-mount for the obvious reason: a project-scoped spelling of "all
|
|
4752
|
+
* projects" is a contradiction. The per-project source is chosen the same way the automation
|
|
4753
|
+
* coordinator's boot fan-out chooses it (`bootContext` for the boot project, `contexts.peek`
|
|
4754
|
+
* for anything this process already owns, disk otherwise) — and never `contexts.context()`,
|
|
4755
|
+
* which would build a context, prune worktrees and `recover()` running agents. Typing in a
|
|
4756
|
+
* search box must not resume work; see `runs/run-index.ts`.
|
|
4757
|
+
*/
|
|
4758
|
+
const runsIndexRoutes = new Hono()
|
|
4759
|
+
.get('/workspace/runs-index', async (c) => {
|
|
4760
|
+
let projects = [];
|
|
4761
|
+
try {
|
|
4762
|
+
const selector = capabilities().singleProject
|
|
4763
|
+
? { projectId: await resolveBootProject() }
|
|
4764
|
+
: undefined;
|
|
4765
|
+
projects = await listProjects(selector);
|
|
3021
4766
|
}
|
|
3022
|
-
|
|
3023
|
-
|
|
3024
|
-
|
|
3025
|
-
|
|
4767
|
+
catch {
|
|
4768
|
+
// unreadable workspace — an empty index, never a 500. The palette degrades to the
|
|
4769
|
+
// active project's own run list, which it holds either way.
|
|
4770
|
+
}
|
|
4771
|
+
const bootId = await resolveBootProject(projects);
|
|
4772
|
+
const runs = [];
|
|
4773
|
+
const truncated = [];
|
|
4774
|
+
for (const project of projects) {
|
|
4775
|
+
// No folder, no runs to read. `not-git` still has an `.ai/cezar` worth indexing.
|
|
4776
|
+
if (project.status === 'missing')
|
|
4777
|
+
continue;
|
|
4778
|
+
const owned = project.id === bootId ? bootContext : contexts.peek(project.id);
|
|
4779
|
+
// `listRuns()` already sorts newest-first; the disk reader returns file order, so both
|
|
4780
|
+
// paths get sorted below rather than trusting either.
|
|
4781
|
+
//
|
|
4782
|
+
// Archived runs are INCLUDED. The active project's rows reach the palette through
|
|
4783
|
+
// `GET /runs`, which has always carried them, and excluding them here would mean a task
|
|
4784
|
+
// is findable while you stand in its project and vanishes the moment you leave — the
|
|
4785
|
+
// exact asymmetry a cross-project finder exists to remove.
|
|
4786
|
+
const recent = (owned ? owned.store.listRuns() : readRunIndexFromDisk(join(project.root, '.ai/cezar'))).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
4787
|
+
if (recent.length > RUNS_INDEX_PER_PROJECT)
|
|
4788
|
+
truncated.push(project.id);
|
|
4789
|
+
for (const run of recent.slice(0, RUNS_INDEX_PER_PROJECT)) {
|
|
4790
|
+
runs.push(runIndexEntry(project.id, run));
|
|
4791
|
+
}
|
|
4792
|
+
}
|
|
4793
|
+
runs.sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
4794
|
+
const body = {
|
|
4795
|
+
runs,
|
|
4796
|
+
perProjectLimit: RUNS_INDEX_PER_PROJECT,
|
|
4797
|
+
truncated,
|
|
4798
|
+
};
|
|
4799
|
+
return c.json(body);
|
|
3026
4800
|
});
|
|
3027
|
-
//
|
|
3028
|
-
//
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
4801
|
+
// Workspace-level families answer for the whole workspace, so they are single-mount: never a
|
|
4802
|
+
// project-scoped spelling, which would be a second surface to protect with no consumer.
|
|
4803
|
+
const workspaceV1 = new Hono()
|
|
4804
|
+
.route('/', healthRoutes)
|
|
4805
|
+
.route('/', modelsRoutes)
|
|
4806
|
+
.route('/', providersRoutes)
|
|
4807
|
+
.route('/', projectsRoutes)
|
|
4808
|
+
.route('/', agentProfilesRoutes)
|
|
4809
|
+
.route('/', skillsUpdateRoutes)
|
|
4810
|
+
.route('/', workspaceConfigRoutes)
|
|
4811
|
+
.route('/', fsBrowseRoutes)
|
|
4812
|
+
.route('/', automationChecksRoutes)
|
|
4813
|
+
.route('/', runsIndexRoutes)
|
|
4814
|
+
.route('/', workspaceEventsRoutes);
|
|
4815
|
+
// ---- mount ---------------------------------------------------------------
|
|
4816
|
+
// Scoped first, then the unscoped alias bound to the boot project. The paths are disjoint (no
|
|
4817
|
+
// route starts with `/p/`), so order between the two never decides a match — but the SPA
|
|
4818
|
+
// catch-all below must still come last. `route()` re-registers the sub-app's routes under
|
|
4819
|
+
// each prefix, handlers shared, internal order preserved.
|
|
4820
|
+
//
|
|
4821
|
+
// Workspace families mount LAST and that is load-bearing: mounting the project table also
|
|
4822
|
+
// mounts its `use('*')` scope resolver over the whole prefix, and Hono runs matched middleware
|
|
4823
|
+
// in registration order. `/health` in particular answers for the workspace, has no project to
|
|
4824
|
+
// resolve, and is the CORS-open discovery route — it must not sit behind the resolver.
|
|
4825
|
+
const routed = app
|
|
4826
|
+
.route(V1_SCOPED_PREFIX, v1)
|
|
4827
|
+
.route(V1_PREFIX, v1)
|
|
4828
|
+
.route(V1_PREFIX, workspaceV1);
|
|
3035
4829
|
// ---- SPA catch-all -------------------------------------------------------
|
|
3036
4830
|
// Last, so every route above still wins. Any other GET gets the cockpit shell:
|
|
3037
4831
|
// react-router owns the route map, including the 404, so `/tasks/:id/changes`
|
|
@@ -3040,14 +4834,38 @@ export function createApp(deps) {
|
|
|
3040
4834
|
// an unknown API path must never answer with HTML.
|
|
3041
4835
|
// Without a web/dist build this serves the built-in build-hint page (dev-only
|
|
3042
4836
|
// state — the published tarball ships web/dist), never a 404.
|
|
3043
|
-
|
|
3044
|
-
return
|
|
4837
|
+
routed.get('*', (c) => serveShell(c) ?? c.notFound());
|
|
4838
|
+
return routed;
|
|
3045
4839
|
}
|
|
3046
4840
|
export function startServer(deps, port) {
|
|
4841
|
+
const workspaceEvents = deps.workspaceEvents ?? new WorkspaceEventBus();
|
|
4842
|
+
const skillsUpdate = deps.skillsUpdate ?? new SkillsUpdateService({ invalidateCatalog: refreshTeamSkills });
|
|
3047
4843
|
// The subscription hub rides the same HTTP server (one port, zero config):
|
|
3048
4844
|
// createApp registers the topics, the `upgrade` hook below owns the socket.
|
|
3049
4845
|
const socketHub = deps.socketHub ?? createSocketHub();
|
|
3050
|
-
const
|
|
4846
|
+
const automationCoordinator = new AutomationCoordinator({ listProjects });
|
|
4847
|
+
const bootProjectId = deps.bootProjectId ?? 'default';
|
|
4848
|
+
const bootAutomationStore = automationCoordinator.store(bootProjectId, deps.repoRoot);
|
|
4849
|
+
const sharedContexts = deps.contexts ?? new ProjectContexts({
|
|
4850
|
+
listProjects,
|
|
4851
|
+
semaphore: deps.semaphore,
|
|
4852
|
+
automationStore: (projectId, root) => automationCoordinator.store(projectId, root),
|
|
4853
|
+
});
|
|
4854
|
+
// #801: GitHub automations are opt-in. Off, the flag must remove the BEHAVIOR and not merely
|
|
4855
|
+
// the UI — no scheduler, no GitHub polling, no launched runs — so every entry point into the
|
|
4856
|
+
// workspace scheduler below is gated on it. Read per call rather than captured, for the same
|
|
4857
|
+
// reason `capabilities()` is inside `createApp`: tests flip the variable between apps.
|
|
4858
|
+
const automationsEnabled = () => resolveCapabilities(process.env, deps.bindHost).automations;
|
|
4859
|
+
let rescheduleAutomations = () => { };
|
|
4860
|
+
const app = createApp({
|
|
4861
|
+
...deps,
|
|
4862
|
+
contexts: sharedContexts,
|
|
4863
|
+
automationStore: bootAutomationStore,
|
|
4864
|
+
workspaceEvents,
|
|
4865
|
+
skillsUpdate,
|
|
4866
|
+
socketHub,
|
|
4867
|
+
automationsChanged: () => rescheduleAutomations(),
|
|
4868
|
+
});
|
|
3051
4869
|
// SECURITY: default to loopback. This server executes agents locally and its endpoints are
|
|
3052
4870
|
// same-origin-trusted (only /api/health is CORS-open); binding to a non-loopback host would
|
|
3053
4871
|
// expose an agent-executing box to the network. `bindHost` exists only for a deliberate
|
|
@@ -3058,13 +4876,101 @@ export function startServer(deps, port) {
|
|
|
3058
4876
|
port,
|
|
3059
4877
|
hostname: deps.bindHost ?? '127.0.0.1',
|
|
3060
4878
|
});
|
|
4879
|
+
const coordinator = new SkillsUpdateCoordinator(skillsUpdate, async () => effectiveSkillsAutoUpdate(await loadWorkspaceConfig()));
|
|
4880
|
+
const automationProjects = new Map();
|
|
4881
|
+
const automationScheduler = new WorkspaceAutomationScheduler({
|
|
4882
|
+
coordinator: automationCoordinator,
|
|
4883
|
+
handle: (projectId, store) => {
|
|
4884
|
+
const project = automationProjects.get(projectId);
|
|
4885
|
+
if (!project)
|
|
4886
|
+
return undefined;
|
|
4887
|
+
return {
|
|
4888
|
+
projectId,
|
|
4889
|
+
owner: project.owner,
|
|
4890
|
+
repo: project.repo,
|
|
4891
|
+
store,
|
|
4892
|
+
poller: new GithubPoller(),
|
|
4893
|
+
onChange: (automationId, revision) => workspaceEvents.emit('automation-change', { project: projectId, automationId, revision }),
|
|
4894
|
+
launch: async (definition, candidate, receiptId) => {
|
|
4895
|
+
const bootId = deps.bootProjectId ?? 'default';
|
|
4896
|
+
const context = projectId === bootId
|
|
4897
|
+
? { root: deps.repoRoot, manager: deps.manager, store: deps.store }
|
|
4898
|
+
: await sharedContexts.context(projectId);
|
|
4899
|
+
return launchAutomationRun({
|
|
4900
|
+
root: context.root,
|
|
4901
|
+
manager: context.manager,
|
|
4902
|
+
store: context.store,
|
|
4903
|
+
definition,
|
|
4904
|
+
candidate,
|
|
4905
|
+
receiptId,
|
|
4906
|
+
});
|
|
4907
|
+
},
|
|
4908
|
+
};
|
|
4909
|
+
},
|
|
4910
|
+
});
|
|
4911
|
+
// The scheduler is inert until `start()` anyway (it constructs `stopped`), but the gate is
|
|
4912
|
+
// stated here rather than inherited from that detail: a definition saved while the flag is off
|
|
4913
|
+
// must not even ask the coordinator to refresh.
|
|
4914
|
+
rescheduleAutomations = () => {
|
|
4915
|
+
if (!automationsEnabled())
|
|
4916
|
+
return;
|
|
4917
|
+
void automationScheduler.reschedule();
|
|
4918
|
+
};
|
|
4919
|
+
const unsubscribe = workspaceEvents.on((event, data) => {
|
|
4920
|
+
if (event === 'project-added') {
|
|
4921
|
+
const project = data.project;
|
|
4922
|
+
if (project && typeof project.id === 'string' && typeof project.root === 'string' && project.status !== 'missing') {
|
|
4923
|
+
coordinator.add(project.id, project.root);
|
|
4924
|
+
void getRepoInfo(project.root).then((info) => {
|
|
4925
|
+
const parsed = parseRemote(info?.remote ?? '');
|
|
4926
|
+
if (parsed?.host === 'github.com')
|
|
4927
|
+
automationProjects.set(project.id, { root: project.root, owner: parsed.owner, repo: parsed.repo });
|
|
4928
|
+
return rescheduleAutomations();
|
|
4929
|
+
});
|
|
4930
|
+
}
|
|
4931
|
+
}
|
|
4932
|
+
else if (event === 'project-removed') {
|
|
4933
|
+
const id = data.id;
|
|
4934
|
+
if (typeof id === 'string')
|
|
4935
|
+
coordinator.remove(id);
|
|
4936
|
+
if (typeof id === 'string') {
|
|
4937
|
+
automationCoordinator.remove(id);
|
|
4938
|
+
automationProjects.delete(id);
|
|
4939
|
+
rescheduleAutomations();
|
|
4940
|
+
}
|
|
4941
|
+
}
|
|
4942
|
+
});
|
|
4943
|
+
server.once('listening', () => {
|
|
4944
|
+
void listProjects().then((projects) => {
|
|
4945
|
+
const all = projects.some((project) => project.root === deps.repoRoot)
|
|
4946
|
+
? projects : [{ id: deps.bootProjectId ?? 'default', root: deps.repoRoot, status: 'ok' }, ...projects];
|
|
4947
|
+
coordinator.start(all);
|
|
4948
|
+
// #801: with automations off there is nothing to warm — no remote to resolve, no receipts
|
|
4949
|
+
// to reconcile, and above all no scheduler to start. The skills-update coordinator above is
|
|
4950
|
+
// a separate feature and starts either way.
|
|
4951
|
+
if (!automationsEnabled())
|
|
4952
|
+
return;
|
|
4953
|
+
void Promise.all(all.map(async (project) => {
|
|
4954
|
+
const parsed = parseRemote((await getRepoInfo(project.root))?.remote ?? '');
|
|
4955
|
+
if (parsed?.host === 'github.com')
|
|
4956
|
+
automationProjects.set(project.id, { root: project.root, owner: parsed.owner, repo: parsed.repo });
|
|
4957
|
+
const automationStore = automationCoordinator.store(project.id, project.root);
|
|
4958
|
+
const runStore = project.id === (deps.bootProjectId ?? 'default')
|
|
4959
|
+
? deps.store
|
|
4960
|
+
: sharedContexts.peek(project.id)?.store;
|
|
4961
|
+
if (automationStore && runStore)
|
|
4962
|
+
reconcileAutomationReceipts(automationStore, runStore);
|
|
4963
|
+
})).then(() => automationScheduler.start()).catch(() => undefined);
|
|
4964
|
+
}).catch(() => undefined);
|
|
4965
|
+
});
|
|
4966
|
+
server.once('close', () => { unsubscribe(); coordinator.stop(); automationScheduler.stop(); });
|
|
3061
4967
|
socketHub.attach(server, (req) => verifyWsUpgrade(req, deps.bindHost));
|
|
3062
4968
|
return server;
|
|
3063
4969
|
}
|
|
3064
4970
|
/**
|
|
3065
4971
|
* The WebSocket twin of the `/api/*` request-origin guard (#426), applied
|
|
3066
|
-
* before the `/api/ws` handshake. WebSocket is NOT subject to CORS — any web
|
|
3067
|
-
* page may open `ws://127.0.0.1:<port>/api/ws` and, unlike a forced HTTP GET,
|
|
4972
|
+
* before the `/api/v1/ws` handshake. WebSocket is NOT subject to CORS — any web
|
|
4973
|
+
* page may open `ws://127.0.0.1:<port>/api/v1/ws` and, unlike a forced HTTP GET,
|
|
3068
4974
|
* would get to READ what comes back — so this guard is load-bearing:
|
|
3069
4975
|
*
|
|
3070
4976
|
* 1. Host allowlist (local mode): a non-loopback Host is a DNS-rebound
|
|
@@ -3213,6 +5119,8 @@ export function resumeCommand(runner, sessionId) {
|
|
|
3213
5119
|
return `codex resume ${sessionId}`;
|
|
3214
5120
|
case 'opencode':
|
|
3215
5121
|
return `opencode --session ${sessionId}`;
|
|
5122
|
+
case 'pi':
|
|
5123
|
+
return `pi --session ${sessionId}`;
|
|
3216
5124
|
default:
|
|
3217
5125
|
return `claude --resume ${sessionId}`;
|
|
3218
5126
|
}
|