@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,1406 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Chat (session) routes: the project-chat cluster (list / runs / usage / create /
|
|
3
|
+
* messages / subagents / context / delete / rename / fork / archive / star / seen /
|
|
4
|
+
* unread / promote). Chat SENDING happens over WS; these are the REST reads +
|
|
5
|
+
* lifecycle mutations.
|
|
6
|
+
*
|
|
7
|
+
* Every route here is workspace-scoped and mounted twice (`/api/root` and
|
|
8
|
+
* `/api/projects/:slug`), so the root — whose key is `""` — is served by these
|
|
9
|
+
* same handlers with no special-casing.
|
|
10
|
+
*/
|
|
11
|
+
import path from "node:path";
|
|
12
|
+
import { PathTraversalError } from "@herdctl/core";
|
|
13
|
+
import { keeperAgentName } from "../herdctl.js";
|
|
14
|
+
import { ROOT_KEY } from "../projects.js";
|
|
15
|
+
import { applyMessageProvenance } from "../message-provenance.js";
|
|
16
|
+
import { buildProjectRuns } from "../runs.js";
|
|
17
|
+
import { DEFAULT_MODEL } from "../models.js";
|
|
18
|
+
import { projectChatsDir } from "../transcripts.js";
|
|
19
|
+
import { readSubagentMessages, readSessionTokenUsageWithSubagents } from "../subagents.js";
|
|
20
|
+
import { readContextSeries } from "../usage.js";
|
|
21
|
+
import { enrichWithToolDetails } from "../tooldetails.js";
|
|
22
|
+
import { scanTranscriptNotice } from "../turn-notice.js";
|
|
23
|
+
import { childOf, HUMAN_ROOT, ADOPTED_ROOT } from "../run-provenance.js";
|
|
24
|
+
import { sendProjectError } from "../route-errors.js";
|
|
25
|
+
import { mapWithConcurrency, FILE_FANOUT_CONCURRENCY } from "../concurrency.js";
|
|
26
|
+
import { CHAT_USAGE_SCOPES, SAFE_SESSION_ID, RUNS_SEEN_SESSION, clampRunsLimit, toChatUsage, buildProjectChats, makeTriggerResolver, makeParentResolver, normalizeBatchSessionIds, BATCH_SESSIONS_MAX, } from "../chat-dto.js";
|
|
27
|
+
/**
|
|
28
|
+
* Workspace-scoped chat routes: paths are declared RELATIVE to the workspace
|
|
29
|
+
* (e.g. `/chats/:sessionId`), and this plugin is mounted TWICE — once at
|
|
30
|
+
* `/api/root` and once at `/api/projects/:slug` (see `workspace-mount.ts`).
|
|
31
|
+
*
|
|
32
|
+
* Handlers still read `req.params.slug`; the root mount injects `slug: ""` in an
|
|
33
|
+
* `onRequest` hook, which runs before params validation, so the `required:
|
|
34
|
+
* ["slug"]` schemas below keep validating unchanged on both mounts.
|
|
35
|
+
*/
|
|
36
|
+
export function registerChatWorkspaceRoutes(app, ctx) {
|
|
37
|
+
const { projects, herdctl, archive, star, readState, unread, parentDetach, runProvenance, messageProvenance, events, readStateUser, agentForSlug, projectDirForSlug, cleanupAttachments, chatUsageResolver, } = ctx;
|
|
38
|
+
/**
|
|
39
|
+
* The single 400 for a batch route whose `sessionIds` body is unusable —
|
|
40
|
+
* missing, empty, over the cap, or holding an id that isn't path-safe. Batch
|
|
41
|
+
* validation is all-or-nothing (see {@link normalizeBatchSessionIds}), so this
|
|
42
|
+
* is the only failure shape those routes have.
|
|
43
|
+
*/
|
|
44
|
+
const sendBadBatch = (reply) => reply.code(400).send({
|
|
45
|
+
error: `sessionIds must be an array of 1-${BATCH_SESSIONS_MAX} valid session ids`,
|
|
46
|
+
code: "bad_request",
|
|
47
|
+
});
|
|
48
|
+
/**
|
|
49
|
+
* Does this session have a live turn right now? The session hub is the only
|
|
50
|
+
* thing that knows (a running turn is in-memory state, not something any
|
|
51
|
+
* sidecar or job record has yet), and REST reaches it through the same
|
|
52
|
+
* context the MCP surface's `list_chats` uses.
|
|
53
|
+
*
|
|
54
|
+
* The context is optional in the dep bag, so a server built without the WS
|
|
55
|
+
* layer reports "nothing is running" rather than throwing — the attention
|
|
56
|
+
* feed then degrades to its unread half instead of 500ing.
|
|
57
|
+
*/
|
|
58
|
+
const isRunning = (sessionId) => ctx.managementOpsContext?.hub.isRunning(sessionId) ?? false;
|
|
59
|
+
// --- chats (sessions) --------------------------------------------------
|
|
60
|
+
app.get("/chats", {
|
|
61
|
+
schema: {
|
|
62
|
+
tags: ["Chats"],
|
|
63
|
+
summary: "List a project's chats",
|
|
64
|
+
description: "Returns the project's chats (sessions) with per-chat metadata (name, last-turn time, archived/starred flags, last-seen watermark, run provenance, trigger info). Usage rings are fetched separately (see GET /api/projects/:slug/chats/usage). Response: an object `{ chats: [...] }`.",
|
|
65
|
+
params: {
|
|
66
|
+
type: "object",
|
|
67
|
+
properties: {
|
|
68
|
+
slug: { type: "string", description: "Project slug." },
|
|
69
|
+
},
|
|
70
|
+
required: ["slug"],
|
|
71
|
+
},
|
|
72
|
+
response: {
|
|
73
|
+
200: {
|
|
74
|
+
description: "Object `{ chats }` where `chats` is an array of chat DTOs.",
|
|
75
|
+
type: "object",
|
|
76
|
+
additionalProperties: true,
|
|
77
|
+
},
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
}, async (req, reply) => {
|
|
81
|
+
try {
|
|
82
|
+
const project = await projects.get(req.params.slug);
|
|
83
|
+
const [sessions, lastTurnAt] = await Promise.all([
|
|
84
|
+
herdctl.listSessions(project).catch(() => []),
|
|
85
|
+
herdctl.lastTurnCompletedAt().catch(() => new Map()),
|
|
86
|
+
]);
|
|
87
|
+
const keeper = keeperAgentName(project.slug);
|
|
88
|
+
const archivedOf = (s) => archive.isArchived(keeper, s.sessionId);
|
|
89
|
+
const starredOf = (s) => star.isStarred(keeper, s.sessionId);
|
|
90
|
+
const user = readStateUser(req);
|
|
91
|
+
const lastSeenOf = (s) => readState.getLastSeen(user, keeper, s.sessionId);
|
|
92
|
+
const unreadOf = (s) => unread.isUnread(user, keeper, s.sessionId);
|
|
93
|
+
const provenanceOf = (s) => runProvenance.get(s.sessionId);
|
|
94
|
+
const triggerOf = makeTriggerResolver(project);
|
|
95
|
+
// Parent edge for the nested chat list: an explicit detach (#508) wins, else
|
|
96
|
+
// the recorded RunProvenance edge, else a backfill from who injected the
|
|
97
|
+
// kickoff prompt. All three are in-memory sidecar reads.
|
|
98
|
+
const parentOf = makeParentResolver(runProvenance, messageProvenance, project.slug, (id) => parentDetach.isDetached(keeper, id));
|
|
99
|
+
// No usage resolver — see the GET /api/projects/:slug route (issue #116).
|
|
100
|
+
// Usage rings are fetched separately so a list refresh stays cheap.
|
|
101
|
+
return {
|
|
102
|
+
chats: await buildProjectChats(project.dir, sessions, undefined, archivedOf, lastTurnAt, lastSeenOf, provenanceOf, triggerOf, starredOf, unreadOf, parentOf),
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
catch (err) {
|
|
106
|
+
return sendProjectError(reply, err);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
// --- the Home tab's attention feed (#599) ----------------------------------
|
|
110
|
+
/**
|
|
111
|
+
* The chats in this workspace's SUBTREE that want the user's attention right
|
|
112
|
+
* now: the ones with a live turn, and the ones holding an unread reply.
|
|
113
|
+
*
|
|
114
|
+
* Subtree, not workspace, is what makes root Home fleet-wide and a project's
|
|
115
|
+
* Home project-scoped through ONE handler. A workspace's key is its path
|
|
116
|
+
* relative to `projectsRoot`, so its descendants are exactly the workspaces
|
|
117
|
+
* whose key it prefixes — and the root's key is `""`, which prefixes every
|
|
118
|
+
* key there is. The root therefore sweeps the whole fleet and a (leaf)
|
|
119
|
+
* project sweeps only itself, with no branch on the key and no second
|
|
120
|
+
* implementation for the root to drift from. Nesting, when it lands, makes an
|
|
121
|
+
* intermediate workspace work for free.
|
|
122
|
+
*
|
|
123
|
+
* `running` is authoritative (the live hub), not inferred from timestamps.
|
|
124
|
+
* `unread` reuses the sidebar badge's derivation — the manual override (#458)
|
|
125
|
+
* or a completed turn newer than the read watermark (#189) — with ONE
|
|
126
|
+
* deliberate difference: archived chats are filed away on purpose, so they
|
|
127
|
+
* are excluded here. That means an archived chat with an unread turn still
|
|
128
|
+
* counts toward the sidebar's badge (`buildChatTurns` never consults
|
|
129
|
+
* `ArchiveStore`) while not appearing in this list. The two can therefore
|
|
130
|
+
* disagree by exactly that set; making them agree means teaching the badge
|
|
131
|
+
* about archiving, which is a behaviour change to the badge, not to this
|
|
132
|
+
* route.
|
|
133
|
+
*/
|
|
134
|
+
app.get("/chats/attention", {
|
|
135
|
+
schema: {
|
|
136
|
+
tags: ["Chats"],
|
|
137
|
+
summary: "List the chats in this workspace's subtree that are running or unread",
|
|
138
|
+
description: "Returns `{ running, unread }` — the chats in this workspace AND its descendants that currently have a live turn (`running`) or hold an unread reply (`unread`). Each row is a chat DTO plus the `projectSlug`/`projectName` it belongs to, so a fleet-wide list stays attributable. On the root mount (`/api/root/chats/attention`) the subtree is the whole instance, because the root workspace's key (`\"\"`) prefixes every workspace key; on a project mount it is that project alone. A chat is never in both lists: a live turn hasn't landed a reply yet, so running wins.",
|
|
139
|
+
params: {
|
|
140
|
+
type: "object",
|
|
141
|
+
properties: {
|
|
142
|
+
slug: { type: "string", description: "Project slug." },
|
|
143
|
+
},
|
|
144
|
+
required: ["slug"],
|
|
145
|
+
},
|
|
146
|
+
response: {
|
|
147
|
+
200: {
|
|
148
|
+
description: "Object `{ running, unread }`, each an array of chat DTOs enriched with `projectSlug` and `projectName`.",
|
|
149
|
+
type: "object",
|
|
150
|
+
additionalProperties: true,
|
|
151
|
+
},
|
|
152
|
+
},
|
|
153
|
+
},
|
|
154
|
+
}, async (req, reply) => {
|
|
155
|
+
try {
|
|
156
|
+
const self = await projects.get(req.params.slug);
|
|
157
|
+
// Descendants = every workspace this one's key prefixes. The root's key
|
|
158
|
+
// is `""`, so its prefix is `""` and the filter admits everything; a
|
|
159
|
+
// project's prefix is `"<slug>/"`. The `=== ROOT_KEY` test is explicit
|
|
160
|
+
// on purpose — `self.slug ? ... : ...` would take the root down the
|
|
161
|
+
// project branch and silently return an empty fleet.
|
|
162
|
+
const prefix = self.slug === ROOT_KEY ? "" : `${self.slug}/`;
|
|
163
|
+
const all = await projects.list().catch(() => []);
|
|
164
|
+
const subtree = [self, ...all.filter((p) => p.slug !== self.slug && p.slug.startsWith(prefix))];
|
|
165
|
+
const user = readStateUser(req);
|
|
166
|
+
// One shared job-record scan for the whole subtree rather than one per
|
|
167
|
+
// workspace: `lastTurnCompletedAt()` is the cached jobs-dir index, and
|
|
168
|
+
// re-reading it per project is what made the old N-request client
|
|
169
|
+
// fan-out expensive.
|
|
170
|
+
const lastTurnAt = await herdctl.lastTurnCompletedAt().catch(() => new Map());
|
|
171
|
+
const running = [];
|
|
172
|
+
const unreadRows = [];
|
|
173
|
+
for (const p of subtree) {
|
|
174
|
+
const sessions = await herdctl.listSessions(p).catch(() => []);
|
|
175
|
+
if (sessions.length === 0)
|
|
176
|
+
continue;
|
|
177
|
+
const keeper = keeperAgentName(p.slug);
|
|
178
|
+
const archivedOf = (s) => archive.isArchived(keeper, s.sessionId);
|
|
179
|
+
const starredOf = (s) => star.isStarred(keeper, s.sessionId);
|
|
180
|
+
const lastSeenOf = (s) => readState.getLastSeen(user, keeper, s.sessionId);
|
|
181
|
+
const unreadOf = (s) => unread.isUnread(user, keeper, s.sessionId);
|
|
182
|
+
const provenanceOf = (s) => runProvenance.get(s.sessionId);
|
|
183
|
+
const triggerOf = makeTriggerResolver(p);
|
|
184
|
+
const parentOf = makeParentResolver(runProvenance, messageProvenance, p.slug, (id) => parentDetach.isDetached(keeper, id));
|
|
185
|
+
const chats = await buildProjectChats(p.dir, sessions, undefined, archivedOf, lastTurnAt, lastSeenOf, provenanceOf, triggerOf, starredOf, unreadOf, parentOf);
|
|
186
|
+
for (const c of chats) {
|
|
187
|
+
const row = { ...c, projectSlug: p.slug, projectName: p.name };
|
|
188
|
+
if (isRunning(c.sessionId)) {
|
|
189
|
+
running.push(row);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
// Archived chats are filed away on purpose; they stay out of the
|
|
193
|
+
// unread feed so archiving is a real way to silence a chat. A
|
|
194
|
+
// RUNNING archived chat still shows — it is live work either way.
|
|
195
|
+
if (c.archived)
|
|
196
|
+
continue;
|
|
197
|
+
const completed = c.lastTurnCompletedAt ? Date.parse(c.lastTurnCompletedAt) : NaN;
|
|
198
|
+
const seen = c.lastSeen ?? 0;
|
|
199
|
+
if (c.unread || (Number.isFinite(completed) && completed > seen))
|
|
200
|
+
unreadRows.push(row);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
return { running, unread: unreadRows };
|
|
204
|
+
}
|
|
205
|
+
catch (err) {
|
|
206
|
+
return sendProjectError(reply, err);
|
|
207
|
+
}
|
|
208
|
+
});
|
|
209
|
+
// --- run history: "while you were away" (E3 / #268 / DD-6) ------------------
|
|
210
|
+
// A project-level view of what ran unattended (scheduled + spawned) plus human
|
|
211
|
+
// runs, sourced from herdctl job records (listProjectRuns) joined with the A1
|
|
212
|
+
// provenance marker (#261) for true origin/depth. Cost is a P3 seam (null).
|
|
213
|
+
//
|
|
214
|
+
// The since-last-visit digest reuses the read-state watermark (#189): a per-
|
|
215
|
+
// user "runs last seen" epoch keyed under the keeper agent with the reserved
|
|
216
|
+
// sentinel session id below (a plain UUID can't contain "__", so it can never
|
|
217
|
+
// alias a real chat's read-state).
|
|
218
|
+
app.get("/runs", {
|
|
219
|
+
schema: {
|
|
220
|
+
tags: ["Chats"],
|
|
221
|
+
summary: "List a project's run history",
|
|
222
|
+
description: "Returns the project's 'while you were away' run history (scheduled + spawned + human runs) sourced from herdctl job records joined with provenance markers, plus the per-user since-last-visit watermark. Response: an object describing runs and the last-seen digest state.",
|
|
223
|
+
params: {
|
|
224
|
+
type: "object",
|
|
225
|
+
properties: {
|
|
226
|
+
slug: { type: "string", description: "Project slug." },
|
|
227
|
+
},
|
|
228
|
+
required: ["slug"],
|
|
229
|
+
},
|
|
230
|
+
querystring: {
|
|
231
|
+
type: "object",
|
|
232
|
+
additionalProperties: true,
|
|
233
|
+
properties: {
|
|
234
|
+
limit: { type: "string", description: "Max number of runs to return (clamped server-side)." },
|
|
235
|
+
},
|
|
236
|
+
},
|
|
237
|
+
response: {
|
|
238
|
+
200: {
|
|
239
|
+
description: "Object with the project's runs and since-last-visit digest state.",
|
|
240
|
+
type: "object",
|
|
241
|
+
additionalProperties: true,
|
|
242
|
+
},
|
|
243
|
+
},
|
|
244
|
+
},
|
|
245
|
+
}, async (req, reply) => {
|
|
246
|
+
try {
|
|
247
|
+
const project = await projects.get(req.params.slug);
|
|
248
|
+
const keeper = keeperAgentName(project.slug);
|
|
249
|
+
const limit = clampRunsLimit(req.query.limit);
|
|
250
|
+
const [jobs, lastSeen] = await Promise.all([
|
|
251
|
+
herdctl.listProjectRuns(project, limit).catch(() => []),
|
|
252
|
+
readState
|
|
253
|
+
.getLastSeen(readStateUser(req), keeper, RUNS_SEEN_SESSION)
|
|
254
|
+
.catch(() => 0),
|
|
255
|
+
]);
|
|
256
|
+
// Resolve provenance for each DISTINCT session referenced by a run — a
|
|
257
|
+
// cheap in-memory map read per id (RunProvenanceStore is lazy-loaded once).
|
|
258
|
+
const sessionIds = [
|
|
259
|
+
...new Set(jobs.map((j) => j.session_id).filter((s) => !!s)),
|
|
260
|
+
];
|
|
261
|
+
const provBySession = new Map();
|
|
262
|
+
await Promise.all(sessionIds.map(async (sid) => {
|
|
263
|
+
const p = await runProvenance.get(sid).catch(() => undefined);
|
|
264
|
+
if (p)
|
|
265
|
+
provBySession.set(sid, p);
|
|
266
|
+
}));
|
|
267
|
+
return buildProjectRuns(jobs, provBySession, lastSeen);
|
|
268
|
+
}
|
|
269
|
+
catch (err) {
|
|
270
|
+
return sendProjectError(reply, err);
|
|
271
|
+
}
|
|
272
|
+
});
|
|
273
|
+
// Advance the per-user "runs last seen" watermark (clears the since-last-visit
|
|
274
|
+
// digest). Mirrors the chat-seen endpoint: optional `{ when }`, defaults to now,
|
|
275
|
+
// monotonic in the store (an older `when` is a no-op).
|
|
276
|
+
app.post("/runs/seen", {
|
|
277
|
+
schema: {
|
|
278
|
+
tags: ["Chats"],
|
|
279
|
+
summary: "Mark a project's runs as seen",
|
|
280
|
+
description: "Advances the per-user 'runs last seen' watermark (clears the since-last-visit digest). Optional body `{ when }` (epoch-ms) defaults to now; monotonic in the store (an older `when` is a no-op). Response: `{ ok, lastSeen }`.",
|
|
281
|
+
params: {
|
|
282
|
+
type: "object",
|
|
283
|
+
properties: {
|
|
284
|
+
slug: { type: "string", description: "Project slug." },
|
|
285
|
+
},
|
|
286
|
+
required: ["slug"],
|
|
287
|
+
},
|
|
288
|
+
body: {
|
|
289
|
+
type: ["object", "null"],
|
|
290
|
+
additionalProperties: true,
|
|
291
|
+
properties: {
|
|
292
|
+
when: { description: "Epoch-ms timestamp to record as last seen; defaults to now." },
|
|
293
|
+
},
|
|
294
|
+
required: [],
|
|
295
|
+
},
|
|
296
|
+
response: {
|
|
297
|
+
200: {
|
|
298
|
+
description: "Object `{ ok, lastSeen }` with the recorded watermark.",
|
|
299
|
+
type: "object",
|
|
300
|
+
additionalProperties: true,
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
},
|
|
304
|
+
}, async (req, reply) => {
|
|
305
|
+
try {
|
|
306
|
+
const keeper = await agentForSlug(req.params.slug);
|
|
307
|
+
const when = typeof req.body?.when === "number" && Number.isFinite(req.body.when)
|
|
308
|
+
? req.body.when
|
|
309
|
+
: Date.now();
|
|
310
|
+
await readState.setLastSeen(readStateUser(req), keeper, RUNS_SEEN_SESSION, when);
|
|
311
|
+
return reply.code(200).send({ ok: true, lastSeen: when });
|
|
312
|
+
}
|
|
313
|
+
catch (err) {
|
|
314
|
+
return sendProjectError(reply, err);
|
|
315
|
+
}
|
|
316
|
+
});
|
|
317
|
+
// Bulk context-window usage for ALL of a project's chats, keyed by session id
|
|
318
|
+
// (issue #116). This is the expensive part the chat list needs for its per-chat
|
|
319
|
+
// usage rings (issue #77) — each session's fill is read by streaming its
|
|
320
|
+
// transcript (memoized on transcript mtime). Split out of the project-detail
|
|
321
|
+
// and chat-list payloads so the ProjectView renders immediately and the client
|
|
322
|
+
// fills rings in progressively. Sessions with no usage data are omitted.
|
|
323
|
+
//
|
|
324
|
+
// Scoped by archived state (issue #537). There is no stored counter: usage is
|
|
325
|
+
// derived by streaming each transcript end to end, so the cost is proportional
|
|
326
|
+
// to transcript bytes, not chat count. The sidebar collapses the Archived group
|
|
327
|
+
// by default, so computing archived rings on every project open is work whose
|
|
328
|
+
// result is never rendered — on a live-scale project that was 72% of the bytes
|
|
329
|
+
// streamed. `scope` therefore defaults to `active`; the client asks for
|
|
330
|
+
// `archived` only once that group is actually expanded.
|
|
331
|
+
app.get("/chats/usage", {
|
|
332
|
+
schema: {
|
|
333
|
+
tags: ["Chats"],
|
|
334
|
+
summary: "Bulk context-window usage for a project's chats",
|
|
335
|
+
description: "Returns per-chat context-window usage keyed by session id — the expensive data the chat list needs for its usage rings. Scoped by `scope`, which defaults to `active` (non-archived chats only): usage is derived by streaming each transcript, and archived rings are hidden behind a collapsed group, so computing them by default is wasted work (issue #537). Pass `scope=archived` when that group is expanded, or `scope=all` for the whole project. Sessions with no usage data are omitted. Response: `{ usage: { <sessionId>: <usage> } }`.",
|
|
336
|
+
params: {
|
|
337
|
+
type: "object",
|
|
338
|
+
properties: {
|
|
339
|
+
slug: { type: "string", description: "Project slug." },
|
|
340
|
+
},
|
|
341
|
+
required: ["slug"],
|
|
342
|
+
},
|
|
343
|
+
querystring: {
|
|
344
|
+
type: "object",
|
|
345
|
+
properties: {
|
|
346
|
+
scope: {
|
|
347
|
+
type: "string",
|
|
348
|
+
enum: [...CHAT_USAGE_SCOPES],
|
|
349
|
+
default: "active",
|
|
350
|
+
description: "Which chats to compute usage for: `active` (non-archived, the default), `archived`, or `all`.",
|
|
351
|
+
},
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
response: {
|
|
355
|
+
200: {
|
|
356
|
+
description: "Object `{ usage }` mapping session id to its context-window usage.",
|
|
357
|
+
type: "object",
|
|
358
|
+
additionalProperties: true,
|
|
359
|
+
},
|
|
360
|
+
},
|
|
361
|
+
},
|
|
362
|
+
}, async (req, reply) => {
|
|
363
|
+
try {
|
|
364
|
+
const project = await projects.get(req.params.slug);
|
|
365
|
+
const sessions = await herdctl.listSessions(project).catch(() => []);
|
|
366
|
+
const scope = req.query.scope ?? "active";
|
|
367
|
+
const keeper = keeperAgentName(project.slug);
|
|
368
|
+
const usageOf = chatUsageResolver(project.dir, project.model ?? DEFAULT_MODEL);
|
|
369
|
+
// Bounded, NOT `Promise.all` (#544): one transcript read per chat, so an
|
|
370
|
+
// unbounded map opens 1,515 concurrent streams on a real project and
|
|
371
|
+
// peaks at 706 MB for no speed — 16 measured faster AND 4× smaller.
|
|
372
|
+
const entries = await mapWithConcurrency(sessions, FILE_FANOUT_CONCURRENCY, async (s) => {
|
|
373
|
+
// `all` skips the archive lookup entirely; the other two split on it.
|
|
374
|
+
// The lookup is an in-memory sidecar read, so it is free next to the
|
|
375
|
+
// transcript stream it is deciding whether to skip.
|
|
376
|
+
if (scope !== "all" &&
|
|
377
|
+
(await archive.isArchived(keeper, s.sessionId)) !== (scope === "archived")) {
|
|
378
|
+
return null;
|
|
379
|
+
}
|
|
380
|
+
const u = await usageOf(s).catch(() => null);
|
|
381
|
+
return u ? [s.sessionId, u] : null;
|
|
382
|
+
});
|
|
383
|
+
const usage = {};
|
|
384
|
+
for (const e of entries)
|
|
385
|
+
if (e)
|
|
386
|
+
usage[e[0]] = e[1];
|
|
387
|
+
return { usage };
|
|
388
|
+
}
|
|
389
|
+
catch (err) {
|
|
390
|
+
return sendProjectError(reply, err);
|
|
391
|
+
}
|
|
392
|
+
});
|
|
393
|
+
app.post("/chats", {
|
|
394
|
+
schema: {
|
|
395
|
+
tags: ["Chats"],
|
|
396
|
+
summary: "Get the WS target for a new project chat",
|
|
397
|
+
description: "Thin endpoint: validates the project and returns the WebSocket target for opening a new chat. It does NOT create the chat or send a message — a new chat is minted lazily by the first WS `chat:send` (with no sessionId), and the session id arrives in `chat:complete`. Responds 201 with `{ projectSlug, sessionId: null, ws: \"/ws\", note }`.",
|
|
398
|
+
params: {
|
|
399
|
+
type: "object",
|
|
400
|
+
properties: {
|
|
401
|
+
slug: { type: "string", description: "Project slug." },
|
|
402
|
+
},
|
|
403
|
+
required: ["slug"],
|
|
404
|
+
},
|
|
405
|
+
response: {
|
|
406
|
+
201: {
|
|
407
|
+
description: "Object `{ projectSlug, sessionId, ws, note }` giving the WS target `/ws`.",
|
|
408
|
+
type: "object",
|
|
409
|
+
additionalProperties: true,
|
|
410
|
+
},
|
|
411
|
+
},
|
|
412
|
+
},
|
|
413
|
+
}, async (req, reply) => {
|
|
414
|
+
// A "new chat" is created lazily by the first WS chat:send with no
|
|
415
|
+
// sessionId (the SDK mints the session id, returned in chat:complete).
|
|
416
|
+
// This endpoint just validates the project and returns the WS target so
|
|
417
|
+
// the client can open a socket. TODO: persist a custom chat name up-front
|
|
418
|
+
// via @herdctl/core SessionMetadataStore once we have a session id.
|
|
419
|
+
try {
|
|
420
|
+
const project = await projects.get(req.params.slug);
|
|
421
|
+
return reply.code(201).send({
|
|
422
|
+
projectSlug: project.slug,
|
|
423
|
+
sessionId: null,
|
|
424
|
+
ws: "/ws",
|
|
425
|
+
note: "Open /ws and send chat:send with this projectSlug; session id arrives in chat:complete.",
|
|
426
|
+
});
|
|
427
|
+
}
|
|
428
|
+
catch (err) {
|
|
429
|
+
return sendProjectError(reply, err);
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
// Messages of a specific chat (session) within a project.
|
|
433
|
+
app.get("/chats/:sessionId/messages", {
|
|
434
|
+
schema: {
|
|
435
|
+
tags: ["Chats"],
|
|
436
|
+
summary: "Read a project chat's messages",
|
|
437
|
+
description: "READ-ONLY: returns the messages of a project chat, enriched with tool details and per-message provenance, plus a synthetic trailing notice if the chat dead-ended at a usage/subscription limit. Sending a message is WebSocket-only (a `chat:send` frame on GET /ws) — there is no HTTP send-message endpoint. Response: `{ messages: [...] }`.",
|
|
438
|
+
params: {
|
|
439
|
+
type: "object",
|
|
440
|
+
properties: {
|
|
441
|
+
slug: { type: "string", description: "Project slug." },
|
|
442
|
+
sessionId: { type: "string", description: "Chat (session) id." },
|
|
443
|
+
},
|
|
444
|
+
required: ["slug", "sessionId"],
|
|
445
|
+
},
|
|
446
|
+
response: {
|
|
447
|
+
200: {
|
|
448
|
+
description: "Object `{ messages }` with the chat's message array.",
|
|
449
|
+
type: "object",
|
|
450
|
+
additionalProperties: true,
|
|
451
|
+
},
|
|
452
|
+
},
|
|
453
|
+
},
|
|
454
|
+
}, async (req, reply) => {
|
|
455
|
+
try {
|
|
456
|
+
const agent = await agentForSlug(req.params.slug);
|
|
457
|
+
const projectDir = await projectDirForSlug(req.params.slug);
|
|
458
|
+
const messages = await herdctl
|
|
459
|
+
.sessionMessages(agent, req.params.sessionId)
|
|
460
|
+
.catch(() => []);
|
|
461
|
+
const enriched = await enrichWithToolDetails(projectDir, req.params.sessionId, messages);
|
|
462
|
+
// Per-message provenance (issue #290): attribute machine-injected turns to
|
|
463
|
+
// their sender (chat / schedule) by joining the ordered injection markers.
|
|
464
|
+
// Human-typed turns match no marker and stay unlabelled (the default).
|
|
465
|
+
const markers = await messageProvenance.list(req.params.sessionId).catch(() => []);
|
|
466
|
+
const withProvenance = applyMessageProvenance(enriched, markers);
|
|
467
|
+
// Per-message context fill (issue #451): join the uuid→contextTokens
|
|
468
|
+
// series (assistant turns only) and forward-fill it across the user/tool
|
|
469
|
+
// messages between turns, so every message answers "how full was the
|
|
470
|
+
// window as of here". Rides the mtime-cached series; open-chat path only.
|
|
471
|
+
const series = SAFE_SESSION_ID.test(req.params.sessionId)
|
|
472
|
+
? await readContextSeries(projectDir, req.params.sessionId).catch(() => new Map())
|
|
473
|
+
: new Map();
|
|
474
|
+
if (series.size > 0) {
|
|
475
|
+
let lastCtx;
|
|
476
|
+
for (const m of withProvenance) {
|
|
477
|
+
if (m.uuid && series.has(m.uuid))
|
|
478
|
+
lastCtx = series.get(m.uuid);
|
|
479
|
+
if (lastCtx !== undefined)
|
|
480
|
+
m.contextTokens = lastCtx;
|
|
481
|
+
}
|
|
482
|
+
}
|
|
483
|
+
// #329: `@herdctl/core` drops synthetic messages when it parses the
|
|
484
|
+
// transcript, so a turn that dead-ended at a subscription/usage limit
|
|
485
|
+
// leaves no visible trace on reload. Re-scan the raw JSONL for a TRAILING
|
|
486
|
+
// usage-limit dead-end and append it as a synthetic notice turn so the
|
|
487
|
+
// reason the chat stopped survives a refresh (a later real reply clears it).
|
|
488
|
+
const notice = SAFE_SESSION_ID.test(req.params.sessionId)
|
|
489
|
+
? await scanTranscriptNotice(path.join(projectChatsDir(projectDir), `${req.params.sessionId}.jsonl`))
|
|
490
|
+
: null;
|
|
491
|
+
if (notice) {
|
|
492
|
+
const last = withProvenance[withProvenance.length - 1];
|
|
493
|
+
withProvenance.push({
|
|
494
|
+
role: "assistant",
|
|
495
|
+
content: "",
|
|
496
|
+
timestamp: last?.timestamp ?? new Date().toISOString(),
|
|
497
|
+
uuid: `notice-${req.params.sessionId}`,
|
|
498
|
+
notice,
|
|
499
|
+
});
|
|
500
|
+
}
|
|
501
|
+
return { messages: withProvenance };
|
|
502
|
+
}
|
|
503
|
+
catch (err) {
|
|
504
|
+
return sendProjectError(reply, err);
|
|
505
|
+
}
|
|
506
|
+
});
|
|
507
|
+
// Step-by-step transcript of a sub-agent launched from a Task/Agent tool block
|
|
508
|
+
// within a project chat (issue #37). `toolUseId` is the parent tool_use id
|
|
509
|
+
// carried on the enriched tool call; it resolves to the sub-agent's own
|
|
510
|
+
// transcript under `.chats/<sessionId>/subagents/`.
|
|
511
|
+
app.get("/chats/:sessionId/subagents/:toolUseId/messages", {
|
|
512
|
+
schema: {
|
|
513
|
+
tags: ["Chats"],
|
|
514
|
+
summary: "Read a sub-agent's transcript within a project chat",
|
|
515
|
+
description: "Returns the step-by-step transcript of a sub-agent launched from a Task/Agent tool block within a project chat. `toolUseId` is the parent tool_use id carried on the enriched tool call. Response: `{ messages: [...] }`.",
|
|
516
|
+
params: {
|
|
517
|
+
type: "object",
|
|
518
|
+
properties: {
|
|
519
|
+
slug: { type: "string", description: "Project slug." },
|
|
520
|
+
sessionId: { type: "string", description: "Chat (session) id." },
|
|
521
|
+
toolUseId: { type: "string", description: "Parent tool_use id of the sub-agent launch." },
|
|
522
|
+
},
|
|
523
|
+
required: ["slug", "sessionId", "toolUseId"],
|
|
524
|
+
},
|
|
525
|
+
response: {
|
|
526
|
+
200: {
|
|
527
|
+
description: "Object `{ messages }` with the sub-agent's message array.",
|
|
528
|
+
type: "object",
|
|
529
|
+
additionalProperties: true,
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
},
|
|
533
|
+
}, async (req, reply) => {
|
|
534
|
+
try {
|
|
535
|
+
const projectDir = await projectDirForSlug(req.params.slug);
|
|
536
|
+
const messages = await readSubagentMessages(projectDir, req.params.sessionId, req.params.toolUseId);
|
|
537
|
+
return { messages };
|
|
538
|
+
}
|
|
539
|
+
catch (err) {
|
|
540
|
+
return sendProjectError(reply, err);
|
|
541
|
+
}
|
|
542
|
+
});
|
|
543
|
+
// Context-window usage for a project chat, read from the transcript's most
|
|
544
|
+
// recent turn. Lets the UI show "context used" for a chat opened from history,
|
|
545
|
+
// before any new turn streams a fresh usage value. `usage` is null when the
|
|
546
|
+
// transcript has no usage data.
|
|
547
|
+
app.get("/chats/:sessionId/context", {
|
|
548
|
+
schema: {
|
|
549
|
+
tags: ["Chats"],
|
|
550
|
+
summary: "Context-window usage for a project chat",
|
|
551
|
+
description: "Returns the context-window usage for a project chat, read from the transcript's most recent turn — lets the UI show 'context used' before a new turn streams a fresh value. Response: `{ usage }`, where `usage` is null when the transcript has no usage data.",
|
|
552
|
+
params: {
|
|
553
|
+
type: "object",
|
|
554
|
+
properties: {
|
|
555
|
+
slug: { type: "string", description: "Project slug." },
|
|
556
|
+
sessionId: { type: "string", description: "Chat (session) id." },
|
|
557
|
+
},
|
|
558
|
+
required: ["slug", "sessionId"],
|
|
559
|
+
},
|
|
560
|
+
response: {
|
|
561
|
+
200: {
|
|
562
|
+
description: "Object `{ usage }` (null when the transcript has no usage data).",
|
|
563
|
+
type: "object",
|
|
564
|
+
additionalProperties: true,
|
|
565
|
+
},
|
|
566
|
+
},
|
|
567
|
+
},
|
|
568
|
+
}, async (req, reply) => {
|
|
569
|
+
try {
|
|
570
|
+
const projectDir = await projectDirForSlug(req.params.slug);
|
|
571
|
+
// Every key here addresses a workspace, so the per-workspace model
|
|
572
|
+
// override always applies.
|
|
573
|
+
const p = await projects.get(req.params.slug).catch(() => null);
|
|
574
|
+
const model = p?.model ?? DEFAULT_MODEL;
|
|
575
|
+
const u = await readSessionTokenUsageWithSubagents(projectDir, req.params.sessionId).catch(() => null);
|
|
576
|
+
return { usage: u ? toChatUsage(u, model) : null };
|
|
577
|
+
}
|
|
578
|
+
catch (err) {
|
|
579
|
+
return sendProjectError(reply, err);
|
|
580
|
+
}
|
|
581
|
+
});
|
|
582
|
+
// Delete a chat (session) within a project: removes its transcript JSONL.
|
|
583
|
+
app.delete("/chats/:sessionId", {
|
|
584
|
+
schema: {
|
|
585
|
+
tags: ["Chats"],
|
|
586
|
+
summary: "Delete a project chat",
|
|
587
|
+
description: "Deletes a project chat (session): removes its transcript JSONL and clears any archived/starred flag so a future session id can't inherit it. Responds 200 with `{ ok, removed }`.",
|
|
588
|
+
params: {
|
|
589
|
+
type: "object",
|
|
590
|
+
properties: {
|
|
591
|
+
slug: { type: "string", description: "Project slug." },
|
|
592
|
+
sessionId: { type: "string", description: "Chat (session) id." },
|
|
593
|
+
},
|
|
594
|
+
required: ["slug", "sessionId"],
|
|
595
|
+
},
|
|
596
|
+
response: {
|
|
597
|
+
200: {
|
|
598
|
+
description: "Object `{ ok, removed }` indicating whether the transcript was removed.",
|
|
599
|
+
type: "object",
|
|
600
|
+
additionalProperties: true,
|
|
601
|
+
},
|
|
602
|
+
},
|
|
603
|
+
},
|
|
604
|
+
}, async (req, reply) => {
|
|
605
|
+
try {
|
|
606
|
+
const agent = await agentForSlug(req.params.slug);
|
|
607
|
+
await cleanupAttachments(agent, req.params.sessionId);
|
|
608
|
+
const removed = await herdctl.deleteSession(agent, req.params.sessionId);
|
|
609
|
+
// Drop any archived/starred/unread/detached flag so a future session id
|
|
610
|
+
// can't inherit it. (Read-state watermarks, other users' unread overrides,
|
|
611
|
+
// provenance and any queued message are deliberately NOT swept here — that
|
|
612
|
+
// predates #508 and is a wider cleanup than a chat delete should attempt.)
|
|
613
|
+
await archive.setArchived(agent, req.params.sessionId, false).catch(() => undefined);
|
|
614
|
+
await star.setStarred(agent, req.params.sessionId, false).catch(() => undefined);
|
|
615
|
+
await unread
|
|
616
|
+
.setUnread(readStateUser(req), agent, req.params.sessionId, false)
|
|
617
|
+
.catch(() => undefined);
|
|
618
|
+
// #508: the detach override is per-chat state like the three above, so it
|
|
619
|
+
// has to be cleared on the same path — otherwise a recycled session id
|
|
620
|
+
// silently starts life detached from a parent it never had.
|
|
621
|
+
await parentDetach.setDetached(agent, req.params.sessionId, false).catch(() => undefined);
|
|
622
|
+
return reply.code(200).send({ ok: true, removed });
|
|
623
|
+
}
|
|
624
|
+
catch (err) {
|
|
625
|
+
return sendProjectError(reply, err);
|
|
626
|
+
}
|
|
627
|
+
});
|
|
628
|
+
// Rename a chat (session): set or clear its custom display name. Now unblocked
|
|
629
|
+
// by @herdctl/core's fleet.setSessionName (issue #10). A null/empty name
|
|
630
|
+
// clears any custom name.
|
|
631
|
+
app.patch("/chats/:sessionId", {
|
|
632
|
+
schema: {
|
|
633
|
+
tags: ["Chats"],
|
|
634
|
+
summary: "Rename a project chat",
|
|
635
|
+
description: "Sets or clears a project chat's custom display name. A null/empty `name` in the body clears any custom name. Responds 200 with `{ ok }`.",
|
|
636
|
+
params: {
|
|
637
|
+
type: "object",
|
|
638
|
+
properties: {
|
|
639
|
+
slug: { type: "string", description: "Project slug." },
|
|
640
|
+
sessionId: { type: "string", description: "Chat (session) id." },
|
|
641
|
+
},
|
|
642
|
+
required: ["slug", "sessionId"],
|
|
643
|
+
},
|
|
644
|
+
body: {
|
|
645
|
+
type: ["object", "null"],
|
|
646
|
+
additionalProperties: true,
|
|
647
|
+
properties: {
|
|
648
|
+
name: { description: "New display name; null/empty clears the custom name." },
|
|
649
|
+
},
|
|
650
|
+
required: [],
|
|
651
|
+
},
|
|
652
|
+
response: {
|
|
653
|
+
200: {
|
|
654
|
+
description: "Object `{ ok }`.",
|
|
655
|
+
type: "object",
|
|
656
|
+
additionalProperties: true,
|
|
657
|
+
},
|
|
658
|
+
},
|
|
659
|
+
},
|
|
660
|
+
}, async (req, reply) => {
|
|
661
|
+
try {
|
|
662
|
+
const agent = await agentForSlug(req.params.slug);
|
|
663
|
+
const name = req.body?.name ?? null;
|
|
664
|
+
await herdctl.renameSession(agent, req.params.sessionId, name);
|
|
665
|
+
return reply.code(200).send({ ok: true });
|
|
666
|
+
}
|
|
667
|
+
catch (err) {
|
|
668
|
+
return sendProjectError(reply, err);
|
|
669
|
+
}
|
|
670
|
+
});
|
|
671
|
+
// Fork a project chat: eagerly duplicate its transcript into a NEW session in
|
|
672
|
+
// the same project (leaving the source untouched) so the fork exists right
|
|
673
|
+
// away — a real, resumable chat with the parent's full history — rather than
|
|
674
|
+
// being created lazily on a first message. Optional `name` sets its title
|
|
675
|
+
// (e.g. "Fork of <parent>"). Returns the new session id.
|
|
676
|
+
app.post("/chats/:sessionId/fork", {
|
|
677
|
+
schema: {
|
|
678
|
+
tags: ["Chats"],
|
|
679
|
+
summary: "Fork a project chat",
|
|
680
|
+
description: "Eagerly duplicates a project chat's transcript into a NEW session in the same project (leaving the source untouched), so the fork exists immediately as a real, resumable chat with the parent's history. Optional body `{ name }` sets the fork's title; optional `{ fromUuid }` branches at an earlier message so the fork inherits only the prefix up to that turn (issue #451). Responds 201 with `{ sessionId }` (the new session id).",
|
|
681
|
+
params: {
|
|
682
|
+
type: "object",
|
|
683
|
+
properties: {
|
|
684
|
+
slug: { type: "string", description: "Project slug." },
|
|
685
|
+
sessionId: { type: "string", description: "Source chat (session) id to fork." },
|
|
686
|
+
},
|
|
687
|
+
required: ["slug", "sessionId"],
|
|
688
|
+
},
|
|
689
|
+
body: {
|
|
690
|
+
type: ["object", "null"],
|
|
691
|
+
additionalProperties: true,
|
|
692
|
+
properties: {
|
|
693
|
+
name: { description: "Title for the new forked chat." },
|
|
694
|
+
fromUuid: {
|
|
695
|
+
description: "Message uuid to branch at; the fork inherits only the history up to that turn. Omitted ⇒ the full transcript.",
|
|
696
|
+
},
|
|
697
|
+
},
|
|
698
|
+
},
|
|
699
|
+
response: {
|
|
700
|
+
201: {
|
|
701
|
+
description: "Object `{ sessionId }` with the new forked session id.",
|
|
702
|
+
type: "object",
|
|
703
|
+
additionalProperties: true,
|
|
704
|
+
},
|
|
705
|
+
},
|
|
706
|
+
},
|
|
707
|
+
}, async (req, reply) => {
|
|
708
|
+
try {
|
|
709
|
+
const project = await projects.get(req.params.slug);
|
|
710
|
+
// `fromUuid` (issue #451): branch at an earlier message instead of copying
|
|
711
|
+
// the whole history — the fork inherits only the prefix up to that turn.
|
|
712
|
+
const newId = await herdctl.forkSession(project, req.params.sessionId, req.body?.name, req.body?.fromUuid);
|
|
713
|
+
// Record the lineage. A UI fork previously stamped NO provenance at all,
|
|
714
|
+
// so a hand-forked chat was indistinguishable from a human root — and its
|
|
715
|
+
// only parent link lived in the forking browser's localStorage. Inherit
|
|
716
|
+
// depth from the source so the fork-bomb bound (#262) still holds.
|
|
717
|
+
const src = await runProvenance.get(req.params.sessionId).catch(() => undefined);
|
|
718
|
+
await runProvenance
|
|
719
|
+
.stamp(newId, childOf(src ?? HUMAN_ROOT, { project: project.slug, sessionId: req.params.sessionId }))
|
|
720
|
+
.catch(() => undefined);
|
|
721
|
+
return reply.code(201).send({ sessionId: newId });
|
|
722
|
+
}
|
|
723
|
+
catch (err) {
|
|
724
|
+
return sendProjectError(reply, err);
|
|
725
|
+
}
|
|
726
|
+
});
|
|
727
|
+
// Revert a project chat back to an earlier message (issue #451): truncate this
|
|
728
|
+
// session's transcript at `uuid`, in place (same session id), so the chat
|
|
729
|
+
// continues as if the later turns never happened. The dropped tail is backed
|
|
730
|
+
// up (recoverable). Rolls back the CONVERSATION only — real side-effects of the
|
|
731
|
+
// reverted turns are NOT undone (the UI warns). Returns the count dropped.
|
|
732
|
+
app.post("/chats/:sessionId/revert", {
|
|
733
|
+
schema: {
|
|
734
|
+
tags: ["Chats"],
|
|
735
|
+
summary: "Revert a project chat to an earlier message",
|
|
736
|
+
description: "Truncates the chat's transcript at `uuid`, in place (same session id), so the conversation continues as if the later turns never happened. The dropped tail is backed up and recoverable. Rolls back the CONVERSATION only — real side-effects of the reverted turns are NOT undone. Requires body `{ uuid }` (400 without it). Returns an object with the count of removed messages.",
|
|
737
|
+
params: {
|
|
738
|
+
type: "object",
|
|
739
|
+
properties: {
|
|
740
|
+
slug: { type: "string", description: "Project slug." },
|
|
741
|
+
sessionId: { type: "string", description: "Chat (session) id to revert." },
|
|
742
|
+
},
|
|
743
|
+
required: ["slug", "sessionId"],
|
|
744
|
+
},
|
|
745
|
+
body: {
|
|
746
|
+
type: ["object", "null"],
|
|
747
|
+
additionalProperties: true,
|
|
748
|
+
properties: {
|
|
749
|
+
uuid: { description: "Message uuid to truncate at (required; 400 when missing)." },
|
|
750
|
+
},
|
|
751
|
+
},
|
|
752
|
+
response: {
|
|
753
|
+
200: {
|
|
754
|
+
description: "Object reporting how many messages were removed.",
|
|
755
|
+
type: "object",
|
|
756
|
+
additionalProperties: true,
|
|
757
|
+
},
|
|
758
|
+
},
|
|
759
|
+
},
|
|
760
|
+
}, async (req, reply) => {
|
|
761
|
+
try {
|
|
762
|
+
const uuid = req.body?.uuid;
|
|
763
|
+
if (!uuid)
|
|
764
|
+
return reply.code(400).send({ error: "uuid is required" });
|
|
765
|
+
const project = await projects.get(req.params.slug);
|
|
766
|
+
const { removed } = await herdctl.revertSession(project, req.params.sessionId, uuid);
|
|
767
|
+
return reply.code(200).send({ ok: true, removed });
|
|
768
|
+
}
|
|
769
|
+
catch (err) {
|
|
770
|
+
return sendProjectError(reply, err);
|
|
771
|
+
}
|
|
772
|
+
});
|
|
773
|
+
// Archive (or unarchive) a project chat (#95). A non-destructive toggle on a
|
|
774
|
+
// persisted per-chat flag — the transcript is untouched and the chat stays
|
|
775
|
+
// openable/resumable/forkable; it just moves into the Archived section.
|
|
776
|
+
app.post("/chats/:sessionId/archive", {
|
|
777
|
+
schema: {
|
|
778
|
+
tags: ["Chats"],
|
|
779
|
+
summary: "Archive or unarchive a project chat",
|
|
780
|
+
description: "Non-destructive toggle of a project chat's persisted archived flag — the transcript is untouched and the chat stays openable/resumable/forkable. Body `{ archived }` defaults to true; a real transition into archived fires the project's onArchive hooks. Responds 200 with `{ ok, archived }`.",
|
|
781
|
+
params: {
|
|
782
|
+
type: "object",
|
|
783
|
+
properties: {
|
|
784
|
+
slug: { type: "string", description: "Project slug." },
|
|
785
|
+
sessionId: { type: "string", description: "Chat (session) id." },
|
|
786
|
+
},
|
|
787
|
+
required: ["slug", "sessionId"],
|
|
788
|
+
},
|
|
789
|
+
body: {
|
|
790
|
+
type: ["object", "null"],
|
|
791
|
+
additionalProperties: true,
|
|
792
|
+
properties: {
|
|
793
|
+
archived: { description: "Target archived state; defaults to true when omitted." },
|
|
794
|
+
},
|
|
795
|
+
required: [],
|
|
796
|
+
},
|
|
797
|
+
response: {
|
|
798
|
+
200: {
|
|
799
|
+
description: "Object `{ ok, archived }` with the resulting archived state.",
|
|
800
|
+
type: "object",
|
|
801
|
+
additionalProperties: true,
|
|
802
|
+
},
|
|
803
|
+
},
|
|
804
|
+
},
|
|
805
|
+
}, async (req, reply) => {
|
|
806
|
+
try {
|
|
807
|
+
const agent = await agentForSlug(req.params.slug);
|
|
808
|
+
const archived = req.body?.archived !== false; // default true
|
|
809
|
+
const changed = await archive.setArchived(agent, req.params.sessionId, archived);
|
|
810
|
+
// Epic G / G1: after the archive COMMITS, emit `onArchive` (only on a real
|
|
811
|
+
// transition into archived) so the hook dispatcher fires the project's enabled
|
|
812
|
+
// onArchive hooks. Fire-and-forget — never blocks/fails the archive response.
|
|
813
|
+
if (changed && archived) {
|
|
814
|
+
events?.emit("onArchive", { slug: req.params.slug, sessionId: req.params.sessionId });
|
|
815
|
+
}
|
|
816
|
+
return reply.code(200).send({ ok: true, archived });
|
|
817
|
+
}
|
|
818
|
+
catch (err) {
|
|
819
|
+
return sendProjectError(reply, err);
|
|
820
|
+
}
|
|
821
|
+
});
|
|
822
|
+
// Star (or unstar) a project chat (#373). A non-destructive toggle on a
|
|
823
|
+
// persisted per-chat flag — orthogonal to archiving; a starred chat just sorts
|
|
824
|
+
// to the top of its population (active or Archived). Fires no lifecycle event.
|
|
825
|
+
app.post("/chats/:sessionId/star", {
|
|
826
|
+
schema: {
|
|
827
|
+
tags: ["Chats"],
|
|
828
|
+
summary: "Star or unstar a project chat",
|
|
829
|
+
description: "Non-destructive toggle of a project chat's persisted starred flag (orthogonal to archiving; a starred chat sorts to the top of its population). Body `{ starred }` defaults to true. Responds 200 with `{ ok, starred }`.",
|
|
830
|
+
params: {
|
|
831
|
+
type: "object",
|
|
832
|
+
properties: {
|
|
833
|
+
slug: { type: "string", description: "Project slug." },
|
|
834
|
+
sessionId: { type: "string", description: "Chat (session) id." },
|
|
835
|
+
},
|
|
836
|
+
required: ["slug", "sessionId"],
|
|
837
|
+
},
|
|
838
|
+
body: {
|
|
839
|
+
type: ["object", "null"],
|
|
840
|
+
additionalProperties: true,
|
|
841
|
+
properties: {
|
|
842
|
+
starred: { description: "Target starred state; defaults to true when omitted." },
|
|
843
|
+
},
|
|
844
|
+
required: [],
|
|
845
|
+
},
|
|
846
|
+
response: {
|
|
847
|
+
200: {
|
|
848
|
+
description: "Object `{ ok, starred }` with the resulting starred state.",
|
|
849
|
+
type: "object",
|
|
850
|
+
additionalProperties: true,
|
|
851
|
+
},
|
|
852
|
+
},
|
|
853
|
+
},
|
|
854
|
+
}, async (req, reply) => {
|
|
855
|
+
try {
|
|
856
|
+
const agent = await agentForSlug(req.params.slug);
|
|
857
|
+
const starred = req.body?.starred !== false; // default true
|
|
858
|
+
await star.setStarred(agent, req.params.sessionId, starred);
|
|
859
|
+
return reply.code(200).send({ ok: true, starred });
|
|
860
|
+
}
|
|
861
|
+
catch (err) {
|
|
862
|
+
return sendProjectError(reply, err);
|
|
863
|
+
}
|
|
864
|
+
});
|
|
865
|
+
// Mark a project chat SEEN (#189): persist the user's last-viewed moment for
|
|
866
|
+
// this chat server-side (keyed by user when present, else a shared bucket), so
|
|
867
|
+
// the unread affordance (#160/#161) follows the user across devices. Body's
|
|
868
|
+
// optional `when` (epoch-ms) lets the client pass its own timestamp; defaults
|
|
869
|
+
// to now. Mirrors the archive toggle's shape/validation. Monotonic in the
|
|
870
|
+
// store (an older `when` is a no-op), so it never resurrects a stale unread.
|
|
871
|
+
app.post("/chats/:sessionId/seen", {
|
|
872
|
+
schema: {
|
|
873
|
+
tags: ["Chats"],
|
|
874
|
+
summary: "Mark a project chat as seen",
|
|
875
|
+
description: "Persists the user's last-viewed moment for a project chat server-side, driving the cross-device unread affordance. Optional body `{ when }` (epoch-ms) defaults to now; monotonic in the store (an older `when` is a no-op). Responds 200 with `{ ok, lastSeen }`.",
|
|
876
|
+
params: {
|
|
877
|
+
type: "object",
|
|
878
|
+
properties: {
|
|
879
|
+
slug: { type: "string", description: "Project slug." },
|
|
880
|
+
sessionId: { type: "string", description: "Chat (session) id." },
|
|
881
|
+
},
|
|
882
|
+
required: ["slug", "sessionId"],
|
|
883
|
+
},
|
|
884
|
+
body: {
|
|
885
|
+
type: ["object", "null"],
|
|
886
|
+
additionalProperties: true,
|
|
887
|
+
properties: {
|
|
888
|
+
when: { description: "Epoch-ms timestamp to record as last seen; defaults to now." },
|
|
889
|
+
},
|
|
890
|
+
required: [],
|
|
891
|
+
},
|
|
892
|
+
response: {
|
|
893
|
+
200: {
|
|
894
|
+
description: "Object `{ ok, lastSeen }` with the recorded watermark.",
|
|
895
|
+
type: "object",
|
|
896
|
+
additionalProperties: true,
|
|
897
|
+
},
|
|
898
|
+
},
|
|
899
|
+
},
|
|
900
|
+
}, async (req, reply) => {
|
|
901
|
+
try {
|
|
902
|
+
const agent = await agentForSlug(req.params.slug);
|
|
903
|
+
const when = typeof req.body?.when === "number" && Number.isFinite(req.body.when)
|
|
904
|
+
? req.body.when
|
|
905
|
+
: Date.now();
|
|
906
|
+
const user = readStateUser(req);
|
|
907
|
+
await readState.setLastSeen(user, agent, req.params.sessionId, when);
|
|
908
|
+
// Marking a chat seen also clears any MANUAL unread override (#458): once
|
|
909
|
+
// the user has viewed it, the "look at it again later" flag is spent.
|
|
910
|
+
await unread.setUnread(user, agent, req.params.sessionId, false).catch(() => undefined);
|
|
911
|
+
return reply.code(200).send({ ok: true, lastSeen: when });
|
|
912
|
+
}
|
|
913
|
+
catch (err) {
|
|
914
|
+
return sendProjectError(reply, err);
|
|
915
|
+
}
|
|
916
|
+
});
|
|
917
|
+
// Mark a project chat UNREAD (#458): set (or clear) the per-user MANUAL unread
|
|
918
|
+
// override, so a chat resurfaces its unread cue even after its last turn was
|
|
919
|
+
// seen — the email "mark as unread" pattern. Mirrors the archive/star toggles'
|
|
920
|
+
// shape (`{ unread?: boolean }`, defaults true). Clearing it is equivalent to
|
|
921
|
+
// marking seen; the client's toggle uses `/seen` for read and this for unread.
|
|
922
|
+
app.post("/chats/:sessionId/unread", {
|
|
923
|
+
schema: {
|
|
924
|
+
tags: ["Chats"],
|
|
925
|
+
summary: "Mark a project chat unread",
|
|
926
|
+
description: "Sets (or clears) the per-user MANUAL unread override so a chat resurfaces its unread cue even after its last turn was seen — the email \"mark as unread\" pattern. Body `{ unread }` defaults to true; clearing it is equivalent to marking seen (see the `/seen` route). Returns `{ ok, unread }`.",
|
|
927
|
+
params: {
|
|
928
|
+
type: "object",
|
|
929
|
+
properties: {
|
|
930
|
+
slug: { type: "string", description: "Project slug." },
|
|
931
|
+
sessionId: { type: "string", description: "Chat (session) id." },
|
|
932
|
+
},
|
|
933
|
+
required: ["slug", "sessionId"],
|
|
934
|
+
},
|
|
935
|
+
body: {
|
|
936
|
+
type: ["object", "null"],
|
|
937
|
+
additionalProperties: true,
|
|
938
|
+
properties: {
|
|
939
|
+
unread: { description: "Whether to flag the chat unread. Defaults to true." },
|
|
940
|
+
},
|
|
941
|
+
},
|
|
942
|
+
response: {
|
|
943
|
+
200: {
|
|
944
|
+
description: "Object `{ ok, unread }` reflecting the stored flag.",
|
|
945
|
+
type: "object",
|
|
946
|
+
additionalProperties: true,
|
|
947
|
+
},
|
|
948
|
+
},
|
|
949
|
+
},
|
|
950
|
+
}, async (req, reply) => {
|
|
951
|
+
try {
|
|
952
|
+
const agent = await agentForSlug(req.params.slug);
|
|
953
|
+
const flag = req.body?.unread !== false; // default true
|
|
954
|
+
await unread.setUnread(readStateUser(req), agent, req.params.sessionId, flag);
|
|
955
|
+
return reply.code(200).send({ ok: true, unread: flag });
|
|
956
|
+
}
|
|
957
|
+
catch (err) {
|
|
958
|
+
return sendProjectError(reply, err);
|
|
959
|
+
}
|
|
960
|
+
});
|
|
961
|
+
// Detach a project chat from its parent (#508): promote it — with its own
|
|
962
|
+
// subtree intact — to the top level of the nested chat list, so a family can be
|
|
963
|
+
// archived/deleted "except this one".
|
|
964
|
+
//
|
|
965
|
+
// This is an OVERRIDE, not an edit: most live parent edges are INFERRED from
|
|
966
|
+
// who injected the kickoff prompt rather than recorded, so clearing an edge
|
|
967
|
+
// would simply be re-derived on the next list load. The flag is checked ahead of
|
|
968
|
+
// both resolver tiers (see `makeParentResolver`). Nothing is destroyed, so
|
|
969
|
+
// `{ detached: false }` re-attaches for free.
|
|
970
|
+
app.post("/chats/:sessionId/detach", {
|
|
971
|
+
schema: {
|
|
972
|
+
tags: ["Chats"],
|
|
973
|
+
summary: "Detach a project chat from its parent",
|
|
974
|
+
description: "Promotes a nested chat to the top level of the chat tree, keeping its own descendants under it. Persisted as an explicit override that is checked AHEAD of both parent-resolution tiers (the recorded `RunProvenance.parentSessionId` and the inferred kickoff-message edge), so it survives a reload. Body `{ detached }` defaults to true; `false` re-attaches (the underlying edge was never destroyed). Responds 200 with `{ ok, detached }`.",
|
|
975
|
+
params: {
|
|
976
|
+
type: "object",
|
|
977
|
+
properties: {
|
|
978
|
+
slug: { type: "string", description: "Project slug." },
|
|
979
|
+
sessionId: { type: "string", description: "Chat (session) id." },
|
|
980
|
+
},
|
|
981
|
+
required: ["slug", "sessionId"],
|
|
982
|
+
},
|
|
983
|
+
body: {
|
|
984
|
+
type: ["object", "null"],
|
|
985
|
+
additionalProperties: true,
|
|
986
|
+
properties: {
|
|
987
|
+
detached: {
|
|
988
|
+
description: "Whether the chat should render as a root regardless of its parent edge. Defaults to true.",
|
|
989
|
+
},
|
|
990
|
+
},
|
|
991
|
+
required: [],
|
|
992
|
+
},
|
|
993
|
+
response: {
|
|
994
|
+
200: {
|
|
995
|
+
description: "Object `{ ok, detached }` with the resulting detached state.",
|
|
996
|
+
type: "object",
|
|
997
|
+
additionalProperties: true,
|
|
998
|
+
},
|
|
999
|
+
},
|
|
1000
|
+
},
|
|
1001
|
+
}, async (req, reply) => {
|
|
1002
|
+
try {
|
|
1003
|
+
const agent = await agentForSlug(req.params.slug);
|
|
1004
|
+
const detached = req.body?.detached !== false; // default true
|
|
1005
|
+
await parentDetach.setDetached(agent, req.params.sessionId, detached);
|
|
1006
|
+
return reply.code(200).send({ ok: true, detached });
|
|
1007
|
+
}
|
|
1008
|
+
catch (err) {
|
|
1009
|
+
return sendProjectError(reply, err);
|
|
1010
|
+
}
|
|
1011
|
+
});
|
|
1012
|
+
// --- batch (subtree) chat actions (#508) -------------------------------
|
|
1013
|
+
//
|
|
1014
|
+
// Shift-clicking a parent row applies archive / mark-read-unread / delete to
|
|
1015
|
+
// that chat AND every descendant. Looping the single-chat routes client-side
|
|
1016
|
+
// over 21 chats is 21 round trips that can half-succeed, leaving a torn family
|
|
1017
|
+
// (parent archived, some children not) with no single thing to roll back. These
|
|
1018
|
+
// routes take the whole id set, so the flag sidecars commit in ONE write and the
|
|
1019
|
+
// client has one call to roll back.
|
|
1020
|
+
//
|
|
1021
|
+
// They sit under the literal `/chats/batch/` prefix; a real session id is a UUID,
|
|
1022
|
+
// so it can never collide with the `/chats/:sessionId/...` routes.
|
|
1023
|
+
app.post("/chats/batch/archive", {
|
|
1024
|
+
schema: {
|
|
1025
|
+
tags: ["Chats"],
|
|
1026
|
+
summary: "Archive or unarchive many project chats",
|
|
1027
|
+
description: "Applies a project chat's archived flag to a whole SET of chats (the nested chat list's shift-click subtree action, #508) in a single atomic sidecar write, so a parent and its descendants can't end up in different states. Body `{ sessionIds, archived }`; `archived` defaults to true. Each real transition into archived fires the project's onArchive triggers, exactly like the single-chat route. Responds 200 with `{ ok, archived, changed }` where `changed` lists the ids whose flag actually moved.",
|
|
1028
|
+
params: {
|
|
1029
|
+
type: "object",
|
|
1030
|
+
properties: { slug: { type: "string", description: "Project slug." } },
|
|
1031
|
+
required: ["slug"],
|
|
1032
|
+
},
|
|
1033
|
+
body: {
|
|
1034
|
+
type: "object",
|
|
1035
|
+
additionalProperties: true,
|
|
1036
|
+
properties: {
|
|
1037
|
+
sessionIds: {
|
|
1038
|
+
type: "array",
|
|
1039
|
+
items: { type: "string" },
|
|
1040
|
+
description: "Chat (session) ids to apply the flag to. 1–500 entries.",
|
|
1041
|
+
},
|
|
1042
|
+
archived: { description: "Target archived state; defaults to true when omitted." },
|
|
1043
|
+
},
|
|
1044
|
+
required: ["sessionIds"],
|
|
1045
|
+
},
|
|
1046
|
+
response: {
|
|
1047
|
+
200: {
|
|
1048
|
+
description: "Object `{ ok, archived, changed }`.",
|
|
1049
|
+
type: "object",
|
|
1050
|
+
additionalProperties: true,
|
|
1051
|
+
},
|
|
1052
|
+
},
|
|
1053
|
+
},
|
|
1054
|
+
}, async (req, reply) => {
|
|
1055
|
+
try {
|
|
1056
|
+
const sessionIds = normalizeBatchSessionIds(req.body?.sessionIds);
|
|
1057
|
+
if (!sessionIds)
|
|
1058
|
+
return sendBadBatch(reply);
|
|
1059
|
+
const agent = await agentForSlug(req.params.slug);
|
|
1060
|
+
const archived = req.body?.archived !== false; // default true
|
|
1061
|
+
const changed = await archive.setManyArchived(agent, sessionIds, archived);
|
|
1062
|
+
// One `onArchive` per REAL transition into archived — the same contract the
|
|
1063
|
+
// single-chat route keeps, so a subtree archive fires the project's triggers
|
|
1064
|
+
// for each newly-archived chat and for none of the already-archived ones.
|
|
1065
|
+
//
|
|
1066
|
+
// Note the FAN-OUT this implies: shift-archiving a 21-chat family emits 21
|
|
1067
|
+
// events, and a project with an enabled onArchive trigger will start 21
|
|
1068
|
+
// agent turns from one click. That is the contract working as specified
|
|
1069
|
+
// (the trigger asked to fire per archived chat), and coalescing here would
|
|
1070
|
+
// silently drop events a subscriber is entitled to — but it is a real cost,
|
|
1071
|
+
// and the first person to enable an onArchive trigger will meet it.
|
|
1072
|
+
// Rate-limiting belongs in the dispatcher, not in this route.
|
|
1073
|
+
if (archived) {
|
|
1074
|
+
for (const sessionId of changed) {
|
|
1075
|
+
events?.emit("onArchive", { slug: req.params.slug, sessionId });
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
return reply.code(200).send({ ok: true, archived, changed });
|
|
1079
|
+
}
|
|
1080
|
+
catch (err) {
|
|
1081
|
+
return sendProjectError(reply, err);
|
|
1082
|
+
}
|
|
1083
|
+
});
|
|
1084
|
+
// Mark many project chats read or unread (#508). `unread: false` is the "mark
|
|
1085
|
+
// read" half and does BOTH halves of what reading a chat means — clears the
|
|
1086
|
+
// manual override AND advances each chat's last-seen watermark — because the
|
|
1087
|
+
// derived unread signal (#160) would otherwise re-raise the cue the instant the
|
|
1088
|
+
// manual flag was cleared.
|
|
1089
|
+
app.post("/chats/batch/unread", {
|
|
1090
|
+
schema: {
|
|
1091
|
+
tags: ["Chats"],
|
|
1092
|
+
summary: "Mark many project chats read or unread",
|
|
1093
|
+
description: "Applies the read/unread action to a whole SET of chats (the nested chat list's shift-click subtree action, #508) in a single atomic sidecar write. Body `{ sessionIds, unread }`; `unread` defaults to true. `unread: false` means \"mark read\": it clears each chat's manual unread override AND advances its last-seen watermark (the derived unread signal would otherwise immediately re-raise the cue). Responds 200 with `{ ok, unread, changed }`.",
|
|
1094
|
+
params: {
|
|
1095
|
+
type: "object",
|
|
1096
|
+
properties: { slug: { type: "string", description: "Project slug." } },
|
|
1097
|
+
required: ["slug"],
|
|
1098
|
+
},
|
|
1099
|
+
body: {
|
|
1100
|
+
type: "object",
|
|
1101
|
+
additionalProperties: true,
|
|
1102
|
+
properties: {
|
|
1103
|
+
sessionIds: {
|
|
1104
|
+
type: "array",
|
|
1105
|
+
items: { type: "string" },
|
|
1106
|
+
description: "Chat (session) ids to apply the flag to. 1–500 entries.",
|
|
1107
|
+
},
|
|
1108
|
+
unread: { description: "Whether to flag the chats unread. Defaults to true." },
|
|
1109
|
+
},
|
|
1110
|
+
required: ["sessionIds"],
|
|
1111
|
+
},
|
|
1112
|
+
response: {
|
|
1113
|
+
200: {
|
|
1114
|
+
description: "Object `{ ok, unread, changed }`.",
|
|
1115
|
+
type: "object",
|
|
1116
|
+
additionalProperties: true,
|
|
1117
|
+
},
|
|
1118
|
+
},
|
|
1119
|
+
},
|
|
1120
|
+
}, async (req, reply) => {
|
|
1121
|
+
try {
|
|
1122
|
+
const sessionIds = normalizeBatchSessionIds(req.body?.sessionIds);
|
|
1123
|
+
if (!sessionIds)
|
|
1124
|
+
return sendBadBatch(reply);
|
|
1125
|
+
const agent = await agentForSlug(req.params.slug);
|
|
1126
|
+
const user = readStateUser(req);
|
|
1127
|
+
const flag = req.body?.unread !== false; // default true
|
|
1128
|
+
const changed = await unread.setManyUnread(user, agent, sessionIds, flag);
|
|
1129
|
+
// Marking read also advances read-state, mirroring the single-chat client
|
|
1130
|
+
// flow (which calls `/seen`, and `/seen` clears the manual flag).
|
|
1131
|
+
if (!flag) {
|
|
1132
|
+
await readState
|
|
1133
|
+
.setManyLastSeen(user, agent, sessionIds, Date.now())
|
|
1134
|
+
.catch(() => undefined);
|
|
1135
|
+
}
|
|
1136
|
+
return reply.code(200).send({ ok: true, unread: flag, changed });
|
|
1137
|
+
}
|
|
1138
|
+
catch (err) {
|
|
1139
|
+
return sendProjectError(reply, err);
|
|
1140
|
+
}
|
|
1141
|
+
});
|
|
1142
|
+
// Delete many project chats (#508) — the shift-click subtree delete, behind a
|
|
1143
|
+
// count-aware confirmation in the UI.
|
|
1144
|
+
//
|
|
1145
|
+
// Unlike the flag routes this can NOT be atomic: each delete unlinks a transcript
|
|
1146
|
+
// file, and the filesystem offers no transaction. So it is best-effort per chat
|
|
1147
|
+
// and honest about it — every id is attempted (one failure never abandons the
|
|
1148
|
+
// rest) and the response names which ones failed, so the client can surface a
|
|
1149
|
+
// partial failure instead of pretending the family is gone.
|
|
1150
|
+
app.post("/chats/batch/delete", {
|
|
1151
|
+
schema: {
|
|
1152
|
+
tags: ["Chats"],
|
|
1153
|
+
summary: "Delete many project chats",
|
|
1154
|
+
description: "Deletes a SET of project chats (the nested chat list's shift-click subtree delete, #508): removes each transcript JSONL and clears its archived/starred/unread/detached flags. Body `{ sessionIds }`. Filesystem deletes cannot be atomic, so this is best-effort per chat: every id is attempted and the response reports `{ ok, removed, failed }` — `removed` lists the ids whose transcript was deleted, `failed` the ids that errored.",
|
|
1155
|
+
params: {
|
|
1156
|
+
type: "object",
|
|
1157
|
+
properties: { slug: { type: "string", description: "Project slug." } },
|
|
1158
|
+
required: ["slug"],
|
|
1159
|
+
},
|
|
1160
|
+
body: {
|
|
1161
|
+
type: "object",
|
|
1162
|
+
additionalProperties: true,
|
|
1163
|
+
properties: {
|
|
1164
|
+
sessionIds: {
|
|
1165
|
+
type: "array",
|
|
1166
|
+
items: { type: "string" },
|
|
1167
|
+
description: "Chat (session) ids to delete. 1–500 entries.",
|
|
1168
|
+
},
|
|
1169
|
+
},
|
|
1170
|
+
required: ["sessionIds"],
|
|
1171
|
+
},
|
|
1172
|
+
response: {
|
|
1173
|
+
200: {
|
|
1174
|
+
description: "Object `{ ok, removed, failed }`.",
|
|
1175
|
+
type: "object",
|
|
1176
|
+
additionalProperties: true,
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
},
|
|
1180
|
+
}, async (req, reply) => {
|
|
1181
|
+
try {
|
|
1182
|
+
const sessionIds = normalizeBatchSessionIds(req.body?.sessionIds);
|
|
1183
|
+
if (!sessionIds)
|
|
1184
|
+
return sendBadBatch(reply);
|
|
1185
|
+
const agent = await agentForSlug(req.params.slug);
|
|
1186
|
+
const user = readStateUser(req);
|
|
1187
|
+
const removed = [];
|
|
1188
|
+
const failed = [];
|
|
1189
|
+
for (const sessionId of sessionIds) {
|
|
1190
|
+
try {
|
|
1191
|
+
await cleanupAttachments(agent, sessionId);
|
|
1192
|
+
const gone = await herdctl.deleteSession(agent, sessionId);
|
|
1193
|
+
if (gone)
|
|
1194
|
+
removed.push(sessionId);
|
|
1195
|
+
else
|
|
1196
|
+
failed.push(sessionId);
|
|
1197
|
+
}
|
|
1198
|
+
catch {
|
|
1199
|
+
failed.push(sessionId);
|
|
1200
|
+
}
|
|
1201
|
+
}
|
|
1202
|
+
// Drop the archived/starred/unread/detached flags of the chats that were
|
|
1203
|
+
// ACTUALLY removed, so a recycled session id can't inherit stale state.
|
|
1204
|
+
//
|
|
1205
|
+
// Deliberately scoped to `removed`, NOT the whole requested set: a chat in
|
|
1206
|
+
// `failed` still exists on disk, and the response has just told the user
|
|
1207
|
+
// so. Clearing its flags would quietly unarchive it (so it reappears in
|
|
1208
|
+
// the active list on the next refetch), unstar it, and re-attach it to the
|
|
1209
|
+
// parent it was detached from — a chat the user was told SURVIVED, mutated
|
|
1210
|
+
// by the delete that spared it.
|
|
1211
|
+
await archive.setManyArchived(agent, removed, false).catch(() => undefined);
|
|
1212
|
+
await unread.setManyUnread(user, agent, removed, false).catch(() => undefined);
|
|
1213
|
+
for (const sessionId of removed) {
|
|
1214
|
+
await star.setStarred(agent, sessionId, false).catch(() => undefined);
|
|
1215
|
+
await parentDetach.setDetached(agent, sessionId, false).catch(() => undefined);
|
|
1216
|
+
}
|
|
1217
|
+
return reply.code(200).send({ ok: true, removed, failed });
|
|
1218
|
+
}
|
|
1219
|
+
catch (err) {
|
|
1220
|
+
return sendProjectError(reply, err);
|
|
1221
|
+
}
|
|
1222
|
+
});
|
|
1223
|
+
// Promote a chat into a NEW project (issue #20): create the project + keeper,
|
|
1224
|
+
// then re-home the chat's transcript into it so it lists + resumes under the
|
|
1225
|
+
// new project. Returns { project, promoted } — `promoted:false` means the
|
|
1226
|
+
// project was created but the transcript couldn't be moved (e.g. an unknown
|
|
1227
|
+
// session id); the project is still usable.
|
|
1228
|
+
//
|
|
1229
|
+
// Nothing about it is root-specific: it takes a source workspace like every
|
|
1230
|
+
// other route in this file, and the root is a workspace.
|
|
1231
|
+
app.post("/chats/:sessionId/promote", {
|
|
1232
|
+
schema: {
|
|
1233
|
+
tags: ["Chats"],
|
|
1234
|
+
summary: "Promote a chat into a new project",
|
|
1235
|
+
description: "Promotes a chat into a NEW project: creates the project + its agent, then re-homes the chat's transcript into it so it lists and resumes under the new project. `name` is required (validated at runtime; a missing/blank name returns 400). Responds 201 with `{ project, promoted, sessionId }` — `promoted:false` means the project was created but the transcript couldn't be moved (e.g. an unknown session id); the project is still usable.",
|
|
1236
|
+
params: {
|
|
1237
|
+
type: "object",
|
|
1238
|
+
properties: {
|
|
1239
|
+
slug: { type: "string", description: "Slug of the project the chat currently lives in." },
|
|
1240
|
+
sessionId: { type: "string", description: "Chat (session) id to promote." },
|
|
1241
|
+
},
|
|
1242
|
+
required: ["slug", "sessionId"],
|
|
1243
|
+
},
|
|
1244
|
+
body: {
|
|
1245
|
+
type: ["object", "null"],
|
|
1246
|
+
additionalProperties: true,
|
|
1247
|
+
properties: {
|
|
1248
|
+
name: { description: "New project name (required; validated at runtime)." },
|
|
1249
|
+
slug: { description: "Optional explicit project slug." },
|
|
1250
|
+
group: { description: "Optional project group." },
|
|
1251
|
+
summary: { description: "Optional project summary." },
|
|
1252
|
+
domain: { description: "Optional project domain tags." },
|
|
1253
|
+
},
|
|
1254
|
+
required: [],
|
|
1255
|
+
},
|
|
1256
|
+
response: {
|
|
1257
|
+
201: {
|
|
1258
|
+
description: "Object `{ project, promoted, sessionId }` describing the created project and whether the transcript was re-homed.",
|
|
1259
|
+
type: "object",
|
|
1260
|
+
additionalProperties: true,
|
|
1261
|
+
},
|
|
1262
|
+
},
|
|
1263
|
+
},
|
|
1264
|
+
}, async (req, reply) => {
|
|
1265
|
+
const body = req.body ?? {};
|
|
1266
|
+
if (!body.name || !body.name.trim()) {
|
|
1267
|
+
return reply.code(400).send({ error: "Project name is required", code: "invalid" });
|
|
1268
|
+
}
|
|
1269
|
+
try {
|
|
1270
|
+
const from = await projects.get(req.params.slug);
|
|
1271
|
+
const project = await projects.create({
|
|
1272
|
+
name: body.name,
|
|
1273
|
+
slug: body.slug,
|
|
1274
|
+
group: body.group,
|
|
1275
|
+
summary: body.summary,
|
|
1276
|
+
domain: Array.isArray(body.domain) ? body.domain : undefined,
|
|
1277
|
+
});
|
|
1278
|
+
// Register the keeper + sweeper (creates the project's .chats symlink)
|
|
1279
|
+
// BEFORE moving the transcript into it.
|
|
1280
|
+
try {
|
|
1281
|
+
await herdctl.ensureProjectAgent(project);
|
|
1282
|
+
}
|
|
1283
|
+
catch (err) {
|
|
1284
|
+
req.log.warn({ err }, "promote: keeper registration failed (project still created)");
|
|
1285
|
+
}
|
|
1286
|
+
let promoted = false;
|
|
1287
|
+
try {
|
|
1288
|
+
await herdctl.promoteSession(req.params.sessionId, from, project);
|
|
1289
|
+
promoted = true;
|
|
1290
|
+
}
|
|
1291
|
+
catch (err) {
|
|
1292
|
+
req.log.warn({ err }, "promote: could not re-home the transcript");
|
|
1293
|
+
}
|
|
1294
|
+
return reply.code(201).send({ project, promoted, sessionId: req.params.sessionId });
|
|
1295
|
+
}
|
|
1296
|
+
catch (err) {
|
|
1297
|
+
return sendProjectError(reply, err);
|
|
1298
|
+
}
|
|
1299
|
+
});
|
|
1300
|
+
// --- importing native Claude Code chats (#588) -------------------------
|
|
1301
|
+
//
|
|
1302
|
+
// A workspace is backed by a working directory, and the user very often
|
|
1303
|
+
// already has terminal `claude` history for it — or for their own checkout of
|
|
1304
|
+
// the same repo elsewhere on disk. These two routes surface that history and
|
|
1305
|
+
// pull it in. Detection lives in `../adoptable.ts`; the actual adoption is the
|
|
1306
|
+
// engine's primitive, threaded through `herdctl.adoptChats`.
|
|
1307
|
+
//
|
|
1308
|
+
// Both are workspace-scoped like everything else in this file, so the ROOT
|
|
1309
|
+
// workspace (key "") gets them from the same mount, unchanged.
|
|
1310
|
+
app.get("/adoptable-chats", {
|
|
1311
|
+
schema: {
|
|
1312
|
+
tags: ["Chats"],
|
|
1313
|
+
summary: "Count the native Claude Code chats this project could import",
|
|
1314
|
+
description: "Returns the terminal `claude` sessions that could be imported into this project but are not yet visible in it: the project's own working directory, plus any Claude transcript folder whose recorded working directory matches the project (by checkout name for a repo-backed project, by exact path for a notebook project). Sidechains, already-imported sessions and sessions belonging to a real Paddock run are excluded by the engine; empty and slash-command-only transcripts are additionally withheld as noise and reported under `filtered`, so a count lower than the raw total always has an explanation. Response: `{ count, sources: [{ sourceCwd, sessionIds }], filtered: [{ sessionId, sourceCwd, reason }] }`. The count is LIVE — re-read it after an import rather than remembering a dismissal.",
|
|
1315
|
+
params: {
|
|
1316
|
+
type: "object",
|
|
1317
|
+
properties: {
|
|
1318
|
+
slug: { type: "string", description: "Project slug." },
|
|
1319
|
+
},
|
|
1320
|
+
required: ["slug"],
|
|
1321
|
+
},
|
|
1322
|
+
response: {
|
|
1323
|
+
200: {
|
|
1324
|
+
description: "Object `{ count, sources, filtered }` describing what this project could import.",
|
|
1325
|
+
type: "object",
|
|
1326
|
+
additionalProperties: true,
|
|
1327
|
+
},
|
|
1328
|
+
},
|
|
1329
|
+
},
|
|
1330
|
+
}, async (req, reply) => {
|
|
1331
|
+
try {
|
|
1332
|
+
const project = await projects.get(req.params.slug);
|
|
1333
|
+
return await herdctl.listAdoptable(project);
|
|
1334
|
+
}
|
|
1335
|
+
catch (err) {
|
|
1336
|
+
return sendProjectError(reply, err);
|
|
1337
|
+
}
|
|
1338
|
+
});
|
|
1339
|
+
app.post("/adopt-chats", {
|
|
1340
|
+
schema: {
|
|
1341
|
+
tags: ["Chats"],
|
|
1342
|
+
summary: "Import native Claude Code chats into this project",
|
|
1343
|
+
description: "Imports the terminal `claude` sessions reported by GET …/adoptable-chats. With no body (or no `sourceCwd`) every detected source is imported; with `sourceCwd` only that source is, and it must be one this project actually offers — an unrecognised path is a 400 rather than an invitation to scan arbitrary directories. Transcripts are COPIED, never moved: the user's own `~/.claude` history is left intact and the copies keep their original mtimes, so imported chats sort by when they really happened. Afterwards the session, run-record and detection caches are dropped, so the chats appear on the very next list request with no restart. Response: `{ adopted, skipped }` — `skipped` carries the engine's reason per session (`sidechain`, `already-adopted`, `destination-exists`, `attributed-to-run`, `unreadable`, `placement-failed`, `record-failed`).",
|
|
1344
|
+
params: {
|
|
1345
|
+
type: "object",
|
|
1346
|
+
properties: {
|
|
1347
|
+
slug: { type: "string", description: "Project slug." },
|
|
1348
|
+
},
|
|
1349
|
+
required: ["slug"],
|
|
1350
|
+
},
|
|
1351
|
+
body: {
|
|
1352
|
+
type: ["object", "null"],
|
|
1353
|
+
additionalProperties: true,
|
|
1354
|
+
properties: {
|
|
1355
|
+
sourceCwd: {
|
|
1356
|
+
description: "Optional single source working directory to import from. Must be one of the `sourceCwd` values reported by GET …/adoptable-chats (or the project's own working directory); anything else is rejected with 400.",
|
|
1357
|
+
},
|
|
1358
|
+
},
|
|
1359
|
+
required: [],
|
|
1360
|
+
},
|
|
1361
|
+
response: {
|
|
1362
|
+
200: {
|
|
1363
|
+
description: "Object `{ adopted, skipped }` describing what was imported.",
|
|
1364
|
+
type: "object",
|
|
1365
|
+
additionalProperties: true,
|
|
1366
|
+
},
|
|
1367
|
+
},
|
|
1368
|
+
},
|
|
1369
|
+
}, async (req, reply) => {
|
|
1370
|
+
try {
|
|
1371
|
+
const project = await projects.get(req.params.slug);
|
|
1372
|
+
const sourceCwd = req.body?.sourceCwd;
|
|
1373
|
+
// `=== undefined`, never truthiness: a caller could legitimately post an
|
|
1374
|
+
// empty body, and the workspace key itself may be "" — falsiness tests
|
|
1375
|
+
// are how the root workspace gets silently dropped (#531).
|
|
1376
|
+
if (sourceCwd !== undefined) {
|
|
1377
|
+
const { sources } = await herdctl.listAdoptable(project);
|
|
1378
|
+
const known = sourceCwd === project.workingDir || sources.some((s) => s.sourceCwd === sourceCwd);
|
|
1379
|
+
if (!known) {
|
|
1380
|
+
return reply
|
|
1381
|
+
.code(400)
|
|
1382
|
+
.send({ error: `Not an importable source: ${sourceCwd}`, code: "invalid" });
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
const result = await herdctl.adoptChats(project, { sourceCwd });
|
|
1386
|
+
// Stamp each imported chat's provenance so the list can badge it as
|
|
1387
|
+
// imported rather than as an ordinary human chat. `stampIfAbsent`, not
|
|
1388
|
+
// `stamp`: provenance describes how a chat came to exist and is never
|
|
1389
|
+
// clobbered — if a marker is somehow already there, it knows more than
|
|
1390
|
+
// the import does. Best-effort; a sidecar write must not fail an import
|
|
1391
|
+
// that already succeeded on disk.
|
|
1392
|
+
await Promise.all(result.adopted.map((sessionId) => runProvenance.stampIfAbsent(sessionId, ADOPTED_ROOT).catch(() => undefined)));
|
|
1393
|
+
return result;
|
|
1394
|
+
}
|
|
1395
|
+
catch (err) {
|
|
1396
|
+
// A session id that isn't path-safe is bad INPUT, not a server fault:
|
|
1397
|
+
// the engine refuses to build a state-file path from it and throws
|
|
1398
|
+
// PathTraversalError. Map it to 400 so it reads as "that id is not
|
|
1399
|
+
// importable" rather than an internal error.
|
|
1400
|
+
if (err instanceof PathTraversalError) {
|
|
1401
|
+
return reply.code(400).send({ error: err.message, code: "invalid" });
|
|
1402
|
+
}
|
|
1403
|
+
return sendProjectError(reply, err);
|
|
1404
|
+
}
|
|
1405
|
+
});
|
|
1406
|
+
}
|