clinkx 1.0.0 → 1.1.1

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 (333) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/README.md +184 -112
  3. package/clinkx-workflows/dist/chairman-knobs.d.ts +24 -0
  4. package/clinkx-workflows/dist/chairman-knobs.js +27 -0
  5. package/clinkx-workflows/dist/chairman-knobs.js.map +1 -0
  6. package/clinkx-workflows/dist/clink-client.d.ts +10 -0
  7. package/clinkx-workflows/dist/clink-client.js +39 -1
  8. package/clinkx-workflows/dist/clink-client.js.map +1 -1
  9. package/clinkx-workflows/dist/config.d.ts +29 -1
  10. package/clinkx-workflows/dist/config.js +35 -2
  11. package/clinkx-workflows/dist/config.js.map +1 -1
  12. package/clinkx-workflows/dist/context-preflight.d.ts +5 -0
  13. package/clinkx-workflows/dist/context-preflight.js.map +1 -1
  14. package/clinkx-workflows/dist/council-execution-policy.d.ts +68 -0
  15. package/clinkx-workflows/dist/council-execution-policy.js +110 -0
  16. package/clinkx-workflows/dist/council-execution-policy.js.map +1 -0
  17. package/clinkx-workflows/dist/council-expander.d.ts +9 -8
  18. package/clinkx-workflows/dist/council-expander.js +18 -9
  19. package/clinkx-workflows/dist/council-expander.js.map +1 -1
  20. package/clinkx-workflows/dist/dashboard/activity-contract.d.ts +5557 -4
  21. package/clinkx-workflows/dist/dashboard/activity-contract.js +55 -3
  22. package/clinkx-workflows/dist/dashboard/activity-contract.js.map +1 -1
  23. package/clinkx-workflows/dist/dashboard/activity-reader.d.ts +9 -5
  24. package/clinkx-workflows/dist/dashboard/activity-reader.js +15 -2
  25. package/clinkx-workflows/dist/dashboard/activity-reader.js.map +1 -1
  26. package/clinkx-workflows/dist/dashboard/api-routes.d.ts +4 -0
  27. package/clinkx-workflows/dist/dashboard/api-routes.js +133 -25
  28. package/clinkx-workflows/dist/dashboard/api-routes.js.map +1 -1
  29. package/clinkx-workflows/dist/dashboard/context-builder-invocation-contract.d.ts +3865 -0
  30. package/clinkx-workflows/dist/dashboard/context-builder-invocation-contract.js +69 -0
  31. package/clinkx-workflows/dist/dashboard/context-builder-invocation-contract.js.map +1 -0
  32. package/clinkx-workflows/dist/dashboard/context-run-reader.d.ts +36 -2
  33. package/clinkx-workflows/dist/dashboard/context-run-reader.js +142 -19
  34. package/clinkx-workflows/dist/dashboard/context-run-reader.js.map +1 -1
  35. package/clinkx-workflows/dist/dashboard/contracts.d.ts +20027 -480
  36. package/clinkx-workflows/dist/dashboard/contracts.js +133 -6
  37. package/clinkx-workflows/dist/dashboard/contracts.js.map +1 -1
  38. package/clinkx-workflows/dist/dashboard/index.d.ts +4 -0
  39. package/clinkx-workflows/dist/dashboard/index.js +4 -0
  40. package/clinkx-workflows/dist/dashboard/index.js.map +1 -1
  41. package/clinkx-workflows/dist/dashboard/invocation-contract.d.ts +786 -0
  42. package/clinkx-workflows/dist/dashboard/invocation-contract.js +201 -0
  43. package/clinkx-workflows/dist/dashboard/invocation-contract.js.map +1 -0
  44. package/clinkx-workflows/dist/dashboard/lazy-content.d.ts +14 -1
  45. package/clinkx-workflows/dist/dashboard/lazy-content.js +53 -2
  46. package/clinkx-workflows/dist/dashboard/lazy-content.js.map +1 -1
  47. package/clinkx-workflows/dist/dashboard/native-transcript/contract.d.ts +90 -0
  48. package/clinkx-workflows/dist/dashboard/native-transcript/contract.js +31 -0
  49. package/clinkx-workflows/dist/dashboard/native-transcript/contract.js.map +1 -0
  50. package/clinkx-workflows/dist/dashboard/native-transcript/projection.d.ts +12 -0
  51. package/clinkx-workflows/dist/dashboard/native-transcript/projection.js +286 -0
  52. package/clinkx-workflows/dist/dashboard/native-transcript/projection.js.map +1 -0
  53. package/clinkx-workflows/dist/dashboard/progress-report-contract.d.ts +147 -4
  54. package/clinkx-workflows/dist/dashboard/progress-report-contract.js +11 -11
  55. package/clinkx-workflows/dist/dashboard/progress-report-contract.js.map +1 -1
  56. package/clinkx-workflows/dist/dashboard/progress-report-reader.js +4 -56
  57. package/clinkx-workflows/dist/dashboard/progress-report-reader.js.map +1 -1
  58. package/clinkx-workflows/dist/dashboard/progress-report-status-store.d.ts +2 -1
  59. package/clinkx-workflows/dist/dashboard/progress-report-status-store.js +6 -5
  60. package/clinkx-workflows/dist/dashboard/progress-report-status-store.js.map +1 -1
  61. package/clinkx-workflows/dist/dashboard/prompt-builder-library-contract.d.ts +194 -0
  62. package/clinkx-workflows/dist/dashboard/prompt-builder-library-contract.js +89 -0
  63. package/clinkx-workflows/dist/dashboard/prompt-builder-library-contract.js.map +1 -0
  64. package/clinkx-workflows/dist/dashboard/prompt-builder-library-store.d.ts +26 -0
  65. package/clinkx-workflows/dist/dashboard/prompt-builder-library-store.js +102 -0
  66. package/clinkx-workflows/dist/dashboard/prompt-builder-library-store.js.map +1 -0
  67. package/clinkx-workflows/dist/dashboard/request-admission.js +4 -0
  68. package/clinkx-workflows/dist/dashboard/request-admission.js.map +1 -1
  69. package/clinkx-workflows/dist/dashboard/scanner.d.ts +3 -0
  70. package/clinkx-workflows/dist/dashboard/scanner.js +52 -8
  71. package/clinkx-workflows/dist/dashboard/scanner.js.map +1 -1
  72. package/clinkx-workflows/dist/dashboard/server-factory.d.ts +14 -1
  73. package/clinkx-workflows/dist/dashboard/server-factory.js +73 -21
  74. package/clinkx-workflows/dist/dashboard/server-factory.js.map +1 -1
  75. package/clinkx-workflows/dist/dashboard/session-log-contract.d.ts +223 -56
  76. package/clinkx-workflows/dist/dashboard/session-log-contract.js +26 -0
  77. package/clinkx-workflows/dist/dashboard/session-log-contract.js.map +1 -1
  78. package/clinkx-workflows/dist/dashboard/session-log-journal.js +10 -3
  79. package/clinkx-workflows/dist/dashboard/session-log-journal.js.map +1 -1
  80. package/clinkx-workflows/dist/dashboard/session-log-redaction.d.ts +9 -1
  81. package/clinkx-workflows/dist/dashboard/session-log-redaction.js +793 -59
  82. package/clinkx-workflows/dist/dashboard/session-log-redaction.js.map +1 -1
  83. package/clinkx-workflows/dist/dashboard-ui/assets/index-JPHQVtn9.js +106 -0
  84. package/clinkx-workflows/dist/dashboard-ui/assets/style-DOMHk-j6.css +1 -0
  85. package/clinkx-workflows/dist/dashboard-ui/index.html +2 -2
  86. package/clinkx-workflows/dist/delegate-finalizer-policy.d.ts +26 -0
  87. package/clinkx-workflows/dist/delegate-finalizer-policy.js +72 -0
  88. package/clinkx-workflows/dist/delegate-finalizer-policy.js.map +1 -0
  89. package/clinkx-workflows/dist/delegate-signature.d.ts +14 -0
  90. package/clinkx-workflows/dist/delegate-signature.js +14 -0
  91. package/clinkx-workflows/dist/delegate-signature.js.map +1 -0
  92. package/clinkx-workflows/dist/delegate-validate.d.ts +32 -2
  93. package/clinkx-workflows/dist/delegate-validate.js +51 -0
  94. package/clinkx-workflows/dist/delegate-validate.js.map +1 -1
  95. package/clinkx-workflows/dist/engine.d.ts +113 -3
  96. package/clinkx-workflows/dist/engine.js +476 -80
  97. package/clinkx-workflows/dist/engine.js.map +1 -1
  98. package/clinkx-workflows/dist/errors.d.ts +22 -2
  99. package/clinkx-workflows/dist/errors.js +30 -2
  100. package/clinkx-workflows/dist/errors.js.map +1 -1
  101. package/clinkx-workflows/dist/fidelity.d.ts +47 -1
  102. package/clinkx-workflows/dist/fidelity.js +54 -1
  103. package/clinkx-workflows/dist/fidelity.js.map +1 -1
  104. package/clinkx-workflows/dist/fingerprint.d.ts +24 -6
  105. package/clinkx-workflows/dist/fingerprint.js +32 -9
  106. package/clinkx-workflows/dist/fingerprint.js.map +1 -1
  107. package/clinkx-workflows/dist/index.js +53 -1
  108. package/clinkx-workflows/dist/index.js.map +1 -1
  109. package/clinkx-workflows/dist/logger.d.ts +1 -1
  110. package/clinkx-workflows/dist/logger.js +17 -3
  111. package/clinkx-workflows/dist/logger.js.map +1 -1
  112. package/clinkx-workflows/dist/monotonic-clock.d.ts +19 -0
  113. package/clinkx-workflows/dist/monotonic-clock.js +22 -0
  114. package/clinkx-workflows/dist/monotonic-clock.js.map +1 -0
  115. package/clinkx-workflows/dist/plan-review/contract.d.ts +52 -52
  116. package/clinkx-workflows/dist/plan-review/publication.d.ts +4 -4
  117. package/clinkx-workflows/dist/plan-review/run-state.d.ts +16 -16
  118. package/clinkx-workflows/dist/roster-contract.d.ts +889 -0
  119. package/clinkx-workflows/dist/roster-contract.js +180 -0
  120. package/clinkx-workflows/dist/roster-contract.js.map +1 -0
  121. package/clinkx-workflows/dist/rosters.d.ts +14 -0
  122. package/clinkx-workflows/dist/rosters.js +169 -0
  123. package/clinkx-workflows/dist/rosters.js.map +1 -0
  124. package/clinkx-workflows/dist/run-health.d.ts +7 -0
  125. package/clinkx-workflows/dist/run-health.js +25 -0
  126. package/clinkx-workflows/dist/run-health.js.map +1 -0
  127. package/clinkx-workflows/dist/schema.d.ts +64 -0
  128. package/clinkx-workflows/dist/schema.js +24 -0
  129. package/clinkx-workflows/dist/schema.js.map +1 -1
  130. package/clinkx-workflows/dist/server.js +2 -0
  131. package/clinkx-workflows/dist/server.js.map +1 -1
  132. package/clinkx-workflows/dist/shutdown.d.ts +15 -2
  133. package/clinkx-workflows/dist/shutdown.js +60 -16
  134. package/clinkx-workflows/dist/shutdown.js.map +1 -1
  135. package/clinkx-workflows/dist/state-schema.d.ts +23 -0
  136. package/clinkx-workflows/dist/state.d.ts +5 -0
  137. package/clinkx-workflows/dist/state.js +128 -21
  138. package/clinkx-workflows/dist/state.js.map +1 -1
  139. package/clinkx-workflows/dist/stdio-watchdog.d.ts +52 -0
  140. package/clinkx-workflows/dist/stdio-watchdog.js +212 -0
  141. package/clinkx-workflows/dist/stdio-watchdog.js.map +1 -0
  142. package/clinkx-workflows/dist/types.d.ts +6 -0
  143. package/clinkx-workflows/dist/validators/council.d.ts +50 -50
  144. package/clinkx-workflows/dist/validators/plan-review.d.ts +3 -3
  145. package/clinkx-workflows/dist/workflow-tools.d.ts +70 -0
  146. package/clinkx-workflows/dist/workflow-tools.js +760 -168
  147. package/clinkx-workflows/dist/workflow-tools.js.map +1 -1
  148. package/clinkx-workflows/templates/council/answer-chairman.txt +3 -0
  149. package/clinkx-workflows/templates/council/answer-cross-reviewer.txt +3 -0
  150. package/clinkx-workflows/templates/council/answer-specialist.txt +3 -0
  151. package/clinkx-workflows/templates/council/chairman.txt +3 -0
  152. package/clinkx-workflows/templates/council/code-review-chairman.txt +3 -0
  153. package/clinkx-workflows/templates/council/code-review-cross-reviewer.txt +3 -0
  154. package/clinkx-workflows/templates/council/code-review-reviewer.txt +3 -0
  155. package/clinkx-workflows/templates/council/debug-chairman.txt +3 -0
  156. package/clinkx-workflows/templates/council/debug-cross-reviewer.txt +3 -0
  157. package/clinkx-workflows/templates/council/debug-debugger.txt +3 -0
  158. package/clinkx-workflows/templates/council/discover-analyst.txt +3 -0
  159. package/clinkx-workflows/templates/council/discover-chairman.txt +3 -0
  160. package/clinkx-workflows/templates/council/discover-cross-reviewer.txt +3 -0
  161. package/clinkx-workflows/templates/council/member.txt +3 -0
  162. package/clinkx-workflows/templates/council/plan-review-chairman.txt +3 -0
  163. package/clinkx-workflows/templates/council/plan-review-cross-reviewer.txt +3 -0
  164. package/clinkx-workflows/templates/council/plan-review-reviewer.txt +3 -0
  165. package/clinkx-workflows/templates/council/reviewer.txt +3 -0
  166. package/clinkx-workflows/workflows/council-answer.yaml +3 -0
  167. package/clinkx-workflows/workflows/council-code-review.yaml +3 -0
  168. package/clinkx-workflows/workflows/council-debug.yaml +3 -0
  169. package/clinkx-workflows/workflows/council-default.yaml +7 -0
  170. package/clinkx-workflows/workflows/council-discover.yaml +3 -0
  171. package/clinkx-workflows/workflows/council-plan-review.yaml +3 -0
  172. package/conf/adapters/agy.json +9 -2
  173. package/conf/adapters/claude.json +7 -0
  174. package/conf/adapters/codex.json +7 -0
  175. package/conf/adapters/glm.json +19 -3
  176. package/conf/adapters/grok.json +9 -2
  177. package/conf/adapters/hapi/agy.json +9 -2
  178. package/conf/adapters/hapi/claude.json +7 -0
  179. package/conf/adapters/hapi/codex.json +7 -0
  180. package/conf/adapters/hapi/glm.json +20 -4
  181. package/conf/rosters/default-council.json +21 -0
  182. package/dist/activity-journal.d.ts +17 -3
  183. package/dist/activity-journal.js +404 -79
  184. package/dist/activity-journal.js.map +1 -1
  185. package/dist/codex/agent-tools.js +36 -0
  186. package/dist/codex/agent-tools.js.map +1 -1
  187. package/dist/codex/app-server-client.d.ts +3 -0
  188. package/dist/codex/app-server-client.js +11 -0
  189. package/dist/codex/app-server-client.js.map +1 -1
  190. package/dist/codex/app-server-registry.d.ts +18 -0
  191. package/dist/codex/app-server-registry.js +88 -2
  192. package/dist/codex/app-server-registry.js.map +1 -1
  193. package/dist/codex/client-types.d.ts +7 -1
  194. package/dist/codex/protocol.d.ts +4 -4
  195. package/dist/config.d.ts +220 -23
  196. package/dist/config.js +104 -1
  197. package/dist/config.js.map +1 -1
  198. package/dist/context-builder/bundle.d.ts +13 -0
  199. package/dist/context-builder/bundle.js +46 -0
  200. package/dist/context-builder/bundle.js.map +1 -1
  201. package/dist/context-builder/delegate-finalizer-effective.d.ts +33 -0
  202. package/dist/context-builder/delegate-finalizer-effective.js +82 -0
  203. package/dist/context-builder/delegate-finalizer-effective.js.map +1 -0
  204. package/dist/context-builder/delegate-packet.d.ts +18 -0
  205. package/dist/context-builder/delegate-packet.js +73 -9
  206. package/dist/context-builder/delegate-packet.js.map +1 -1
  207. package/dist/context-builder/delegate-reconstruction.d.ts +29 -0
  208. package/dist/context-builder/delegate-reconstruction.js +110 -41
  209. package/dist/context-builder/delegate-reconstruction.js.map +1 -1
  210. package/dist/context-builder/delegate-validation.d.ts +31 -0
  211. package/dist/context-builder/delegate-validation.js +87 -10
  212. package/dist/context-builder/delegate-validation.js.map +1 -1
  213. package/dist/context-builder/delegate.d.ts +9 -0
  214. package/dist/context-builder/delegate.js +210 -59
  215. package/dist/context-builder/delegate.js.map +1 -1
  216. package/dist/context-builder/diagnostics.d.ts +1 -1
  217. package/dist/context-builder/diagnostics.js.map +1 -1
  218. package/dist/context-builder/discovery-progress.d.ts +25 -0
  219. package/dist/context-builder/discovery-progress.js +64 -0
  220. package/dist/context-builder/discovery-progress.js.map +1 -0
  221. package/dist/context-builder/discovery-watchdog.d.ts +15 -0
  222. package/dist/context-builder/discovery-watchdog.js +39 -0
  223. package/dist/context-builder/discovery-watchdog.js.map +1 -0
  224. package/dist/context-builder/discovery.d.ts +7 -0
  225. package/dist/context-builder/discovery.js +119 -7
  226. package/dist/context-builder/discovery.js.map +1 -1
  227. package/dist/context-builder/external-gaps.d.ts +73 -0
  228. package/dist/context-builder/external-gaps.js +248 -0
  229. package/dist/context-builder/external-gaps.js.map +1 -0
  230. package/dist/context-builder/globs.d.ts +1 -0
  231. package/dist/context-builder/globs.js +15 -0
  232. package/dist/context-builder/globs.js.map +1 -1
  233. package/dist/context-builder/handler.d.ts +42 -1
  234. package/dist/context-builder/handler.js +690 -62
  235. package/dist/context-builder/handler.js.map +1 -1
  236. package/dist/context-builder/invocation-sidecar.d.ts +41 -0
  237. package/dist/context-builder/invocation-sidecar.js +189 -0
  238. package/dist/context-builder/invocation-sidecar.js.map +1 -0
  239. package/dist/context-builder/lifecycle-contract.d.ts +51 -51
  240. package/dist/context-builder/mutation-lock.d.ts +32 -0
  241. package/dist/context-builder/mutation-lock.js +321 -0
  242. package/dist/context-builder/mutation-lock.js.map +1 -0
  243. package/dist/context-builder/review-coverage.d.ts +32 -0
  244. package/dist/context-builder/review-coverage.js +130 -23
  245. package/dist/context-builder/review-coverage.js.map +1 -1
  246. package/dist/context-builder/schema.d.ts +76 -1
  247. package/dist/context-builder/schema.js +21 -1
  248. package/dist/context-builder/schema.js.map +1 -1
  249. package/dist/context-builder/secrets.d.ts +31 -0
  250. package/dist/context-builder/secrets.js +871 -84
  251. package/dist/context-builder/secrets.js.map +1 -1
  252. package/dist/context-builder/status.d.ts +1 -1
  253. package/dist/context-builder/status.js +1 -0
  254. package/dist/context-builder/status.js.map +1 -1
  255. package/dist/context-builder/storage.d.ts +29 -5
  256. package/dist/context-builder/storage.js +311 -236
  257. package/dist/context-builder/storage.js.map +1 -1
  258. package/dist/context-builder/versions.d.ts +19 -4
  259. package/dist/context-builder/versions.js +19 -4
  260. package/dist/context-builder/versions.js.map +1 -1
  261. package/dist/context-builder/workflow-runner.d.ts +6 -0
  262. package/dist/context-builder/workflow-runner.js +363 -173
  263. package/dist/context-builder/workflow-runner.js.map +1 -1
  264. package/dist/dashboard-adapter-catalog.js +7 -0
  265. package/dist/dashboard-adapter-catalog.js.map +1 -1
  266. package/dist/dashboard-cli.d.ts +2 -0
  267. package/dist/dashboard-cli.js +40 -2
  268. package/dist/dashboard-cli.js.map +1 -1
  269. package/dist/dashboard-companion/client.d.ts +1 -0
  270. package/dist/dashboard-companion/client.js +22 -3
  271. package/dist/dashboard-companion/client.js.map +1 -1
  272. package/dist/dashboard-companion/protocol.js +4 -1
  273. package/dist/dashboard-companion/protocol.js.map +1 -1
  274. package/dist/dashboard-gateway/index.d.ts +1 -0
  275. package/dist/dashboard-gateway/index.js +33 -2
  276. package/dist/dashboard-gateway/index.js.map +1 -1
  277. package/dist/handler.js +5 -1
  278. package/dist/handler.js.map +1 -1
  279. package/dist/index.d.ts +18 -1
  280. package/dist/index.js +145 -7
  281. package/dist/index.js.map +1 -1
  282. package/dist/logger.d.ts +1 -1
  283. package/dist/logger.js +18 -3
  284. package/dist/logger.js.map +1 -1
  285. package/dist/parsers/agy-stream-json.d.ts +14 -0
  286. package/dist/parsers/agy-stream-json.js +172 -0
  287. package/dist/parsers/agy-stream-json.js.map +1 -0
  288. package/dist/parsers/extract.js +5 -1
  289. package/dist/parsers/extract.js.map +1 -1
  290. package/dist/parsers/observed-invocation.d.ts +20 -0
  291. package/dist/parsers/observed-invocation.js +42 -0
  292. package/dist/parsers/observed-invocation.js.map +1 -0
  293. package/dist/pipeline.d.ts +13 -0
  294. package/dist/pipeline.js +60 -10
  295. package/dist/pipeline.js.map +1 -1
  296. package/dist/progress-report/lock.d.ts +74 -0
  297. package/dist/progress-report/lock.js +343 -0
  298. package/dist/progress-report/lock.js.map +1 -0
  299. package/dist/progress-report/schema.d.ts +808 -0
  300. package/dist/progress-report/schema.js +119 -0
  301. package/dist/progress-report/schema.js.map +1 -0
  302. package/dist/progress-report/store.d.ts +66 -0
  303. package/dist/progress-report/store.js +766 -0
  304. package/dist/progress-report/store.js.map +1 -0
  305. package/dist/progress-report/tools.d.ts +20 -0
  306. package/dist/progress-report/tools.js +141 -0
  307. package/dist/progress-report/tools.js.map +1 -0
  308. package/dist/prompt-mode.d.ts +1 -1
  309. package/dist/prompt-mode.js +7 -2
  310. package/dist/prompt-mode.js.map +1 -1
  311. package/dist/runner.d.ts +7 -0
  312. package/dist/runner.js +10 -3
  313. package/dist/runner.js.map +1 -1
  314. package/dist/safety.d.ts +50 -0
  315. package/dist/safety.js +182 -1
  316. package/dist/safety.js.map +1 -1
  317. package/dist/schema.d.ts +8 -0
  318. package/dist/schema.js +20 -0
  319. package/dist/schema.js.map +1 -1
  320. package/dist/server.d.ts +6 -1
  321. package/dist/server.js +94 -8
  322. package/dist/server.js.map +1 -1
  323. package/dist/session-log-stream.d.ts +1 -1
  324. package/dist/session-log-stream.js +280 -33
  325. package/dist/session-log-stream.js.map +1 -1
  326. package/dist/stdio-activity.d.ts +28 -0
  327. package/dist/stdio-activity.js +72 -0
  328. package/dist/stdio-activity.js.map +1 -0
  329. package/docs/configuration.md +426 -0
  330. package/docs/security.md +293 -0
  331. package/package.json +5 -5
  332. package/clinkx-workflows/dist/dashboard-ui/assets/index-DOkMXTfB.js +0 -25
  333. package/clinkx-workflows/dist/dashboard-ui/assets/style-ChJsn0KD.css +0 -1
