@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.
Files changed (294) hide show
  1. package/README.md +69 -0
  2. package/install-notice.mjs +29 -0
  3. package/package.json +58 -5
  4. package/packages/server/dist/adoptable.d.ts +127 -0
  5. package/packages/server/dist/adoptable.js +315 -0
  6. package/packages/server/dist/app.d.ts +67 -0
  7. package/packages/server/dist/app.js +325 -0
  8. package/packages/server/dist/archive.d.ts +42 -0
  9. package/packages/server/dist/archive.js +125 -0
  10. package/packages/server/dist/attachments-config.d.ts +114 -0
  11. package/packages/server/dist/attachments-config.js +165 -0
  12. package/packages/server/dist/attachments-hint.d.ts +53 -0
  13. package/packages/server/dist/attachments-hint.js +140 -0
  14. package/packages/server/dist/attachments.d.ts +33 -0
  15. package/packages/server/dist/attachments.js +117 -0
  16. package/packages/server/dist/auth.d.ts +84 -0
  17. package/packages/server/dist/auth.js +247 -0
  18. package/packages/server/dist/background.d.ts +36 -0
  19. package/packages/server/dist/background.js +137 -0
  20. package/packages/server/dist/bind-safety.d.ts +56 -0
  21. package/packages/server/dist/bind-safety.js +51 -0
  22. package/packages/server/dist/brand.d.ts +44 -0
  23. package/packages/server/dist/brand.js +88 -0
  24. package/packages/server/dist/chat-dto.d.ts +210 -0
  25. package/packages/server/dist/chat-dto.js +308 -0
  26. package/packages/server/dist/cli/import-chats.d.ts +21 -0
  27. package/packages/server/dist/cli/import-chats.js +197 -0
  28. package/packages/server/dist/cli/paddock.d.ts +2 -0
  29. package/packages/server/dist/cli/paddock.js +247 -0
  30. package/packages/server/dist/concurrency.d.ts +57 -0
  31. package/packages/server/dist/concurrency.js +71 -0
  32. package/packages/server/dist/config.d.ts +512 -0
  33. package/packages/server/dist/config.js +593 -0
  34. package/packages/server/dist/curation-config.d.ts +55 -0
  35. package/packages/server/dist/curation-config.js +64 -0
  36. package/packages/server/dist/event-bus.d.ts +87 -0
  37. package/packages/server/dist/event-bus.js +48 -0
  38. package/packages/server/dist/git.d.ts +128 -0
  39. package/packages/server/dist/git.js +446 -0
  40. package/packages/server/dist/github-auth.d.ts +44 -0
  41. package/packages/server/dist/github-auth.js +169 -0
  42. package/packages/server/dist/herdctl-agent-config.d.ts +98 -0
  43. package/packages/server/dist/herdctl-agent-config.js +331 -0
  44. package/packages/server/dist/herdctl-agent-names.d.ts +172 -0
  45. package/packages/server/dist/herdctl-agent-names.js +228 -0
  46. package/packages/server/dist/herdctl-jobs-index.d.ts +65 -0
  47. package/packages/server/dist/herdctl-jobs-index.js +225 -0
  48. package/packages/server/dist/herdctl-jobs.d.ts +98 -0
  49. package/packages/server/dist/herdctl-jobs.js +225 -0
  50. package/packages/server/dist/herdctl.d.ts +572 -0
  51. package/packages/server/dist/herdctl.js +1218 -0
  52. package/packages/server/dist/hook-config.d.ts +217 -0
  53. package/packages/server/dist/hook-config.js +224 -0
  54. package/packages/server/dist/http-bytes.d.ts +24 -0
  55. package/packages/server/dist/http-bytes.js +52 -0
  56. package/packages/server/dist/index.d.ts +1 -0
  57. package/packages/server/dist/index.js +14 -0
  58. package/packages/server/dist/instance-config.d.ts +135 -0
  59. package/packages/server/dist/instance-config.js +345 -0
  60. package/packages/server/dist/localcommand.d.ts +23 -0
  61. package/packages/server/dist/localcommand.js +140 -0
  62. package/packages/server/dist/management-auth.d.ts +99 -0
  63. package/packages/server/dist/management-auth.js +165 -0
  64. package/packages/server/dist/management-config.d.ts +164 -0
  65. package/packages/server/dist/management-config.js +286 -0
  66. package/packages/server/dist/management-mcp-server.d.ts +20 -0
  67. package/packages/server/dist/management-mcp-server.js +95 -0
  68. package/packages/server/dist/management-metadata.d.ts +67 -0
  69. package/packages/server/dist/management-metadata.js +56 -0
  70. package/packages/server/dist/management-ops.d.ts +110 -0
  71. package/packages/server/dist/management-ops.js +449 -0
  72. package/packages/server/dist/management-policy.d.ts +172 -0
  73. package/packages/server/dist/management-policy.js +259 -0
  74. package/packages/server/dist/message-provenance.d.ts +112 -0
  75. package/packages/server/dist/message-provenance.js +260 -0
  76. package/packages/server/dist/models.d.ts +164 -0
  77. package/packages/server/dist/models.js +208 -0
  78. package/packages/server/dist/openapi.d.ts +66 -0
  79. package/packages/server/dist/openapi.js +183 -0
  80. package/packages/server/dist/parent-detach.d.ts +30 -0
  81. package/packages/server/dist/parent-detach.js +109 -0
  82. package/packages/server/dist/preload.d.ts +43 -0
  83. package/packages/server/dist/preload.js +55 -0
  84. package/packages/server/dist/project-files.d.ts +76 -0
  85. package/packages/server/dist/project-files.js +191 -0
  86. package/packages/server/dist/project-mime.d.ts +35 -0
  87. package/packages/server/dist/project-mime.js +55 -0
  88. package/packages/server/dist/project-paths.d.ts +88 -0
  89. package/packages/server/dist/project-paths.js +126 -0
  90. package/packages/server/dist/project-types.d.ts +316 -0
  91. package/packages/server/dist/project-types.js +48 -0
  92. package/packages/server/dist/projects.d.ts +202 -0
  93. package/packages/server/dist/projects.js +894 -0
  94. package/packages/server/dist/queued-message.d.ts +35 -0
  95. package/packages/server/dist/queued-message.js +116 -0
  96. package/packages/server/dist/read-state.d.ts +46 -0
  97. package/packages/server/dist/read-state.js +139 -0
  98. package/packages/server/dist/recovery-config.d.ts +111 -0
  99. package/packages/server/dist/recovery-config.js +105 -0
  100. package/packages/server/dist/recovery.d.ts +262 -0
  101. package/packages/server/dist/recovery.js +490 -0
  102. package/packages/server/dist/resume-drain.d.ts +122 -0
  103. package/packages/server/dist/resume-drain.js +158 -0
  104. package/packages/server/dist/route-context.d.ts +168 -0
  105. package/packages/server/dist/route-context.js +39 -0
  106. package/packages/server/dist/route-errors.d.ts +8 -0
  107. package/packages/server/dist/route-errors.js +13 -0
  108. package/packages/server/dist/routes/chats.d.ts +12 -0
  109. package/packages/server/dist/routes/chats.js +1406 -0
  110. package/packages/server/dist/routes/git.d.ts +13 -0
  111. package/packages/server/dist/routes/git.js +236 -0
  112. package/packages/server/dist/routes/mcp.d.ts +83 -0
  113. package/packages/server/dist/routes/mcp.js +250 -0
  114. package/packages/server/dist/routes/meta.d.ts +15 -0
  115. package/packages/server/dist/routes/meta.js +447 -0
  116. package/packages/server/dist/routes/projects.d.ts +25 -0
  117. package/packages/server/dist/routes/projects.js +736 -0
  118. package/packages/server/dist/routes/triggers.d.ts +21 -0
  119. package/packages/server/dist/routes/triggers.js +324 -0
  120. package/packages/server/dist/routes/workspace-mount.d.ts +48 -0
  121. package/packages/server/dist/routes/workspace-mount.js +32 -0
  122. package/packages/server/dist/routes.d.ts +26 -0
  123. package/packages/server/dist/routes.js +29 -0
  124. package/packages/server/dist/run-provenance.d.ts +109 -0
  125. package/packages/server/dist/run-provenance.js +206 -0
  126. package/packages/server/dist/runs.d.ts +92 -0
  127. package/packages/server/dist/runs.js +72 -0
  128. package/packages/server/dist/schedule-config.d.ts +68 -0
  129. package/packages/server/dist/schedule-config.js +75 -0
  130. package/packages/server/dist/schedule-session.d.ts +34 -0
  131. package/packages/server/dist/schedule-session.js +120 -0
  132. package/packages/server/dist/self-mcp-descriptions.d.ts +20 -0
  133. package/packages/server/dist/self-mcp-descriptions.js +145 -0
  134. package/packages/server/dist/self-mcp-read.d.ts +15 -0
  135. package/packages/server/dist/self-mcp-read.js +97 -0
  136. package/packages/server/dist/self-mcp-triggers.d.ts +30 -0
  137. package/packages/server/dist/self-mcp-triggers.js +144 -0
  138. package/packages/server/dist/self-mcp-types.d.ts +248 -0
  139. package/packages/server/dist/self-mcp-types.js +10 -0
  140. package/packages/server/dist/self-mcp-util.d.ts +73 -0
  141. package/packages/server/dist/self-mcp-util.js +157 -0
  142. package/packages/server/dist/self-mcp-write.d.ts +35 -0
  143. package/packages/server/dist/self-mcp-write.js +215 -0
  144. package/packages/server/dist/self-mcp.d.ts +95 -0
  145. package/packages/server/dist/self-mcp.js +448 -0
  146. package/packages/server/dist/send-file-mcp.d.ts +46 -0
  147. package/packages/server/dist/send-file-mcp.js +281 -0
  148. package/packages/server/dist/session-hub.d.ts +186 -0
  149. package/packages/server/dist/session-hub.js +291 -0
  150. package/packages/server/dist/spawn-capability.d.ts +99 -0
  151. package/packages/server/dist/spawn-capability.js +106 -0
  152. package/packages/server/dist/star.d.ts +26 -0
  153. package/packages/server/dist/star.js +94 -0
  154. package/packages/server/dist/start.d.ts +1 -0
  155. package/packages/server/dist/start.js +21 -0
  156. package/packages/server/dist/subagents.d.ts +337 -0
  157. package/packages/server/dist/subagents.js +626 -0
  158. package/packages/server/dist/sweep.d.ts +122 -0
  159. package/packages/server/dist/sweep.js +696 -0
  160. package/packages/server/dist/tooldetails.d.ts +33 -0
  161. package/packages/server/dist/tooldetails.js +466 -0
  162. package/packages/server/dist/transcribe.d.ts +69 -0
  163. package/packages/server/dist/transcribe.js +119 -0
  164. package/packages/server/dist/transcripts.d.ts +42 -0
  165. package/packages/server/dist/transcripts.js +164 -0
  166. package/packages/server/dist/trigger-config.d.ts +278 -0
  167. package/packages/server/dist/trigger-config.js +417 -0
  168. package/packages/server/dist/trigger-runtime.d.ts +94 -0
  169. package/packages/server/dist/trigger-runtime.js +82 -0
  170. package/packages/server/dist/trigger-session.d.ts +34 -0
  171. package/packages/server/dist/trigger-session.js +122 -0
  172. package/packages/server/dist/triggers.d.ts +83 -0
  173. package/packages/server/dist/triggers.js +124 -0
  174. package/packages/server/dist/trusted-proxy.d.ts +57 -0
  175. package/packages/server/dist/trusted-proxy.js +157 -0
  176. package/packages/server/dist/turn-notice.d.ts +154 -0
  177. package/packages/server/dist/turn-notice.js +271 -0
  178. package/packages/server/dist/unread.d.ts +36 -0
  179. package/packages/server/dist/unread.js +123 -0
  180. package/packages/server/dist/usage.d.ts +66 -0
  181. package/packages/server/dist/usage.js +243 -0
  182. package/packages/server/dist/wake-injection.d.ts +156 -0
  183. package/packages/server/dist/wake-injection.js +136 -0
  184. package/packages/server/dist/whisper-local.d.ts +9 -0
  185. package/packages/server/dist/whisper-local.js +122 -0
  186. package/packages/server/dist/ws-context.d.ts +158 -0
  187. package/packages/server/dist/ws-context.js +1 -0
  188. package/packages/server/dist/ws-protocol.d.ts +423 -0
  189. package/packages/server/dist/ws-protocol.js +251 -0
  190. package/packages/server/dist/ws-self-mcp.d.ts +52 -0
  191. package/packages/server/dist/ws-self-mcp.js +28 -0
  192. package/packages/server/dist/ws-triggers.d.ts +29 -0
  193. package/packages/server/dist/ws-triggers.js +261 -0
  194. package/packages/server/dist/ws-turn.d.ts +86 -0
  195. package/packages/server/dist/ws-turn.js +751 -0
  196. package/packages/server/dist/ws.d.ts +62 -0
  197. package/packages/server/dist/ws.js +1024 -0
  198. package/packages/server/package.json +6 -0
  199. package/packages/web/dist/assets/InstanceConfigPage-4cQGTj9Y.js +2 -0
  200. package/packages/web/dist/assets/MarkdownRenderer-CJZFLj_8.js +30 -0
  201. package/packages/web/dist/assets/ProjectRedirect-BHwVbbqB.js +2 -0
  202. package/packages/web/dist/assets/ProjectView-BOvuAITg.js +13 -0
  203. package/packages/web/dist/assets/ProjectsGrid-BCojZOGY.js +2 -0
  204. package/packages/web/dist/assets/arc-BVyxvnkl.js +2 -0
  205. package/packages/web/dist/assets/architectureDiagram-3BPJPVTR-Cd9Puqg1.js +37 -0
  206. package/packages/web/dist/assets/bash-I8pq0VWm.js +2 -0
  207. package/packages/web/dist/assets/blockDiagram-GPEHLZMM-B_AQt_Rx.js +133 -0
  208. package/packages/web/dist/assets/c-D2F1sQUL.js +2 -0
  209. package/packages/web/dist/assets/c4Diagram-AAUBKEIU-7e0TQtXB.js +11 -0
  210. package/packages/web/dist/assets/channel-C_1aISNn.js +2 -0
  211. package/packages/web/dist/assets/chunk-2J33WTMH-Dz4WZmUZ.js +2 -0
  212. package/packages/web/dist/assets/chunk-4BX2VUAB-DVWYldo6.js +2 -0
  213. package/packages/web/dist/assets/chunk-55IACEB6-BIMrizQd.js +2 -0
  214. package/packages/web/dist/assets/chunk-727SXJPM-CL2q9fF1.js +207 -0
  215. package/packages/web/dist/assets/chunk-AQP2D5EJ-BSq3HlQ9.js +232 -0
  216. package/packages/web/dist/assets/chunk-FMBD7UC4-Bgt64pLE.js +16 -0
  217. package/packages/web/dist/assets/chunk-ND2GUHAM-C8Pr2A38.js +2 -0
  218. package/packages/web/dist/assets/chunk-QZHKN3VN-BAOqh-MN.js +2 -0
  219. package/packages/web/dist/assets/classDiagram-4FO5ZUOK-B8JE7cHJ.js +2 -0
  220. package/packages/web/dist/assets/classDiagram-v2-Q7XG4LA2-B8JE7cHJ.js +2 -0
  221. package/packages/web/dist/assets/core-lJeGACQg.js +4 -0
  222. package/packages/web/dist/assets/cose-bilkent-S5V4N54A-BF4tameA.js +2 -0
  223. package/packages/web/dist/assets/cpp-Bm3ii6kY.js +2 -0
  224. package/packages/web/dist/assets/csharp-BNpuYZMG.js +2 -0
  225. package/packages/web/dist/assets/css-DazXZka4.js +2 -0
  226. package/packages/web/dist/assets/cytoscape.esm-CUqq0XTU.js +332 -0
  227. package/packages/web/dist/assets/dagre-BM42HDAG-DdsLM6ql.js +5 -0
  228. package/packages/web/dist/assets/defaultLocale-DX6XiGOO.js +2 -0
  229. package/packages/web/dist/assets/diagram-2AECGRRQ-BQ78S_RM.js +44 -0
  230. package/packages/web/dist/assets/diagram-5GNKFQAL-CAKE6nHE.js +11 -0
  231. package/packages/web/dist/assets/diagram-KO2AKTUF-fEvHRXgr.js +4 -0
  232. package/packages/web/dist/assets/diagram-LMA3HP47-CtWsgeSm.js +25 -0
  233. package/packages/web/dist/assets/diagram-OG6HWLK6-Buog8k7_.js +25 -0
  234. package/packages/web/dist/assets/erDiagram-TEJ5UH35-CWvVVPZS.js +86 -0
  235. package/packages/web/dist/assets/flowDiagram-I6XJVG4X-DsCGXtp4.js +163 -0
  236. package/packages/web/dist/assets/format-DamJQ9LO.js +2 -0
  237. package/packages/web/dist/assets/ganttDiagram-6RSMTGT7-CsdwIvvC.js +293 -0
  238. package/packages/web/dist/assets/gitGraphDiagram-PVQCEYII-hZvrX1sD.js +107 -0
  239. package/packages/web/dist/assets/go-BPx_y7j8.js +2 -0
  240. package/packages/web/dist/assets/graph-BlwPajkw.js +2 -0
  241. package/packages/web/dist/assets/index-CGbZdrm8.js +71 -0
  242. package/packages/web/dist/assets/index-DJDQ210-.css +1 -0
  243. package/packages/web/dist/assets/infoDiagram-5YYISTIA-nE_Vtz_l.js +3 -0
  244. package/packages/web/dist/assets/ini-B3BVgEsG.js +2 -0
  245. package/packages/web/dist/assets/init-Gi6I4Gst.js +2 -0
  246. package/packages/web/dist/assets/ishikawaDiagram-YF4QCWOH-2Nlqs0oH.js +71 -0
  247. package/packages/web/dist/assets/java-B5VKqtH2.js +2 -0
  248. package/packages/web/dist/assets/javascript-BKRaQes9.js +2 -0
  249. package/packages/web/dist/assets/journeyDiagram-JHISSGLW-mjU1fMc4.js +140 -0
  250. package/packages/web/dist/assets/json-DIYVocXf.js +2 -0
  251. package/packages/web/dist/assets/kanban-definition-UN3LZRKU-C_9xNaa3.js +90 -0
  252. package/packages/web/dist/assets/katex-C5jXJg4s.js +258 -0
  253. package/packages/web/dist/assets/kotlin-Cw-eF_bm.js +3 -0
  254. package/packages/web/dist/assets/lastTab-CU1zsF0j.js +2 -0
  255. package/packages/web/dist/assets/layout-Boy8vhSP.js +2 -0
  256. package/packages/web/dist/assets/linear-BxKd91ho.js +2 -0
  257. package/packages/web/dist/assets/mermaid.core-C-CuhD8I.js +310 -0
  258. package/packages/web/dist/assets/mindmap-definition-RKZ34NQL-Bs6yfcZv.js +97 -0
  259. package/packages/web/dist/assets/ordinal-Cboi1Yqb.js +2 -0
  260. package/packages/web/dist/assets/php-CRdd4dfS.js +3 -0
  261. package/packages/web/dist/assets/pieDiagram-4H26LBE5-DNdqX7O5.js +31 -0
  262. package/packages/web/dist/assets/python-kSlZHhJb.js +2 -0
  263. package/packages/web/dist/assets/quadrantDiagram-W4KKPZXB-ZMJl9OdZ.js +8 -0
  264. package/packages/web/dist/assets/queued-LamEU0GH.js +2 -0
  265. package/packages/web/dist/assets/requirementDiagram-4Y6WPE33-RmxMdm2q.js +85 -0
  266. package/packages/web/dist/assets/ruby-7T4ktxyv.js +2 -0
  267. package/packages/web/dist/assets/rust-HC3MBUhY.js +2 -0
  268. package/packages/web/dist/assets/sankeyDiagram-5OEKKPKP-OAAhWcfK.js +41 -0
  269. package/packages/web/dist/assets/scss-DZLA7YXz.js +2 -0
  270. package/packages/web/dist/assets/sequenceDiagram-3UESZ5HK-BWF7rCyN.js +163 -0
  271. package/packages/web/dist/assets/sql-BC88IN-V.js +2 -0
  272. package/packages/web/dist/assets/stateDiagram-AJRCARHV-M3b_zqqo.js +2 -0
  273. package/packages/web/dist/assets/stateDiagram-v2-BHNVJYJU-CI7saFDa.js +2 -0
  274. package/packages/web/dist/assets/swift-Dn8kKO_1.js +2 -0
  275. package/packages/web/dist/assets/timeline-definition-PNZ67QCA-BpOCh63N.js +121 -0
  276. package/packages/web/dist/assets/typescript-C2FFdlUC.js +2 -0
  277. package/packages/web/dist/assets/vennDiagram-CIIHVFJN-D_iPH45Q.js +35 -0
  278. package/packages/web/dist/assets/wardley-L42UT6IY-DVD9I2Om.js +174 -0
  279. package/packages/web/dist/assets/wardleyDiagram-YWT4CUSO-Bn1IKwjn.js +79 -0
  280. package/packages/web/dist/assets/xml-BXBhIUeX.js +2 -0
  281. package/packages/web/dist/assets/xychartDiagram-2RQKCTM6-DwtI0bkR.js +8 -0
  282. package/packages/web/dist/assets/yaml-drsDnIKT.js +2 -0
  283. package/packages/web/dist/favicon.ico +0 -0
  284. package/packages/web/dist/fonts/inter-latin.woff2 +0 -0
  285. package/packages/web/dist/fonts/jetbrains-mono-latin.woff2 +0 -0
  286. package/packages/web/dist/icons/apple-touch-icon.png +0 -0
  287. package/packages/web/dist/icons/favicon-16.png +0 -0
  288. package/packages/web/dist/icons/favicon-32.png +0 -0
  289. package/packages/web/dist/icons/icon-192.png +0 -0
  290. package/packages/web/dist/icons/icon-512.png +0 -0
  291. package/packages/web/dist/icons/maskable-512.png +0 -0
  292. package/packages/web/dist/index.html +56 -0
  293. package/packages/web/dist/manifest.webmanifest +32 -0
  294. 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
+ }