@edspencer/paddock 0.0.1 → 0.56.0
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/README.md +69 -0
- package/install-notice.mjs +29 -0
- package/package.json +58 -5
- package/packages/server/dist/adoptable.d.ts +127 -0
- package/packages/server/dist/adoptable.js +315 -0
- package/packages/server/dist/app.d.ts +67 -0
- package/packages/server/dist/app.js +325 -0
- package/packages/server/dist/archive.d.ts +42 -0
- package/packages/server/dist/archive.js +125 -0
- package/packages/server/dist/attachments-config.d.ts +114 -0
- package/packages/server/dist/attachments-config.js +165 -0
- package/packages/server/dist/attachments-hint.d.ts +53 -0
- package/packages/server/dist/attachments-hint.js +140 -0
- package/packages/server/dist/attachments.d.ts +33 -0
- package/packages/server/dist/attachments.js +117 -0
- package/packages/server/dist/auth.d.ts +84 -0
- package/packages/server/dist/auth.js +247 -0
- package/packages/server/dist/background.d.ts +36 -0
- package/packages/server/dist/background.js +137 -0
- package/packages/server/dist/bind-safety.d.ts +56 -0
- package/packages/server/dist/bind-safety.js +51 -0
- package/packages/server/dist/brand.d.ts +44 -0
- package/packages/server/dist/brand.js +88 -0
- package/packages/server/dist/chat-dto.d.ts +210 -0
- package/packages/server/dist/chat-dto.js +308 -0
- package/packages/server/dist/cli/import-chats.d.ts +21 -0
- package/packages/server/dist/cli/import-chats.js +197 -0
- package/packages/server/dist/cli/paddock.d.ts +2 -0
- package/packages/server/dist/cli/paddock.js +247 -0
- package/packages/server/dist/concurrency.d.ts +57 -0
- package/packages/server/dist/concurrency.js +71 -0
- package/packages/server/dist/config.d.ts +512 -0
- package/packages/server/dist/config.js +593 -0
- package/packages/server/dist/curation-config.d.ts +55 -0
- package/packages/server/dist/curation-config.js +64 -0
- package/packages/server/dist/event-bus.d.ts +87 -0
- package/packages/server/dist/event-bus.js +48 -0
- package/packages/server/dist/git.d.ts +128 -0
- package/packages/server/dist/git.js +446 -0
- package/packages/server/dist/github-auth.d.ts +44 -0
- package/packages/server/dist/github-auth.js +169 -0
- package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
- package/packages/server/dist/herdctl-agent-config.js +331 -0
- package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
- package/packages/server/dist/herdctl-agent-names.js +228 -0
- package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
- package/packages/server/dist/herdctl-jobs-index.js +225 -0
- package/packages/server/dist/herdctl-jobs.d.ts +98 -0
- package/packages/server/dist/herdctl-jobs.js +225 -0
- package/packages/server/dist/herdctl.d.ts +572 -0
- package/packages/server/dist/herdctl.js +1218 -0
- package/packages/server/dist/hook-config.d.ts +217 -0
- package/packages/server/dist/hook-config.js +224 -0
- package/packages/server/dist/http-bytes.d.ts +24 -0
- package/packages/server/dist/http-bytes.js +52 -0
- package/packages/server/dist/index.d.ts +1 -0
- package/packages/server/dist/index.js +14 -0
- package/packages/server/dist/instance-config.d.ts +135 -0
- package/packages/server/dist/instance-config.js +345 -0
- package/packages/server/dist/localcommand.d.ts +23 -0
- package/packages/server/dist/localcommand.js +140 -0
- package/packages/server/dist/management-auth.d.ts +99 -0
- package/packages/server/dist/management-auth.js +165 -0
- package/packages/server/dist/management-config.d.ts +164 -0
- package/packages/server/dist/management-config.js +286 -0
- package/packages/server/dist/management-mcp-server.d.ts +20 -0
- package/packages/server/dist/management-mcp-server.js +95 -0
- package/packages/server/dist/management-metadata.d.ts +67 -0
- package/packages/server/dist/management-metadata.js +56 -0
- package/packages/server/dist/management-ops.d.ts +110 -0
- package/packages/server/dist/management-ops.js +449 -0
- package/packages/server/dist/management-policy.d.ts +172 -0
- package/packages/server/dist/management-policy.js +259 -0
- package/packages/server/dist/message-provenance.d.ts +112 -0
- package/packages/server/dist/message-provenance.js +260 -0
- package/packages/server/dist/models.d.ts +164 -0
- package/packages/server/dist/models.js +208 -0
- package/packages/server/dist/openapi.d.ts +66 -0
- package/packages/server/dist/openapi.js +183 -0
- package/packages/server/dist/parent-detach.d.ts +30 -0
- package/packages/server/dist/parent-detach.js +109 -0
- package/packages/server/dist/preload.d.ts +43 -0
- package/packages/server/dist/preload.js +55 -0
- package/packages/server/dist/project-files.d.ts +76 -0
- package/packages/server/dist/project-files.js +191 -0
- package/packages/server/dist/project-mime.d.ts +35 -0
- package/packages/server/dist/project-mime.js +55 -0
- package/packages/server/dist/project-paths.d.ts +88 -0
- package/packages/server/dist/project-paths.js +126 -0
- package/packages/server/dist/project-types.d.ts +316 -0
- package/packages/server/dist/project-types.js +48 -0
- package/packages/server/dist/projects.d.ts +202 -0
- package/packages/server/dist/projects.js +894 -0
- package/packages/server/dist/queued-message.d.ts +35 -0
- package/packages/server/dist/queued-message.js +116 -0
- package/packages/server/dist/read-state.d.ts +46 -0
- package/packages/server/dist/read-state.js +139 -0
- package/packages/server/dist/recovery-config.d.ts +111 -0
- package/packages/server/dist/recovery-config.js +105 -0
- package/packages/server/dist/recovery.d.ts +262 -0
- package/packages/server/dist/recovery.js +490 -0
- package/packages/server/dist/resume-drain.d.ts +122 -0
- package/packages/server/dist/resume-drain.js +158 -0
- package/packages/server/dist/route-context.d.ts +168 -0
- package/packages/server/dist/route-context.js +39 -0
- package/packages/server/dist/route-errors.d.ts +8 -0
- package/packages/server/dist/route-errors.js +13 -0
- package/packages/server/dist/routes/chats.d.ts +12 -0
- package/packages/server/dist/routes/chats.js +1406 -0
- package/packages/server/dist/routes/git.d.ts +13 -0
- package/packages/server/dist/routes/git.js +236 -0
- package/packages/server/dist/routes/mcp.d.ts +83 -0
- package/packages/server/dist/routes/mcp.js +250 -0
- package/packages/server/dist/routes/meta.d.ts +15 -0
- package/packages/server/dist/routes/meta.js +447 -0
- package/packages/server/dist/routes/projects.d.ts +25 -0
- package/packages/server/dist/routes/projects.js +736 -0
- package/packages/server/dist/routes/triggers.d.ts +21 -0
- package/packages/server/dist/routes/triggers.js +324 -0
- package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
- package/packages/server/dist/routes/workspace-mount.js +32 -0
- package/packages/server/dist/routes.d.ts +26 -0
- package/packages/server/dist/routes.js +29 -0
- package/packages/server/dist/run-provenance.d.ts +109 -0
- package/packages/server/dist/run-provenance.js +206 -0
- package/packages/server/dist/runs.d.ts +92 -0
- package/packages/server/dist/runs.js +72 -0
- package/packages/server/dist/schedule-config.d.ts +68 -0
- package/packages/server/dist/schedule-config.js +75 -0
- package/packages/server/dist/schedule-session.d.ts +34 -0
- package/packages/server/dist/schedule-session.js +120 -0
- package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
- package/packages/server/dist/self-mcp-descriptions.js +145 -0
- package/packages/server/dist/self-mcp-read.d.ts +15 -0
- package/packages/server/dist/self-mcp-read.js +97 -0
- package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
- package/packages/server/dist/self-mcp-triggers.js +144 -0
- package/packages/server/dist/self-mcp-types.d.ts +248 -0
- package/packages/server/dist/self-mcp-types.js +10 -0
- package/packages/server/dist/self-mcp-util.d.ts +73 -0
- package/packages/server/dist/self-mcp-util.js +157 -0
- package/packages/server/dist/self-mcp-write.d.ts +35 -0
- package/packages/server/dist/self-mcp-write.js +215 -0
- package/packages/server/dist/self-mcp.d.ts +95 -0
- package/packages/server/dist/self-mcp.js +448 -0
- package/packages/server/dist/send-file-mcp.d.ts +46 -0
- package/packages/server/dist/send-file-mcp.js +281 -0
- package/packages/server/dist/session-hub.d.ts +186 -0
- package/packages/server/dist/session-hub.js +291 -0
- package/packages/server/dist/spawn-capability.d.ts +99 -0
- package/packages/server/dist/spawn-capability.js +106 -0
- package/packages/server/dist/star.d.ts +26 -0
- package/packages/server/dist/star.js +94 -0
- package/packages/server/dist/start.d.ts +1 -0
- package/packages/server/dist/start.js +21 -0
- package/packages/server/dist/subagents.d.ts +337 -0
- package/packages/server/dist/subagents.js +626 -0
- package/packages/server/dist/sweep.d.ts +122 -0
- package/packages/server/dist/sweep.js +696 -0
- package/packages/server/dist/tooldetails.d.ts +33 -0
- package/packages/server/dist/tooldetails.js +466 -0
- package/packages/server/dist/transcribe.d.ts +69 -0
- package/packages/server/dist/transcribe.js +119 -0
- package/packages/server/dist/transcripts.d.ts +42 -0
- package/packages/server/dist/transcripts.js +164 -0
- package/packages/server/dist/trigger-config.d.ts +278 -0
- package/packages/server/dist/trigger-config.js +417 -0
- package/packages/server/dist/trigger-runtime.d.ts +94 -0
- package/packages/server/dist/trigger-runtime.js +82 -0
- package/packages/server/dist/trigger-session.d.ts +34 -0
- package/packages/server/dist/trigger-session.js +122 -0
- package/packages/server/dist/triggers.d.ts +83 -0
- package/packages/server/dist/triggers.js +124 -0
- package/packages/server/dist/trusted-proxy.d.ts +57 -0
- package/packages/server/dist/trusted-proxy.js +157 -0
- package/packages/server/dist/turn-notice.d.ts +154 -0
- package/packages/server/dist/turn-notice.js +271 -0
- package/packages/server/dist/unread.d.ts +36 -0
- package/packages/server/dist/unread.js +123 -0
- package/packages/server/dist/usage.d.ts +66 -0
- package/packages/server/dist/usage.js +243 -0
- package/packages/server/dist/wake-injection.d.ts +156 -0
- package/packages/server/dist/wake-injection.js +136 -0
- package/packages/server/dist/whisper-local.d.ts +9 -0
- package/packages/server/dist/whisper-local.js +122 -0
- package/packages/server/dist/ws-context.d.ts +158 -0
- package/packages/server/dist/ws-context.js +1 -0
- package/packages/server/dist/ws-protocol.d.ts +423 -0
- package/packages/server/dist/ws-protocol.js +251 -0
- package/packages/server/dist/ws-self-mcp.d.ts +52 -0
- package/packages/server/dist/ws-self-mcp.js +28 -0
- package/packages/server/dist/ws-triggers.d.ts +29 -0
- package/packages/server/dist/ws-triggers.js +261 -0
- package/packages/server/dist/ws-turn.d.ts +86 -0
- package/packages/server/dist/ws-turn.js +751 -0
- package/packages/server/dist/ws.d.ts +62 -0
- package/packages/server/dist/ws.js +1024 -0
- package/packages/server/package.json +6 -0
- package/packages/web/dist/assets/InstanceConfigPage-4cQGTj9Y.js +2 -0
- package/packages/web/dist/assets/MarkdownRenderer-CJZFLj_8.js +30 -0
- package/packages/web/dist/assets/ProjectRedirect-BHwVbbqB.js +2 -0
- package/packages/web/dist/assets/ProjectView-BOvuAITg.js +13 -0
- package/packages/web/dist/assets/ProjectsGrid-BCojZOGY.js +2 -0
- package/packages/web/dist/assets/arc-BVyxvnkl.js +2 -0
- package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-Cd9Puqg1.js +37 -0
- package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
- package/packages/web/dist/assets/blockDiagram-GPEHLZMM-B_AQt_Rx.js +133 -0
- package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
- package/packages/web/dist/assets/c4Diagram-AAUBKEIU-7e0TQtXB.js +11 -0
- package/packages/web/dist/assets/channel-C_1aISNn.js +2 -0
- package/packages/web/dist/assets/chunk-2J33WTMH-Dz4WZmUZ.js +2 -0
- package/packages/web/dist/assets/chunk-4BX2VUAB-DVWYldo6.js +2 -0
- package/packages/web/dist/assets/chunk-55IACEB6-BIMrizQd.js +2 -0
- package/packages/web/dist/assets/chunk-727SXJPM-CL2q9fF1.js +207 -0
- package/packages/web/dist/assets/chunk-AQP2D5EJ-BSq3HlQ9.js +232 -0
- package/packages/web/dist/assets/chunk-FMBD7UC4-Bgt64pLE.js +16 -0
- package/packages/web/dist/assets/chunk-ND2GUHAM-C8Pr2A38.js +2 -0
- package/packages/web/dist/assets/chunk-QZHKN3VN-BAOqh-MN.js +2 -0
- package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B8JE7cHJ.js +2 -0
- package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B8JE7cHJ.js +2 -0
- package/packages/web/dist/assets/core-lJeGACQg.js +4 -0
- package/packages/web/dist/assets/cose-bilkent-S5V4N54A-BF4tameA.js +2 -0
- package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
- package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
- package/packages/web/dist/assets/css-DazXZka4.js +2 -0
- package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
- package/packages/web/dist/assets/dagre-BM42HDAG-DdsLM6ql.js +5 -0
- package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
- package/packages/web/dist/assets/diagram-2AECGRRQ-BQ78S_RM.js +44 -0
- package/packages/web/dist/assets/diagram-5GNKFQAL-CAKE6nHE.js +11 -0
- package/packages/web/dist/assets/diagram-KO2AKTUF-fEvHRXgr.js +4 -0
- package/packages/web/dist/assets/diagram-LMA3HP47-CtWsgeSm.js +25 -0
- package/packages/web/dist/assets/diagram-OG6HWLK6-Buog8k7_.js +25 -0
- package/packages/web/dist/assets/erDiagram-TEJ5UH35-CWvVVPZS.js +86 -0
- package/packages/web/dist/assets/flowDiagram-I6XJVG4X-DsCGXtp4.js +163 -0
- package/packages/web/dist/assets/format-DamJQ9LO.js +2 -0
- package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-CsdwIvvC.js +293 -0
- package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-hZvrX1sD.js +107 -0
- package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
- package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
- package/packages/web/dist/assets/index-CGbZdrm8.js +71 -0
- package/packages/web/dist/assets/index-DJDQ210-.css +1 -0
- package/packages/web/dist/assets/infoDiagram-5YYISTIA-nE_Vtz_l.js +3 -0
- package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
- package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
- package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-2Nlqs0oH.js +71 -0
- package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
- package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
- package/packages/web/dist/assets/journeyDiagram-JHISSGLW-mjU1fMc4.js +140 -0
- package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
- package/packages/web/dist/assets/kanban-definition-UN3LZRKU-C_9xNaa3.js +90 -0
- package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
- package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
- package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
- package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
- package/packages/web/dist/assets/linear-BxKd91ho.js +2 -0
- package/packages/web/dist/assets/mermaid.core-C-CuhD8I.js +310 -0
- package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bs6yfcZv.js +97 -0
- package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
- package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
- package/packages/web/dist/assets/pieDiagram-4H26LBE5-DNdqX7O5.js +31 -0
- package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
- package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-ZMJl9OdZ.js +8 -0
- package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
- package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-RmxMdm2q.js +85 -0
- package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
- package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
- package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-OAAhWcfK.js +41 -0
- package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
- package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-BWF7rCyN.js +163 -0
- package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
- package/packages/web/dist/assets/stateDiagram-AJRCARHV-M3b_zqqo.js +2 -0
- package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CI7saFDa.js +2 -0
- package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
- package/packages/web/dist/assets/timeline-definition-PNZ67QCA-BpOCh63N.js +121 -0
- package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
- package/packages/web/dist/assets/vennDiagram-CIIHVFJN-D_iPH45Q.js +35 -0
- package/packages/web/dist/assets/wardley-L42UT6IY-DVD9I2Om.js +174 -0
- package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-Bn1IKwjn.js +79 -0
- package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
- package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-DwtI0bkR.js +8 -0
- package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
- package/packages/web/dist/favicon.ico +0 -0
- package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
- package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
- package/packages/web/dist/icons/apple-touch-icon.png +0 -0
- package/packages/web/dist/icons/favicon-16.png +0 -0
- package/packages/web/dist/icons/favicon-32.png +0 -0
- package/packages/web/dist/icons/icon-192.png +0 -0
- package/packages/web/dist/icons/icon-512.png +0 -0
- package/packages/web/dist/icons/maskable-512.png +0 -0
- package/packages/web/dist/index.html +56 -0
- package/packages/web/dist/manifest.webmanifest +32 -0
- package/packages/web/dist/sw.js +139 -0
|
@@ -0,0 +1,736 @@
|
|
|
1
|
+
import { ProjectError, ROOT_KEY, } from "../projects.js";
|
|
2
|
+
import { keeperAgentName } from "../herdctl.js";
|
|
3
|
+
import { isKnownModel, isKnownPermissionMode, isValidMaxTurns, isKnownDriveMode, resolveModels, MAX_TURNS_LIMIT, } from "../models.js";
|
|
4
|
+
import { isValidMaxSpawnDepth, MAX_SPAWN_DEPTH_LIMIT } from "../spawn-capability.js";
|
|
5
|
+
import { sendProjectError } from "../route-errors.js";
|
|
6
|
+
import { buildProjectChats, makeTriggerResolver, makeParentResolver } from "../chat-dto.js";
|
|
7
|
+
/**
|
|
8
|
+
* Build ONE workspace's compact `{ sessionId, lastTurnCompletedAt, lastSeen?,
|
|
9
|
+
* unread? }` list — the sidebar's UNREAD-badge feed (#161), server-backed (#189).
|
|
10
|
+
*
|
|
11
|
+
* Extracted so the root workspace and every project are literally the same fold
|
|
12
|
+
* (#553): the badge's semantics are "whatever this function returns", and there
|
|
13
|
+
* is no second implementation for the root to drift from. The completed-turn
|
|
14
|
+
* side comes from the cheap job-record scan (grouped by keeper agent), so this
|
|
15
|
+
* costs no `listSessions` fan-out and no transcript parse.
|
|
16
|
+
*
|
|
17
|
+
* `key` is a WORKSPACE key, so `""` (the root) is a perfectly ordinary argument:
|
|
18
|
+
* `turnsByProject` is already keyed that way (`lastTurnCompletedAtByProject`
|
|
19
|
+
* compares `keeperSlugFromAgent(...) === null`, not falsiness) and
|
|
20
|
+
* `keeperAgentName("")` encodes to `keeper-_root`. Nothing here may test `key`
|
|
21
|
+
* for truthiness.
|
|
22
|
+
*/
|
|
23
|
+
async function buildChatTurns(key, turnsByProject, user, readState, unread) {
|
|
24
|
+
const bySession = turnsByProject.get(key);
|
|
25
|
+
if (!bySession)
|
|
26
|
+
return [];
|
|
27
|
+
const keeper = keeperAgentName(key);
|
|
28
|
+
return Promise.all([...bySession].map(async ([sessionId, lastTurnCompletedAt]) => {
|
|
29
|
+
const lastSeen = await readState.getLastSeen(user, keeper, sessionId).catch(() => 0);
|
|
30
|
+
// Manual unread override (#458): carry it so the sidebar's workspace
|
|
31
|
+
// unread badge counts a manually-flagged chat, staying in step with
|
|
32
|
+
// the per-chat unread dot (which folds the same flag in).
|
|
33
|
+
const isUnread = await unread.isUnread(user, keeper, sessionId).catch(() => false);
|
|
34
|
+
return {
|
|
35
|
+
sessionId,
|
|
36
|
+
lastTurnCompletedAt,
|
|
37
|
+
...(lastSeen ? { lastSeen } : {}),
|
|
38
|
+
...(isUnread ? { unread: true } : {}),
|
|
39
|
+
};
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
export function registerProjectRoutes(app, ctx) {
|
|
43
|
+
const { projects, herdctl, git, readState, unread, readStateUser } = ctx;
|
|
44
|
+
app.get("/api/projects", {
|
|
45
|
+
schema: {
|
|
46
|
+
tags: ["Projects"],
|
|
47
|
+
summary: "List projects",
|
|
48
|
+
description: "Returns `{ projects, root }`. `projects` is an array of the root workspace's CHILDREN; each entry carries the project metadata plus a compact `chatTurns` array (per-chat `{ sessionId, lastTurnCompletedAt, lastSeen? }` for the sidebar UNREAD count) and a `dirty` uncommitted-file count. `root` is the ROOT workspace itself, carrying the same `chatTurns` field so the sidebar's Home entry gets the identical badge; it is `null` only if the root record could not be read.",
|
|
49
|
+
response: {
|
|
50
|
+
200: {
|
|
51
|
+
description: "Object with a `projects` array (each item a project enriched with `chatTurns` and `dirty`) and a `root` workspace object enriched with `chatTurns`.",
|
|
52
|
+
type: "object",
|
|
53
|
+
additionalProperties: true,
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
}, async (req) => {
|
|
58
|
+
// Fold a compact per-workspace list of `{ sessionId, lastTurnCompletedAt,
|
|
59
|
+
// lastSeen }` into the payload so the sidebar can compute each workspace's
|
|
60
|
+
// UNREAD count (#161) server-backed (#189) — see `buildChatTurns`.
|
|
61
|
+
//
|
|
62
|
+
// The ROOT workspace rides along here rather than on its own endpoint (#553).
|
|
63
|
+
// `GET /api/projects` enumerates the root's CHILDREN, so the root can't be an
|
|
64
|
+
// array member without turning up in the grid and the sidebar project list —
|
|
65
|
+
// but it IS the sidebar's other badge-bearing row, and `lastTurnCompletedAtByProject`
|
|
66
|
+
// already grouped its chats under the `""` key. Putting it in the same response,
|
|
67
|
+
// through the same fold, is what keeps Home and a project row on one code path.
|
|
68
|
+
const [list, root, turnsByProject, dirty] = await Promise.all([
|
|
69
|
+
projects.list(),
|
|
70
|
+
// The root always exists; tolerate a read failure rather than 500 the
|
|
71
|
+
// whole list, exactly as the client's separate fetch used to.
|
|
72
|
+
projects.get(ROOT_KEY).catch(() => null),
|
|
73
|
+
herdctl.lastTurnCompletedAtByProject().catch(() => new Map()),
|
|
74
|
+
// Uncommitted-file count per project subtree — one cheap `git status` over
|
|
75
|
+
// the whole store, drives the projects-grid "N uncommitted" chip (#258).
|
|
76
|
+
git.dirtyCounts().catch(() => ({})),
|
|
77
|
+
]);
|
|
78
|
+
const user = readStateUser(req);
|
|
79
|
+
const projectsOut = await Promise.all(list.map(async (p) => ({
|
|
80
|
+
...p,
|
|
81
|
+
chatTurns: await buildChatTurns(p.slug, turnsByProject, user, readState, unread),
|
|
82
|
+
dirty: dirty[p.slug] ?? 0,
|
|
83
|
+
})));
|
|
84
|
+
// No `dirty` for the root: `dirtyCounts()` buckets by first path segment, so
|
|
85
|
+
// a file directly in the projects root has no bucket at all. Reporting 0
|
|
86
|
+
// would be a fabricated clean; the field is optional, so it stays absent.
|
|
87
|
+
const rootOut = root
|
|
88
|
+
? { ...root, chatTurns: await buildChatTurns(ROOT_KEY, turnsByProject, user, readState, unread) }
|
|
89
|
+
: null;
|
|
90
|
+
return { projects: projectsOut, root: rootOut };
|
|
91
|
+
});
|
|
92
|
+
app.post("/api/projects", {
|
|
93
|
+
schema: {
|
|
94
|
+
tags: ["Projects"],
|
|
95
|
+
summary: "Create a project",
|
|
96
|
+
description: "Creates a project from `CreateProjectInput` and registers its agent and its sweeper. Set `repo` to create a repo-backed project (the cloned checkout becomes the agent's working directory); omit it for a notebook project. Responds 201 with `{ project }`.",
|
|
97
|
+
body: {
|
|
98
|
+
type: ["object", "null"],
|
|
99
|
+
additionalProperties: true,
|
|
100
|
+
properties: {
|
|
101
|
+
name: { description: "Human-readable project name." },
|
|
102
|
+
slug: { description: "URL slug (derived from name if omitted)." },
|
|
103
|
+
status: { description: "Project status." },
|
|
104
|
+
domain: {
|
|
105
|
+
description: "Domain tags for the project.",
|
|
106
|
+
},
|
|
107
|
+
group: { description: "Group the project belongs to." },
|
|
108
|
+
visibility: { description: "Project visibility." },
|
|
109
|
+
summary: { description: "Short project summary." },
|
|
110
|
+
links: {
|
|
111
|
+
description: "Related links for the project.",
|
|
112
|
+
},
|
|
113
|
+
repo: {
|
|
114
|
+
description: "External git repo URL to back this project (repo-backed if set).",
|
|
115
|
+
},
|
|
116
|
+
},
|
|
117
|
+
required: [],
|
|
118
|
+
},
|
|
119
|
+
response: {
|
|
120
|
+
201: {
|
|
121
|
+
description: "Object with the created `project`.",
|
|
122
|
+
type: "object",
|
|
123
|
+
additionalProperties: true,
|
|
124
|
+
},
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
}, async (req, reply) => {
|
|
128
|
+
try {
|
|
129
|
+
const project = await projects.create(req.body ?? {});
|
|
130
|
+
// Register the keeper + sweeper agents at runtime (fleet.addAgent).
|
|
131
|
+
try {
|
|
132
|
+
await herdctl.ensureProjectAgent(project);
|
|
133
|
+
}
|
|
134
|
+
catch (err) {
|
|
135
|
+
req.log.warn({ err }, "keeper-agent registration failed (project still created)");
|
|
136
|
+
}
|
|
137
|
+
return reply.code(201).send({ project });
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
return sendProjectError(reply, err);
|
|
141
|
+
}
|
|
142
|
+
});
|
|
143
|
+
}
|
|
144
|
+
/**
|
|
145
|
+
* The workspace-scoped project routes — everything addressed to ONE workspace.
|
|
146
|
+
*
|
|
147
|
+
* Registered twice by the composition root (see `workspace-mount.ts`): once at
|
|
148
|
+
* `/api/root` for the root workspace and once at `/api/projects/:slug` for a
|
|
149
|
+
* project. Paths here are relative to the workspace; handlers still read
|
|
150
|
+
* `req.params.slug`, which the root mount injects as `""`.
|
|
151
|
+
*/
|
|
152
|
+
export function registerProjectWorkspaceRoutes(app, ctx) {
|
|
153
|
+
const { projects, herdctl, archive, star, readState, unread, parentDetach, runProvenance, messageProvenance, readStateUser, cfg, } = ctx;
|
|
154
|
+
app.get("/", {
|
|
155
|
+
schema: {
|
|
156
|
+
tags: ["Projects"],
|
|
157
|
+
summary: "Get a project",
|
|
158
|
+
description: "Returns `{ project, changelog, overview, chats }` — the project metadata, its raw CHANGELOG.md and OVERVIEW.md text (empty strings when absent), and its chats (sessions) enriched with archived/starred/last-seen/provenance/trigger flags. Per-chat usage rings are filled separately by the client.",
|
|
159
|
+
params: {
|
|
160
|
+
type: "object",
|
|
161
|
+
properties: {
|
|
162
|
+
slug: { type: "string", description: "Project slug." },
|
|
163
|
+
},
|
|
164
|
+
required: ["slug"],
|
|
165
|
+
},
|
|
166
|
+
response: {
|
|
167
|
+
200: {
|
|
168
|
+
description: "Object with `project`, `changelog` text, `overview` text, and a `chats` array.",
|
|
169
|
+
type: "object",
|
|
170
|
+
additionalProperties: true,
|
|
171
|
+
},
|
|
172
|
+
},
|
|
173
|
+
},
|
|
174
|
+
}, async (req, reply) => {
|
|
175
|
+
try {
|
|
176
|
+
const project = await projects.get(req.params.slug);
|
|
177
|
+
// Enrich with changelog text + the project's chats (sessions).
|
|
178
|
+
const [changelog, overview, sessions, lastTurnAt] = await Promise.all([
|
|
179
|
+
projects.readFile(project.slug, "CHANGELOG.md").catch(() => ""),
|
|
180
|
+
// OVERVIEW.md rides along with CHANGELOG.md (#599): Home renders both as
|
|
181
|
+
// sibling collapsible cards, so fetching them together is one request
|
|
182
|
+
// instead of two and they can never render a beat apart. `""` when the
|
|
183
|
+
// workspace has no overview yet — the same "absent reads as empty"
|
|
184
|
+
// contract the changelog above has always had.
|
|
185
|
+
projects.readOverview(project.slug).catch(() => ""),
|
|
186
|
+
herdctl.listSessions(project).catch(() => []),
|
|
187
|
+
herdctl.lastTurnCompletedAt().catch(() => new Map()),
|
|
188
|
+
]);
|
|
189
|
+
const keeper = keeperAgentName(project.slug);
|
|
190
|
+
const archivedOf = (s) => archive.isArchived(keeper, s.sessionId);
|
|
191
|
+
const starredOf = (s) => star.isStarred(keeper, s.sessionId);
|
|
192
|
+
const user = readStateUser(req);
|
|
193
|
+
const lastSeenOf = (s) => readState.getLastSeen(user, keeper, s.sessionId);
|
|
194
|
+
// Manual unread override (#458): the per-user "mark unread" flag, resolved
|
|
195
|
+
// inline like the read-state above (a cheap in-memory sidecar read).
|
|
196
|
+
const unreadOf = (s) => unread.isUnread(user, keeper, s.sessionId);
|
|
197
|
+
// Provenance badge (#267): how each chat was created — human / scheduled /
|
|
198
|
+
// spawned (A1's #261 marker). A cheap in-memory map read, so unlike the
|
|
199
|
+
// usage ring (#116) it's fine to resolve inline for the initial payload.
|
|
200
|
+
const provenanceOf = (s) => runProvenance.get(s.sessionId);
|
|
201
|
+
// Trigger capability descriptor for trigger chats (Epic T / T4) — truthful from
|
|
202
|
+
// the registered trigger agent config. A no-op for the keeper chats that dominate.
|
|
203
|
+
const triggerOf = makeTriggerResolver(project);
|
|
204
|
+
// Parent edge for the nested chat list: an explicit detach (#508) wins, else
|
|
205
|
+
// the recorded RunProvenance edge, else a backfill from who injected the
|
|
206
|
+
// kickoff prompt. All three are in-memory sidecar reads.
|
|
207
|
+
const parentOf = makeParentResolver(runProvenance, messageProvenance, project.slug, (id) => parentDetach.isDetached(keeper, id));
|
|
208
|
+
// Deliberately NO usage resolver here (issue #116): the per-chat context
|
|
209
|
+
// ring requires streaming+parsing each session's full transcript, which is
|
|
210
|
+
// O(chats × transcript size) and blocked the whole ProjectView from
|
|
211
|
+
// rendering (2–3s on chat-heavy projects). Chats come back immediately from
|
|
212
|
+
// cached name/preview/mtime; the client fills rings in afterwards via the
|
|
213
|
+
// separate GET .../chats/usage endpoint below.
|
|
214
|
+
return {
|
|
215
|
+
project,
|
|
216
|
+
changelog,
|
|
217
|
+
overview,
|
|
218
|
+
chats: await buildProjectChats(project.dir, sessions, undefined, archivedOf, lastTurnAt, lastSeenOf, provenanceOf, triggerOf, starredOf, unreadOf, parentOf),
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
catch (err) {
|
|
222
|
+
return sendProjectError(reply, err);
|
|
223
|
+
}
|
|
224
|
+
});
|
|
225
|
+
app.patch("/", {
|
|
226
|
+
schema: {
|
|
227
|
+
tags: ["Projects"],
|
|
228
|
+
summary: "Update a project",
|
|
229
|
+
description: "Applies an `UpdateProjectInput` partial and re-registers the project's agent. Agent overrides (`model`, `permissionMode`, `maxTurns`, `docker`, `driveMode`, `maxSpawnDepth`, `hooksMcpEnabled`) are validated at runtime — an invalid value returns 400. Tri-state override fields accept `null` to clear the override. Responds with `{ project }`.",
|
|
230
|
+
params: {
|
|
231
|
+
type: "object",
|
|
232
|
+
properties: {
|
|
233
|
+
slug: { type: "string", description: "Project slug." },
|
|
234
|
+
},
|
|
235
|
+
required: ["slug"],
|
|
236
|
+
},
|
|
237
|
+
body: {
|
|
238
|
+
type: ["object", "null"],
|
|
239
|
+
additionalProperties: true,
|
|
240
|
+
properties: {
|
|
241
|
+
name: { description: "Human-readable project name." },
|
|
242
|
+
status: { description: "Project status." },
|
|
243
|
+
domain: {
|
|
244
|
+
description: "Domain tags.",
|
|
245
|
+
},
|
|
246
|
+
group: { description: "Group the project belongs to." },
|
|
247
|
+
visibility: { description: "Project visibility." },
|
|
248
|
+
summary: { description: "Short project summary." },
|
|
249
|
+
links: {
|
|
250
|
+
description: "Related links.",
|
|
251
|
+
},
|
|
252
|
+
model: { description: "Agent model override (validated at runtime)." },
|
|
253
|
+
permissionMode: {
|
|
254
|
+
description: "Agent permission mode override (validated at runtime).",
|
|
255
|
+
},
|
|
256
|
+
maxTurns: {
|
|
257
|
+
description: "Agent max-turns override (validated at runtime).",
|
|
258
|
+
},
|
|
259
|
+
docker: { description: "Whether the agent runs in Docker." },
|
|
260
|
+
driveMode: {
|
|
261
|
+
description: "Drive-mode override; `null` clears it.",
|
|
262
|
+
},
|
|
263
|
+
maxSpawnDepth: {
|
|
264
|
+
description: "Max spawn-depth override; `null` clears it.",
|
|
265
|
+
},
|
|
266
|
+
hooksMcpEnabled: {
|
|
267
|
+
description: "Hook-management MCP override; `null` clears it.",
|
|
268
|
+
},
|
|
269
|
+
recovery: {
|
|
270
|
+
description: "Chat-recovery override object; `null` clears it.",
|
|
271
|
+
},
|
|
272
|
+
attachments: {
|
|
273
|
+
description: "Inbound-attachment override object; `null` clears it.",
|
|
274
|
+
},
|
|
275
|
+
curation: {
|
|
276
|
+
description: "Sweeper-curation budget override object; `null` clears it.",
|
|
277
|
+
},
|
|
278
|
+
},
|
|
279
|
+
required: [],
|
|
280
|
+
},
|
|
281
|
+
response: {
|
|
282
|
+
200: {
|
|
283
|
+
description: "Object with the updated `project`.",
|
|
284
|
+
type: "object",
|
|
285
|
+
additionalProperties: true,
|
|
286
|
+
},
|
|
287
|
+
},
|
|
288
|
+
},
|
|
289
|
+
}, async (req, reply) => {
|
|
290
|
+
try {
|
|
291
|
+
const body = req.body ?? {};
|
|
292
|
+
// Validate explicit keeper-agent overrides before touching disk (400 if
|
|
293
|
+
// bad) — these re-register the keeper, so a bad value must not persist.
|
|
294
|
+
if (body.model !== undefined && !isKnownModel(body.model)) {
|
|
295
|
+
return reply.code(400).send({ error: `Unknown model: ${body.model}`, code: "invalid" });
|
|
296
|
+
}
|
|
297
|
+
// Offered-models allow-list (issue #457 Step 2). `null` / an empty array is
|
|
298
|
+
// valid — it clears the per-project override (offer the instance list). A
|
|
299
|
+
// non-empty array must be a subset of the instance allow-list: each id must
|
|
300
|
+
// be a known catalog model AND currently offered by the instance (an
|
|
301
|
+
// operator can't offer a project a model the instance itself hides).
|
|
302
|
+
if (body.models !== undefined && body.models !== null) {
|
|
303
|
+
if (!Array.isArray(body.models)) {
|
|
304
|
+
return reply.code(400).send({ error: "models must be an array of ids", code: "invalid" });
|
|
305
|
+
}
|
|
306
|
+
const offered = new Set(resolveModels(cfg.models).map((m) => m.id));
|
|
307
|
+
for (const id of body.models) {
|
|
308
|
+
if (typeof id !== "string" || !isKnownModel(id)) {
|
|
309
|
+
return reply.code(400).send({ error: `Unknown model: ${String(id)}`, code: "invalid" });
|
|
310
|
+
}
|
|
311
|
+
if (!offered.has(id)) {
|
|
312
|
+
return reply.code(400).send({
|
|
313
|
+
error: `Model not offered by this instance: ${id}`,
|
|
314
|
+
code: "invalid",
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (body.permissionMode !== undefined && !isKnownPermissionMode(body.permissionMode)) {
|
|
320
|
+
return reply
|
|
321
|
+
.code(400)
|
|
322
|
+
.send({ error: `Unknown permission mode: ${body.permissionMode}`, code: "invalid" });
|
|
323
|
+
}
|
|
324
|
+
if (body.maxTurns !== undefined && !isValidMaxTurns(body.maxTurns)) {
|
|
325
|
+
return reply
|
|
326
|
+
.code(400)
|
|
327
|
+
.send({ error: `max_turns must be an integer 1–${MAX_TURNS_LIMIT}`, code: "invalid" });
|
|
328
|
+
}
|
|
329
|
+
if (body.docker !== undefined && typeof body.docker !== "boolean") {
|
|
330
|
+
return reply.code(400).send({ error: "docker must be a boolean", code: "invalid" });
|
|
331
|
+
}
|
|
332
|
+
// `null` is valid — it clears the per-project override (inherit the
|
|
333
|
+
// global default, issue #122). Only a non-null, unknown string is a 400.
|
|
334
|
+
if (body.driveMode !== undefined &&
|
|
335
|
+
body.driveMode !== null &&
|
|
336
|
+
!isKnownDriveMode(body.driveMode)) {
|
|
337
|
+
return reply
|
|
338
|
+
.code(400)
|
|
339
|
+
.send({ error: `Unknown drive mode: ${body.driveMode}`, code: "invalid" });
|
|
340
|
+
}
|
|
341
|
+
// `null` is valid — it clears the per-project override (inherit the
|
|
342
|
+
// instance default, #262). Only a non-null, out-of-range value is a 400.
|
|
343
|
+
if (body.maxSpawnDepth !== undefined &&
|
|
344
|
+
body.maxSpawnDepth !== null &&
|
|
345
|
+
!isValidMaxSpawnDepth(body.maxSpawnDepth)) {
|
|
346
|
+
return reply.code(400).send({
|
|
347
|
+
error: `max_spawn_depth must be an integer 0–${MAX_SPAWN_DEPTH_LIMIT}`,
|
|
348
|
+
code: "invalid",
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
// `null` clears the per-project hook-MCP override (inherit the instance
|
|
352
|
+
// default, G5). Only a non-null, non-boolean value is a 400.
|
|
353
|
+
if (body.hooksMcpEnabled !== undefined &&
|
|
354
|
+
body.hooksMcpEnabled !== null &&
|
|
355
|
+
typeof body.hooksMcpEnabled !== "boolean") {
|
|
356
|
+
return reply
|
|
357
|
+
.code(400)
|
|
358
|
+
.send({ error: "hooks_mcp_enabled must be a boolean", code: "invalid" });
|
|
359
|
+
}
|
|
360
|
+
const project = await projects.update(req.params.slug, body);
|
|
361
|
+
// Re-register the keeper so the new model takes effect (the keeper is a
|
|
362
|
+
// long-lived in-memory agent; addAgent replace:true updates its config).
|
|
363
|
+
try {
|
|
364
|
+
await herdctl.ensureProjectAgent(project);
|
|
365
|
+
}
|
|
366
|
+
catch (err) {
|
|
367
|
+
req.log.warn({ err }, "keeper-agent re-registration failed after update");
|
|
368
|
+
}
|
|
369
|
+
return { project };
|
|
370
|
+
}
|
|
371
|
+
catch (err) {
|
|
372
|
+
return sendProjectError(reply, err);
|
|
373
|
+
}
|
|
374
|
+
});
|
|
375
|
+
// Delete a project: remove its directory and unregister its keeper + sweeper
|
|
376
|
+
// agents at runtime (fleet.removeAgent) — the inverse of the create flow.
|
|
377
|
+
app.delete("/", {
|
|
378
|
+
schema: {
|
|
379
|
+
tags: ["Projects"],
|
|
380
|
+
summary: "Delete a project",
|
|
381
|
+
description: "Removes the project directory and unregisters its agent and its sweeper (the inverse of create). Responds with `{ ok: true, slug }`.",
|
|
382
|
+
params: {
|
|
383
|
+
type: "object",
|
|
384
|
+
properties: {
|
|
385
|
+
slug: { type: "string", description: "Project slug." },
|
|
386
|
+
},
|
|
387
|
+
required: ["slug"],
|
|
388
|
+
},
|
|
389
|
+
response: {
|
|
390
|
+
200: {
|
|
391
|
+
description: "Object with `ok: true` and the deleted project `slug`.",
|
|
392
|
+
type: "object",
|
|
393
|
+
additionalProperties: true,
|
|
394
|
+
},
|
|
395
|
+
},
|
|
396
|
+
},
|
|
397
|
+
}, async (req, reply) => {
|
|
398
|
+
try {
|
|
399
|
+
const project = await projects.remove(req.params.slug); // throws not_found
|
|
400
|
+
try {
|
|
401
|
+
await herdctl.removeProjectAgent(project.slug, Object.keys(project.hooks ?? {}), Object.keys(project.triggers ?? {}));
|
|
402
|
+
}
|
|
403
|
+
catch (err) {
|
|
404
|
+
req.log.warn({ err }, "keeper-agent unregister failed (project dir already removed)");
|
|
405
|
+
}
|
|
406
|
+
return reply.code(200).send({ ok: true, slug: project.slug });
|
|
407
|
+
}
|
|
408
|
+
catch (err) {
|
|
409
|
+
return sendProjectError(reply, err);
|
|
410
|
+
}
|
|
411
|
+
});
|
|
412
|
+
// Promote a NOTEBOOK project into a REPO-BACKED one in place (issue #213):
|
|
413
|
+
// clone the given repo into the nested checkout, flip the keeper's cwd to it,
|
|
414
|
+
// and re-register the keeper (which re-symlinks the new cwd's transcript path at
|
|
415
|
+
// the project's existing `.chats/` store, so every chat stays listed + resumable).
|
|
416
|
+
// The project's chats + sidecar metadata (OVERVIEW/CHANGELOG/settings) are kept.
|
|
417
|
+
// On clone failure the notebook is left wholly intact (rollback in `promote()`).
|
|
418
|
+
app.post("/promote", {
|
|
419
|
+
schema: {
|
|
420
|
+
tags: ["Projects"],
|
|
421
|
+
summary: "Promote a notebook project to repo-backed",
|
|
422
|
+
description: "Clones `repo` into the project's nested checkout, flips the agent's working directory to it, and re-registers the agent (keeping existing chats + metadata). A missing/blank `repo` returns 400; clone failure rolls back to the intact notebook. Responds with `{ project }`.",
|
|
423
|
+
params: {
|
|
424
|
+
type: "object",
|
|
425
|
+
properties: {
|
|
426
|
+
slug: { type: "string", description: "Project slug." },
|
|
427
|
+
},
|
|
428
|
+
required: ["slug"],
|
|
429
|
+
},
|
|
430
|
+
body: {
|
|
431
|
+
type: ["object", "null"],
|
|
432
|
+
additionalProperties: true,
|
|
433
|
+
properties: {
|
|
434
|
+
repo: {
|
|
435
|
+
description: "Git repo URL to clone and back the project with.",
|
|
436
|
+
},
|
|
437
|
+
},
|
|
438
|
+
required: [],
|
|
439
|
+
},
|
|
440
|
+
response: {
|
|
441
|
+
200: {
|
|
442
|
+
description: "Object with the promoted `project`.",
|
|
443
|
+
type: "object",
|
|
444
|
+
additionalProperties: true,
|
|
445
|
+
},
|
|
446
|
+
},
|
|
447
|
+
},
|
|
448
|
+
}, async (req, reply) => {
|
|
449
|
+
try {
|
|
450
|
+
const repo = req.body?.repo;
|
|
451
|
+
if (typeof repo !== "string" || !repo.trim()) {
|
|
452
|
+
return reply.code(400).send({ error: "A repo URL is required", code: "invalid" });
|
|
453
|
+
}
|
|
454
|
+
const project = await projects.promote(req.params.slug, repo);
|
|
455
|
+
// Re-register the keeper at the NEW working dir (the checkout) and re-symlink
|
|
456
|
+
// its transcript path at the project's `.chats/` — this is what keeps the
|
|
457
|
+
// existing chats listed + resumable under the new cwd (issue #213 #1).
|
|
458
|
+
try {
|
|
459
|
+
await herdctl.ensureProjectAgent(project);
|
|
460
|
+
}
|
|
461
|
+
catch (err) {
|
|
462
|
+
req.log.warn({ err }, "promote: keeper re-registration failed (project still promoted)");
|
|
463
|
+
}
|
|
464
|
+
return reply.code(200).send({ project });
|
|
465
|
+
}
|
|
466
|
+
catch (err) {
|
|
467
|
+
return sendProjectError(reply, err);
|
|
468
|
+
}
|
|
469
|
+
});
|
|
470
|
+
// One level of a project's file tree (issue #259). `?path=<subpath>` descends
|
|
471
|
+
// into a subdirectory ("" / omitted = the project root). The response is a
|
|
472
|
+
// discriminated union on `kind`: a directory returns its `entries` (each with
|
|
473
|
+
// its own file|dir kind, dirs sorted first) so the Files tab can navigate into
|
|
474
|
+
// folders; a path that names a FILE returns `{ kind: "file", entries: [] }`
|
|
475
|
+
// (200, not an error — so the client can drop straight into the viewer without
|
|
476
|
+
// a noisy 409). Path-traversal guarded by ProjectStore.resolveInProject.
|
|
477
|
+
app.get("/files", {
|
|
478
|
+
schema: {
|
|
479
|
+
tags: ["Projects"],
|
|
480
|
+
summary: "List one level of a project's file tree",
|
|
481
|
+
description: "Lists a single directory level. `?path=<subpath>` descends into a subdirectory (omitted/empty = project root). Returns `{ path, kind, entries }`: a directory yields `kind: \"dir\"` with `entries` (dirs first); a path naming a file yields `kind: \"file\"` with empty `entries` (200, not an error).",
|
|
482
|
+
params: {
|
|
483
|
+
type: "object",
|
|
484
|
+
properties: {
|
|
485
|
+
slug: { type: "string", description: "Project slug." },
|
|
486
|
+
},
|
|
487
|
+
required: ["slug"],
|
|
488
|
+
},
|
|
489
|
+
querystring: {
|
|
490
|
+
type: "object",
|
|
491
|
+
additionalProperties: true,
|
|
492
|
+
properties: {
|
|
493
|
+
path: {
|
|
494
|
+
type: "string",
|
|
495
|
+
description: "Subpath to descend into; omitted/empty = project root.",
|
|
496
|
+
},
|
|
497
|
+
},
|
|
498
|
+
},
|
|
499
|
+
response: {
|
|
500
|
+
200: {
|
|
501
|
+
description: "Object with `path`, `kind` (\"dir\" | \"file\"), and an `entries` array.",
|
|
502
|
+
type: "object",
|
|
503
|
+
additionalProperties: true,
|
|
504
|
+
},
|
|
505
|
+
},
|
|
506
|
+
},
|
|
507
|
+
}, async (req, reply) => {
|
|
508
|
+
try {
|
|
509
|
+
await projects.get(req.params.slug);
|
|
510
|
+
const subpath = req.query.path ?? "";
|
|
511
|
+
try {
|
|
512
|
+
const entries = await projects.listFiles(req.params.slug, subpath);
|
|
513
|
+
return { path: subpath, kind: "dir", entries };
|
|
514
|
+
}
|
|
515
|
+
catch (err) {
|
|
516
|
+
// `subpath` is a file, not a directory — a valid target, just rendered
|
|
517
|
+
// by the single-file viewer rather than listed.
|
|
518
|
+
if (err instanceof ProjectError && err.code === "not_directory") {
|
|
519
|
+
return { path: subpath, kind: "file", entries: [] };
|
|
520
|
+
}
|
|
521
|
+
throw err;
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
catch (err) {
|
|
525
|
+
return sendProjectError(reply, err);
|
|
526
|
+
}
|
|
527
|
+
});
|
|
528
|
+
app.get("/changelog", {
|
|
529
|
+
schema: {
|
|
530
|
+
tags: ["Projects"],
|
|
531
|
+
summary: "Get a project's CHANGELOG.md",
|
|
532
|
+
description: "Returns the raw CHANGELOG.md text with content-type `text/markdown; charset=utf-8` (empty string if none). No response schema — the body is raw markdown text, not JSON.",
|
|
533
|
+
params: {
|
|
534
|
+
type: "object",
|
|
535
|
+
properties: {
|
|
536
|
+
slug: { type: "string", description: "Project slug." },
|
|
537
|
+
},
|
|
538
|
+
required: ["slug"],
|
|
539
|
+
},
|
|
540
|
+
},
|
|
541
|
+
}, async (req, reply) => {
|
|
542
|
+
try {
|
|
543
|
+
await projects.get(req.params.slug);
|
|
544
|
+
const content = await projects.readFile(req.params.slug, "CHANGELOG.md").catch(() => "");
|
|
545
|
+
reply.header("content-type", "text/markdown; charset=utf-8");
|
|
546
|
+
return content;
|
|
547
|
+
}
|
|
548
|
+
catch (err) {
|
|
549
|
+
return sendProjectError(reply, err);
|
|
550
|
+
}
|
|
551
|
+
});
|
|
552
|
+
// Raw OVERVIEW.md (the sweep-curated current-state context). Returns "" if
|
|
553
|
+
// the project has no overview yet (issue #2).
|
|
554
|
+
app.get("/overview", {
|
|
555
|
+
schema: {
|
|
556
|
+
tags: ["Projects"],
|
|
557
|
+
summary: "Get a project's OVERVIEW.md",
|
|
558
|
+
description: "Returns the raw OVERVIEW.md text (sweep-curated current-state context) with content-type `text/markdown; charset=utf-8`; empty string if the project has no overview yet. No response schema — the body is raw markdown text, not JSON.",
|
|
559
|
+
params: {
|
|
560
|
+
type: "object",
|
|
561
|
+
properties: {
|
|
562
|
+
slug: { type: "string", description: "Project slug." },
|
|
563
|
+
},
|
|
564
|
+
required: ["slug"],
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
}, async (req, reply) => {
|
|
568
|
+
try {
|
|
569
|
+
await projects.get(req.params.slug); // 404s for unknown slug
|
|
570
|
+
const content = await projects.readOverview(req.params.slug);
|
|
571
|
+
reply.header("content-type", "text/markdown; charset=utf-8");
|
|
572
|
+
return content;
|
|
573
|
+
}
|
|
574
|
+
catch (err) {
|
|
575
|
+
return sendProjectError(reply, err);
|
|
576
|
+
}
|
|
577
|
+
});
|
|
578
|
+
// Slash commands available to a project's keeper agent, for the composer's
|
|
579
|
+
// autocomplete menu (issue #103). Built-ins (`/compact`, `/clear`, …) plus the
|
|
580
|
+
// project's `.claude/commands` and any MCP-provided commands. NOT per-session —
|
|
581
|
+
// a dedicated endpoint mirroring GET /api/models, not part of toChatDto. The
|
|
582
|
+
// underlying listAgentCommands spawns a short-lived `claude` subprocess, so the
|
|
583
|
+
// service memoizes the result per agent (see HerdctlService.listCommands).
|
|
584
|
+
app.get("/commands", {
|
|
585
|
+
schema: {
|
|
586
|
+
tags: ["Projects"],
|
|
587
|
+
summary: "List a project's slash commands",
|
|
588
|
+
description: "Returns `{ commands }` — the slash commands available to the project's agent (built-ins plus the project's `.claude/commands` and MCP-provided commands) for composer autocomplete. Result is memoized per agent.",
|
|
589
|
+
params: {
|
|
590
|
+
type: "object",
|
|
591
|
+
properties: {
|
|
592
|
+
slug: { type: "string", description: "Project slug." },
|
|
593
|
+
},
|
|
594
|
+
required: ["slug"],
|
|
595
|
+
},
|
|
596
|
+
response: {
|
|
597
|
+
200: {
|
|
598
|
+
description: "Object with a `commands` array.",
|
|
599
|
+
type: "object",
|
|
600
|
+
additionalProperties: true,
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
}, async (req, reply) => {
|
|
605
|
+
try {
|
|
606
|
+
await projects.get(req.params.slug); // 404s for unknown slug
|
|
607
|
+
const commands = await herdctl.listCommands(keeperAgentName(req.params.slug));
|
|
608
|
+
return { commands };
|
|
609
|
+
}
|
|
610
|
+
catch (err) {
|
|
611
|
+
return sendProjectError(reply, err);
|
|
612
|
+
}
|
|
613
|
+
});
|
|
614
|
+
// Single file content + a render-kind hint (markdown | html | text | image),
|
|
615
|
+
// derived from the extension. Path-traversal guarded by ProjectStore. Feeds the
|
|
616
|
+
// UI's markdown/Mermaid + sandboxed-iframe renderers (issue #3).
|
|
617
|
+
//
|
|
618
|
+
// With `?raw=1` it instead streams the file's RAW BYTES with the correct
|
|
619
|
+
// Content-Type (issue #61) — how the image viewer loads an <img>, so binary
|
|
620
|
+
// bytes are no longer mangled by UTF-8 decoding. Byte responses are locked
|
|
621
|
+
// down (CSP sandbox + nosniff + inline disposition) so a directly-opened SVG
|
|
622
|
+
// or HTML file can't execute script in the app's origin.
|
|
623
|
+
app.get("/files/:name", {
|
|
624
|
+
schema: {
|
|
625
|
+
tags: ["Projects"],
|
|
626
|
+
summary: "Get a single project file",
|
|
627
|
+
description: "Dual-mode. By default returns JSON with the file content plus a render-kind hint (markdown | html | text | image). With `?raw=1` it instead STREAMS the file's raw bytes with the correct Content-Type (locked down: sandbox CSP + nosniff + inline disposition) — how the image viewer loads an `<img>`. No response schema is declared so the raw byte stream is never corrupted.",
|
|
628
|
+
params: {
|
|
629
|
+
type: "object",
|
|
630
|
+
properties: {
|
|
631
|
+
slug: { type: "string", description: "Project slug." },
|
|
632
|
+
name: { type: "string", description: "URL-encoded file name." },
|
|
633
|
+
},
|
|
634
|
+
required: ["slug", "name"],
|
|
635
|
+
},
|
|
636
|
+
querystring: {
|
|
637
|
+
type: "object",
|
|
638
|
+
additionalProperties: true,
|
|
639
|
+
properties: {
|
|
640
|
+
raw: {
|
|
641
|
+
type: "string",
|
|
642
|
+
description: "Set to a truthy value (e.g. `1`) to stream raw bytes instead of JSON.",
|
|
643
|
+
},
|
|
644
|
+
},
|
|
645
|
+
},
|
|
646
|
+
},
|
|
647
|
+
}, async (req, reply) => {
|
|
648
|
+
try {
|
|
649
|
+
await projects.get(req.params.slug); // 404s for unknown slug
|
|
650
|
+
const name = decodeURIComponent(req.params.name);
|
|
651
|
+
if (req.query.raw !== undefined && req.query.raw !== "0" && req.query.raw !== "false") {
|
|
652
|
+
const { bytes, mime } = await projects.readFileBytes(req.params.slug, name);
|
|
653
|
+
return reply
|
|
654
|
+
.header("content-type", mime)
|
|
655
|
+
.header("content-disposition", "inline")
|
|
656
|
+
.header("x-content-type-options", "nosniff")
|
|
657
|
+
.header("content-security-policy", "sandbox; default-src 'none'")
|
|
658
|
+
.header("cache-control", "private, max-age=60")
|
|
659
|
+
.send(bytes);
|
|
660
|
+
}
|
|
661
|
+
return await projects.readFileWithKind(req.params.slug, name);
|
|
662
|
+
}
|
|
663
|
+
catch (err) {
|
|
664
|
+
return sendProjectError(reply, err);
|
|
665
|
+
}
|
|
666
|
+
});
|
|
667
|
+
// Pin a file as a sibling tab (issue #4). Validates the file exists + dedupes.
|
|
668
|
+
app.put("/pins", {
|
|
669
|
+
schema: {
|
|
670
|
+
tags: ["Projects"],
|
|
671
|
+
summary: "Pin a file as a sibling tab",
|
|
672
|
+
description: "Pins `file` as a sibling tab (validates the file exists + dedupes). Responds with `{ project }` reflecting the updated pins.",
|
|
673
|
+
params: {
|
|
674
|
+
type: "object",
|
|
675
|
+
properties: {
|
|
676
|
+
slug: { type: "string", description: "Project slug." },
|
|
677
|
+
},
|
|
678
|
+
required: ["slug"],
|
|
679
|
+
},
|
|
680
|
+
body: {
|
|
681
|
+
type: ["object", "null"],
|
|
682
|
+
additionalProperties: true,
|
|
683
|
+
properties: {
|
|
684
|
+
file: { description: "Path of the file to pin." },
|
|
685
|
+
},
|
|
686
|
+
required: [],
|
|
687
|
+
},
|
|
688
|
+
response: {
|
|
689
|
+
200: {
|
|
690
|
+
description: "Object with the updated `project`.",
|
|
691
|
+
type: "object",
|
|
692
|
+
additionalProperties: true,
|
|
693
|
+
},
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
}, async (req, reply) => {
|
|
697
|
+
try {
|
|
698
|
+
const project = await projects.pinFile(req.params.slug, req.body?.file ?? "");
|
|
699
|
+
return { project };
|
|
700
|
+
}
|
|
701
|
+
catch (err) {
|
|
702
|
+
return sendProjectError(reply, err);
|
|
703
|
+
}
|
|
704
|
+
});
|
|
705
|
+
// Unpin a file (URL-encoded name) (issue #4).
|
|
706
|
+
app.delete("/pins/:file", {
|
|
707
|
+
schema: {
|
|
708
|
+
tags: ["Projects"],
|
|
709
|
+
summary: "Unpin a file",
|
|
710
|
+
description: "Removes the pin for the given URL-encoded file `name`. Responds with `{ project }` reflecting the updated pins.",
|
|
711
|
+
params: {
|
|
712
|
+
type: "object",
|
|
713
|
+
properties: {
|
|
714
|
+
slug: { type: "string", description: "Project slug." },
|
|
715
|
+
file: { type: "string", description: "URL-encoded name of the pinned file to remove." },
|
|
716
|
+
},
|
|
717
|
+
required: ["slug", "file"],
|
|
718
|
+
},
|
|
719
|
+
response: {
|
|
720
|
+
200: {
|
|
721
|
+
description: "Object with the updated `project`.",
|
|
722
|
+
type: "object",
|
|
723
|
+
additionalProperties: true,
|
|
724
|
+
},
|
|
725
|
+
},
|
|
726
|
+
},
|
|
727
|
+
}, async (req, reply) => {
|
|
728
|
+
try {
|
|
729
|
+
const project = await projects.unpinFile(req.params.slug, decodeURIComponent(req.params.file));
|
|
730
|
+
return { project };
|
|
731
|
+
}
|
|
732
|
+
catch (err) {
|
|
733
|
+
return sendProjectError(reply, err);
|
|
734
|
+
}
|
|
735
|
+
});
|
|
736
|
+
}
|