@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,206 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RunProvenanceStore — a Paddock-side sidecar recording HOW each chat was
|
|
3
|
+
* created: its provenance `origin` (human / scheduled / spawned) and its spawn
|
|
4
|
+
* `depth` within a fan-out tree. This is the FOUNDATION marker for the
|
|
5
|
+
* Events / Schedules / Config initiative (issue #261 / design decisions DD-3,
|
|
6
|
+
* DD-6). Later tickets read it:
|
|
7
|
+
* - #262 reads a chat's `depth` to gate whether it may spawn children (the
|
|
8
|
+
* fork-bomb bound: inject the self-MCP write tools iff depth < maxSpawnDepth).
|
|
9
|
+
* - #267 reads a chat's `origin` to badge it (human / scheduled / spawned) in
|
|
10
|
+
* the chat list.
|
|
11
|
+
*
|
|
12
|
+
* This ticket (A1) only CARRIES and PERSISTS the marker — nothing gates on it
|
|
13
|
+
* yet, and spawn behaviour is unchanged.
|
|
14
|
+
*
|
|
15
|
+
* Shape: a `sessionId -> { origin, depth }` map persisted as a plain JSON object
|
|
16
|
+
* — the exact ArchiveStore / ReadStateStore sidecar pattern (lazy-loaded,
|
|
17
|
+
* write-through, serialised, corruption-tolerant, `0o600`). Provenance is
|
|
18
|
+
* low-sensitivity but there's no reason for it to be world-readable.
|
|
19
|
+
*
|
|
20
|
+
* Semantics: provenance describes how a chat was CREATED, so it is stamped once —
|
|
21
|
+
* at creation — and is NEVER clobbered by a later turn. Resuming, waking, or
|
|
22
|
+
* `send_message`-ing an existing chat leaves its recorded provenance intact.
|
|
23
|
+
* Callers use {@link RunProvenanceStore.stamp} at the point they create a chat and
|
|
24
|
+
* {@link RunProvenanceStore.stampIfAbsent} on paths that may resume an
|
|
25
|
+
* already-known chat (e.g. a scheduler wake), so an established marker can't be
|
|
26
|
+
* overwritten.
|
|
27
|
+
*/
|
|
28
|
+
import { promises as fs } from "node:fs";
|
|
29
|
+
import path from "node:path";
|
|
30
|
+
const STATE_FILE = "run-provenance.json";
|
|
31
|
+
/**
|
|
32
|
+
* Every origin, as VALUES — the single source of truth for both the type and the
|
|
33
|
+
* runtime allowlist {@link coerce} validates against.
|
|
34
|
+
*
|
|
35
|
+
* Declared this way round on purpose (#588). It used to be a hand-written union
|
|
36
|
+
* with a separate `readonly TurnOrigin[]` list, and `readonly TurnOrigin[]` only
|
|
37
|
+
* asserts that each element IS an origin — never that every origin is an
|
|
38
|
+
* element. So when the union grew, the allowlist silently lagged: `coerce()`
|
|
39
|
+
* returned null for the new member, and every chat carrying it lost BOTH its
|
|
40
|
+
* badge and its parent edge on read, with no error anywhere. Deriving the type
|
|
41
|
+
* from the list makes that impossible rather than merely documented.
|
|
42
|
+
*/
|
|
43
|
+
export const ORIGINS = ["human", "scheduled", "spawned", "hook", "adopted"];
|
|
44
|
+
/** The root of any chat tree a human starts: origin `human`, depth 0. */
|
|
45
|
+
export const HUMAN_ROOT = { origin: "human", depth: 0 };
|
|
46
|
+
/**
|
|
47
|
+
* A schedule-fired chat: origin `scheduled`, depth 0. A cron is a root trigger,
|
|
48
|
+
* exactly like a human — there is no scheduler wired yet (that's Epic D), but the
|
|
49
|
+
* marker path accepts it now.
|
|
50
|
+
*/
|
|
51
|
+
export const SCHEDULED_ROOT = { origin: "scheduled", depth: 0 };
|
|
52
|
+
/**
|
|
53
|
+
* An IMPORTED terminal chat (#588): origin `adopted`, depth 0.
|
|
54
|
+
*
|
|
55
|
+
* Depth 0 because the session was never spawned by anything in this instance —
|
|
56
|
+
* the user ran it themselves in a terminal and Paddock only adopted the
|
|
57
|
+
* transcript. Stamped with `stampIfAbsent`, never `stamp`: if a chat somehow
|
|
58
|
+
* already carries a marker, whatever created it here knows more about its
|
|
59
|
+
* origin than the import does.
|
|
60
|
+
*/
|
|
61
|
+
export const ADOPTED_ROOT = { origin: "adopted", depth: 0 };
|
|
62
|
+
/**
|
|
63
|
+
* The provenance a child inherits when `parent` spawns it via a self-MCP write
|
|
64
|
+
* tool: origin becomes `spawned` and depth is one deeper. So a fan-out tree's
|
|
65
|
+
* depth is always the number of spawn hops from its human/scheduled root, which
|
|
66
|
+
* is exactly what #262 bounds with `maxSpawnDepth`.
|
|
67
|
+
*/
|
|
68
|
+
export function childOf(parent, parentRef) {
|
|
69
|
+
return {
|
|
70
|
+
origin: "spawned",
|
|
71
|
+
depth: parent.depth + 1,
|
|
72
|
+
// Record WHICH chat spawned it, when the caller knows. Optional so every
|
|
73
|
+
// existing `childOf(p)` call site stays correct (just without the edge).
|
|
74
|
+
...(parentRef?.sessionId
|
|
75
|
+
? { parentSessionId: parentRef.sessionId, parentProject: parentRef.project }
|
|
76
|
+
: {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
function isOrigin(v) {
|
|
80
|
+
return typeof v === "string" && ORIGINS.includes(v);
|
|
81
|
+
}
|
|
82
|
+
/** A session id we're willing to key on — mirrors attributeRunningSession's guard. */
|
|
83
|
+
function isSafeId(sessionId) {
|
|
84
|
+
return typeof sessionId === "string" && /^[A-Za-z0-9._-]+$/.test(sessionId);
|
|
85
|
+
}
|
|
86
|
+
/** Validate + normalize an untrusted value into a RunProvenance, or null. */
|
|
87
|
+
function coerce(value) {
|
|
88
|
+
if (!value || typeof value !== "object")
|
|
89
|
+
return null;
|
|
90
|
+
const o = value;
|
|
91
|
+
if (!isOrigin(o.origin))
|
|
92
|
+
return null;
|
|
93
|
+
const depth = o.depth;
|
|
94
|
+
if (typeof depth !== "number" || !Number.isFinite(depth) || depth < 0)
|
|
95
|
+
return null;
|
|
96
|
+
// The parent edge is optional and only carried when BOTH halves survive
|
|
97
|
+
// validation — a session id without its project can't be resolved to a chat.
|
|
98
|
+
const parentSessionId = typeof o.parentSessionId === "string" && isSafeId(o.parentSessionId)
|
|
99
|
+
? o.parentSessionId
|
|
100
|
+
: undefined;
|
|
101
|
+
const parentProject = typeof o.parentProject === "string" ? o.parentProject : undefined;
|
|
102
|
+
return {
|
|
103
|
+
origin: o.origin,
|
|
104
|
+
depth: Math.floor(depth),
|
|
105
|
+
// `parentProject` is a workspace key; `""` is the ROOT workspace, not
|
|
106
|
+
// "missing". Test for presence, not truthiness, or a root chat's children
|
|
107
|
+
// lose their recorded parent edge on every parse.
|
|
108
|
+
...(parentSessionId && parentProject !== undefined
|
|
109
|
+
? { parentSessionId, parentProject }
|
|
110
|
+
: {}),
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
export class RunProvenanceStore {
|
|
114
|
+
stateFile;
|
|
115
|
+
/** In-memory map of sessionId -> provenance (loaded once, written through). */
|
|
116
|
+
state = null;
|
|
117
|
+
/**
|
|
118
|
+
* The in-flight load, cached so concurrent first-callers share ONE read.
|
|
119
|
+
* Caching only the resolved `state` would let several stamps that begin before
|
|
120
|
+
* the first `fs.readFile` resolves each build their own map and race on the
|
|
121
|
+
* assignment — a lost update (last writer's map wins, dropping the others'
|
|
122
|
+
* keys). Reachable when a keeper spawns several children right after startup.
|
|
123
|
+
*/
|
|
124
|
+
loadPromise = null;
|
|
125
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
126
|
+
writing = Promise.resolve();
|
|
127
|
+
constructor(dataDir) {
|
|
128
|
+
this.stateFile = path.join(dataDir, STATE_FILE);
|
|
129
|
+
}
|
|
130
|
+
/** Load the persisted map (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
131
|
+
ensureLoaded() {
|
|
132
|
+
if (this.state)
|
|
133
|
+
return Promise.resolve(this.state);
|
|
134
|
+
if (!this.loadPromise) {
|
|
135
|
+
this.loadPromise = (async () => {
|
|
136
|
+
const map = new Map();
|
|
137
|
+
try {
|
|
138
|
+
const raw = await fs.readFile(this.stateFile, "utf8");
|
|
139
|
+
const parsed = JSON.parse(raw);
|
|
140
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
141
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
142
|
+
const p = coerce(v);
|
|
143
|
+
if (p)
|
|
144
|
+
map.set(k, p);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
/* missing or unreadable — start empty */
|
|
150
|
+
}
|
|
151
|
+
this.state = map;
|
|
152
|
+
return map;
|
|
153
|
+
})();
|
|
154
|
+
}
|
|
155
|
+
return this.loadPromise;
|
|
156
|
+
}
|
|
157
|
+
/** This chat's recorded provenance, or undefined if none. Non-throwing. */
|
|
158
|
+
async get(sessionId) {
|
|
159
|
+
const map = await this.ensureLoaded().catch(() => new Map());
|
|
160
|
+
return map.get(sessionId);
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* Record a chat's provenance. Idempotent (a repeat of the same marker is a
|
|
164
|
+
* no-op), and a blank/unsafe session id or malformed marker is ignored. Callers
|
|
165
|
+
* stamp a chat when they CREATE it, so this is a fresh key in practice; it does
|
|
166
|
+
* overwrite if called again (unlike {@link stampIfAbsent}).
|
|
167
|
+
*/
|
|
168
|
+
async stamp(sessionId, provenance) {
|
|
169
|
+
if (!isSafeId(sessionId))
|
|
170
|
+
return;
|
|
171
|
+
const p = coerce(provenance);
|
|
172
|
+
if (!p)
|
|
173
|
+
return;
|
|
174
|
+
const map = await this.ensureLoaded();
|
|
175
|
+
const prev = map.get(sessionId);
|
|
176
|
+
if (prev && prev.origin === p.origin && prev.depth === p.depth)
|
|
177
|
+
return; // no-op
|
|
178
|
+
map.set(sessionId, p);
|
|
179
|
+
await this.persist(map);
|
|
180
|
+
}
|
|
181
|
+
/**
|
|
182
|
+
* Record provenance ONLY if this chat has none yet — never clobbers an existing
|
|
183
|
+
* marker. Used on resume/wake paths (a scheduler wake resumes a chat that may
|
|
184
|
+
* already have been created by a human or a spawn), so its creation provenance
|
|
185
|
+
* survives.
|
|
186
|
+
*/
|
|
187
|
+
async stampIfAbsent(sessionId, provenance) {
|
|
188
|
+
if (!isSafeId(sessionId))
|
|
189
|
+
return;
|
|
190
|
+
const map = await this.ensureLoaded();
|
|
191
|
+
if (map.has(sessionId))
|
|
192
|
+
return;
|
|
193
|
+
await this.stamp(sessionId, provenance);
|
|
194
|
+
}
|
|
195
|
+
/** Write-through, serialised so overlapping stamps can't corrupt the file. */
|
|
196
|
+
persist(map) {
|
|
197
|
+
this.writing = this.writing.then(async () => {
|
|
198
|
+
const obj = {};
|
|
199
|
+
for (const [k, v] of map)
|
|
200
|
+
obj[k] = v;
|
|
201
|
+
const json = JSON.stringify(obj, null, 2);
|
|
202
|
+
await fs.writeFile(this.stateFile, json, { encoding: "utf8", mode: 0o600 }).catch(() => undefined);
|
|
203
|
+
});
|
|
204
|
+
return this.writing;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-history DTO builder (E3 / #268 / DD-6) — the "while you were away" view.
|
|
3
|
+
*
|
|
4
|
+
* A *run* is one herdctl job record (a keeper turn written by a batch
|
|
5
|
+
* `trigger()`): it carries timing (`started_at`/`finished_at`/`duration_seconds`),
|
|
6
|
+
* `status`, `session_id`, and the schedule/fork that triggered it. This module
|
|
7
|
+
* JOINS those raw records with Paddock's {@link RunProvenanceStore} marker so
|
|
8
|
+
* each run reports its true origin — **human / scheduled / spawned** — plus the
|
|
9
|
+
* spawn `depth`. Provenance is authoritative here: paddock-initiated turns still
|
|
10
|
+
* persist `trigger_type:"manual"` on the job record (see ws.ts), so the
|
|
11
|
+
* scheduled/spawned distinction lives in the provenance store, not in the enum.
|
|
12
|
+
*
|
|
13
|
+
* The build step is deliberately PURE (no I/O): the route resolves the job
|
|
14
|
+
* records ({@link HerdctlService.listProjectRuns}), the provenance map, and the
|
|
15
|
+
* per-user "runs last seen" watermark, then hands them here. That keeps the
|
|
16
|
+
* join, the since-last-visit split, and the cost seam unit-testable.
|
|
17
|
+
*
|
|
18
|
+
* **Cost is P3 (deferred to X1/#378 + X2/#271).** herdctl does not yet persist
|
|
19
|
+
* per-run token accounting on the job record, so {@link RunSummary.cost} is a
|
|
20
|
+
* documented seam that is ALWAYS `null` today; when the accounting lands it slots
|
|
21
|
+
* in here without touching the wire shape.
|
|
22
|
+
*/
|
|
23
|
+
import type { JobMetadata } from "@herdctl/core";
|
|
24
|
+
import type { RunProvenance, TurnOrigin } from "./run-provenance.js";
|
|
25
|
+
/**
|
|
26
|
+
* Per-run cost, priced from herdctl's persisted per-model token accounting
|
|
27
|
+
* (DD-4). A SEAM only — always `null` until X1/#378 (herdctl persists the
|
|
28
|
+
* tokens) + X2/#271 (Paddock prices them) land. Shape reserved so adding it is
|
|
29
|
+
* additive.
|
|
30
|
+
*/
|
|
31
|
+
export interface RunCost {
|
|
32
|
+
/** Total cost in USD across every model the run touched. */
|
|
33
|
+
usd: number;
|
|
34
|
+
/** Whether `usd` is a real SDK figure or a CLI/Max-plan estimate. */
|
|
35
|
+
estimated: boolean;
|
|
36
|
+
}
|
|
37
|
+
/** One row in the run-history view: a job record joined with its provenance. */
|
|
38
|
+
export interface RunSummary {
|
|
39
|
+
/** herdctl job id (`job-YYYY-MM-DD-<rand>`); stable per run. */
|
|
40
|
+
jobId: string;
|
|
41
|
+
/** The chat this run belongs to (link target), or null if unresolved. */
|
|
42
|
+
sessionId: string | null;
|
|
43
|
+
/** How the run came to exist (provenance marker; `human` when unmarked). */
|
|
44
|
+
origin: TurnOrigin;
|
|
45
|
+
/** Spawn hops from the human/scheduled root (0 = root). */
|
|
46
|
+
depth: number;
|
|
47
|
+
/** herdctl's persisted trigger type — a secondary signal (see module note). */
|
|
48
|
+
triggerType: string;
|
|
49
|
+
/** Schedule name that fired this run, when scheduled. */
|
|
50
|
+
schedule: string | null;
|
|
51
|
+
/** Parent job id, when this run was forked from another. */
|
|
52
|
+
forkedFrom: string | null;
|
|
53
|
+
/** Terminal/live status: completed | failed | cancelled | running | pending. */
|
|
54
|
+
status: string;
|
|
55
|
+
/** Why it exited (success | error | max_turns | timeout | cancelled), if done. */
|
|
56
|
+
exitReason: string | null;
|
|
57
|
+
/** ISO timestamp the run started. */
|
|
58
|
+
startedAt: string;
|
|
59
|
+
/** ISO timestamp the run finished, or null while running. */
|
|
60
|
+
finishedAt: string | null;
|
|
61
|
+
/** Wall-clock seconds, or null while running / unrecorded. */
|
|
62
|
+
durationSeconds: number | null;
|
|
63
|
+
/** The prompt the run was given (truncation is the client's job). */
|
|
64
|
+
prompt: string | null;
|
|
65
|
+
/** The agent's own one-line summary of the run, when it wrote one. */
|
|
66
|
+
summary: string | null;
|
|
67
|
+
/**
|
|
68
|
+
* True when the run completed AFTER the viewer's "runs last seen" watermark —
|
|
69
|
+
* i.e. it happened while they were away. Drives the since-last-visit digest.
|
|
70
|
+
*/
|
|
71
|
+
isNew: boolean;
|
|
72
|
+
/** Per-run cost — P3 seam, always null today (see {@link RunCost}). */
|
|
73
|
+
cost: RunCost | null;
|
|
74
|
+
}
|
|
75
|
+
/** The payload the run-history endpoint returns. */
|
|
76
|
+
export interface ProjectRuns {
|
|
77
|
+
runs: RunSummary[];
|
|
78
|
+
/** Epoch-ms the viewer last visited the run-history view (0 = never). */
|
|
79
|
+
lastSeen: number;
|
|
80
|
+
/** Count of unattended (scheduled + spawned) runs newer than `lastSeen`. */
|
|
81
|
+
newUnattended: number;
|
|
82
|
+
}
|
|
83
|
+
/** A run is "unattended" when a schedule or another chat — not a human — ran it. */
|
|
84
|
+
export declare function isUnattended(origin: TurnOrigin): boolean;
|
|
85
|
+
/**
|
|
86
|
+
* Join raw job records with the provenance map and the viewer's watermark into
|
|
87
|
+
* the wire DTO. `jobs` is expected most-recent-first (as `listJobs` returns);
|
|
88
|
+
* order is preserved. `provenanceBySession` maps a session id to its marker;
|
|
89
|
+
* a missing entry (or a null session id) defaults to `human`/depth 0. Runs that
|
|
90
|
+
* completed after `lastSeenMs` are flagged `isNew`.
|
|
91
|
+
*/
|
|
92
|
+
export declare function buildProjectRuns(jobs: JobMetadata[], provenanceBySession: ReadonlyMap<string, RunProvenance>, lastSeenMs: number): ProjectRuns;
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which origins ran WITHOUT the user watching — the "while you were away"
|
|
3
|
+
* digest's whole basis.
|
|
4
|
+
*
|
|
5
|
+
* A total `Record`, not a predicate with an `||` chain: a missing key is a
|
|
6
|
+
* compile error, so adding an origin forces a decision here instead of quietly
|
|
7
|
+
* defaulting. It must never become `origin !== "human"`, which reads the same
|
|
8
|
+
* today and is wrong tomorrow — `adopted` (#588) is a session the user ran
|
|
9
|
+
* themselves in a terminal and later imported, so one import of 22 sessions
|
|
10
|
+
* would announce "22 runs happened while you were away", the exact opposite of
|
|
11
|
+
* the truth.
|
|
12
|
+
*/
|
|
13
|
+
const ORIGIN_IS_UNATTENDED = {
|
|
14
|
+
human: false,
|
|
15
|
+
scheduled: true,
|
|
16
|
+
spawned: true,
|
|
17
|
+
hook: false,
|
|
18
|
+
adopted: false,
|
|
19
|
+
};
|
|
20
|
+
/** A run is "unattended" when a schedule or another chat — not a human — ran it. */
|
|
21
|
+
export function isUnattended(origin) {
|
|
22
|
+
return ORIGIN_IS_UNATTENDED[origin] === true;
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* The epoch-ms a run "completed" for since-last-visit purposes: its finish time
|
|
26
|
+
* when it has one, else its start time (a still-running run counts as new the
|
|
27
|
+
* moment it starts). Returns 0 for an unparseable timestamp so it never spuriously
|
|
28
|
+
* counts as new.
|
|
29
|
+
*/
|
|
30
|
+
function completionMs(job) {
|
|
31
|
+
const iso = job.finished_at ?? job.started_at;
|
|
32
|
+
const ms = iso ? Date.parse(iso) : NaN;
|
|
33
|
+
return Number.isFinite(ms) ? ms : 0;
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Join raw job records with the provenance map and the viewer's watermark into
|
|
37
|
+
* the wire DTO. `jobs` is expected most-recent-first (as `listJobs` returns);
|
|
38
|
+
* order is preserved. `provenanceBySession` maps a session id to its marker;
|
|
39
|
+
* a missing entry (or a null session id) defaults to `human`/depth 0. Runs that
|
|
40
|
+
* completed after `lastSeenMs` are flagged `isNew`.
|
|
41
|
+
*/
|
|
42
|
+
export function buildProjectRuns(jobs, provenanceBySession, lastSeenMs) {
|
|
43
|
+
let newUnattended = 0;
|
|
44
|
+
const runs = jobs.map((job) => {
|
|
45
|
+
const sessionId = job.session_id ?? null;
|
|
46
|
+
const prov = sessionId ? provenanceBySession.get(sessionId) : undefined;
|
|
47
|
+
const origin = prov?.origin ?? "human";
|
|
48
|
+
const depth = prov?.depth ?? 0;
|
|
49
|
+
const isNew = completionMs(job) > lastSeenMs;
|
|
50
|
+
if (isNew && isUnattended(origin))
|
|
51
|
+
newUnattended += 1;
|
|
52
|
+
return {
|
|
53
|
+
jobId: job.id,
|
|
54
|
+
sessionId,
|
|
55
|
+
origin,
|
|
56
|
+
depth,
|
|
57
|
+
triggerType: job.trigger_type,
|
|
58
|
+
schedule: job.schedule ?? null,
|
|
59
|
+
forkedFrom: job.forked_from ?? null,
|
|
60
|
+
status: job.status,
|
|
61
|
+
exitReason: job.exit_reason ?? null,
|
|
62
|
+
startedAt: job.started_at,
|
|
63
|
+
finishedAt: job.finished_at ?? null,
|
|
64
|
+
durationSeconds: job.duration_seconds ?? null,
|
|
65
|
+
prompt: job.prompt ?? null,
|
|
66
|
+
summary: job.summary ?? null,
|
|
67
|
+
isNew,
|
|
68
|
+
cost: null, // P3 seam — see RunCost
|
|
69
|
+
};
|
|
70
|
+
});
|
|
71
|
+
return { runs, lastSeen: lastSeenMs, newUnattended };
|
|
72
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled-chat config helpers (issue #265 / DD-2).
|
|
3
|
+
*
|
|
4
|
+
* A Paddock schedule IS herdctl's `ScheduleSchema` shape, passed through the stack
|
|
5
|
+
* **unmolested** — the same field names herdctl's cron engine already reads, so a
|
|
6
|
+
* `schedules` block on the keeper agent config is armed with no translation. This
|
|
7
|
+
* module holds the small, pure surface around that pass-through:
|
|
8
|
+
*
|
|
9
|
+
* - {@link sanitizeSchedules} — validate/normalise a hand-edited `project.yaml`
|
|
10
|
+
* `schedules` map, DROPPING malformed entries so one bad edit can't brick the
|
|
11
|
+
* keeper's `addAgent` registration (which would throw on an invalid schedule).
|
|
12
|
+
*
|
|
13
|
+
* The `schedules` block itself is now declared only via unified TRIGGERS
|
|
14
|
+
* (trigger-config.ts forwards SCHEDULE-type triggers into the keeper agent); this
|
|
15
|
+
* module retains only the back-compat parser for any legacy `schedules:` block still
|
|
16
|
+
* present in a hand-edited `project.yaml`.
|
|
17
|
+
*/
|
|
18
|
+
/**
|
|
19
|
+
* A scheduled chat declaration — herdctl's `ScheduleSchema` shape (same field
|
|
20
|
+
* names) plus the Paddock-only {@link PaddockSchedule.promptFile}. v1 arms the two
|
|
21
|
+
* timer types (`cron` / `interval`); herdctl's schema also defines `webhook` /
|
|
22
|
+
* `chat`, left out here since Paddock only schedules timers.
|
|
23
|
+
*/
|
|
24
|
+
export interface PaddockSchedule {
|
|
25
|
+
/** `cron` (5-field/`@daily` expression) or `interval` (e.g. `"30m"`, `"1h"`). */
|
|
26
|
+
type: "cron" | "interval";
|
|
27
|
+
/** The cron expression — required when `type: cron`. */
|
|
28
|
+
cron?: string;
|
|
29
|
+
/** The interval string (e.g. `"15m"`) — required when `type: interval`. */
|
|
30
|
+
interval?: string;
|
|
31
|
+
/**
|
|
32
|
+
* The inline prompt the scheduled turn runs. A schedule may instead point at a
|
|
33
|
+
* {@link promptFile}, which Paddock resolves to a prompt at fire time (so herdctl
|
|
34
|
+
* only ever receives a plain `prompt`). When both are set the file wins.
|
|
35
|
+
*/
|
|
36
|
+
prompt?: string;
|
|
37
|
+
/** Whether the schedule is armed. Defaults true (herdctl's default). */
|
|
38
|
+
enabled?: boolean;
|
|
39
|
+
/**
|
|
40
|
+
* New-vs-accrete (DD-2). `false` (default) → a FRESH chat every fire; `true` →
|
|
41
|
+
* resume the schedule's ONE owned session (created on first fire, reused after),
|
|
42
|
+
* so a "manager" accretes one transcript.
|
|
43
|
+
*/
|
|
44
|
+
resume_session?: boolean;
|
|
45
|
+
/**
|
|
46
|
+
* PADDOCK-ONLY convenience (DD-2): a git-tracked, keeper-editable prompt file
|
|
47
|
+
* under the project's `.paddock/schedules/` dir (e.g. `"daily-manager.md"`),
|
|
48
|
+
* relative to that dir. Read at fire time and forwarded as the schedule's
|
|
49
|
+
* `prompt`. NEVER forwarded into the herdctl config (kept pure). Traversal
|
|
50
|
+
* outside `.paddock/schedules/` and non-`.md` names are rejected.
|
|
51
|
+
*/
|
|
52
|
+
promptFile?: string;
|
|
53
|
+
}
|
|
54
|
+
/** A schedule name we're willing to key on (also a safe herdctl schedule key). */
|
|
55
|
+
export declare function isValidScheduleName(name: string): boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Validate + normalise one untrusted schedule record into a {@link PaddockSchedule},
|
|
58
|
+
* or `null` if it's malformed. A `cron` schedule MUST carry a `cron`; an `interval`
|
|
59
|
+
* one MUST carry an `interval` (herdctl would reject a type/field mismatch, so we
|
|
60
|
+
* drop it here rather than let `addAgent` throw and take the whole keeper down).
|
|
61
|
+
*/
|
|
62
|
+
export declare function sanitizeSchedule(raw: unknown): PaddockSchedule | null;
|
|
63
|
+
/**
|
|
64
|
+
* Sanitise a whole `schedules` map, dropping malformed entries and entries with an
|
|
65
|
+
* unsafe name. Returns `undefined` when nothing survives (so it stays absent on
|
|
66
|
+
* disk / off the agent config).
|
|
67
|
+
*/
|
|
68
|
+
export declare function sanitizeSchedules(raw: unknown): Record<string, PaddockSchedule> | undefined;
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Scheduled-chat config helpers (issue #265 / DD-2).
|
|
3
|
+
*
|
|
4
|
+
* A Paddock schedule IS herdctl's `ScheduleSchema` shape, passed through the stack
|
|
5
|
+
* **unmolested** — the same field names herdctl's cron engine already reads, so a
|
|
6
|
+
* `schedules` block on the keeper agent config is armed with no translation. This
|
|
7
|
+
* module holds the small, pure surface around that pass-through:
|
|
8
|
+
*
|
|
9
|
+
* - {@link sanitizeSchedules} — validate/normalise a hand-edited `project.yaml`
|
|
10
|
+
* `schedules` map, DROPPING malformed entries so one bad edit can't brick the
|
|
11
|
+
* keeper's `addAgent` registration (which would throw on an invalid schedule).
|
|
12
|
+
*
|
|
13
|
+
* The `schedules` block itself is now declared only via unified TRIGGERS
|
|
14
|
+
* (trigger-config.ts forwards SCHEDULE-type triggers into the keeper agent); this
|
|
15
|
+
* module retains only the back-compat parser for any legacy `schedules:` block still
|
|
16
|
+
* present in a hand-edited `project.yaml`.
|
|
17
|
+
*/
|
|
18
|
+
const SCHEDULE_TYPES = new Set(["cron", "interval"]);
|
|
19
|
+
/** A schedule name we're willing to key on (also a safe herdctl schedule key). */
|
|
20
|
+
export function isValidScheduleName(name) {
|
|
21
|
+
return typeof name === "string" && /^[A-Za-z0-9._-]+$/.test(name) && name.length <= 64;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Validate + normalise one untrusted schedule record into a {@link PaddockSchedule},
|
|
25
|
+
* or `null` if it's malformed. A `cron` schedule MUST carry a `cron`; an `interval`
|
|
26
|
+
* one MUST carry an `interval` (herdctl would reject a type/field mismatch, so we
|
|
27
|
+
* drop it here rather than let `addAgent` throw and take the whole keeper down).
|
|
28
|
+
*/
|
|
29
|
+
export function sanitizeSchedule(raw) {
|
|
30
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
31
|
+
return null;
|
|
32
|
+
const o = raw;
|
|
33
|
+
const type = o.type;
|
|
34
|
+
if (typeof type !== "string" || !SCHEDULE_TYPES.has(type))
|
|
35
|
+
return null;
|
|
36
|
+
const out = { type: type };
|
|
37
|
+
if (type === "cron") {
|
|
38
|
+
if (typeof o.cron !== "string" || o.cron.trim() === "")
|
|
39
|
+
return null;
|
|
40
|
+
out.cron = o.cron.trim();
|
|
41
|
+
}
|
|
42
|
+
else {
|
|
43
|
+
if (typeof o.interval !== "string" || o.interval.trim() === "")
|
|
44
|
+
return null;
|
|
45
|
+
out.interval = o.interval.trim();
|
|
46
|
+
}
|
|
47
|
+
if (typeof o.prompt === "string")
|
|
48
|
+
out.prompt = o.prompt;
|
|
49
|
+
if (typeof o.enabled === "boolean")
|
|
50
|
+
out.enabled = o.enabled;
|
|
51
|
+
if (typeof o.resume_session === "boolean")
|
|
52
|
+
out.resume_session = o.resume_session;
|
|
53
|
+
if (typeof o.promptFile === "string" && o.promptFile.trim() !== "") {
|
|
54
|
+
out.promptFile = o.promptFile.trim();
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
/**
|
|
59
|
+
* Sanitise a whole `schedules` map, dropping malformed entries and entries with an
|
|
60
|
+
* unsafe name. Returns `undefined` when nothing survives (so it stays absent on
|
|
61
|
+
* disk / off the agent config).
|
|
62
|
+
*/
|
|
63
|
+
export function sanitizeSchedules(raw) {
|
|
64
|
+
if (!raw || typeof raw !== "object" || Array.isArray(raw))
|
|
65
|
+
return undefined;
|
|
66
|
+
const out = {};
|
|
67
|
+
for (const [name, val] of Object.entries(raw)) {
|
|
68
|
+
if (!isValidScheduleName(name))
|
|
69
|
+
continue;
|
|
70
|
+
const s = sanitizeSchedule(val);
|
|
71
|
+
if (s)
|
|
72
|
+
out[name] = s;
|
|
73
|
+
}
|
|
74
|
+
return Object.keys(out).length > 0 ? out : undefined;
|
|
75
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
export declare class ScheduleSessionStore {
|
|
2
|
+
private readonly stateFile;
|
|
3
|
+
/** In-memory map of `<slug>\0<scheduleName>` -> owned sessionId. */
|
|
4
|
+
private state;
|
|
5
|
+
/**
|
|
6
|
+
* The in-flight load, cached so concurrent first-callers share ONE read (the
|
|
7
|
+
* RunProvenanceStore fix): caching only the resolved `state` would let several
|
|
8
|
+
* fires that begin before the first `fs.readFile` resolves each build their own
|
|
9
|
+
* map and race on the assignment — a lost update.
|
|
10
|
+
*/
|
|
11
|
+
private loadPromise;
|
|
12
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
13
|
+
private writing;
|
|
14
|
+
constructor(dataDir: string);
|
|
15
|
+
private static key;
|
|
16
|
+
/** Load the persisted map (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
17
|
+
private ensureLoaded;
|
|
18
|
+
/** The session id this schedule owns, or undefined if it hasn't fired yet. Non-throwing. */
|
|
19
|
+
get(slug: string, scheduleName: string): Promise<string | undefined>;
|
|
20
|
+
/**
|
|
21
|
+
* Record the session a schedule owns (its first-fire chat). Idempotent for the
|
|
22
|
+
* same id; an unsafe/blank id is ignored. Overwrites if called again (used only
|
|
23
|
+
* on a fresh first fire, or to re-point after a stale id was cleared).
|
|
24
|
+
*/
|
|
25
|
+
set(slug: string, scheduleName: string, sessionId: string): Promise<void>;
|
|
26
|
+
/**
|
|
27
|
+
* Forget a schedule's owned session — used when the recorded transcript has
|
|
28
|
+
* vanished (so the next fire re-creates one) or the schedule was removed. No-op
|
|
29
|
+
* if there was nothing recorded.
|
|
30
|
+
*/
|
|
31
|
+
clear(slug: string, scheduleName: string): Promise<void>;
|
|
32
|
+
/** Write-through, serialised so overlapping writes can't corrupt the file. */
|
|
33
|
+
private persist;
|
|
34
|
+
}
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ScheduleSessionStore — a Paddock-side sidecar mapping an *accreting* schedule
|
|
3
|
+
* to the ONE chat session it owns (issue #265 / DD-2). A schedule declared with
|
|
4
|
+
* `resume_session: true` should build up a single long-lived transcript across
|
|
5
|
+
* every fire (a "manager" that accretes context), rather than spawning a fresh
|
|
6
|
+
* chat each time. To make that resume unambiguous, Paddock records the session id
|
|
7
|
+
* the schedule created on its FIRST fire and reuses it thereafter.
|
|
8
|
+
*
|
|
9
|
+
* Chosen over "resume the latest chat of a lineage" (DD-2, resolved decision #1):
|
|
10
|
+
* a stable owned id is unambiguous and survives new chats a human might start in
|
|
11
|
+
* the same project. A `resume_session: false` schedule owns nothing — it gets a
|
|
12
|
+
* new chat every fire and never touches this store.
|
|
13
|
+
*
|
|
14
|
+
* Shape + concurrency: a `"<slug>\0<scheduleName>" -> sessionId` map persisted as
|
|
15
|
+
* a plain JSON object — the exact ArchiveStore / ReadStateStore / RunProvenanceStore
|
|
16
|
+
* sidecar pattern (lazy-loaded, write-through, serialised, corruption-tolerant,
|
|
17
|
+
* `0o600`), INCLUDING the in-flight-load-promise fix: `ensureLoaded` caches the
|
|
18
|
+
* load PROMISE (not just the resolved map) so concurrent first-callers share one
|
|
19
|
+
* read and can't each build a separate map and race on the assignment (a lost
|
|
20
|
+
* update). Reachable if several schedules fire in the same tick right after boot.
|
|
21
|
+
*/
|
|
22
|
+
import { promises as fs } from "node:fs";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
const STATE_FILE = "schedule-sessions.json";
|
|
25
|
+
/** The composite key separator — NUL can't appear in a slug or schedule name. */
|
|
26
|
+
const SEP = "\0";
|
|
27
|
+
/** A session id we're willing to key on — mirrors RunProvenanceStore's guard. */
|
|
28
|
+
function isSafeId(sessionId) {
|
|
29
|
+
return typeof sessionId === "string" && /^[A-Za-z0-9._-]+$/.test(sessionId);
|
|
30
|
+
}
|
|
31
|
+
export class ScheduleSessionStore {
|
|
32
|
+
stateFile;
|
|
33
|
+
/** In-memory map of `<slug>\0<scheduleName>` -> owned sessionId. */
|
|
34
|
+
state = null;
|
|
35
|
+
/**
|
|
36
|
+
* The in-flight load, cached so concurrent first-callers share ONE read (the
|
|
37
|
+
* RunProvenanceStore fix): caching only the resolved `state` would let several
|
|
38
|
+
* fires that begin before the first `fs.readFile` resolves each build their own
|
|
39
|
+
* map and race on the assignment — a lost update.
|
|
40
|
+
*/
|
|
41
|
+
loadPromise = null;
|
|
42
|
+
/** Serialises concurrent writes so the file never interleaves. */
|
|
43
|
+
writing = Promise.resolve();
|
|
44
|
+
constructor(dataDir) {
|
|
45
|
+
this.stateFile = path.join(dataDir, STATE_FILE);
|
|
46
|
+
}
|
|
47
|
+
static key(slug, scheduleName) {
|
|
48
|
+
return `${slug}${SEP}${scheduleName}`;
|
|
49
|
+
}
|
|
50
|
+
/** Load the persisted map (lazily, deduped; tolerant of a missing/corrupt file). */
|
|
51
|
+
ensureLoaded() {
|
|
52
|
+
if (this.state)
|
|
53
|
+
return Promise.resolve(this.state);
|
|
54
|
+
if (!this.loadPromise) {
|
|
55
|
+
this.loadPromise = (async () => {
|
|
56
|
+
const map = new Map();
|
|
57
|
+
try {
|
|
58
|
+
const raw = await fs.readFile(this.stateFile, "utf8");
|
|
59
|
+
const parsed = JSON.parse(raw);
|
|
60
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
61
|
+
for (const [k, v] of Object.entries(parsed)) {
|
|
62
|
+
if (typeof v === "string" && isSafeId(v))
|
|
63
|
+
map.set(k, v);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* missing or unreadable — start empty */
|
|
69
|
+
}
|
|
70
|
+
this.state = map;
|
|
71
|
+
return map;
|
|
72
|
+
})();
|
|
73
|
+
}
|
|
74
|
+
return this.loadPromise;
|
|
75
|
+
}
|
|
76
|
+
/** The session id this schedule owns, or undefined if it hasn't fired yet. Non-throwing. */
|
|
77
|
+
async get(slug, scheduleName) {
|
|
78
|
+
const map = await this.ensureLoaded().catch(() => new Map());
|
|
79
|
+
return map.get(ScheduleSessionStore.key(slug, scheduleName));
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Record the session a schedule owns (its first-fire chat). Idempotent for the
|
|
83
|
+
* same id; an unsafe/blank id is ignored. Overwrites if called again (used only
|
|
84
|
+
* on a fresh first fire, or to re-point after a stale id was cleared).
|
|
85
|
+
*/
|
|
86
|
+
async set(slug, scheduleName, sessionId) {
|
|
87
|
+
if (!isSafeId(sessionId))
|
|
88
|
+
return;
|
|
89
|
+
const key = ScheduleSessionStore.key(slug, scheduleName);
|
|
90
|
+
const map = await this.ensureLoaded();
|
|
91
|
+
if (map.get(key) === sessionId)
|
|
92
|
+
return; // no-op
|
|
93
|
+
map.set(key, sessionId);
|
|
94
|
+
await this.persist(map);
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Forget a schedule's owned session — used when the recorded transcript has
|
|
98
|
+
* vanished (so the next fire re-creates one) or the schedule was removed. No-op
|
|
99
|
+
* if there was nothing recorded.
|
|
100
|
+
*/
|
|
101
|
+
async clear(slug, scheduleName) {
|
|
102
|
+
const key = ScheduleSessionStore.key(slug, scheduleName);
|
|
103
|
+
const map = await this.ensureLoaded();
|
|
104
|
+
if (!map.has(key))
|
|
105
|
+
return;
|
|
106
|
+
map.delete(key);
|
|
107
|
+
await this.persist(map);
|
|
108
|
+
}
|
|
109
|
+
/** Write-through, serialised so overlapping writes can't corrupt the file. */
|
|
110
|
+
persist(map) {
|
|
111
|
+
this.writing = this.writing.then(async () => {
|
|
112
|
+
const obj = {};
|
|
113
|
+
for (const [k, v] of map)
|
|
114
|
+
obj[k] = v;
|
|
115
|
+
const json = JSON.stringify(obj, null, 2);
|
|
116
|
+
await fs.writeFile(this.stateFile, json, { encoding: "utf8", mode: 0o600 }).catch(() => undefined);
|
|
117
|
+
});
|
|
118
|
+
return this.writing;
|
|
119
|
+
}
|
|
120
|
+
}
|