@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,228 @@
|
|
|
1
|
+
import { agentKeyFor, keyFromAgentKey } from "./project-paths.js";
|
|
2
|
+
/**
|
|
3
|
+
* Maps a workspace key to its keeper agent name. Kept deterministic so the
|
|
4
|
+
* runtime registration and runtime lookups always agree.
|
|
5
|
+
*
|
|
6
|
+
* The key is encoded through {@link agentKeyFor}, because the root workspace's
|
|
7
|
+
* key is `""` and a bare `keeper-` is not a legal herdctl agent name. Every
|
|
8
|
+
* builder below goes through the same encoding, so none of them needs a root
|
|
9
|
+
* branch. See `project-paths.ts` for why the encoding lives at this boundary
|
|
10
|
+
* rather than in the identity.
|
|
11
|
+
*/
|
|
12
|
+
export function keeperAgentName(slug) {
|
|
13
|
+
return `keeper-${agentKeyFor(slug)}`;
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* Inverse of {@link keeperAgentName}: recover a workspace key from a keeper
|
|
17
|
+
* agent name (`keeper-<agentKey>` → `<key>`). Returns `null` for a non-keeper
|
|
18
|
+
* agent (e.g. a sweeper or a trigger agent), which is how a scheduler-fired wake
|
|
19
|
+
* tells "this is a chat to route back to a workspace" from "this is not a chat
|
|
20
|
+
* at all".
|
|
21
|
+
*
|
|
22
|
+
* **Callers must distinguish `null` from `""`.** The root workspace's key IS the
|
|
23
|
+
* empty string, so a falsy check (`if (!key) return;`) silently drops every root
|
|
24
|
+
* chat from wake/turn/job routing. Compare against `null` explicitly.
|
|
25
|
+
*/
|
|
26
|
+
export function keeperSlugFromAgent(agentName) {
|
|
27
|
+
if (!agentName.startsWith("keeper-"))
|
|
28
|
+
return null;
|
|
29
|
+
return keyFromAgentKey(agentName.slice("keeper-".length));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The lightweight curator agent used by the post-turn sweep. Runs on a cheap
|
|
33
|
+
* model (Haiku 4.5) with only read/write tools, no Bash.
|
|
34
|
+
*
|
|
35
|
+
* NOTE: herdctl agents bind working_directory per agent. Because a single
|
|
36
|
+
* shared sweeper can only have one cwd, the SweepService registers ONE sweeper
|
|
37
|
+
* agent PER PROJECT (keeper-style) so each has the right cwd. The name is
|
|
38
|
+
* derived from the slug; the agent reads/writes that project's files.
|
|
39
|
+
*/
|
|
40
|
+
export const SWEEPER_PREFIX = "sweeper-";
|
|
41
|
+
/** Maps a project slug to its sweeper agent name. */
|
|
42
|
+
export function sweeperAgentName(slug) {
|
|
43
|
+
return `${SWEEPER_PREFIX}${agentKeyFor(slug)}`;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The agent-name prefix for event hooks (Epic G / G1). Each hook is its OWN herdctl
|
|
47
|
+
* agent `hook-<slug>-<name>` (GG-1) — registered alongside the keeper/sweeper — whose
|
|
48
|
+
* tool config IS the hook's capability set.
|
|
49
|
+
*/
|
|
50
|
+
export const HOOK_AGENT_PREFIX = "hook-";
|
|
51
|
+
/**
|
|
52
|
+
* Maps a project slug + hook name to its agent name `hook-<slug>-<name>`. Kept
|
|
53
|
+
* deterministic so runtime registration and firing (`startAgentTurn`) always agree.
|
|
54
|
+
* (`slug` is a kebab id and `name` matches `[A-Za-z0-9._-]+`, so the composed name is
|
|
55
|
+
* a valid herdctl agent name; the reverse mapping for visibility (G3) is resolved
|
|
56
|
+
* from a project's declared hooks, not by parsing this string.)
|
|
57
|
+
*/
|
|
58
|
+
export function hookAgentName(slug, hookName) {
|
|
59
|
+
return `${HOOK_AGENT_PREFIX}${agentKeyFor(slug)}-${hookName}`;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The agent-name prefix for unified triggers (Epic T / T1). An EVENT trigger is its
|
|
63
|
+
* OWN herdctl agent `trigger-<slug>-<name>` (tool config = capability), registered
|
|
64
|
+
* alongside the keeper/sweeper — exactly like a hook agent. Schedule triggers run on
|
|
65
|
+
* the keeper (T1) and webhook triggers are reserved, but every trigger carries this
|
|
66
|
+
* deterministic name in its DTO so the mapping is stable across T2–T5.
|
|
67
|
+
*/
|
|
68
|
+
export const TRIGGER_AGENT_PREFIX = "trigger-";
|
|
69
|
+
/**
|
|
70
|
+
* Maps a project slug + trigger name to its agent name `trigger-<slug>-<name>`. Kept
|
|
71
|
+
* deterministic so runtime registration and firing always agree. The reverse mapping
|
|
72
|
+
* (for visibility) is resolved from a project's declared triggers, not by parsing this
|
|
73
|
+
* string (a slug may contain hyphens). Mirrors {@link hookAgentName}.
|
|
74
|
+
*/
|
|
75
|
+
export function triggerAgentName(slug, triggerName) {
|
|
76
|
+
return `${TRIGGER_AGENT_PREFIX}${agentKeyFor(slug)}-${triggerName}`;
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* The agents whose chats are VISIBLE in a project's chat list (Epic G / G3, GG-5):
|
|
80
|
+
* the keeper plus every event hook the project declares (`hook-<slug>-<name>`). This
|
|
81
|
+
* is the generalization of the old hard "keeper-only" listing — "all of a project's
|
|
82
|
+
* agents EXCEPT those marked hidden."
|
|
83
|
+
*
|
|
84
|
+
* The **sweeper** (`sweeper-<slug>`) is the one hidden agent: it is deliberately
|
|
85
|
+
* omitted here so its post-turn curation chats never surface (the `hideChats` case),
|
|
86
|
+
* exactly as before. Disabled hooks are still included — a hook chat that already ran should stay
|
|
87
|
+
* visible regardless of whether the hook is currently armed.
|
|
88
|
+
*
|
|
89
|
+
* Kept pure + exported so the listing filter is unit-testable in isolation (the
|
|
90
|
+
* sweeper-stays-hidden regression lives here), and so future callers have ONE place
|
|
91
|
+
* that answers "which of a project's agents' chats do we show?".
|
|
92
|
+
*/
|
|
93
|
+
export function visibleProjectAgentNames(project) {
|
|
94
|
+
const names = [keeperAgentName(project.slug)];
|
|
95
|
+
for (const hookName of Object.keys(project.hooks ?? {})) {
|
|
96
|
+
names.push(hookAgentName(project.slug, hookName));
|
|
97
|
+
}
|
|
98
|
+
// Unified triggers (Epic T): a trigger that runs on its OWN `trigger-<slug>-<name>`
|
|
99
|
+
// agent produces visible chats (like a hook) — every event trigger, plus a scoped
|
|
100
|
+
// schedule trigger (T2: one with a `run.tools` allow-list). An unscoped schedule runs
|
|
101
|
+
// on the keeper (already listed) and a webhook never fires, so they add no distinct
|
|
102
|
+
// agent — but registering the deterministic name for every trigger is harmless (a name
|
|
103
|
+
// with no chats simply contributes nothing to the listing).
|
|
104
|
+
for (const triggerName of Object.keys(project.triggers ?? {})) {
|
|
105
|
+
names.push(triggerAgentName(project.slug, triggerName));
|
|
106
|
+
}
|
|
107
|
+
return names;
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The Claude Code tool pattern for the Playwright browser MCP. Must live on the
|
|
111
|
+
* agent allowlist (both runtimes auto-deny any tool not on the allow-list —
|
|
112
|
+
* `--allowedTools` on the CLI, `options.allowedTools` on the SDK — same reason
|
|
113
|
+
* `Skill` is listed) — so it is added to `defaults.allowed_tools`,
|
|
114
|
+
* which the keeper agents inherit and the tool-less sweeper overrides
|
|
115
|
+
* away. Harmless when the server isn't enabled: an allowed-but-absent tool is a
|
|
116
|
+
* no-op.
|
|
117
|
+
*/
|
|
118
|
+
export const BROWSER_MCP_TOOL = "mcp__playwright__*";
|
|
119
|
+
/**
|
|
120
|
+
* The keeper's default `denied_tools` — a **best-effort, defence-in-depth**
|
|
121
|
+
* denylist, NOT a sandbox. Real isolation (per-agent filesystem confinement)
|
|
122
|
+
* is tracked in #7; these string patterns are trivially bypassable (a relative
|
|
123
|
+
* path, a `$VAR`, a `find -delete`) and are here only to make the obvious
|
|
124
|
+
* catastrophic footguns require deliberate rephrasing.
|
|
125
|
+
*
|
|
126
|
+
* Claude Code Bash patterns are prefix-with-`*` matches: `Bash(foo *)` denies
|
|
127
|
+
* any command starting with `foo `, and `Bash(foo)` denies EXACTLY `foo`.
|
|
128
|
+
*
|
|
129
|
+
* History (#179): this list used to carry `Bash(rm -rf /*)`, intended as "don't
|
|
130
|
+
* wipe root". But `/*` is a trailing wildcard, so it actually denied
|
|
131
|
+
* `rm -rf /<ANYTHING>` — i.e. every absolute-path delete, including the keeper
|
|
132
|
+
* cleaning up its OWN scratch/clone dirs (`rm -rf /tmp/foo`,
|
|
133
|
+
* `rm -rf /var/lib/.../clones/x`). That was both over-broad (blocked legitimate
|
|
134
|
+
* work, wasted turns, confusing "denied" cards) and false security (the agent
|
|
135
|
+
* just switched to a relative `rm -rf clones/x` and it went through).
|
|
136
|
+
*
|
|
137
|
+
* The honest replacement targets genuinely-catastrophic *bare* roots:
|
|
138
|
+
* - `rm -rf /` and `rm -rf / *` (root, incl. `--no-preserve-root` variants),
|
|
139
|
+
* - `rm -rf ~` / `rm -rf $HOME` (home, exact — a trailing wildcard would block
|
|
140
|
+
* `rm -rf ~/.cache/foo`, which is legitimate),
|
|
141
|
+
* - bare top-level system dirs matched EXACTLY (`Bash(rm -rf /etc)`, …) so that
|
|
142
|
+
* real subpath deletes under them (notably `/var/lib/paddock/...` and
|
|
143
|
+
* `/tmp/...`) are NOT denied.
|
|
144
|
+
*
|
|
145
|
+
* Note the deliberate gaps: a literal `rm -rf /*` glob command and
|
|
146
|
+
* `rm -rf /usr/local/...` are NOT caught, because the only pattern that would
|
|
147
|
+
* catch them (`Bash(rm -rf /*)`, `Bash(rm -rf /usr*)`) also re-blocks the
|
|
148
|
+
* legitimate absolute-path deletes we exist to allow. Narrow-and-honest beats
|
|
149
|
+
* broad-and-leaky; the sandbox (#7) is the real fix.
|
|
150
|
+
*/
|
|
151
|
+
export const DENIED_TOOLS = [
|
|
152
|
+
// Privilege / permission footguns (unchanged from the original list).
|
|
153
|
+
"Bash(sudo *)",
|
|
154
|
+
"Bash(chmod 777 *)",
|
|
155
|
+
// Root wipes: exact `rm -rf /`, plus `rm -rf / <args>` (the trailing space
|
|
156
|
+
// means this does NOT match `rm -rf /tmp...`, only `rm -rf / --no-preserve-root`
|
|
157
|
+
// and friends).
|
|
158
|
+
"Bash(rm -rf /)",
|
|
159
|
+
"Bash(rm -rf / *)",
|
|
160
|
+
// Home-directory wipes (exact — no trailing wildcard, see doc-comment).
|
|
161
|
+
"Bash(rm -rf ~)",
|
|
162
|
+
"Bash(rm -rf ~/)",
|
|
163
|
+
"Bash(rm -rf $HOME)",
|
|
164
|
+
"Bash(rm -rf $HOME/)",
|
|
165
|
+
// Bare top-level system directories, matched EXACTLY so legitimate subpath
|
|
166
|
+
// deletes (e.g. `/var/lib/paddock/...`, `/tmp/...`) still pass.
|
|
167
|
+
"Bash(rm -rf /bin)",
|
|
168
|
+
"Bash(rm -rf /boot)",
|
|
169
|
+
"Bash(rm -rf /dev)",
|
|
170
|
+
"Bash(rm -rf /etc)",
|
|
171
|
+
"Bash(rm -rf /home)",
|
|
172
|
+
"Bash(rm -rf /lib)",
|
|
173
|
+
"Bash(rm -rf /lib64)",
|
|
174
|
+
"Bash(rm -rf /opt)",
|
|
175
|
+
"Bash(rm -rf /proc)",
|
|
176
|
+
"Bash(rm -rf /root)",
|
|
177
|
+
"Bash(rm -rf /sbin)",
|
|
178
|
+
"Bash(rm -rf /srv)",
|
|
179
|
+
"Bash(rm -rf /sys)",
|
|
180
|
+
"Bash(rm -rf /usr)",
|
|
181
|
+
"Bash(rm -rf /var)",
|
|
182
|
+
];
|
|
183
|
+
/**
|
|
184
|
+
* The Playwright browser MCP server given to the keeper agents so
|
|
185
|
+
* Claude Code can drive a headless Chromium (navigate / click / fill / snapshot
|
|
186
|
+
* / screenshot). Returns `undefined` when `enabled` is false (sourced from
|
|
187
|
+
* `cfg.browserMcp`, i.e. `PADDOCK_BROWSER_MCP=1` — issue #269), so a box WITHOUT
|
|
188
|
+
* the browser stack simply omits the server (no failed spawns) and enabling it
|
|
189
|
+
* is a per-box env flip — no code change.
|
|
190
|
+
*
|
|
191
|
+
* The browser is installed box-side by the homelab `paddock` Ansible role
|
|
192
|
+
* (`npm i -g @playwright/mcp` + `playwright install chromium`, exposing the
|
|
193
|
+
* `playwright-mcp` bin on PATH). The boxes are unprivileged LXCs, so Chromium
|
|
194
|
+
* must run headless + `--no-sandbox` (the container is the isolation boundary);
|
|
195
|
+
* `--isolated` keeps each session's profile in-memory (no persisted user-data
|
|
196
|
+
* dir). `--browser chromium` is REQUIRED: @playwright/mcp defaults to the
|
|
197
|
+
* `chrome` channel (branded Google Chrome), which isn't installed — without this
|
|
198
|
+
* flag the server tries to `playwright install chrome` at first use and stalls.
|
|
199
|
+
* The role installs the open-source `chromium` engine, so we select it here.
|
|
200
|
+
* The tool-less sweeper deliberately never receives this server.
|
|
201
|
+
*/
|
|
202
|
+
export function browserMcpServers(enabled) {
|
|
203
|
+
if (!enabled)
|
|
204
|
+
return undefined;
|
|
205
|
+
return {
|
|
206
|
+
playwright: {
|
|
207
|
+
command: "playwright-mcp",
|
|
208
|
+
args: ["--headless", "--no-sandbox", "--isolated", "--browser", "chromium"],
|
|
209
|
+
},
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* How many chat turns a project's keeper may run at once. herdctl defaults an
|
|
214
|
+
* agent to `max_concurrent: 1`, which would serialize a project's chats and make
|
|
215
|
+
* a second turn (e.g. the first message of a freshly *forked* chat sent while the
|
|
216
|
+
* parent is still streaming) fail with a ConcurrencyLimitError. Paddock is a
|
|
217
|
+
* single-user box that explicitly wants parallel chats per project — especially
|
|
218
|
+
* forks — so we lift the keeper's limit. (The shared-keeper model is still
|
|
219
|
+
* last-write-wins across concurrent chats of the same project; forks default to
|
|
220
|
+
* the parent's model, so that caveat rarely bites in practice.)
|
|
221
|
+
*/
|
|
222
|
+
export const KEEPER_MAX_CONCURRENT = 10;
|
|
223
|
+
/**
|
|
224
|
+
* How long herdctl keeps a keeper's fallback session alive (Paddock#111). Sized
|
|
225
|
+
* to the reaper's 7-day recurring-wake expiry so a fallback resume still finds
|
|
226
|
+
* the session; explicit-id resume (Paddock's norm) bypasses this anyway.
|
|
227
|
+
*/
|
|
228
|
+
export const KEEPER_SESSION_TIMEOUT = "168h";
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/** The shared jobs directory herdctl writes `job-*.yaml` records into. */
|
|
2
|
+
export declare function jobsDirOf(stateDir: string): string;
|
|
3
|
+
/**
|
|
4
|
+
* The only three fields any caller of this index reads, lifted off a job record.
|
|
5
|
+
*
|
|
6
|
+
* Only records that resolved to a session AND completed are represented — the
|
|
7
|
+
* same records the un-indexed scan kept. `agent` is `null` when the record
|
|
8
|
+
* carries no usable agent name (such a record counts for the flat
|
|
9
|
+
* session→timestamp map but cannot be grouped by project).
|
|
10
|
+
*/
|
|
11
|
+
export interface JobIndexEntry {
|
|
12
|
+
readonly sessionId: string;
|
|
13
|
+
readonly finishedAt: string;
|
|
14
|
+
readonly agent: string | null;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* An incremental view of one jobs directory.
|
|
18
|
+
*
|
|
19
|
+
* One instance per {@link import("./herdctl.js").HerdctlService} (mirroring core's
|
|
20
|
+
* "a single builder instance must be reused across builds to get the benefit"),
|
|
21
|
+
* so a test server, a fork of the app, and production each get their own — no
|
|
22
|
+
* module-level state keyed by path.
|
|
23
|
+
*/
|
|
24
|
+
export declare class JobsDirIndex {
|
|
25
|
+
private readonly cache;
|
|
26
|
+
private inFlight;
|
|
27
|
+
private readonly jobsDir;
|
|
28
|
+
/**
|
|
29
|
+
* Diagnostics only (exercised by tests): scans performed, and files parsed /
|
|
30
|
+
* reused on the last scan, plus how many files the cache currently tracks.
|
|
31
|
+
*/
|
|
32
|
+
stats: {
|
|
33
|
+
scans: number;
|
|
34
|
+
parsed: number;
|
|
35
|
+
reused: number;
|
|
36
|
+
tracked: number;
|
|
37
|
+
};
|
|
38
|
+
constructor(stateDir: string);
|
|
39
|
+
/**
|
|
40
|
+
* Every completed, session-resolved job record in the directory, in no
|
|
41
|
+
* particular order.
|
|
42
|
+
*
|
|
43
|
+
* Concurrent callers COALESCE onto one scan: the `/projects` grid fires seven
|
|
44
|
+
* parallel `/chats` requests, and running seven identical directory passes
|
|
45
|
+
* helps nobody. A caller that arrives mid-scan therefore observes the directory
|
|
46
|
+
* as of that scan's `readdir` — at most one scan (~20 ms) stale, against the
|
|
47
|
+
* ~850 ms of drift the un-indexed version already had between its own `readdir`
|
|
48
|
+
* and its last parse. A call made AFTER a write always sees that write.
|
|
49
|
+
*
|
|
50
|
+
* Never throws: an unreadable directory yields `[]`, and an unreadable or
|
|
51
|
+
* half-written record is skipped, exactly as before.
|
|
52
|
+
*/
|
|
53
|
+
read(): Promise<readonly JobIndexEntry[]>;
|
|
54
|
+
/**
|
|
55
|
+
* Forget everything, forcing the next {@link read} to re-parse.
|
|
56
|
+
*
|
|
57
|
+
* The mtime+size key already self-heals after Paddock's own jobs-dir writes
|
|
58
|
+
* (`reattributeSession` rewrites records, `writeAgentAdoptionJob` adds them),
|
|
59
|
+
* so this is belt-and-braces called alongside the existing
|
|
60
|
+
* `invalidateSessions` at those sites — it makes the freshness guarantee
|
|
61
|
+
* independent of filesystem timestamp granularity.
|
|
62
|
+
*/
|
|
63
|
+
invalidate(): void;
|
|
64
|
+
private scan;
|
|
65
|
+
}
|
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* herdctl-jobs-index — an incremental, mtime-keyed index of the shared
|
|
3
|
+
* `<stateDir>/jobs` directory (issue #529).
|
|
4
|
+
*
|
|
5
|
+
* ## Why this exists
|
|
6
|
+
*
|
|
7
|
+
* {@link import("./herdctl-jobs.js").lastTurnCompletedAt} and its per-project
|
|
8
|
+
* sibling are the server signal behind the unread affordance, so they run on
|
|
9
|
+
* `GET /api/projects`, `GET /api/projects/:slug` and
|
|
10
|
+
* `GET /api/projects/:slug/chats` — i.e. on essentially every page load. They
|
|
11
|
+
* used to `readdir` + `YAML.parse` EVERY `job-*.yaml` on every one of those
|
|
12
|
+
* requests, with no cache. On a real instance (1,996 records, 46.6 MB) a CPU
|
|
13
|
+
* profile attributed **61% of all busy server CPU** to that parse. Because it is
|
|
14
|
+
* synchronous work on the single event loop it pinned throughput at ~1.1 req/s
|
|
15
|
+
* and made an unrelated 2 ms endpoint take ~850 ms while a scan was in flight.
|
|
16
|
+
* The jobs dir is shared fleet-wide and never pruned, so the cost was flat in
|
|
17
|
+
* project size and linear in instance age.
|
|
18
|
+
*
|
|
19
|
+
* ## The invariant that makes an index safe
|
|
20
|
+
*
|
|
21
|
+
* A job record is written when a turn starts and rewritten when it finishes; it
|
|
22
|
+
* is **immutable once `finished_at` is set**, and the directory is append-only.
|
|
23
|
+
* So a warm index only ever has to parse files it has not seen before. Measured
|
|
24
|
+
* on the same 1,996-record dir: full parse 821 ms → stat-only pass 18 ms (45×) →
|
|
25
|
+
* `readdir` alone 2.8 ms (290×).
|
|
26
|
+
*
|
|
27
|
+
* Two deliberate belts on that invariant, because a wrong unread badge is worse
|
|
28
|
+
* than a slow one:
|
|
29
|
+
*
|
|
30
|
+
* 1. **An entry is only cached once its record has a `finished_at`.** Anything
|
|
31
|
+
* still running (or half-written, or corrupt) is re-read on every scan, so a
|
|
32
|
+
* mid-turn record can never be memoized as if it were final. That is the one
|
|
33
|
+
* mutable case, and it is tiny — 4 of 1,996 records on the production corpus.
|
|
34
|
+
* 2. **The cache key is `mtimeMs` AND `size`**, not mtime alone (core's
|
|
35
|
+
* `AttributionIndexBuilder` uses mtime only). `mtimeMs` carries sub-millisecond
|
|
36
|
+
* precision here, and adding size closes the theoretical same-tick rewrite.
|
|
37
|
+
*
|
|
38
|
+
* Entries for files that have disappeared are dropped each scan, so the cache
|
|
39
|
+
* tracks the directory rather than growing without bound: it holds one ~100-byte
|
|
40
|
+
* tuple per record, never the 24.5 KB average record body (97.3% of which is
|
|
41
|
+
* `prompt`/`summary` prose no caller reads).
|
|
42
|
+
*
|
|
43
|
+
* ## Why not reuse core's index
|
|
44
|
+
*
|
|
45
|
+
* `@herdctl/core` exports {@link AttributionIndexBuilder}, which keeps exactly
|
|
46
|
+
* this kind of mtime-keyed `jobFileCache` — but its public `AttributionIndex`
|
|
47
|
+
* surfaces only `getAttribute(sessionId) -> { origin, agentName, triggerType }`.
|
|
48
|
+
* It carries **no `finished_at`** and cannot enumerate its sessions, so it cannot
|
|
49
|
+
* answer "the latest completed turn per session" at all. The only core API that
|
|
50
|
+
* returns whole records, `listJobs`, is itself uncached (herdctl#415). So this
|
|
51
|
+
* reuses core's *pattern* — the shape it validated — rather than its *code*.
|
|
52
|
+
*/
|
|
53
|
+
import { promises as fs } from "node:fs";
|
|
54
|
+
import path from "node:path";
|
|
55
|
+
import YAML from "yaml";
|
|
56
|
+
/** The shared jobs directory herdctl writes `job-*.yaml` records into. */
|
|
57
|
+
export function jobsDirOf(stateDir) {
|
|
58
|
+
return path.join(stateDir, "jobs");
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* An incremental view of one jobs directory.
|
|
62
|
+
*
|
|
63
|
+
* One instance per {@link import("./herdctl.js").HerdctlService} (mirroring core's
|
|
64
|
+
* "a single builder instance must be reused across builds to get the benefit"),
|
|
65
|
+
* so a test server, a fork of the app, and production each get their own — no
|
|
66
|
+
* module-level state keyed by path.
|
|
67
|
+
*/
|
|
68
|
+
export class JobsDirIndex {
|
|
69
|
+
cache = new Map();
|
|
70
|
+
inFlight = null;
|
|
71
|
+
jobsDir;
|
|
72
|
+
/**
|
|
73
|
+
* Diagnostics only (exercised by tests): scans performed, and files parsed /
|
|
74
|
+
* reused on the last scan, plus how many files the cache currently tracks.
|
|
75
|
+
*/
|
|
76
|
+
stats = { scans: 0, parsed: 0, reused: 0, tracked: 0 };
|
|
77
|
+
constructor(stateDir) {
|
|
78
|
+
this.jobsDir = jobsDirOf(stateDir);
|
|
79
|
+
}
|
|
80
|
+
/**
|
|
81
|
+
* Every completed, session-resolved job record in the directory, in no
|
|
82
|
+
* particular order.
|
|
83
|
+
*
|
|
84
|
+
* Concurrent callers COALESCE onto one scan: the `/projects` grid fires seven
|
|
85
|
+
* parallel `/chats` requests, and running seven identical directory passes
|
|
86
|
+
* helps nobody. A caller that arrives mid-scan therefore observes the directory
|
|
87
|
+
* as of that scan's `readdir` — at most one scan (~20 ms) stale, against the
|
|
88
|
+
* ~850 ms of drift the un-indexed version already had between its own `readdir`
|
|
89
|
+
* and its last parse. A call made AFTER a write always sees that write.
|
|
90
|
+
*
|
|
91
|
+
* Never throws: an unreadable directory yields `[]`, and an unreadable or
|
|
92
|
+
* half-written record is skipped, exactly as before.
|
|
93
|
+
*/
|
|
94
|
+
async read() {
|
|
95
|
+
if (this.inFlight)
|
|
96
|
+
return this.inFlight;
|
|
97
|
+
const scan = this.scan();
|
|
98
|
+
this.inFlight = scan;
|
|
99
|
+
try {
|
|
100
|
+
return await scan;
|
|
101
|
+
}
|
|
102
|
+
finally {
|
|
103
|
+
if (this.inFlight === scan)
|
|
104
|
+
this.inFlight = null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* Forget everything, forcing the next {@link read} to re-parse.
|
|
109
|
+
*
|
|
110
|
+
* The mtime+size key already self-heals after Paddock's own jobs-dir writes
|
|
111
|
+
* (`reattributeSession` rewrites records, `writeAgentAdoptionJob` adds them),
|
|
112
|
+
* so this is belt-and-braces called alongside the existing
|
|
113
|
+
* `invalidateSessions` at those sites — it makes the freshness guarantee
|
|
114
|
+
* independent of filesystem timestamp granularity.
|
|
115
|
+
*/
|
|
116
|
+
invalidate() {
|
|
117
|
+
this.cache.clear();
|
|
118
|
+
}
|
|
119
|
+
async scan() {
|
|
120
|
+
let names;
|
|
121
|
+
try {
|
|
122
|
+
names = await fs.readdir(this.jobsDir);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
// No jobs dir yet (fresh instance). Drop any stale entries with it.
|
|
126
|
+
this.cache.clear();
|
|
127
|
+
return [];
|
|
128
|
+
}
|
|
129
|
+
this.stats.scans++;
|
|
130
|
+
let parsed = 0;
|
|
131
|
+
let reused = 0;
|
|
132
|
+
// Only `.yaml` records are candidates — the sibling `.jsonl` output files are
|
|
133
|
+
// half the directory and are never stat'd, let alone read.
|
|
134
|
+
const yamls = names.filter((n) => n.endsWith(".yaml"));
|
|
135
|
+
const out = [];
|
|
136
|
+
await Promise.all(yamls.map(async (name) => {
|
|
137
|
+
const file = path.join(this.jobsDir, name);
|
|
138
|
+
let st;
|
|
139
|
+
try {
|
|
140
|
+
st = await fs.stat(file);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
return; // vanished between readdir and stat
|
|
144
|
+
}
|
|
145
|
+
const cached = this.cache.get(name);
|
|
146
|
+
if (cached && cached.mtimeMs === st.mtimeMs && cached.size === st.size) {
|
|
147
|
+
reused++;
|
|
148
|
+
if (cached.entry)
|
|
149
|
+
out.push(cached.entry);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
parsed++;
|
|
153
|
+
const entry = await parseJobFile(file);
|
|
154
|
+
// Only a COMPLETED record is immutable, so only a completed record is
|
|
155
|
+
// safe to memoize. A running/half-written/corrupt one is re-read next
|
|
156
|
+
// scan — there are a handful of those, and being wrong about one would
|
|
157
|
+
// freeze a chat's unread state.
|
|
158
|
+
if (entry)
|
|
159
|
+
this.cache.set(name, { mtimeMs: st.mtimeMs, size: st.size, entry });
|
|
160
|
+
else
|
|
161
|
+
this.cache.delete(name);
|
|
162
|
+
if (entry)
|
|
163
|
+
out.push(entry);
|
|
164
|
+
}));
|
|
165
|
+
// Track the directory: forget files that are no longer there, so the cache
|
|
166
|
+
// stays the size of the dir rather than growing across a process's lifetime.
|
|
167
|
+
// (Unconditional — comparing sizes first would miss a scan that deletes and
|
|
168
|
+
// adds the same number of files, for no measurable saving.)
|
|
169
|
+
const present = new Set(yamls);
|
|
170
|
+
for (const name of this.cache.keys())
|
|
171
|
+
if (!present.has(name))
|
|
172
|
+
this.cache.delete(name);
|
|
173
|
+
this.stats.parsed = parsed;
|
|
174
|
+
this.stats.reused = reused;
|
|
175
|
+
this.stats.tracked = this.cache.size;
|
|
176
|
+
return out;
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Return a standalone copy of a string.
|
|
181
|
+
*
|
|
182
|
+
* The `yaml` parser produces scalars by slicing the whole document text, and V8
|
|
183
|
+
* represents those as SlicedStrings that keep the entire ~24 KB source alive. So
|
|
184
|
+
* caching a scalar verbatim retains the record body this index exists to avoid
|
|
185
|
+
* holding — the ~100-byte tuple promised above is really 24.5 KB.
|
|
186
|
+
*
|
|
187
|
+
* Measured over this instance's 2,016 records: **95.6 MB retained verbatim
|
|
188
|
+
* versus 1.8 MB detached**, a 53× overhead for identical data (issue #543).
|
|
189
|
+
* Core's own job index hit this and fixed it the same way
|
|
190
|
+
* (`state/job-index.ts` `detachString`, measured there at 99 MB → 2.1 MB); this
|
|
191
|
+
* matches that implementation deliberately rather than inventing a variant.
|
|
192
|
+
*/
|
|
193
|
+
function detachString(value) {
|
|
194
|
+
return JSON.parse(JSON.stringify(value));
|
|
195
|
+
}
|
|
196
|
+
/**
|
|
197
|
+
* Parse one `job-*.yaml` down to the three fields the index keeps.
|
|
198
|
+
*
|
|
199
|
+
* Returns `null` — meaning "nothing to index, and nothing to cache" — for a
|
|
200
|
+
* record that is unreadable, half-written, still running (`finished_at` unset),
|
|
201
|
+
* or not yet session-resolved. That is the same skip the un-indexed scan applied;
|
|
202
|
+
* it never throws.
|
|
203
|
+
*
|
|
204
|
+
* Every retained string is {@link detachString}'d: these three scalars outlive
|
|
205
|
+
* the parse, and holding them verbatim pins the whole source document.
|
|
206
|
+
*/
|
|
207
|
+
async function parseJobFile(file) {
|
|
208
|
+
let parsedYaml = null;
|
|
209
|
+
try {
|
|
210
|
+
parsedYaml = YAML.parse(await fs.readFile(file, "utf8"));
|
|
211
|
+
}
|
|
212
|
+
catch {
|
|
213
|
+
return null; // skip an unreadable/half-written record
|
|
214
|
+
}
|
|
215
|
+
const sid = parsedYaml?.session_id;
|
|
216
|
+
const finished = parsedYaml?.finished_at;
|
|
217
|
+
if (typeof sid !== "string" || typeof finished !== "string")
|
|
218
|
+
return null;
|
|
219
|
+
const agent = parsedYaml?.agent;
|
|
220
|
+
return {
|
|
221
|
+
sessionId: detachString(sid),
|
|
222
|
+
finishedAt: detachString(finished),
|
|
223
|
+
agent: typeof agent === "string" ? detachString(agent) : null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
import { type JobMetadata } from "@herdctl/core";
|
|
2
|
+
import type { Project } from "./projects.js";
|
|
3
|
+
import { type JobsDirIndex } from "./herdctl-jobs-index.js";
|
|
4
|
+
/**
|
|
5
|
+
* Map each chat session id to the ISO timestamp of its most recent COMPLETED
|
|
6
|
+
* turn, read cheaply from herdctl's job-metadata records (NOT by parsing
|
|
7
|
+
* transcripts). In `batch` drive mode every keeper turn runs via `trigger()`,
|
|
8
|
+
* which writes a `job-*.yaml` whose `finished_at` is stamped when the turn
|
|
9
|
+
* finishes and whose `session_id` is filled in on completion — so the latest
|
|
10
|
+
* `finished_at` across a session's records is exactly "the agent last finished a
|
|
11
|
+
* turn." NOTE the default drive mode is `session`, whose turns write NO job
|
|
12
|
+
* record (see the caveat on {@link listProjectRuns}); on that path this map is
|
|
13
|
+
* fed only by paddock's synthetic adoption records. This is the server signal
|
|
14
|
+
* for the unread affordance (#160, reused per-project by #161): unlike the
|
|
15
|
+
* transcript mtime (`DiscoveredSession.mtime`) it does NOT tick on the user's
|
|
16
|
+
* own sends.
|
|
17
|
+
*
|
|
18
|
+
* Records still running (no `finished_at`) or not yet session-resolved (no
|
|
19
|
+
* `session_id`) are skipped. The synthetic adoption records paddock writes
|
|
20
|
+
* carry an earlier, mid-turn `finished_at`, so the max naturally prefers the
|
|
21
|
+
* real completion. Session-mode turns (`openChatSession`) write no job record,
|
|
22
|
+
* so their chats have no server timestamp and rely on the client live event.
|
|
23
|
+
*
|
|
24
|
+
* Reads through {@link JobsDirIndex}, which keeps the parse incremental
|
|
25
|
+
* (mtime+size keyed, completed records only) — see there for why that is safe
|
|
26
|
+
* and what it is worth. This function itself is a pure fold over the index.
|
|
27
|
+
*/
|
|
28
|
+
export declare function lastTurnCompletedAt(index: JobsDirIndex): Promise<Map<string, string>>;
|
|
29
|
+
/**
|
|
30
|
+
* Per-project variant of {@link lastTurnCompletedAt} for the sidebar unread
|
|
31
|
+
* badge (#161): group the same cheap job-record scan by the KEEPER agent that
|
|
32
|
+
* owns each session, so the projects-list payload can carry a compact
|
|
33
|
+
* `{ sessionId, lastTurnCompletedAt }` list per project WITHOUT the N+1
|
|
34
|
+
* `listSessions` fan-out or any transcript parse. Returns `slug -> (sessionId
|
|
35
|
+
* -> latest finished_at)`.
|
|
36
|
+
*
|
|
37
|
+
* Only keeper-attributed records (`agent: keeper-<slug>`) are kept — sweeper,
|
|
38
|
+
* trigger and hook records carry their own session ids that are not chats, so
|
|
39
|
+
* `keeperSlugFromAgent` returning `null` naturally filters them out. A promoted
|
|
40
|
+
* chat is grouped under its keeper slug (its keeper record).
|
|
41
|
+
*/
|
|
42
|
+
export declare function lastTurnCompletedAtByProject(index: JobsDirIndex): Promise<Map<string, Map<string, string>>>;
|
|
43
|
+
/**
|
|
44
|
+
* The raw herdctl job records for one project's keeper agent, most-recent
|
|
45
|
+
* first — the data source for the "while you were away" run-history view (E3 /
|
|
46
|
+
* #268 / DD-6). Each `trigger()` (batch drive mode) turn writes one
|
|
47
|
+
* `job-*.yaml` carrying `trigger_type`, `status`, `started_at`/`finished_at`,
|
|
48
|
+
* `duration_seconds`, `session_id`, `schedule` and `forked_from`; this reads
|
|
49
|
+
* them via core's `listJobs` (importable from `@herdctl/core`, sorted by
|
|
50
|
+
* `started_at` descending) filtered to `keeper-<slug>`, so sweeper/trigger
|
|
51
|
+
* records are excluded.
|
|
52
|
+
*
|
|
53
|
+
* The true human/scheduled/spawned provenance is carried by Paddock's
|
|
54
|
+
* {@link RunProvenanceStore} (origin/depth keyed by `session_id`), NOT by
|
|
55
|
+
* `trigger_type` — paddock-initiated turns still write `trigger_type:"manual"`
|
|
56
|
+
* (see ws.ts). The caller joins the two.
|
|
57
|
+
*
|
|
58
|
+
* Caveat (documented at {@link lastTurnCompletedAt}): session-mode turns
|
|
59
|
+
* (`openChatSession`) write NO job record, so runs driven that way don't
|
|
60
|
+
* appear here — only batch `trigger()` turns and paddock's synthetic adoption
|
|
61
|
+
* records do. Cost columns (DD-4) are P3 and not yet on the record.
|
|
62
|
+
*/
|
|
63
|
+
export declare function listProjectRuns(stateDir: string, project: Project, limit?: number): Promise<JobMetadata[]>;
|
|
64
|
+
/**
|
|
65
|
+
* Job records for a SET of agents, most-recent first (Epic T follow-up / #327) —
|
|
66
|
+
* the data source for the Triggers tab's per-trigger last-run column. Used to pull
|
|
67
|
+
* one project's keeper AND every scoped `trigger-<slug>-<name>` agent in a single
|
|
68
|
+
* pass so {@link import("./trigger-runtime.js").buildTriggerRuntime} can attribute a
|
|
69
|
+
* scoped trigger's newest run by agent name. `listJobs` has no multi-agent filter,
|
|
70
|
+
* so this scans the jobs dir once (unfiltered) and keeps only the requested agents;
|
|
71
|
+
* order (started_at descending) is preserved. Errors swallow to `[]` so the runtime
|
|
72
|
+
* view degrades to config-only rather than failing to render.
|
|
73
|
+
*/
|
|
74
|
+
export declare function listRunsForAgents(stateDir: string, agents: string[], limit?: number): Promise<JobMetadata[]>;
|
|
75
|
+
/**
|
|
76
|
+
* Point every herdctl job record for `sessionId` at the project's keeper so
|
|
77
|
+
* the core attribution index (last-write-wins per session) lists the session
|
|
78
|
+
* under the project. A session can already have MANY job records (one per
|
|
79
|
+
* turn) attributed elsewhere; simply adding a keeper record alongside them is
|
|
80
|
+
* not enough — whichever record the index visits last wins. So we rewrite the
|
|
81
|
+
* `agent` field of all existing records for the session. When none exist
|
|
82
|
+
* (e.g. a transcript migrated from outside paddock), we synthesize one.
|
|
83
|
+
*/
|
|
84
|
+
export declare function reattributeSession(stateDir: string, sessionId: string, project: Project, when: Date): Promise<void>;
|
|
85
|
+
/**
|
|
86
|
+
* Write a herdctl job-metadata YAML mapping `sessionId -> keeper agent` so the
|
|
87
|
+
* core attribution index lists the session under the project. Mirrors the shape
|
|
88
|
+
* of a real job record (and the JobMetadataSchema: the id must match
|
|
89
|
+
* `job-YYYY-MM-DD-[a-z0-9]{6}`).
|
|
90
|
+
*/
|
|
91
|
+
export declare function writeAdoptionJob(stateDir: string, sessionId: string, project: Project, when: Date): Promise<void>;
|
|
92
|
+
/**
|
|
93
|
+
* Underlying adoption-record writer, parametrized by the target agent name so
|
|
94
|
+
* every caller (fork/promote/adopt — see {@link attributeRunningSession}) shares
|
|
95
|
+
* one implementation. Writes a `<jobId>.yaml` mapping the
|
|
96
|
+
* session id to `agentName` plus a matching empty `.jsonl` output file.
|
|
97
|
+
*/
|
|
98
|
+
export declare function writeAgentAdoptionJob(stateDir: string, sessionId: string, agentName: string, when: Date): Promise<void>;
|