catherd-cli 1.4.0 → 2.0.0-beta.20261022113309

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 (320) hide show
  1. package/README.md +188 -169
  2. package/THIRD_PARTY_NOTICES.md +217 -35
  3. package/catalog/jev.json +27 -1
  4. package/catalog/models.json +56 -239
  5. package/package.json +6 -8
  6. package/plugin/.claude-plugin/plugin.json +1 -1
  7. package/plugin/.codex-plugin/plugin.json +1 -1
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/commands/catherd.md +2 -2
  10. package/plugin/skills/catherd/SKILL.md +97 -356
  11. package/plugin/skills/catherd-setup/SKILL.md +21 -20
  12. package/src/adapters/all.ts +0 -4
  13. package/src/adapters/backend.ts +19 -4
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/codex/events.ts +10 -0
  16. package/src/adapters/codex/index.ts +17 -5
  17. package/src/adapters/cursor/home.ts +1 -1
  18. package/src/adapters/cursor/index.ts +10 -4
  19. package/src/adapters/opencode/index.ts +38 -1
  20. package/src/cli.ts +27 -27
  21. package/src/domain/agents.ts +3 -1
  22. package/src/domain/bars.ts +33 -107
  23. package/src/domain/blocked.ts +72 -0
  24. package/src/domain/branch.ts +51 -0
  25. package/src/domain/brief.ts +106 -0
  26. package/src/domain/catalog.ts +69 -313
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +19 -29
  29. package/src/domain/errors.ts +6 -2
  30. package/src/domain/fail-lines.ts +26 -0
  31. package/src/domain/failover.ts +37 -75
  32. package/src/domain/findings.ts +47 -0
  33. package/src/domain/gate-brief.ts +53 -0
  34. package/src/domain/hints.ts +13 -2
  35. package/src/domain/ids.ts +13 -2
  36. package/src/domain/jev.ts +47 -2
  37. package/src/domain/judge.ts +124 -0
  38. package/src/domain/lane.ts +118 -3
  39. package/src/domain/milestone-memory.ts +53 -0
  40. package/src/domain/notice.ts +32 -8
  41. package/src/domain/notify.ts +32 -0
  42. package/src/domain/plan-doc.ts +198 -0
  43. package/src/domain/plan-lint.ts +141 -0
  44. package/src/domain/profile-rules.ts +175 -138
  45. package/src/domain/profile.ts +140 -63
  46. package/src/domain/record.ts +68 -3
  47. package/src/domain/reply.ts +29 -0
  48. package/src/domain/role-prompts.ts +182 -56
  49. package/src/domain/role-scope.ts +76 -0
  50. package/src/domain/role-tools.ts +34 -0
  51. package/src/domain/roles.ts +2 -0
  52. package/src/domain/route.ts +90 -0
  53. package/src/domain/rules.ts +33 -0
  54. package/src/domain/run-events.ts +298 -0
  55. package/src/domain/run-machine.ts +668 -0
  56. package/src/domain/run-state.ts +1310 -0
  57. package/src/domain/scorecard.ts +197 -0
  58. package/src/domain/select.ts +229 -72
  59. package/src/domain/slopalytics.ts +72 -0
  60. package/src/domain/state.ts +5 -0
  61. package/src/domain/util.ts +22 -0
  62. package/src/domain/workspace.ts +130 -0
  63. package/src/entry/answer-command.ts +39 -0
  64. package/src/entry/catalog-command.ts +24 -165
  65. package/src/entry/check-bin.ts +17 -0
  66. package/src/entry/cli-kit.ts +18 -2
  67. package/src/entry/daemon-bin.ts +22 -0
  68. package/src/entry/deps.ts +2 -0
  69. package/src/entry/doctor-command.ts +20 -2
  70. package/src/entry/glyphs.ts +15 -2
  71. package/src/entry/host-arg.ts +28 -0
  72. package/src/entry/init-command.ts +20 -69
  73. package/src/entry/knowledge-command.ts +121 -5
  74. package/src/entry/lock-command.ts +103 -8
  75. package/src/entry/mcp/handshake.ts +6 -2
  76. package/src/entry/mcp/role-bin.ts +20 -0
  77. package/src/entry/mcp/role-server.ts +134 -0
  78. package/src/entry/mcp/server.ts +85 -21
  79. package/src/entry/mcp/session-tools.ts +149 -0
  80. package/src/entry/panel/openapi.ts +189 -0
  81. package/src/entry/panel/server.ts +410 -0
  82. package/src/entry/panel-command.ts +68 -0
  83. package/src/entry/pause-command.ts +67 -0
  84. package/src/entry/profile-command.ts +33 -18
  85. package/src/entry/role-cli-command.ts +159 -0
  86. package/src/entry/runs-command.ts +81 -27
  87. package/src/entry/scorecard-command.ts +187 -0
  88. package/src/entry/supervise-bin.ts +1 -1
  89. package/src/entry/supervise-command.ts +30 -3
  90. package/src/entry/workspace-command.ts +44 -0
  91. package/src/infra/boot-lock.ts +25 -0
  92. package/src/infra/delivery.ts +3 -0
  93. package/src/infra/dispatch-dir.ts +2 -0
  94. package/src/infra/env.ts +11 -3
  95. package/src/infra/git.ts +70 -0
  96. package/src/infra/heavy-lock.ts +133 -6
  97. package/src/infra/host-probe.ts +16 -0
  98. package/src/infra/launch.ts +63 -7
  99. package/src/infra/lock-activity.ts +88 -0
  100. package/src/infra/login-env.ts +48 -0
  101. package/src/infra/memory.ts +45 -0
  102. package/src/infra/ntfy.ts +40 -0
  103. package/src/infra/open-url.ts +42 -0
  104. package/src/infra/paths.ts +37 -2
  105. package/src/infra/role-mcp.ts +69 -0
  106. package/src/infra/slopalytics.ts +30 -0
  107. package/src/infra/store.ts +6 -1
  108. package/src/infra/supervisor.ts +107 -16
  109. package/src/services/admission.ts +133 -31
  110. package/src/services/backends.ts +4 -6
  111. package/src/services/budget.ts +26 -6
  112. package/src/services/capture.ts +1 -22
  113. package/src/services/catalog-service.ts +155 -254
  114. package/src/services/credentials.ts +15 -26
  115. package/src/services/daemon.ts +181 -0
  116. package/src/services/dispatch-service.ts +209 -17
  117. package/src/services/dispatches.ts +30 -2
  118. package/src/services/doctor-access.ts +8 -3
  119. package/src/services/doctor-backends.ts +1 -1
  120. package/src/services/doctor-checks.ts +47 -0
  121. package/src/services/doctor-docker.ts +221 -0
  122. package/src/services/doctor-role-mcp.ts +65 -0
  123. package/src/services/doctor.ts +72 -15
  124. package/src/services/end-push.ts +157 -0
  125. package/src/services/failover-rungs.ts +109 -0
  126. package/src/services/finalize.ts +24 -2
  127. package/src/services/gate-env.ts +217 -0
  128. package/src/services/gate-service.ts +231 -45
  129. package/src/services/global-install.ts +46 -13
  130. package/src/services/jev-service.ts +2 -4
  131. package/src/services/lane-edit.ts +154 -0
  132. package/src/services/lane-service.ts +291 -55
  133. package/src/services/milestone-check.ts +433 -0
  134. package/src/services/milestone-tree.ts +170 -0
  135. package/src/services/milestones.ts +167 -29
  136. package/src/services/notifier.ts +124 -4
  137. package/src/services/notify-service.ts +26 -0
  138. package/src/services/orchestrator-wait.ts +82 -0
  139. package/src/services/panel-setup.ts +116 -0
  140. package/src/services/pause.ts +161 -0
  141. package/src/services/peek.ts +79 -17
  142. package/src/services/plan-input.ts +151 -0
  143. package/src/services/ports.ts +60 -5
  144. package/src/services/preflight.ts +232 -24
  145. package/src/services/profile-service.ts +31 -3
  146. package/src/services/profile-store.ts +18 -6
  147. package/src/services/protocol.ts +123 -25
  148. package/src/services/provenance.ts +34 -89
  149. package/src/services/questions.ts +24 -0
  150. package/src/services/reconcile.ts +33 -1
  151. package/src/services/reentry.ts +7 -6
  152. package/src/services/role-access.ts +27 -0
  153. package/src/services/routing-service.ts +161 -42
  154. package/src/services/run-briefs.ts +477 -0
  155. package/src/services/run-client.ts +217 -0
  156. package/src/services/run-effects.ts +837 -0
  157. package/src/services/run-events-store.ts +174 -0
  158. package/src/services/run-evidence.ts +1 -1
  159. package/src/services/run-pin.ts +125 -0
  160. package/src/services/run-read.ts +221 -0
  161. package/src/services/run-report.ts +176 -0
  162. package/src/services/run-service.ts +204 -23
  163. package/src/services/run-store.ts +159 -18
  164. package/src/services/scorecard-metrics.ts +86 -0
  165. package/src/services/scorecard.ts +382 -0
  166. package/src/services/scratch.ts +60 -0
  167. package/src/services/sessions.ts +3 -1
  168. package/src/services/state.ts +21 -0
  169. package/src/services/summary.ts +76 -12
  170. package/src/services/tree-path.ts +27 -0
  171. package/src/services/verifier-brief.ts +23 -0
  172. package/src/services/verifier-step.ts +48 -0
  173. package/src/services/workspace-admission.ts +259 -0
  174. package/src/services/workspace-service.ts +259 -0
  175. package/src/services/workspace-store.ts +95 -0
  176. package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
  177. package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
  178. package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
  179. package/web/dist/assets/Modal-Dxc5so53.js +1 -0
  180. package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
  181. package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
  182. package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
  183. package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
  184. package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
  185. package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
  186. package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
  187. package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
  188. package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
  189. package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
  190. package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
  191. package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
  192. package/web/dist/assets/drawer-Du3cdlok.js +1 -0
  193. package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
  194. package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
  195. package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
  196. package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
  197. package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
  198. package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
  199. package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
  200. package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
  201. package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
  202. package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
  203. package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
  204. package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
  205. package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
  206. package/web/dist/assets/index-B6GiwDkh.js +11 -0
  207. package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
  208. package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
  209. package/web/dist/assets/index-C5IbylvM.css +2 -0
  210. package/web/dist/assets/index-C5IbylvM.css.br +0 -0
  211. package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
  212. package/web/dist/assets/label-DFSYBc62.js +1 -0
  213. package/web/dist/assets/label-DFSYBc62.js.br +0 -0
  214. package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
  215. package/web/dist/assets/link-D8EcKZUt.js +1 -0
  216. package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
  217. package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
  218. package/web/dist/assets/login-DaN5HPPW.js +1 -0
  219. package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
  220. package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
  221. package/web/dist/assets/query-Cu--ccT3.js +1 -0
  222. package/web/dist/assets/questions-shoO9OVj.js +1 -0
  223. package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
  224. package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
  225. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
  226. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
  227. package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
  228. package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
  229. package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
  230. package/web/dist/assets/runs-C3rg2zZc.js +1 -0
  231. package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
  232. package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
  233. package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
  234. package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
  235. package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
  236. package/web/dist/assets/table-BOxXhqS4.js +1 -0
  237. package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
  238. package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
  239. package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
  240. package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
  241. package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
  242. package/web/dist/assets/time--9kSQbbp.js +1 -0
  243. package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
  244. package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
  245. package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
  246. package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
  247. package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
  248. package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
  249. package/web/dist/index.html +15 -0
  250. package/catalog/ATTRIBUTION.md +0 -36
  251. package/catalog/scores.json +0 -3202
  252. package/catalog/sources.json +0 -97
  253. package/src/adapters/antigravity/events.ts +0 -131
  254. package/src/adapters/antigravity/home.ts +0 -46
  255. package/src/adapters/antigravity/index.ts +0 -320
  256. package/src/adapters/antigravity/models.ts +0 -37
  257. package/src/adapters/grok/events.ts +0 -112
  258. package/src/adapters/grok/home.ts +0 -132
  259. package/src/adapters/grok/index.ts +0 -339
  260. package/src/adapters/grok/models.ts +0 -48
  261. package/src/domain/calibration.ts +0 -125
  262. package/src/domain/sources.ts +0 -144
  263. package/src/entry/mcp/dispatch-tools.ts +0 -52
  264. package/src/entry/mcp/lane-tools.ts +0 -84
  265. package/src/entry/mcp/protocol-tools.ts +0 -57
  266. package/src/entry/mcp/run-tools.ts +0 -148
  267. package/src/entry/mcp/setup-tools.ts +0 -108
  268. package/src/entry/tui/commands.ts +0 -563
  269. package/src/entry/tui/effects.ts +0 -381
  270. package/src/entry/tui/fixtures.ts +0 -637
  271. package/src/entry/tui/profile-edits.ts +0 -146
  272. package/src/entry/tui/profile-tree.ts +0 -429
  273. package/src/entry/tui/providers/app.tsx +0 -162
  274. package/src/entry/tui/providers/boundary.tsx +0 -57
  275. package/src/entry/tui/providers/data.tsx +0 -160
  276. package/src/entry/tui/providers/keymap.tsx +0 -166
  277. package/src/entry/tui/providers/root.tsx +0 -45
  278. package/src/entry/tui/providers/theme.tsx +0 -17
  279. package/src/entry/tui/providers/toast.tsx +0 -77
  280. package/src/entry/tui/run.tsx +0 -114
  281. package/src/entry/tui/state.ts +0 -373
  282. package/src/entry/tui/stories.ts +0 -114
  283. package/src/entry/tui/text.ts +0 -130
  284. package/src/entry/tui/theme.ts +0 -140
  285. package/src/entry/tui/views/app.tsx +0 -208
  286. package/src/entry/tui/views/dialogs.tsx +0 -41
  287. package/src/entry/tui/views/profile-actions.ts +0 -331
  288. package/src/entry/tui/views/profiles.tsx +0 -520
  289. package/src/entry/tui/views/runs.tsx +0 -573
  290. package/src/entry/tui/views/save-dialog.tsx +0 -340
  291. package/src/entry/tui/views/status.tsx +0 -209
  292. package/src/entry/tui/widgets/chrome.tsx +0 -114
  293. package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
  294. package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
  295. package/src/entry/tui/widgets/dialog-select.tsx +0 -212
  296. package/src/entry/tui/widgets/dialog.tsx +0 -68
  297. package/src/entry/tui/widgets/line.tsx +0 -92
  298. package/src/entry/tui/widgets/list.tsx +0 -197
  299. package/src/entry/tui/widgets/spinner.tsx +0 -49
  300. package/src/entry/tui/widgets/toast.tsx +0 -62
  301. package/src/infra/sources/arena.ts +0 -87
  302. package/src/infra/sources/artificial-analysis.ts +0 -112
  303. package/src/infra/sources/cache.ts +0 -124
  304. package/src/infra/sources/csv.ts +0 -38
  305. package/src/infra/sources/epoch.ts +0 -82
  306. package/src/infra/sources/http.ts +0 -75
  307. package/src/infra/sources/litellm.ts +0 -58
  308. package/src/infra/sources/models-dev.ts +0 -59
  309. package/src/infra/sources/openrouter-endpoints.ts +0 -52
  310. package/src/infra/sources/openrouter-models.ts +0 -60
  311. package/src/infra/sources/rows.ts +0 -38
  312. package/src/infra/sources/vectara.ts +0 -69
  313. package/src/infra/sources/zip.ts +0 -48
  314. package/src/services/catalog-refresh.ts +0 -201
  315. package/src/services/doctor-sources.ts +0 -76
  316. package/src/services/runs-page.ts +0 -255
  317. package/src/services/source-derive.ts +0 -374
  318. package/src/services/source-sync.ts +0 -305
  319. package/src/services/standins.ts +0 -204
  320. package/src/services/treat-likes.ts +0 -97