@@ -0,0 +1,766 @@
1
+ import { createHash } from "node:crypto";
2
+ import { randomBytes } from "node:crypto";
3
+ import { constants as fsConstants } from "node:fs";
4
+ import { lstat, mkdir, open, readdir, rename, unlink } from "node:fs/promises";
5
+ import { join } from "node:path";
6
+ import { ProgressReportDocumentSchema, } from "../../clinkx-workflows/dist/dashboard/progress-report-contract.js";
7
+ import { getStateDir } from "../../clinkx-workflows/dist/config.js";
8
+ import { ExecutionError, InvalidParamsError } from "../errors.js";
9
+ import { logger } from "../logger.js";
10
+ import { withSessionLock } from "./lock.js";
11
+ export const REPORTS_SUBDIR = "progress-reports";
12
+ export const REPORT_FILENAME = "report-data.json";
13
+ export const CONTROL_FILENAME = ".control.json";
14
+ /** Mirrors the dashboard reader's own per-file cap (progress-report-reader.ts). */
15
+ export const MAX_REPORT_FILE_BYTES = 8 * 1024 * 1024;
16
+ /** Bound directory enumeration the same way the reader does. */
17
+ export const SCAN_CAP = 10_000;
18
+ /**
19
+ * How many directories are actually READ per listing. Enumeration is stat-only;
20
+ * only the newest this many are opened, exactly as the dashboard reader does.
21
+ */
22
+ export const MAX_LIST_CANDIDATES = 200;
23
+ /** Aggregate read budget for one listing, matching the reader's own budget. */
24
+ export const MAX_LIST_SCAN_BYTES = 256 * 1024 * 1024;
25
+ /** How many bounded request_id receipts to retain per session. */
26
+ export const RECEIPT_RETENTION = 32;
27
+ /** Section caps, straight from ProgressReportDocumentSchema. */
28
+ const CAPS = {
29
+ agents: 50,
30
+ tasks: 200,
31
+ log: 200,
32
+ files: 500,
33
+ blockers: 50,
34
+ decisions: 100,
35
+ };
36
+ export function reportsRoot(env = process.env) {
37
+ return join(getStateDir(env), REPORTS_SUBDIR);
38
+ }
39
+ function sessionDirFor(sessionId, env) {
40
+ return join(reportsRoot(env), sessionId);
41
+ }
42
+ function nowIso() {
43
+ return new Date().toISOString().replace(/\.\d{3}Z$/, "Z");
44
+ }
45
+ /**
46
+ * `SESSION-<32 hex>`, a PURE FUNCTION of request_id.
47
+ *
48
+ * This is what makes `progress_report_start` idempotent: a retry derives the
49
+ * same directory, so the exclusive `mkdir` that already reserves the id doubles
50
+ * as the replay detector and no second session can be created. Any timestamp
51
+ * component would defeat that — a retry one second later would derive a
52
+ * different id and silently create a duplicate report, which is precisely the
53
+ * failure this tool surface exists to remove. The start time is not lost: it is
54
+ * `session.started_at` inside the document. Nothing parses the id (the dashboard
55
+ * sorts on updated_at with the id as an opaque string tie-break), so dropping the
56
+ * embedded timestamp costs only human scannability of directory names.
57
+ *
58
+ * The `\u0000` between the domain tag and the request_id is a domain separator
59
+ * and is load-bearing: normalizing it to a space changes every derived id, so a
60
+ * retry would no longer find its own directory and would open a duplicate
61
+ * report. It is written as an ESCAPE, never as a raw byte — a raw NUL makes git
62
+ * classify this file as binary, which hides it from diffs, blame and grep.
63
+ */
64
+ function buildSessionId(requestId) {
65
+ const digest = createHash("sha256")
66
+ .update(`clinkx-progress-report-start\u0000${requestId}`)
67
+ .digest("hex");
68
+ return `SESSION-${digest.slice(0, 32)}`;
69
+ }
70
+ function digestOf(value) {
71
+ return createHash("sha256").update(stableStringify(value)).digest("hex");
72
+ }
73
+ /** Key-sorted JSON so a replayed request digests identically regardless of key order. */
74
+ function stableStringify(value) {
75
+ if (value === null || typeof value !== "object")
76
+ return JSON.stringify(value) ?? "null";
77
+ if (Array.isArray(value))
78
+ return `[${value.map(stableStringify).join(",")}]`;
79
+ const entries = Object.entries(value)
80
+ .filter(([, v]) => v !== undefined)
81
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0));
82
+ return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${stableStringify(v)}`).join(",")}}`;
83
+ }
84
+ // --- persistence -------------------------------------------------------
85
+ /**
86
+ * Reject a session path that is not a real directory.
87
+ *
88
+ * `lstat`, not `stat`: the dashboard reader refuses a symlinked session
89
+ * directory outright, and a writer that silently followed one would write
90
+ * outside the reports root while the report stayed invisible on the dashboard.
91
+ * Writer and reader must admit exactly the same set.
92
+ */
93
+ async function isRealDirectory(dir) {
94
+ const stats = await lstat(dir).catch(() => null);
95
+ return stats !== null && stats.isDirectory() && !stats.isSymbolicLink();
96
+ }
97
+ async function writeAtomic(dir, filename, text) {
98
+ const tmp = join(dir, `.tmp-${randomBytes(8).toString("hex")}.json`);
99
+ let renamed = false;
100
+ try {
101
+ const handle = await open(tmp, "wx", 0o600);
102
+ try {
103
+ await handle.writeFile(text, "utf8");
104
+ // Durability, not just atomicity: rename is atomic against other readers
105
+ // but says nothing about surviving power loss.
106
+ await handle.sync().catch(() => undefined);
107
+ }
108
+ finally {
109
+ await handle.close();
110
+ }
111
+ await rename(tmp, join(dir, filename));
112
+ renamed = true;
113
+ const dirHandle = await open(dir, fsConstants.O_RDONLY).catch(() => null);
114
+ if (dirHandle !== null) {
115
+ try {
116
+ await dirHandle.sync().catch(() => undefined);
117
+ }
118
+ finally {
119
+ await dirHandle.close();
120
+ }
121
+ }
122
+ }
123
+ finally {
124
+ // A temp left behind by a failed write or rename is invisible to the reader
125
+ // but accumulates forever, since nothing else enumerates it.
126
+ if (!renamed)
127
+ await unlink(tmp).catch(() => undefined);
128
+ }
129
+ }
130
+ /**
131
+ * Read and fully validate one report.
132
+ *
133
+ * `expectedId` closes the directory/document identity gap: a session directory
134
+ * whose name disagrees with the `session.id` inside it (a renamed directory, a
135
+ * partially-restored backup) would otherwise let a write land in one directory
136
+ * while the receipt — and the dashboard row — claim another id, so the row's
137
+ * detail link resolves to a different report. Fail closed.
138
+ */
139
+ async function readDocument(sessionDir, expectedId) {
140
+ const path = join(sessionDir, REPORT_FILENAME);
141
+ const stats = await lstat(path).catch(() => null);
142
+ if (stats === null || !stats.isFile() || stats.isSymbolicLink()) {
143
+ throw new InvalidParamsError(`progress report not found: ${path}`);
144
+ }
145
+ if (stats.size > MAX_REPORT_FILE_BYTES) {
146
+ throw new ExecutionError(`progress report exceeds the ${String(MAX_REPORT_FILE_BYTES)}-byte cap and cannot be updated`);
147
+ }
148
+ let raw;
149
+ try {
150
+ // O_NOFOLLOW so a symlinked report file is refused rather than followed,
151
+ // matching the reader's containment discipline.
152
+ const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
153
+ try {
154
+ raw = await handle.readFile("utf8");
155
+ }
156
+ finally {
157
+ await handle.close();
158
+ }
159
+ }
160
+ catch (err) {
161
+ const code = err.code;
162
+ if (code === "ELOOP") {
163
+ throw new ExecutionError(`progress report at ${path} is a symlink and was refused`);
164
+ }
165
+ throw new ExecutionError(`progress report at ${path} could not be read: ${err.message}`);
166
+ }
167
+ let parsed;
168
+ try {
169
+ parsed = JSON.parse(raw);
170
+ }
171
+ catch {
172
+ throw new ExecutionError(`progress report at ${path} is not valid JSON; the session cannot be written until an operator removes the directory`);
173
+ }
174
+ const result = ProgressReportDocumentSchema.safeParse(parsed);
175
+ if (!result.success) {
176
+ throw new ExecutionError(`progress report at ${path} does not satisfy progress-report-v1: ${result.error.issues
177
+ .slice(0, 3)
178
+ .map((i) => `${i.path.join(".")}: ${i.message}`)
179
+ .join("; ")}`);
180
+ }
181
+ if (expectedId !== undefined && result.data.session.id !== expectedId) {
182
+ throw new ExecutionError(`progress report identity mismatch: directory ${JSON.stringify(expectedId)} holds a document whose session.id is ${JSON.stringify(result.data.session.id)}`);
183
+ }
184
+ return {
185
+ document: result.data,
186
+ digest: createHash("sha256").update(raw, "utf8").digest("hex"),
187
+ };
188
+ }
189
+ function receiptsFrom(value, path) {
190
+ if (!Array.isArray(value)) {
191
+ throw new ExecutionError(`progress report control sidecar at ${path} has a malformed receipts list`);
192
+ }
193
+ return value.map((entry) => {
194
+ const record = entry;
195
+ const requestId = record?.["request_id"];
196
+ const revision = record?.["revision"];
197
+ const digest = record?.["digest"];
198
+ if (typeof entry !== "object" ||
199
+ entry === null ||
200
+ typeof requestId !== "string" ||
201
+ typeof revision !== "number" ||
202
+ !Number.isSafeInteger(revision) ||
203
+ typeof digest !== "string") {
204
+ throw new ExecutionError(`progress report control sidecar at ${path} has a malformed receipt entry`);
205
+ }
206
+ const status = record["status"];
207
+ const updatedAt = record["updated_at"];
208
+ return {
209
+ request_id: requestId,
210
+ revision,
211
+ digest,
212
+ ...(typeof status === "string" ? { status } : {}),
213
+ ...(typeof updatedAt === "string" ? { updated_at: updatedAt } : {}),
214
+ ...(record["kind"] === "start" ? { kind: "start" } : {}),
215
+ };
216
+ });
217
+ }
218
+ /**
219
+ * Read the control sidecar, FAILING CLOSED on corruption.
220
+ *
221
+ * Only a genuinely ABSENT sidecar starts a fresh revision line — that is the
222
+ * legitimate case of a report written before the sidecar existed. Every other
223
+ * failure (a truncated file, a permission error, a non-integer revision) throws.
224
+ * Collapsing those into the same silent `{revision: 1}` fallback would void the
225
+ * entire compare-and-swap contract: an established report at revision 40 would
226
+ * silently reset to 1, and a stale `expected_revision: 1` write would then be
227
+ * ACCEPTED. The repair for a corrupt sidecar is to delete it, which returns the
228
+ * session to the legacy path deliberately rather than by accident.
229
+ */
230
+ async function readControl(sessionDir) {
231
+ const path = join(sessionDir, CONTROL_FILENAME);
232
+ let raw;
233
+ try {
234
+ const handle = await open(path, fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW);
235
+ try {
236
+ raw = await handle.readFile("utf8");
237
+ }
238
+ finally {
239
+ await handle.close();
240
+ }
241
+ }
242
+ catch (err) {
243
+ const code = err.code;
244
+ if (code === "ENOENT")
245
+ return { revision: 1, receipts: [] };
246
+ throw new ExecutionError(`progress report control sidecar at ${path} could not be read (${String(code)}); delete it to restart the revision line`);
247
+ }
248
+ let parsed;
249
+ try {
250
+ parsed = JSON.parse(raw);
251
+ }
252
+ catch {
253
+ throw new ExecutionError(`progress report control sidecar at ${path} is not valid JSON; delete it to restart the revision line`);
254
+ }
255
+ if (typeof parsed !== "object" || parsed === null) {
256
+ throw new ExecutionError(`progress report control sidecar at ${path} is not an object; delete it to restart the revision line`);
257
+ }
258
+ const record = parsed;
259
+ const revision = record["revision"];
260
+ if (typeof revision !== "number" || !Number.isSafeInteger(revision) || revision < 1) {
261
+ // revision 0 or a negative would be unmatchable by any legal
262
+ // expected_revision (which is .min(1)), wedging the session forever.
263
+ throw new ExecutionError(`progress report control sidecar at ${path} has an invalid revision; delete it to restart the revision line`);
264
+ }
265
+ const documentDigest = record["document_digest"];
266
+ return {
267
+ revision,
268
+ receipts: receiptsFrom(record["receipts"] ?? [], path),
269
+ ...(typeof documentDigest === "string" ? { document_digest: documentDigest } : {}),
270
+ };
271
+ }
272
+ /**
273
+ * Whether the control sidecar exists at all.
274
+ *
275
+ * `readControl` deliberately maps only ENOENT onto a fresh revision line, so an
276
+ * absent sidecar and a sidecar that merely lacks a given receipt are
277
+ * indistinguishable from its return value — and they need opposite handling on
278
+ * the start path.
279
+ */
280
+ async function controlSidecarExists(sessionDir) {
281
+ return (await lstat(join(sessionDir, CONTROL_FILENAME)).catch(() => null)) !== null;
282
+ }
283
+ function serializeDocument(document) {
284
+ const text = `${JSON.stringify(document, null, 2)}\n`;
285
+ if (Buffer.byteLength(text, "utf8") > MAX_REPORT_FILE_BYTES) {
286
+ throw new InvalidParamsError(`resulting progress report would exceed the ${String(MAX_REPORT_FILE_BYTES)}-byte cap`);
287
+ }
288
+ return text;
289
+ }
290
+ /**
291
+ * Write the document, then the sidecar that describes it.
292
+ *
293
+ * These are two renames, not one transaction. The sidecar records the document's
294
+ * digest so a tear between them is DETECTABLE rather than silent: a reader that
295
+ * finds a digest mismatch knows the document is one commit ahead of the recorded
296
+ * revision, and `updateReport` heals it into a visible revision_conflict instead
297
+ * of letting a retry pass the CAS and double-apply an append.
298
+ */
299
+ async function commit(sessionDir, document, control) {
300
+ const text = serializeDocument(document);
301
+ const next = {
302
+ ...control,
303
+ document_digest: createHash("sha256").update(text, "utf8").digest("hex"),
304
+ };
305
+ await writeAtomic(sessionDir, REPORT_FILENAME, text);
306
+ await writeAtomic(sessionDir, CONTROL_FILENAME, `${JSON.stringify(next, null, 2)}\n`);
307
+ }
308
+ /**
309
+ * Read the document and its revision as a COHERENT PAIR.
310
+ *
311
+ * Control FIRST, then the document, then control again to prove nothing moved
312
+ * in between. A writer commits document-then-sidecar, so reading in that same
313
+ * order can hand back a stale document with a fresh revision — and a patch
314
+ * computed from that view would pass the CAS and silently lose the concurrent
315
+ * write. Reading control first biases the other way: at worst the caller gets a
316
+ * spurious revision_conflict, which is self-correcting.
317
+ *
318
+ * Every unlocked reader must use this. `listReports` reading the document and
319
+ * then the control is the same defect, reached through a path callers are told
320
+ * to hit before every start.
321
+ */
322
+ async function readCoherentSnapshot(sessionDir, expectedId) {
323
+ for (let attempt = 0; attempt < 3; attempt += 1) {
324
+ const before = await readControl(sessionDir);
325
+ const loaded = await readDocument(sessionDir, expectedId);
326
+ const after = await readControl(sessionDir);
327
+ if (before.revision === after.revision)
328
+ return { loaded, revision: before.revision };
329
+ }
330
+ throw new ExecutionError(`progress report ${expectedId} is being written too rapidly to read a coherent snapshot; retry`);
331
+ }
332
+ function receiptFor(document, sessionDir, revision, replayed) {
333
+ return {
334
+ session_id: document.session.id,
335
+ revision,
336
+ path: join(sessionDir, REPORT_FILENAME),
337
+ status: document.session.status,
338
+ updated_at: document.session.updated_at,
339
+ replayed,
340
+ };
341
+ }
342
+ /** A replay returns what the ORIGINAL write produced, not the report's current state. */
343
+ function replayReceipt(stored, loaded, sessionDir) {
344
+ return {
345
+ session_id: loaded.document.session.id,
346
+ revision: stored.revision,
347
+ path: join(sessionDir, REPORT_FILENAME),
348
+ status: stored.status ?? loaded.document.session.status,
349
+ updated_at: stored.updated_at ?? loaded.document.session.updated_at,
350
+ replayed: true,
351
+ };
352
+ }
353
+ /** Keep the pinned opening receipt plus the newest others, never evicting start. */
354
+ function appendReceipt(existing, next) {
355
+ const start = existing.find((r) => r.kind === "start");
356
+ const others = existing.filter((r) => r.kind !== "start");
357
+ const budget = RECEIPT_RETENTION - (start === undefined ? 0 : 1);
358
+ const kept = [...others, next].slice(-budget);
359
+ return start === undefined ? kept : [start, ...kept];
360
+ }
361
+ function validateDocument(document) {
362
+ const result = ProgressReportDocumentSchema.safeParse(document);
363
+ if (!result.success) {
364
+ // Without this the raw ZodError escapes as an unclassified error, losing the
365
+ // -32602 / isError distinction the rest of this server relies on.
366
+ throw new InvalidParamsError(`progress report would become invalid: ${result.error.issues
367
+ .slice(0, 5)
368
+ .map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`)
369
+ .join("; ")}`);
370
+ }
371
+ return result.data;
372
+ }
373
+ // --- operations --------------------------------------------------------
374
+ /**
375
+ * The document a start OPENS, as a pure function of the input plus the one
376
+ * server-owned timestamp.
377
+ *
378
+ * Factored out so the fresh-start path and the torn-open replay check cannot
379
+ * drift: the replay check's whole argument is that it rebuilds byte-for-byte
380
+ * what this request WOULD have written, so any divergence between the two
381
+ * constructions would silently turn a genuine replay into a conflict or, worse,
382
+ * a changed request into an accepted replay.
383
+ */
384
+ function buildOpeningDocument(input, sessionId, startedAt) {
385
+ return {
386
+ schema_version: "progress-report-v1",
387
+ session: {
388
+ id: sessionId,
389
+ title: input.title,
390
+ goal: input.goal,
391
+ status: "running",
392
+ started_at: startedAt,
393
+ updated_at: startedAt,
394
+ project_folder: input.project_folder,
395
+ },
396
+ ...(input.agents != null
397
+ ? { agents: input.agents.map((a) => ({ ...a, updated_at: startedAt })) }
398
+ : {}),
399
+ ...(input.tasks != null ? { tasks: input.tasks } : {}),
400
+ log: [{ t: startedAt, agent: input.opened_by, kind: "info", msg: "session started" }],
401
+ };
402
+ }
403
+ export async function startReport(input, env = process.env) {
404
+ const root = reportsRoot(env);
405
+ try {
406
+ await mkdir(root, { recursive: true });
407
+ }
408
+ catch (err) {
409
+ throw new ExecutionError(`could not create the progress reports root at ${root}: ${err.message}`);
410
+ }
411
+ const sessionId = buildSessionId(input.request_id);
412
+ const sessionDir = join(root, sessionId);
413
+ try {
414
+ // Exclusive mkdir IS the reservation. Because the id is a pure function of
415
+ // request_id, EEXIST is now SEMANTIC — it means "this request already ran".
416
+ await mkdir(sessionDir, { recursive: false, mode: 0o700 });
417
+ }
418
+ catch (err) {
419
+ if (err.code !== "EEXIST") {
420
+ throw new ExecutionError(`could not reserve progress report directory ${sessionDir}: ${err.message}`);
421
+ }
422
+ }
423
+ if (!(await isRealDirectory(sessionDir))) {
424
+ throw new ExecutionError(`progress report path ${sessionDir} exists but is not a real directory`);
425
+ }
426
+ const startedAt = nowIso();
427
+ const requestDigest = digestOf(input);
428
+ // Creation is never authority: a concurrent identical start may have won the
429
+ // mkdir and still be initializing, so always inspect under the lock.
430
+ return withSessionLock(sessionDir, async (guard) => {
431
+ let existing;
432
+ try {
433
+ existing = await readDocument(sessionDir, sessionId);
434
+ }
435
+ catch (err) {
436
+ if (err instanceof InvalidParamsError) {
437
+ // No document yet: a fresh reservation, or a previous start that created
438
+ // the directory and died before committing. Both heal by initializing.
439
+ existing = null;
440
+ }
441
+ else {
442
+ throw err;
443
+ }
444
+ }
445
+ if (existing !== null) {
446
+ const control = await readControl(sessionDir);
447
+ const prior = control.receipts.find((r) => r.request_id === input.request_id);
448
+ if (prior === undefined) {
449
+ if (await controlSidecarExists(sessionDir)) {
450
+ // The sidecar is present and the start receipt is PINNED, so a real
451
+ // opening receipt would still be in it. Something else wrote here.
452
+ throw new ExecutionError(`progress report ${sessionId} already exists but has no receipt for this request_id; refusing to overwrite it`);
453
+ }
454
+ // No sidecar at all: the opening commit may have torn between its two
455
+ // renames. The directory name is the first 128 bits of a domain-separated
456
+ // sha256(request_id) and the document's session.id matches it, so this
457
+ // document can only have come from THIS request_id,
458
+ // and synthesizing the receipt it should have had is the only way the
459
+ // retrying caller ever gets its session id back — start is the only tool
460
+ // that returns one for a new report.
461
+ //
462
+ // But the directory name proves the request ID ONLY, never the request
463
+ // BODY, and the receipt that would have carried the request digest is
464
+ // exactly what is missing here. So prove content the other way round:
465
+ // rebuild THIS request's opening document and compare digests. That is a
466
+ // SOUND FAIL-CLOSED test, not an exact one. An absent sidecar does NOT
467
+ // prove the document is pristine — an operator deleting `.control.json`,
468
+ // or a backup restoring `report-data.json` without its sidecar, both land
469
+ // here over a document that HAS been updated, and both mismatch. What the
470
+ // comparison actually establishes is the only thing that matters: when it
471
+ // does not match, we cannot prove this request produced this report, so we
472
+ // refuse.
473
+ //
474
+ // Refusing is right rather than replaying because `readControl` reports
475
+ // revision 1 for an absent sidecar. Synthesizing a start receipt on top of
476
+ // a document already at revision 2 would rewind an established revision
477
+ // line (2 -> 1) and make a stale `expected_revision: 1` acceptable again —
478
+ // the exact silent-CAS-void failure that `readControl`'s own fail-closed
479
+ // design exists to prevent. Accepting a changed retry would additionally
480
+ // record THIS digest against the ORIGINAL document and lock the genuine
481
+ // original request out of its own report forever.
482
+ const rebuilt = validateDocument(buildOpeningDocument(input, sessionId, existing.document.session.started_at));
483
+ if (digestOf(rebuilt) !== digestOf(existing.document)) {
484
+ throw new InvalidParamsError(`request_id_conflict: the document on disk does not match what this request would have opened. Either request_id ${JSON.stringify(input.request_id)} was reused with different content, or this report's control sidecar was removed after the report had already been updated. If this report is yours, adopt it with progress_report_update (progress_report_list will show it); use a fresh request_id only for a genuinely new report.`);
485
+ }
486
+ logger.warn({ sessionDir, sessionId }, "progress-report: opening commit left no control sidecar; reconstructing the start receipt");
487
+ const reconstructed = {
488
+ request_id: input.request_id,
489
+ revision: control.revision,
490
+ digest: requestDigest,
491
+ status: existing.document.session.status,
492
+ updated_at: existing.document.session.updated_at,
493
+ kind: "start",
494
+ };
495
+ await guard.assertHeld();
496
+ await commit(sessionDir, existing.document, {
497
+ revision: control.revision,
498
+ receipts: [reconstructed],
499
+ });
500
+ return replayReceipt(reconstructed, existing, sessionDir);
501
+ }
502
+ if (prior.digest !== requestDigest) {
503
+ throw new InvalidParamsError(`request_id_conflict: request_id ${JSON.stringify(input.request_id)} already opened a progress report with different content. If that report is yours, adopt it with progress_report_update (progress_report_list will show it); use a fresh request_id only for a genuinely new report.`);
504
+ }
505
+ return replayReceipt(prior, existing, sessionDir);
506
+ }
507
+ const validated = validateDocument(buildOpeningDocument(input, sessionId, startedAt));
508
+ await guard.assertHeld();
509
+ await commit(sessionDir, validated, {
510
+ revision: 1,
511
+ receipts: [
512
+ {
513
+ request_id: input.request_id,
514
+ revision: 1,
515
+ digest: requestDigest,
516
+ status: validated.session.status,
517
+ updated_at: validated.session.updated_at,
518
+ kind: "start",
519
+ },
520
+ ],
521
+ });
522
+ return receiptFor(validated, sessionDir, 1, false);
523
+ });
524
+ }
525
+ export async function updateReport(args, env = process.env) {
526
+ const sessionDir = sessionDirFor(args.report_id, env);
527
+ if (!(await isRealDirectory(sessionDir))) {
528
+ // Never recreate: a removed directory is an explicit operator action.
529
+ throw new InvalidParamsError(`unknown progress report id: ${args.report_id}`);
530
+ }
531
+ return withSessionLock(sessionDir, async (guard) => {
532
+ let control = await readControl(sessionDir);
533
+ const requestDigest = digestOf({
534
+ changes: args.changes,
535
+ reopen: args.reopen ?? false,
536
+ });
537
+ const prior = control.receipts.find((r) => r.request_id === args.request_id);
538
+ if (prior !== undefined) {
539
+ if (prior.digest !== requestDigest) {
540
+ throw new InvalidParamsError(`request_id_conflict: request_id ${JSON.stringify(args.request_id)} was already used for a different update on this report`);
541
+ }
542
+ const existing = await readDocument(sessionDir, args.report_id);
543
+ return replayReceipt(prior, existing, sessionDir);
544
+ }
545
+ const current = await readDocument(sessionDir, args.report_id);
546
+ if (control.document_digest === undefined) {
547
+ // No baseline: every report written before document_digest existed, plus
548
+ // every legacy report with no sidecar at all. Without one, torn-commit
549
+ // detection is simply SKIPPED — an update whose document lands and whose
550
+ // sidecar does not leaves a retry finding revision N with neither a
551
+ // receipt nor a digest, so it passes the CAS and applies its appends a
552
+ // second time. Record the digest of what is ALREADY on disk, at the SAME
553
+ // revision, so this establishes a baseline and never a version bump.
554
+ await guard.assertHeld();
555
+ await commit(sessionDir, current.document, {
556
+ revision: control.revision,
557
+ receipts: control.receipts,
558
+ });
559
+ control = await readControl(sessionDir);
560
+ }
561
+ else if (control.document_digest !== current.digest) {
562
+ // A digest mismatch proves the last commit tore between its two renames:
563
+ // the document landed, the sidecar did not. Heal it into a visible
564
+ // conflict — otherwise a retry finds no receipt, passes the CAS against a
565
+ // revision that no longer describes the document, and double-applies its
566
+ // appends.
567
+ const healed = control.revision + 1;
568
+ logger.warn({ sessionDir, recorded: control.revision, healed }, "progress-report: torn commit detected; advancing the revision to match the document on disk");
569
+ await guard.assertHeld();
570
+ await commit(sessionDir, current.document, {
571
+ revision: healed,
572
+ receipts: control.receipts,
573
+ });
574
+ control = await readControl(sessionDir);
575
+ }
576
+ if (control.revision !== args.expected_revision) {
577
+ throw new InvalidParamsError(`revision_conflict: expected_revision ${String(args.expected_revision)} but the report is at revision ${String(control.revision)} — re-read it with progress_report_get and retry`);
578
+ }
579
+ const next = applyChanges(current.document, args.changes, args.reopen ?? false);
580
+ const validated = validateDocument(next);
581
+ const revision = control.revision + 1;
582
+ await guard.assertHeld();
583
+ await commit(sessionDir, validated, {
584
+ revision,
585
+ receipts: appendReceipt(control.receipts, {
586
+ request_id: args.request_id,
587
+ revision,
588
+ digest: requestDigest,
589
+ status: validated.session.status,
590
+ updated_at: validated.session.updated_at,
591
+ }),
592
+ });
593
+ return receiptFor(validated, sessionDir, revision, false);
594
+ });
595
+ }
596
+ export async function getReport(reportId, env = process.env) {
597
+ const sessionDir = sessionDirFor(reportId, env);
598
+ const { loaded, revision } = await readCoherentSnapshot(sessionDir, reportId);
599
+ return {
600
+ document: loaded.document,
601
+ revision,
602
+ path: join(sessionDir, REPORT_FILENAME),
603
+ };
604
+ }
605
+ export async function listReports(options, env = process.env) {
606
+ const root = reportsRoot(env);
607
+ const entries = await readdir(root, { withFileTypes: true }).catch(() => []);
608
+ // Pass 1 is stat-only. The previous implementation opened and fully validated
609
+ // every candidate BEFORE applying `limit`, on a path callers are told to hit
610
+ // before every start, against a set that only grows because retention pruning
611
+ // is deliberately disabled.
612
+ const seen = [];
613
+ let scanned = 0;
614
+ let hitScanCap = false;
615
+ for (const entry of entries) {
616
+ if (!entry.isDirectory() || entry.isSymbolicLink())
617
+ continue;
618
+ if (scanned >= SCAN_CAP) {
619
+ hitScanCap = true;
620
+ break;
621
+ }
622
+ scanned += 1;
623
+ const stats = await lstat(join(root, entry.name, REPORT_FILENAME)).catch(() => null);
624
+ if (stats === null || !stats.isFile() || stats.isSymbolicLink())
625
+ continue;
626
+ seen.push({ name: entry.name, mtimeMs: stats.mtimeMs, size: stats.size });
627
+ }
628
+ seen.sort((a, b) => b.mtimeMs - a.mtimeMs);
629
+ const admitted = seen.slice(0, MAX_LIST_CANDIDATES);
630
+ let truncated = hitScanCap || seen.length > MAX_LIST_CANDIDATES;
631
+ const candidates = [];
632
+ let bytes = 0;
633
+ for (const item of admitted) {
634
+ if (bytes + item.size > MAX_LIST_SCAN_BYTES) {
635
+ truncated = true;
636
+ break;
637
+ }
638
+ bytes += item.size;
639
+ const sessionDir = join(root, item.name);
640
+ let loaded;
641
+ let revision;
642
+ try {
643
+ // One coherent read, so a listing can never pair a stale document with a
644
+ // revision fresh enough to pass the CAS on a later adopt-then-write.
645
+ ({ loaded, revision } = await readCoherentSnapshot(sessionDir, item.name));
646
+ }
647
+ catch (err) {
648
+ // Silence here is exactly the "where did my report go" case the identity
649
+ // check exists to catch, so say something instead of dropping it mutely —
650
+ // a report missing from a listing an agent was told to consult first is a
651
+ // report that gets opened a second time. This covers an unreadable
652
+ // document, a corrupt sidecar and a snapshot that would not stabilise.
653
+ logger.warn({ sessionDir, err: err.message }, "progress-report: skipping an unreadable report directory");
654
+ continue;
655
+ }
656
+ if (options.project_folder !== undefined &&
657
+ loaded.document.session.project_folder !== options.project_folder) {
658
+ continue;
659
+ }
660
+ candidates.push({
661
+ id: loaded.document.session.id,
662
+ path: join(sessionDir, REPORT_FILENAME),
663
+ title: loaded.document.session.title,
664
+ goal: loaded.document.session.goal,
665
+ status: loaded.document.session.status,
666
+ revision,
667
+ updated_at: loaded.document.session.updated_at,
668
+ agents: (loaded.document.agents ?? []).map((a) => a.name),
669
+ tasks: (loaded.document.tasks ?? []).map((t) => t.title),
670
+ });
671
+ }
672
+ // Timestamps are second-granularity, so ties are ordinary rather than rare.
673
+ // Break them on id so the candidate order a caller walks is a deterministic
674
+ // total order, not filesystem enumeration order.
675
+ candidates.sort((a, b) => {
676
+ const byTime = Date.parse(b.updated_at) - Date.parse(a.updated_at);
677
+ if (byTime !== 0)
678
+ return byTime;
679
+ return a.id < b.id ? 1 : a.id > b.id ? -1 : 0;
680
+ });
681
+ if (candidates.length > options.limit)
682
+ truncated = true;
683
+ return { candidates: candidates.slice(0, options.limit), truncated };
684
+ }
685
+ // --- patch application -------------------------------------------------
686
+ function upsert(existing, incoming, key, cap, label) {
687
+ const merged = [...(existing ?? [])];
688
+ for (const item of incoming) {
689
+ const index = merged.findIndex((m) => m[key] === item[key]);
690
+ if (index >= 0) {
691
+ merged[index] = { ...merged[index], ...item };
692
+ }
693
+ else {
694
+ merged.push(item);
695
+ }
696
+ }
697
+ if (merged.length > cap) {
698
+ // Keyed sections do NOT evict: silently dropping the oldest agent, task or
699
+ // file would delete an entity that may still be live and still referenced,
700
+ // and the board would then under-report the work without telling anyone. A
701
+ // loud refusal is better than a quiet lie.
702
+ throw new InvalidParamsError(`${label} is at its ${String(cap)}-entry cap; there is no removal operation — update an existing entry in place, or start a new report`);
703
+ }
704
+ return merged;
705
+ }
706
+ /**
707
+ * Apply one bounded patch. Every server-owned field (timestamps, ids) is
708
+ * stamped here rather than trusted from the caller, so a caller cannot publish
709
+ * a guessed clock or rewrite an identity.
710
+ */
711
+ export function applyChanges(current, changes, reopen) {
712
+ const at = nowIso();
713
+ const terminal = current.session.status === "done" || current.session.status === "failed";
714
+ const reopening = reopen || (changes.session?.status != null && changes.session.status === "running");
715
+ if (terminal && !reopening) {
716
+ throw new InvalidParamsError(`progress report ${current.session.id} is ${current.session.status}; pass reopen:true (or set session.status to "running") to write to it again`);
717
+ }
718
+ const next = {
719
+ ...current,
720
+ session: {
721
+ ...current.session,
722
+ ...(changes.session?.title != null ? { title: changes.session.title } : {}),
723
+ ...(changes.session?.goal != null ? { goal: changes.session.goal } : {}),
724
+ ...(changes.session?.status != null
725
+ ? { status: changes.session.status }
726
+ : reopen
727
+ ? { status: "running" }
728
+ : {}),
729
+ updated_at: at,
730
+ },
731
+ };
732
+ if (changes.agents_upsert != null) {
733
+ next.agents = upsert(current.agents, changes.agents_upsert.map((a) => ({ ...a, updated_at: at })), "name", CAPS.agents, "agents");
734
+ }
735
+ if (changes.tasks_upsert != null) {
736
+ next.tasks = upsert(current.tasks, changes.tasks_upsert, "id", CAPS.tasks, "tasks");
737
+ }
738
+ if (changes.files_upsert != null) {
739
+ next.files = upsert(current.files, changes.files_upsert, "path", CAPS.files, "files");
740
+ }
741
+ if (changes.log_append != null) {
742
+ // Oldest-first eviction is the documented behavior for log, not an error.
743
+ const log = [...(current.log ?? []), ...changes.log_append.map((e) => ({ ...e, t: at }))];
744
+ next.log = log.slice(-CAPS.log);
745
+ }
746
+ if (changes.decisions_append != null) {
747
+ // Decisions are a chronological stream like log, so they evict rather than
748
+ // wedging the section at the cap with no way to make room.
749
+ const decisions = [
750
+ ...(current.decisions ?? []),
751
+ ...changes.decisions_append.map((d) => ({ ...d, t: at })),
752
+ ];
753
+ next.decisions = decisions.slice(-CAPS.decisions);
754
+ }
755
+ if (changes.tests_set != null) {
756
+ next.tests = { ...changes.tests_set, last_run: at };
757
+ }
758
+ if (changes.blockers_set != null) {
759
+ if (changes.blockers_set.length > CAPS.blockers) {
760
+ throw new InvalidParamsError(`blockers would exceed its ${String(CAPS.blockers)}-entry cap`);
761
+ }
762
+ next.blockers = [...changes.blockers_set];
763
+ }
764
+ return next;
765
+ }
766
+ //# sourceMappingURL=store.js.map