@@ -0,0 +1,174 @@
1
+ import { closeSync, existsSync, fstatSync, ftruncateSync, openSync, readFileSync, readSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { CatherdError, UPGRADE } from "../domain/errors.ts";
4
+ import { EVENTS_SCHEMA, RunEventSchema, type RunEvent, type RunEventInput } from "../domain/run-events.ts";
5
+ import { type RunState, replay } from "../domain/run-state.ts";
6
+ import { withFileLock } from "../infra/filelock.ts";
7
+ import { log } from "../infra/log.ts";
8
+ import { appendJsonlBatch } from "../infra/store.ts";
9
+ import type { Run } from "./run-store.ts";
10
+
11
+ /** `<run>/events.jsonl`: one fact per line, dense `seq`, the only state a 2.0 run keeps. */
12
+ export const eventsFile = (run: Run): string => join(run.dir, "events.jsonl");
13
+
14
+ /** A 2.0 run has an events.jsonl; a run without one is a 1.5 run, and the daemon never drives it. */
15
+ export const isDaemonRun = (run: Run): boolean => existsSync(eventsFile(run));
16
+
17
+ const corrupt = (run: Run, after: number, why: string): CatherdError =>
18
+ new CatherdError("E_RUN_CORRUPT", `${eventsFile(run)} is corrupt after seq ${after}: ${why}`, {
19
+ fix: `restore events.jsonl from a backup, or finish run ${run.id} with catherd 1.x`,
20
+ });
21
+
22
+ const tooOld = (run: Run, v: unknown): CatherdError =>
23
+ new CatherdError(
24
+ "E_RUNTIME_TOO_OLD",
25
+ `${eventsFile(run)} holds events of version ${String(v)}, newer than this catherd (${EVENTS_SCHEMA})`,
26
+ { fix: `upgrade catherd: ${UPGRADE}` },
27
+ );
28
+
29
+ /** One line as an event: a newer `v` is too old a catherd, anything else invalid is corrupt (null here). */
30
+ function decode(run: Run, line: string): RunEvent | null {
31
+ let raw: unknown;
32
+ try {
33
+ raw = JSON.parse(line);
34
+ } catch {
35
+ return null;
36
+ }
37
+ const v = (raw as { v?: unknown } | null)?.v;
38
+ if (typeof v === "number" && v > EVENTS_SCHEMA) throw tooOld(run, v);
39
+ const r = RunEventSchema.safeParse(raw);
40
+ return r.success ? r.data : null;
41
+ }
42
+
43
+ /**
44
+ * The run's events from `fromSeq` (1 reads them all and checks every line). A last line without its newline
45
+ * that does not parse is a torn append: skipped and logged. Any other line that does not parse, or a seq that
46
+ * is not the line's number, refuses the read (E_RUN_CORRUPT, naming the seq before it); a newer `v`, too.
47
+ * Lines before `fromSeq` are skipped unread: the caller replayed them already.
48
+ */
49
+ export function readEvents(run: Run, fromSeq = 1): RunEvent[] {
50
+ let text: string;
51
+ try {
52
+ text = readFileSync(eventsFile(run), "utf8");
53
+ } catch {
54
+ return [];
55
+ }
56
+ const lines = text.split("\n");
57
+ const tail = lines.pop() ?? "";
58
+ if (tail) lines.push(tail);
59
+ const out: RunEvent[] = [];
60
+ for (const [i, line] of lines.entries()) {
61
+ if (i + 1 < fromSeq || !line.trim()) continue;
62
+ const e = decode(run, line);
63
+ if (e === null) {
64
+ if (i === lines.length - 1 && line === tail) {
65
+ log("warn", "events", { run: run.id, torn: i + 1 });
66
+ break;
67
+ }
68
+ throw corrupt(run, i, "a line is not an event");
69
+ }
70
+ if (e.seq !== i + 1) throw corrupt(run, i, `seq ${e.seq} stands where ${i + 1} belongs`);
71
+ out.push(e);
72
+ }
73
+ return out;
74
+ }
75
+
76
+ export const replayRun = (run: Run): RunState => replay(readEvents(run), run.id);
77
+
78
+ /** The last seq of the file; a torn tail is cut off first, so the next line starts clean. Under the file's lock. */
79
+ function lastSeq(run: Run): number {
80
+ const file = eventsFile(run);
81
+ if (!existsSync(file)) return 0;
82
+ const fd = openSync(file, "r+");
83
+ try {
84
+ const size = fstatSync(fd).size;
85
+ let chunk = 64 * 1024;
86
+ for (;;) {
87
+ const start = Math.max(0, size - chunk);
88
+ const buf = Buffer.alloc(size - start);
89
+ readSync(fd, buf, 0, buf.length, start);
90
+ const text = buf.toString("utf8");
91
+ const cut = text.lastIndexOf("\n");
92
+ const tail = text.slice(cut + 1);
93
+ const body = text.slice(0, cut + 1).trimEnd();
94
+ // the whole last complete line is in the chunk, or the chunk is the whole file
95
+ if (start > 0 && !body.includes("\n")) {
96
+ chunk *= 4;
97
+ continue;
98
+ }
99
+ if (tail && decode(run, tail) === null) {
100
+ // a torn append: not an event, never will be one
101
+ ftruncateSync(fd, size - Buffer.byteLength(tail));
102
+ log("warn", "events", { run: run.id, tornTail: Buffer.byteLength(tail) });
103
+ }
104
+ const line = tail && decode(run, tail) !== null ? tail : body.slice(body.lastIndexOf("\n") + 1);
105
+ if (!line) return 0;
106
+ const last = decode(run, line);
107
+ if (!last) throw corrupt(run, 0, "its last line is not an event");
108
+ return last.seq;
109
+ }
110
+ } finally {
111
+ closeSync(fd);
112
+ }
113
+ }
114
+
115
+ /** Runs `fn` under the events file's lock, handing it an append that does not take the lock again. */
116
+ export function withEventsLock<T>(
117
+ run: Run,
118
+ fn: (add: (e: RunEventInput, now?: Date) => RunEvent) => T | Promise<T>,
119
+ ): Promise<T> {
120
+ return withFileLock(eventsFile(run), () => fn((e, now) => writeEvents(run, [e], now)[0] as RunEvent));
121
+ }
122
+
123
+ /** The events with the next dense seqs, validated all before the one write that appends them. */
124
+ function writeEvents(run: Run, es: RunEventInput[], now: Date = new Date()): RunEvent[] {
125
+ const first = lastSeq(run) + 1;
126
+ const out = es.map((e, i) =>
127
+ RunEventSchema.parse({ v: EVENTS_SCHEMA, seq: first + i, at: now.toISOString(), ...e }),
128
+ );
129
+ appendJsonlBatch(eventsFile(run), out);
130
+ return out;
131
+ }
132
+
133
+ /** Appends one event under the file's lock, with the next dense `seq`, and returns it as written. */
134
+ export async function appendEvent(run: Run, e: RunEventInput, now: Date = new Date()): Promise<RunEvent> {
135
+ return withEventsLock(run, (add) => add(e, now));
136
+ }
137
+
138
+ /** Appends events that belong together in one write under the file's lock: all of them or, on a crash, a torn tail. */
139
+ export function appendEvents(run: Run, es: RunEventInput[], now: Date = new Date()): Promise<RunEvent[]> {
140
+ return withFileLock(eventsFile(run), () => writeEvents(run, es, now));
141
+ }
142
+
143
+ /**
144
+ * Calls `onEvent` with each event after `fromSeq - 1`, in order, as they are appended, until the returned stop
145
+ * is called. A poll that finds the file unreadable waits for the next one.
146
+ */
147
+ export function tailEvents(
148
+ run: Run,
149
+ fromSeq: number,
150
+ onEvent: (e: RunEvent) => void,
151
+ o: { pollMs?: number } = {},
152
+ ): () => void {
153
+ let next = fromSeq;
154
+ let stopped = false;
155
+ let timer: ReturnType<typeof setTimeout> | null = null;
156
+ const poll = (): void => {
157
+ if (stopped) return;
158
+ try {
159
+ for (const e of readEvents(run, next)) {
160
+ if (stopped) return;
161
+ next = e.seq + 1;
162
+ onEvent(e);
163
+ }
164
+ } catch (err) {
165
+ log("warn", "events", { run: run.id, tail: err instanceof Error ? err.message : String(err) });
166
+ }
167
+ if (!stopped) timer = setTimeout(poll, o.pollMs ?? 500);
168
+ };
169
+ poll();
170
+ return () => {
171
+ stopped = true;
172
+ if (timer) clearTimeout(timer);
173
+ };
174
+ }
@@ -17,7 +17,7 @@ export interface Evidence {
17
17
  fails: number;
18
18
  }
19
19
 
20
- /** `<canonical rung>|<kind or *>` → its evidence. Shown by route, catalog list and the TUI; never routes. */
20
+ /** `<canonical rung>|<kind or *>` → its evidence. Shown by route and catalog list; never routes. */
21
21
  export type EvidenceTable = Record<string, Evidence>;
22
22
 
23
23
  const VERDICT_FAIL = /^VERDICT: FAIL\b/;
@@ -0,0 +1,125 @@
1
+ import { existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { errorMessage } from "../domain/errors.ts";
5
+ import { ACCESS } from "../domain/record.ts";
6
+ import { ROLES } from "../domain/roles.ts";
7
+ import { readVersioned, writeJsonAtomic } from "../infra/store.ts";
8
+ import type { Deps, ProfileView } from "./ports.ts";
9
+ import { findRun, type Run } from "./run-store.ts";
10
+ import { refreshState } from "./state.ts";
11
+
12
+ // Spec 1.5 "Pinned per run": run_start records the profile the run started on, each role's access and each
13
+ // backend's isolation. A later change (another active profile, a toggled isolation, a version that changed a
14
+ // default) is logged in state.md and status, and dispatch keeps the pinned values until the owner re-pins.
15
+
16
+ const PinSchema = z.looseObject({
17
+ schema: z.literal(1),
18
+ at: z.string(),
19
+ profile: z.string(),
20
+ access: z.partialRecord(z.enum(ROLES), z.enum(ACCESS)),
21
+ isolated: z.record(z.string(), z.boolean()),
22
+ });
23
+ export type Pin = z.infer<typeof PinSchema>;
24
+
25
+ export const pinFile = (run: Run): string => join(run.dir, "pin.json");
26
+
27
+ /** The run's pin, or null: a run from before 1.5, or a pin that cannot be read (the profile then rules). */
28
+ export function readPin(run: Run): Pin | null {
29
+ if (!existsSync(pinFile(run))) return null;
30
+ try {
31
+ return readVersioned(pinFile(run), PinSchema, 1);
32
+ } catch {
33
+ return null;
34
+ }
35
+ }
36
+
37
+ const pinOf = (view: ProfileView, at: string): Pin => ({
38
+ schema: 1,
39
+ at,
40
+ profile: view.name,
41
+ access: Object.fromEntries(
42
+ Object.entries(view.roles).flatMap(([role, rc]) => (rc ? [[role, rc.access]] : [])),
43
+ ),
44
+ isolated: Object.fromEntries(Object.entries(view.isolated).map(([k, v]) => [k, v === true])),
45
+ });
46
+
47
+ /** Records what the run runs on now: the profile its repo runs on, each role's access, each backend's isolation. */
48
+ export function writePin(deps: Deps, run: Run): Pin {
49
+ const pin = pinOf(deps.profiles.forRepo(run.meta.repo), new Date(deps.now()).toISOString());
50
+ writeJsonAtomic(pinFile(run), pin);
51
+ return pin;
52
+ }
53
+
54
+ /** The pinned profile's view, or null with why when it can no longer be read (deleted, invalid). */
55
+ function pinnedView(deps: Deps, run: Run, pin: Pin): { view: ProfileView | null; why: string | null } {
56
+ const current = deps.profiles.forRepo(run.meta.repo);
57
+ if (current.name === pin.profile) return { view: current, why: null };
58
+ try {
59
+ return { view: deps.profiles.get(pin.profile, run.meta.repo).profile, why: null };
60
+ } catch (e) {
61
+ return { view: null, why: errorMessage(e) };
62
+ }
63
+ }
64
+
65
+ /**
66
+ * The profile a run's dispatch, routing and failover read: its pinned profile, with its pinned access and
67
+ * isolation laid over it. A run with no pin, or whose pinned profile is gone, reads its repo's profile.
68
+ */
69
+ export function runProfile(deps: Deps, run: Run): ProfileView {
70
+ const pin = readPin(run);
71
+ if (!pin) return deps.profiles.forRepo(run.meta.repo);
72
+ const view = pinnedView(deps, run, pin).view ?? deps.profiles.forRepo(run.meta.repo);
73
+ const roles: ProfileView["roles"] = {};
74
+ for (const [role, rc] of Object.entries(view.roles) as [
75
+ keyof ProfileView["roles"],
76
+ NonNullable<ProfileView["roles"][keyof ProfileView["roles"]]>,
77
+ ][])
78
+ roles[role] = { ...rc, access: pin.access[role] ?? rc.access };
79
+ // a backend the pin does not name ran with no isolation when the run started
80
+ const backends = new Set([...Object.keys(view.isolated), ...Object.keys(pin.isolated)]);
81
+ const isolated = Object.fromEntries([...backends].map((k) => [k, pin.isolated[k] === true]));
82
+ return { ...view, roles, isolated };
83
+ }
84
+
85
+ /** What changed since the pin, one line each, in words; none while the run's repo still runs as pinned. */
86
+ export function pinChanges(deps: Deps, run: Run): string[] {
87
+ const pin = readPin(run);
88
+ if (!pin) return [];
89
+ let now: ProfileView;
90
+ try {
91
+ now = deps.profiles.forRepo(run.meta.repo);
92
+ } catch {
93
+ // a profile that cannot be read is doctor's and profile validate's to report, not a pin change
94
+ return [];
95
+ }
96
+ const out: string[] = [];
97
+ if (now.name !== pin.profile) {
98
+ const { why } = pinnedView(deps, run, pin);
99
+ out.push(
100
+ `profile: pinned ${pin.profile}, the repo now runs on ${now.name}${why ? ` (${pin.profile} cannot be read: ${why}; dispatch reads ${now.name})` : ""}`,
101
+ );
102
+ }
103
+ for (const [role, access] of Object.entries(pin.access)) {
104
+ const was = now.roles[role as keyof ProfileView["roles"]]?.access;
105
+ if (was !== undefined && was !== access) out.push(`${role} access: pinned ${access}, now ${was}`);
106
+ }
107
+ for (const backend of new Set([...Object.keys(pin.isolated), ...Object.keys(now.isolated)])) {
108
+ const isolated = pin.isolated[backend] === true;
109
+ const was = now.isolated[backend] === true;
110
+ if (was !== isolated) out.push(`${backend} isolated: pinned ${isolated}, now ${was}`);
111
+ }
112
+ return out;
113
+ }
114
+
115
+ /** `run_pin`: the owner re-pins the run to what its repo runs on now. Returns the new pin and what it changed. */
116
+ export async function repin(
117
+ deps: Deps,
118
+ i: { run: string },
119
+ ): Promise<{ pin: Pin; changed: string[]; hints?: string[] }> {
120
+ const run = findRun(i.run);
121
+ const changed = pinChanges(deps, run);
122
+ const pin = writePin(deps, run);
123
+ const { hints } = await refreshState(run, { pinChanges: [] });
124
+ return { pin, changed, ...(hints.length ? { hints } : {}) };
125
+ }
@@ -0,0 +1,221 @@
1
+ import { z } from "zod";
2
+ import { errorMessage } from "../domain/errors.ts";
3
+ import { PARK_REASONS, describeEvent } from "../domain/run-events.ts";
4
+ import { type RunState, liveDispatches } from "../domain/run-state.ts";
5
+ import { log } from "../infra/log.ts";
6
+ import { daemonAlive, ensureDaemon, readHeartbeat } from "./daemon.ts";
7
+ import { liveDispatches as liveDispatchesOf } from "./dispatches.ts";
8
+ import { landedMilestones } from "./milestones.ts";
9
+ import type { Deps } from "./ports.ts";
10
+ import { openQuestions } from "./questions.ts";
11
+ import { isDaemonRun, readEvents, replayRun, tailEvents } from "./run-events-store.ts";
12
+ import { findRun, listRuns, type Run } from "./run-store.ts";
13
+
14
+ /**
15
+ * The read side of a run, for the panel (plan 31) and the session's `status`: each shape has a zod
16
+ * schema, which is its contract. A 1.5 run reads as `engine: "1.5"`, with what its folder says.
17
+ */
18
+ export const ParkedViewSchema = z.object({
19
+ run: z.string(),
20
+ runTitle: z.string(),
21
+ unit: z.string(),
22
+ question: z.string(),
23
+ reason: z.enum(PARK_REASONS),
24
+ at: z.string(),
25
+ /** the command that answers it */
26
+ command: z.string(),
27
+ });
28
+ export type ParkedView = z.infer<typeof ParkedViewSchema>;
29
+
30
+ export const TimelineEntrySchema = z.object({
31
+ seq: z.number().int(),
32
+ at: z.string(),
33
+ type: z.string(),
34
+ text: z.string(),
35
+ });
36
+ export type TimelineEntry = z.infer<typeof TimelineEntrySchema>;
37
+
38
+ export const RunViewSchema = z.object({
39
+ id: z.string(),
40
+ title: z.string(),
41
+ repo: z.string(),
42
+ engine: z.enum(["2.0", "1.5"]),
43
+ phase: z.enum(["planning", "approval", "running", "ended"]).nullable(),
44
+ outcome: z.enum(["done", "cancelled", "failed"]).nullable(),
45
+ daemon: z.object({
46
+ alive: z.boolean(),
47
+ pid: z.number().int().nullable(),
48
+ beatAgeSec: z.number().nullable(),
49
+ }),
50
+ milestones: z.array(
51
+ z.object({
52
+ id: z.string(),
53
+ title: z.string(),
54
+ status: z.string(),
55
+ round: z.number().int(),
56
+ commit: z.string().nullable(),
57
+ }),
58
+ ),
59
+ live: z.array(
60
+ z.object({ name: z.string(), role: z.string(), rung: z.string(), unit: z.string(), since: z.string() }),
61
+ ),
62
+ parked: z.array(ParkedViewSchema),
63
+ holds: z.array(z.object({ quota: z.string(), until: z.string() })),
64
+ /** the latest event, in one line */
65
+ last: z.string().nullable(),
66
+ startedAt: z.string(),
67
+ updatedAt: z.string(),
68
+ });
69
+ export type RunView = z.infer<typeof RunViewSchema>;
70
+
71
+ export { tailEvents };
72
+
73
+ const answerCommand = (run: string, unit: string): string => `catherd answer ${run} ${unit} "<answer>"`;
74
+
75
+ function parkedOf(run: Run, s: RunState): ParkedView[] {
76
+ return Object.values(s.questions).map((q) => ({
77
+ run: run.id,
78
+ runTitle: run.meta.title,
79
+ unit: q.unit,
80
+ question: q.question,
81
+ reason: q.reason,
82
+ at: q.at,
83
+ command: answerCommand(run.id, q.unit),
84
+ }));
85
+ }
86
+
87
+ /** A daemon of an unfinished 2.0 run is started again when it is dead; a failure to start never fails a read. */
88
+ function revive(run: Run): void {
89
+ try {
90
+ ensureDaemon(run);
91
+ } catch (e) {
92
+ log("warn", "daemon", { run: run.id, revive: errorMessage(e) });
93
+ }
94
+ }
95
+
96
+ function view15(deps: Deps, run: Run): RunView {
97
+ const now = deps.now();
98
+ return {
99
+ id: run.id,
100
+ title: run.meta.title,
101
+ repo: run.meta.repo,
102
+ engine: "1.5",
103
+ phase: null,
104
+ outcome: null,
105
+ daemon: { alive: false, pid: null, beatAgeSec: null },
106
+ milestones: landedMilestones(run).map((id) => ({
107
+ id,
108
+ title: id,
109
+ status: "landed",
110
+ round: 0,
111
+ commit: null,
112
+ })),
113
+ live: liveDispatchesOf(run, now).map((d) => ({
114
+ name: d.admit.name,
115
+ role: d.admit.role,
116
+ rung: d.admit.rung,
117
+ unit: d.admit.lane ?? d.admit.name,
118
+ since: d.admit.admittedAt,
119
+ })),
120
+ parked: openQuestions(run).map((q) => ({
121
+ run: run.id,
122
+ runTitle: run.meta.title,
123
+ unit: q.milestone,
124
+ question: q.question,
125
+ reason: "needs_user" as const,
126
+ at: q.at,
127
+ command: `answer(run: "${run.id}", milestone: "${q.milestone}", answer: "<answer>")`,
128
+ })),
129
+ holds: [],
130
+ last: null,
131
+ startedAt: run.meta.createdAt,
132
+ updatedAt: run.meta.createdAt,
133
+ };
134
+ }
135
+
136
+ /** One run at a glance. Reading an unfinished 2.0 run starts its daemon again if it died. */
137
+ export function runView(deps: Deps, runId: string): RunView {
138
+ const run = findRun(runId);
139
+ if (!isDaemonRun(run)) return view15(deps, run);
140
+ revive(run);
141
+ const s = replayRun(run);
142
+ const beat = readHeartbeat(run);
143
+ const events = readEvents(run);
144
+ const last = events.at(-1);
145
+ return {
146
+ id: run.id,
147
+ title: run.meta.title,
148
+ repo: run.meta.repo,
149
+ engine: "2.0",
150
+ phase: s.phase,
151
+ outcome: s.outcome,
152
+ daemon: {
153
+ alive: daemonAlive(run),
154
+ pid: beat?.pid ?? null,
155
+ beatAgeSec: beat ? Math.max(0, Math.round((deps.now() - Date.parse(beat.beatAt)) / 1000)) : null,
156
+ },
157
+ milestones: s.milestones.map((m) => ({
158
+ id: m.id,
159
+ title: m.title,
160
+ status: m.status,
161
+ round: m.round,
162
+ commit: m.commit,
163
+ })),
164
+ live: liveDispatches(s).map((d) => ({
165
+ name: d.name,
166
+ role: d.role,
167
+ rung: d.rung,
168
+ unit: d.unit,
169
+ since: d.startedAt,
170
+ })),
171
+ parked: parkedOf(run, s),
172
+ holds: Object.entries(s.holds).map(([quota, until]) => ({ quota, until })),
173
+ last: last ? describeEvent(last, (id) => s.dispatches[id]?.rung) : null,
174
+ startedAt: run.meta.createdAt,
175
+ updatedAt: last?.at ?? run.meta.createdAt,
176
+ };
177
+ }
178
+
179
+ /** Every run, newest first (one repo's with `repo`), at most `limit` (default 50). */
180
+ export function listRunViews(deps: Deps, o: { repo?: string; limit?: number } = {}): RunView[] {
181
+ const runs = listRuns(o.repo ? [o.repo] : undefined).runs.slice(0, o.limit ?? 50);
182
+ const out: RunView[] = [];
183
+ for (const r of runs) {
184
+ try {
185
+ out.push(runView(deps, r.id));
186
+ } catch (e) {
187
+ log("warn", "read", { run: r.id, error: errorMessage(e) });
188
+ }
189
+ }
190
+ return out;
191
+ }
192
+
193
+ /** The run's events from `fromSeq`, one line each (never a reply's body), at most `limit` (default 200). */
194
+ export function runTimeline(runId: string, fromSeq = 1, limit = 200): TimelineEntry[] {
195
+ const run = findRun(runId);
196
+ if (!isDaemonRun(run)) return [];
197
+ const s = replayRun(run);
198
+ return readEvents(run, fromSeq)
199
+ .slice(0, limit)
200
+ .map((e) => ({
201
+ seq: e.seq,
202
+ at: e.at,
203
+ type: e.type,
204
+ text: describeEvent(e, (id) => s.dispatches[id]?.rung),
205
+ }));
206
+ }
207
+
208
+ /** Every unfinished 2.0 run's open questions (one run's with `run`), each with the command that answers it. */
209
+ export function parkedQuestions(o: { run?: string } = {}): ParkedView[] {
210
+ const runs = o.run ? [findRun(o.run)] : listRuns().runs;
211
+ return runs.flatMap((run) => {
212
+ try {
213
+ if (!isDaemonRun(run)) return [];
214
+ const s = replayRun(run);
215
+ return s.phase === "ended" ? [] : parkedOf(run, s);
216
+ } catch (e) {
217
+ log("warn", "read", { run: run.id, error: errorMessage(e) });
218
+ return [];
219
+ }
220
+ });
221
+ }