catherd-cli 1.3.0 → 1.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (159) hide show
  1. package/README.md +144 -42
  2. package/catalog/ATTRIBUTION.md +12 -4
  3. package/catalog/models.json +8 -0
  4. package/package.json +1 -1
  5. package/plugin/.claude-plugin/plugin.json +1 -1
  6. package/plugin/.codex-plugin/plugin.json +11 -0
  7. package/plugin/.mcp-codex.json +10 -0
  8. package/plugin/bin/catherd-mcp +1 -1
  9. package/plugin/skills/catherd/SKILL.md +150 -79
  10. package/plugin/skills/catherd-setup/SKILL.md +24 -8
  11. package/src/adapters/access.ts +7 -1
  12. package/src/adapters/antigravity/index.ts +18 -3
  13. package/src/adapters/backend.ts +18 -2
  14. package/src/adapters/claude-code/index.ts +126 -14
  15. package/src/adapters/cli.ts +1 -64
  16. package/src/adapters/codex/events.ts +10 -0
  17. package/src/adapters/codex/index.ts +17 -5
  18. package/src/adapters/cursor/index.ts +7 -1
  19. package/src/adapters/grok/home.ts +75 -20
  20. package/src/adapters/grok/index.ts +27 -8
  21. package/src/adapters/opencode/index.ts +38 -1
  22. package/src/cli.ts +53 -19
  23. package/src/domain/agents.ts +3 -1
  24. package/src/domain/brief.ts +91 -0
  25. package/src/domain/calibration.ts +7 -0
  26. package/src/domain/catalog.ts +12 -1
  27. package/src/domain/changes.ts +23 -1
  28. package/src/domain/cost.ts +8 -3
  29. package/src/domain/errors.ts +4 -0
  30. package/src/domain/failover.ts +17 -5
  31. package/src/domain/gate-brief.ts +52 -0
  32. package/src/domain/hints.ts +13 -2
  33. package/src/domain/host.ts +17 -0
  34. package/src/domain/lane.ts +99 -3
  35. package/src/domain/notice.ts +14 -4
  36. package/src/domain/profile-rules.ts +129 -16
  37. package/src/domain/profile.ts +56 -5
  38. package/src/domain/record.ts +17 -2
  39. package/src/domain/reply.ts +29 -0
  40. package/src/domain/role-prompts.ts +56 -16
  41. package/src/domain/role-scope.ts +106 -0
  42. package/src/domain/role-tools.ts +34 -0
  43. package/src/domain/route.ts +73 -0
  44. package/src/domain/select.ts +177 -25
  45. package/src/domain/state.ts +5 -0
  46. package/src/domain/util.ts +22 -0
  47. package/src/domain/workspace.ts +130 -0
  48. package/src/entry/catalog-command.ts +18 -8
  49. package/src/entry/cli-kit.ts +17 -1
  50. package/src/entry/deps.ts +8 -3
  51. package/src/entry/doctor-command.ts +29 -2
  52. package/src/entry/host-arg.ts +56 -0
  53. package/src/entry/init-command.ts +20 -8
  54. package/src/entry/knowledge-command.ts +78 -3
  55. package/src/entry/lock-command.ts +111 -10
  56. package/src/entry/mcp/dispatch-tools.ts +3 -3
  57. package/src/entry/mcp/handshake.ts +8 -2
  58. package/src/entry/mcp/lane-tools.ts +55 -8
  59. package/src/entry/mcp/protocol-tools.ts +10 -4
  60. package/src/entry/mcp/push-tools.ts +16 -0
  61. package/src/entry/mcp/role-bin.ts +20 -0
  62. package/src/entry/mcp/role-server.ts +116 -0
  63. package/src/entry/mcp/run-tools.ts +25 -6
  64. package/src/entry/mcp/server.ts +224 -44
  65. package/src/entry/mcp/setup-tools.ts +11 -5
  66. package/src/entry/mcp/workspace-tools.ts +142 -0
  67. package/src/entry/pause-command.ts +67 -0
  68. package/src/entry/profile-command.ts +131 -16
  69. package/src/entry/role-cli-command.ts +115 -0
  70. package/src/entry/runs-command.ts +157 -11
  71. package/src/entry/supervise-command.ts +30 -3
  72. package/src/entry/tui/effects.ts +23 -8
  73. package/src/entry/tui/fixtures.ts +33 -10
  74. package/src/entry/tui/run.tsx +10 -4
  75. package/src/entry/tui/state.ts +56 -16
  76. package/src/entry/tui/stories.ts +11 -4
  77. package/src/entry/tui/views/profile-actions.ts +4 -2
  78. package/src/entry/tui/views/profiles.tsx +3 -3
  79. package/src/entry/tui/views/runs.tsx +66 -20
  80. package/src/entry/tui/views/save-dialog.tsx +8 -4
  81. package/src/entry/tui/views/status.tsx +6 -2
  82. package/src/entry/workspace-command.ts +44 -0
  83. package/src/infra/boot-lock.ts +25 -0
  84. package/src/infra/cli.ts +65 -0
  85. package/src/infra/codex-queue.ts +165 -0
  86. package/src/infra/delivery.ts +86 -0
  87. package/src/infra/dispatch-dir.ts +3 -0
  88. package/src/infra/env.ts +15 -2
  89. package/src/infra/git.ts +6 -0
  90. package/src/infra/heavy-lock.ts +93 -4
  91. package/src/infra/host-context.ts +58 -0
  92. package/src/infra/host-probe.ts +16 -0
  93. package/src/infra/lock-activity.ts +88 -0
  94. package/src/infra/login-env.ts +48 -0
  95. package/src/infra/paths.ts +35 -0
  96. package/src/infra/peer-inbox.ts +4 -1
  97. package/src/infra/role-mcp.ts +69 -0
  98. package/src/infra/sources/arena.ts +11 -3
  99. package/src/infra/sources/artificial-analysis.ts +8 -1
  100. package/src/infra/sources/cache.ts +2 -0
  101. package/src/infra/supervisor.ts +110 -18
  102. package/src/services/admission.ts +117 -26
  103. package/src/services/agent-links.ts +30 -5
  104. package/src/services/backends.ts +7 -0
  105. package/src/services/budget.ts +26 -6
  106. package/src/services/catalog-service.ts +20 -12
  107. package/src/services/credentials.ts +11 -6
  108. package/src/services/dispatch-service.ts +220 -22
  109. package/src/services/dispatches.ts +36 -3
  110. package/src/services/doctor-access.ts +8 -3
  111. package/src/services/doctor-backends.ts +30 -7
  112. package/src/services/doctor-checks.ts +20 -5
  113. package/src/services/doctor-docker.ts +221 -0
  114. package/src/services/doctor-push.ts +69 -90
  115. package/src/services/doctor-role-mcp.ts +65 -0
  116. package/src/services/doctor.ts +172 -38
  117. package/src/services/end-push.ts +157 -0
  118. package/src/services/finalize.ts +13 -3
  119. package/src/services/gate-env.ts +218 -0
  120. package/src/services/gate-service.ts +216 -45
  121. package/src/services/global-install.ts +46 -13
  122. package/src/services/lane-edit.ts +154 -0
  123. package/src/services/lane-service.ts +258 -52
  124. package/src/services/milestones.ts +137 -26
  125. package/src/services/notifier.ts +377 -72
  126. package/src/services/orchestrator-wait.ts +82 -0
  127. package/src/services/pause.ts +161 -0
  128. package/src/services/peek.ts +122 -23
  129. package/src/services/ports.ts +39 -4
  130. package/src/services/preflight.ts +153 -19
  131. package/src/services/profile-service.ts +117 -30
  132. package/src/services/profile-store.ts +31 -7
  133. package/src/services/protocol.ts +119 -25
  134. package/src/services/provenance.ts +16 -12
  135. package/src/services/questions.ts +15 -0
  136. package/src/services/reconcile.ts +22 -1
  137. package/src/services/reentry.ts +7 -6
  138. package/src/services/role-access.ts +27 -0
  139. package/src/services/routing-service.ts +150 -37
  140. package/src/services/run-debug.ts +98 -4
  141. package/src/services/run-pin.ts +125 -0
  142. package/src/services/run-service.ts +153 -19
  143. package/src/services/run-store.ts +119 -19
  144. package/src/services/runs-page.ts +45 -9
  145. package/src/services/scratch.ts +60 -0
  146. package/src/services/session-view.ts +18 -13
  147. package/src/services/sessions.ts +34 -16
  148. package/src/services/setup.ts +31 -10
  149. package/src/services/source-derive.ts +28 -5
  150. package/src/services/source-sync.ts +9 -2
  151. package/src/services/standins.ts +40 -6
  152. package/src/services/state.ts +21 -0
  153. package/src/services/summary.ts +86 -11
  154. package/src/services/treat-likes.ts +41 -12
  155. package/src/services/verifier-brief.ts +23 -0
  156. package/src/services/verifier-step.ts +48 -0
  157. package/src/services/workspace-admission.ts +267 -0
  158. package/src/services/workspace-service.ts +254 -0
  159. package/src/services/workspace-store.ts +95 -0
@@ -1,3 +1,4 @@
1
+ import { assertNativeHost } from "./backends.ts";
1
2
  import { BUDGET_CHEAP_AT } from "../domain/budget.ts";
2
3
  import { errorMessage } from "../domain/errors.ts";
3
4
  import {
@@ -12,11 +13,13 @@ import {
12
13
  import { type Difficulty, type Kind, parseLaneHeader } from "../domain/lane.ts";
13
14
  import type { Role } from "../domain/roles.ts";
14
15
  import type { RouteJev } from "../domain/route.ts";
16
+ import type { Catalog } from "../domain/catalog.ts";
15
17
  import {
16
18
  candidates,
17
19
  defaultDifficulty,
18
20
  defaultLadder,
19
21
  type Pick,
22
+ quotaUsage,
20
23
  type RoutingProfile,
21
24
  select,
22
25
  } from "../domain/select.ts";
@@ -25,11 +28,17 @@ import { catalogQuery, freshenDiscovery, loadCatalog } from "./catalog-service.t
25
28
  import { type Asked, askJev, type JevOpts, jevQuestions, logJev } from "./jev-service.ts";
26
29
  import type { ProfileView, RouteAnswer, RouteRequest, RoutingPort, Verdict } from "./ports.ts";
27
30
  import { provenanceOf } from "./provenance.ts";
28
- import { runEvidence } from "./run-evidence.ts";
31
+ import { type EvidenceTable, runEvidence } from "./run-evidence.ts";
29
32
 
30
- function routingProfile(v: ProfileView, role: Role, spentFraction: number): RoutingProfile {
33
+ function routingProfile(
34
+ v: ProfileView,
35
+ role: Role,
36
+ spentFraction: number,
37
+ usage: Record<string, number> = {},
38
+ ): RoutingProfile {
31
39
  const rc = v.roles[role];
32
40
  return {
41
+ usage,
33
42
  // spec §4.6: from 80 % of the budget on, start at the cheapest rung that clears the bar
34
43
  objective: spentFraction >= BUDGET_CHEAP_AT ? "cost" : v.objective,
35
44
  billing: v.billing,
@@ -49,14 +58,60 @@ const answer = (
49
58
  asked: Asked | null,
50
59
  jev: RouteJev | null,
51
60
  ): RouteAnswer => ({
52
- ...pick,
61
+ rung: pick.rung,
62
+ ladder: pick.ladder,
53
63
  source,
54
64
  kind,
55
65
  difficulty,
56
66
  questionSet: asked?.answers ? asked.meta.questionSet : null,
57
67
  jev,
68
+ jevSaid: null,
69
+ why: "",
70
+ ...(pick.noClear ? { noClear: pick.noClear } : {}),
71
+ ...(pick.tie ? { tie: pick.tie } : {}),
58
72
  });
59
73
 
74
+ /** Spec 1.5 plan 24: what Jev said when it differs from the kind and difficulty the route used, else null. */
75
+ function jevSaid(
76
+ judged: ReturnType<typeof judgeRoute> | null,
77
+ kind: Kind | null,
78
+ difficulty: Difficulty | null,
79
+ ): string | null {
80
+ if (!judged) return null;
81
+ const k = judged.kind;
82
+ const d = judged.track ? judged.difficulty : null;
83
+ if ((k === null || k === kind) && (d === null || d === difficulty)) return null;
84
+ return `Jev said ${k ?? "no sure kind"}/${d ?? "no sure difficulty"}`;
85
+ }
86
+
87
+ /** Spec 1.5 plan 24: the route's one-line why, for the coordinator; the provenance goes to routes.jsonl. */
88
+ function whyOf(a: RouteAnswer, hasLane: boolean): string {
89
+ const what = `${a.kind}/${a.difficulty}`;
90
+ const head =
91
+ a.source === "lane"
92
+ ? `the lane's Kind/Difficulty, ${what}`
93
+ : a.source === "jev"
94
+ ? `Jev: ${what}`
95
+ : a.source === "jev-kind"
96
+ ? `Jev's kind with ${what}`
97
+ : hasLane
98
+ ? "the role's default rung: neither Jev nor the lane file gave a kind and difficulty"
99
+ : "the role's default rung";
100
+ const start =
101
+ a.noClear ?? (a.source === "default" ? null : "the first rung in objective order that clears it");
102
+ return [head, a.jevSaid, start, a.tie].filter((x): x is string => Boolean(x)).join("; ");
103
+ }
104
+
105
+ /** Spec 1.2 §8 evidence is display only: an unreadable run on this machine never fails a route. */
106
+ function evidenceOrNone(c: Catalog): EvidenceTable | null {
107
+ try {
108
+ return runEvidence(c);
109
+ } catch (e) {
110
+ log("debug", "route", { evidence: errorMessage(e) });
111
+ return null;
112
+ }
113
+ }
114
+
60
115
  /** How long `route` waits on the daily discovery refresh before routing on the cached listing. */
61
116
  const DISCOVERY_BUDGET_MS = 5_000;
62
117
 
@@ -78,53 +133,77 @@ async function freshenWithin(rungs: string[], repo: string, ms: number): Promise
78
133
  clearTimeout(timer);
79
134
  }
80
135
 
136
+ /** What every route of one call shares: the role's routing profile and the catalog. */
137
+ interface Prepared {
138
+ p: RoutingProfile;
139
+ c: Catalog;
140
+ }
141
+
142
+ async function prepare(req: RouteRequest, o: RoutingOpts): Promise<Prepared> {
143
+ const p = routingProfile(req.profile, req.role, req.spentFraction, req.usage);
144
+ await freshenWithin(p.role.rungs, req.repo, o.discoveryBudgetMs ?? DISCOVERY_BUDGET_MS);
145
+ return { p, c: loadCatalog({ repo: req.repo }) };
146
+ }
147
+
148
+ /** Jev's answer about a lane; null when the route reads no lane, empty when it asks nothing (Jev off, one usable rung). */
149
+ interface Judged {
150
+ asked: Asked | null;
151
+ judged: ReturnType<typeof judgeRoute> | null;
152
+ }
153
+
154
+ async function judge(req: RouteRequest, { p, c }: Prepared, o: RoutingOpts): Promise<Judged | null> {
155
+ if (req.laneText === null) return null;
156
+ // one usable rung: nothing for Jev to choose, but the lane's header still names its kind and difficulty
157
+ if (req.profile.jev.use === "off" || candidates(c, p, req.role).length <= 1)
158
+ return { asked: null, judged: null };
159
+ const asked = await askJev(req.runDir, "route-v2", laneState(req.laneText), o);
160
+ const judged = asked.answers ? judgeRoute(jevQuestions().sets["route-v2"].rule, asked.answers) : null;
161
+ return { asked, judged };
162
+ }
163
+
81
164
  /**
82
- * Spec §5.4: kind and difficulty from Jev (§5.5's rule), else the lane file's `Kind:`/`Difficulty:`,
83
- * else the role's default rung. A kind Jev is sure of survives a difficulty in the dead band: the
84
- * difficulty then comes from the lane, else the role's default difficulty (`jev-kind`). A role with one
85
- * usable rung never asks Jev.
165
+ * Spec §5.4: kind and difficulty from the lane file's `Kind:`/`Difficulty:` when it declares both (spec 1.5
166
+ * plan 24: a declared header wins, and `jevSaid` names where Jev disagreed), else from Jev (§5.5's rule), else
167
+ * the role's default rung. A kind Jev is sure of survives a difficulty in the dead band: the difficulty then
168
+ * comes from the lane, else the role's default difficulty (`jev-kind`). A role with one usable rung never asks
169
+ * Jev.
86
170
  */
87
- async function route(req: RouteRequest, o: RoutingOpts): Promise<RouteAnswer> {
88
- const p = routingProfile(req.profile, req.role, req.spentFraction);
89
- await freshenWithin(p.role.rungs, req.repo, o.discoveryBudgetMs ?? DISCOVERY_BUDGET_MS);
90
- const c = loadCatalog({ repo: req.repo });
171
+ function decide(req: RouteRequest, { p, c }: Prepared, j: Judged | null): RouteAnswer {
91
172
  const fallback = () => defaultLadder(c, p, req.role);
92
- if (req.laneText === null || candidates(c, p, req.role).length <= 1) {
93
- const out = answer(fallback(), "default", null, null, null, null);
94
- return { ...out, provenance: provenanceOf(c, out.rung, null, null, p.billing, runEvidence(c)) };
95
- }
173
+ const finish = (out: RouteAnswer): RouteAnswer => {
174
+ out.why = whyOf(out, req.laneText !== null);
175
+ out.provenance = provenanceOf(c, out.rung, out.kind, out.difficulty, p.billing, evidenceOrNone(c));
176
+ return out;
177
+ };
178
+ if (req.laneText === null || j === null)
179
+ return finish(answer(fallback(), "default", null, null, null, null));
96
180
  const lane = parseLaneHeader(req.laneText);
97
- let asked: Asked | null = null;
98
- let judged: ReturnType<typeof judgeRoute> | null = null;
99
- if (req.profile.jev.use !== "off") {
100
- asked = await askJev(req.runDir, "route-v2", laneState(req.laneText), o);
101
- if (asked.answers) judged = judgeRoute(jevQuestions().sets["route-v2"].rule, asked.answers);
102
- }
181
+ const { asked, judged } = j;
103
182
  const jev: RouteJev | null = judged
104
183
  ? { pKind: judged.pKind, pA: judged.pA, pB: judged.pB, nouls: judged.nouls }
105
184
  : null;
106
185
  let out: RouteAnswer;
107
- if (judged?.track && judged.difficulty) {
108
- const kind = judged.kind ?? lane.kind ?? "repo_code";
109
- out = answer(select(c, p, req.role, kind, judged.difficulty), "jev", kind, judged.difficulty, asked, jev);
110
- } else if (judged?.kind) {
111
- const difficulty = lane.difficulty ?? defaultDifficulty(c, p, req.role, judged.kind);
186
+ if (lane.kind && lane.difficulty) {
112
187
  out = answer(
113
- select(c, p, req.role, judged.kind, difficulty),
114
- "jev-kind",
115
- judged.kind,
116
- difficulty,
188
+ select(c, p, req.role, lane.kind, lane.difficulty),
189
+ "lane",
190
+ lane.kind,
191
+ lane.difficulty,
117
192
  asked,
118
193
  jev,
119
194
  );
195
+ } else if (judged?.track && judged.difficulty) {
196
+ const kind = lane.kind ?? judged.kind ?? "repo_code";
197
+ out = answer(select(c, p, req.role, kind, judged.difficulty), "jev", kind, judged.difficulty, asked, jev);
198
+ } else if (judged?.kind || (judged && lane.kind)) {
199
+ const kind = (lane.kind ?? judged.kind) as Kind;
200
+ const difficulty = lane.difficulty ?? defaultDifficulty(c, p, req.role, kind);
201
+ out = answer(select(c, p, req.role, kind, difficulty), "jev-kind", kind, difficulty, asked, jev);
120
202
  } else {
121
- const kind = lane.kind;
122
- out =
123
- kind && lane.difficulty
124
- ? answer(select(c, p, req.role, kind, lane.difficulty), "lane", kind, lane.difficulty, asked, jev)
125
- : answer(fallback(), "default", null, null, asked, jev);
203
+ out = answer(fallback(), "default", null, null, asked, jev);
126
204
  }
127
- out.provenance = provenanceOf(c, out.rung, out.kind, out.difficulty, p.billing, runEvidence(c));
205
+ out.jevSaid = out.source === "default" ? null : jevSaid(judged, out.kind, out.difficulty);
206
+ finish(out);
128
207
  if (asked) {
129
208
  logJev(req.runDir, {
130
209
  ...asked.meta,
@@ -142,6 +221,31 @@ async function route(req: RouteRequest, o: RoutingOpts): Promise<RouteAnswer> {
142
221
  return out;
143
222
  }
144
223
 
224
+ async function route(req: RouteRequest, o: RoutingOpts): Promise<RouteAnswer> {
225
+ const prep = await prepare(req, o);
226
+ return decide(req, prep, await judge(req, prep, o));
227
+ }
228
+
229
+ /**
230
+ * Spec 1.5 plan 24, batch `route`: the lanes of one role (one run, one profile) share one discovery refresh and
231
+ * one catalog, and Jev is asked about all of them at once (Ruling 6: one request per lane, all in flight
232
+ * together, so each keeps its own cache key and jev.jsonl row). Then each lane is decided in order, every
233
+ * routed start counting as a use of its quota, so lanes that tie spread over the quotas.
234
+ */
235
+ async function routeMany(reqs: RouteRequest[], o: RoutingOpts): Promise<RouteAnswer[]> {
236
+ const first = reqs[0];
237
+ if (!first) return [];
238
+ const prep = await prepare(first, o);
239
+ const judged = await Promise.all(reqs.map((r) => judge(r, prep, o)));
240
+ const usage: Record<string, number> = { ...first.usage };
241
+ return reqs.map((req, i) => {
242
+ const out = decide(req, { ...prep, p: { ...prep.p, usage: { ...usage } } }, judged[i] ?? null);
243
+ const q = quotaUsage([out.rung]);
244
+ for (const [k, n] of Object.entries(q)) usage[k] = (usage[k] ?? 0) + n;
245
+ return out;
246
+ });
247
+ }
248
+
145
249
  async function verdict<T extends string>(
146
250
  runDir: string,
147
251
  set: Extract<SetName, "finding" | "same-defect">,
@@ -171,7 +275,16 @@ async function verdict<T extends string>(
171
275
  /** The routing port over the 1.0 catalog and Jev (spec §5); `o` lets tests inject Jev's transport. */
172
276
  export function routingService(o: RoutingOpts = {}): RoutingPort {
173
277
  return {
174
- route: (req) => route(req, o),
278
+ route: async (req) => {
279
+ const result = await route(req, o);
280
+ assertNativeHost(result.rung, req.host ?? "unknown");
281
+ return result;
282
+ },
283
+ routeMany: async (reqs) => {
284
+ const results = await routeMany(reqs, o);
285
+ results.forEach((r, i) => assertNativeHost(r.rung, reqs[i]?.host ?? "unknown"));
286
+ return results;
287
+ },
175
288
  finding: (runDir, laneText, finding, use) =>
176
289
  verdict(
177
290
  runDir,
@@ -1,15 +1,107 @@
1
- import { closeSync, fstatSync, openSync, readSync } from "node:fs";
1
+ import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs";
2
+ import { sessionKey, type HostContext, type HostSessionRef } from "../domain/host.ts";
3
+ import { deliveryState, readDelivery, type DeliveryState } from "../infra/delivery.ts";
2
4
  import type { ExitInfo, RunRecord } from "../domain/record.ts";
3
- import { dispatchPaths, readExit } from "../infra/dispatch-dir.ts";
5
+ import { awaitsCollect, dispatchPaths, readExit } from "../infra/dispatch-dir.ts";
4
6
  import { redact } from "../infra/log.ts";
5
- import { listDispatches } from "./dispatches.ts";
7
+ import { type Dispatch, listDispatches } from "./dispatches.ts";
6
8
  import { registerSavedSecrets } from "./jev-service.ts";
9
+ import { runOwner } from "./sessions.ts";
7
10
  import { readRecords, type Run } from "./run-store.ts";
8
11
 
9
12
  /** How many trailing lines of stderr, events and the supervisor log `runs show --debug` prints. */
10
13
  export const TAIL_LINES = 20;
11
14
 
12
- export interface DispatchDebug {
15
+ export function inspectionHost(host: HostContext): HostContext {
16
+ const target = host.session;
17
+ return {
18
+ host: host.host,
19
+ conflict: host.conflict,
20
+ session: target
21
+ ? {
22
+ host: target.host,
23
+ sessionId: target.sessionId,
24
+ hostSessionId: target.hostSessionId,
25
+ name: target.name,
26
+ }
27
+ : null,
28
+ };
29
+ }
30
+
31
+ export interface DeliveryInspection {
32
+ eventId: string;
33
+ delivery: DeliveryState;
34
+ receipt: { msgId: string; target: HostSessionRef; at: string } | null;
35
+ }
36
+
37
+ export function inspectDelivery(
38
+ run: Run,
39
+ d: Dispatch,
40
+ recorded: boolean,
41
+ eventId = JSON.stringify([
42
+ run.id,
43
+ d.admit.dispatchId,
44
+ !recorded && existsSync(dispatchPaths(d.dir).stall) ? "stalled" : "finished",
45
+ ]),
46
+ ): DeliveryInspection {
47
+ const owner = runOwner(run);
48
+ const target = owner
49
+ ? { host: owner.host, sessionId: owner.sessionId, hostSessionId: null, name: null }
50
+ : null;
51
+ const finished = eventId === JSON.stringify([run.id, d.admit.dispatchId, "finished"]);
52
+ const collected = finished && recorded && !awaitsCollect(d.dir);
53
+ try {
54
+ const receipt = target
55
+ ? readDelivery(d.dir).findLast(
56
+ (a) =>
57
+ a.status === "accepted" &&
58
+ a.eventIds.includes(eventId) &&
59
+ sessionKey(a.target) === sessionKey(target),
60
+ )
61
+ : undefined;
62
+ return {
63
+ eventId,
64
+ delivery: collected
65
+ ? "collected"
66
+ : (recorded || !finished) && target
67
+ ? deliveryState(d.dir, target, eventId)
68
+ : "pending",
69
+ receipt: receipt?.msgId
70
+ ? {
71
+ msgId: receipt.msgId,
72
+ at: receipt.at,
73
+ target: {
74
+ host: receipt.target.host,
75
+ sessionId: receipt.target.sessionId,
76
+ hostSessionId: receipt.target.hostSessionId,
77
+ name: receipt.target.name,
78
+ },
79
+ }
80
+ : null,
81
+ };
82
+ } catch {
83
+ return { eventId, delivery: collected ? "collected" : "ambiguous", receipt: null };
84
+ }
85
+ }
86
+
87
+ /**
88
+ * Stored stalled and finished events are separate; only finished records can be collected. A stall the role
89
+ * outlived that is still pending for the current owner can never go out (the notifier drops it and retry
90
+ * refuses it), so it is not shown; one with an outcome for this owner (accepted, ambiguous) still is.
91
+ */
92
+ export function inspectDeliveries(run: Run, d: Dispatch, recorded: boolean): DeliveryInspection[] {
93
+ const exited = existsSync(dispatchPaths(d.dir).exit);
94
+ return [...(recorded ? ["finished"] : []), ...(existsSync(dispatchPaths(d.dir).stall) ? ["stalled"] : [])]
95
+ .map((kind) => ({
96
+ kind,
97
+ ...inspectDelivery(run, d, recorded, JSON.stringify([run.id, d.admit.dispatchId, kind])),
98
+ }))
99
+ .filter((e) => !(e.kind === "stalled" && exited && e.delivery === "pending"))
100
+ .map(({ kind: _kind, ...e }) => e);
101
+ }
102
+
103
+ export interface DispatchDebug extends DeliveryInspection {
104
+ deliveries: DeliveryInspection[];
13
105
  name: string;
14
106
  dispatchId: string;
15
107
  rung: string;
@@ -65,6 +157,8 @@ export function runDebug(run: Run, name?: string): DispatchDebug[] {
65
157
  .map((d) => {
66
158
  const p = dispatchPaths(d.dir);
67
159
  return redact({
160
+ ...inspectDelivery(run, d, records.has(d.admit.dispatchId)),
161
+ deliveries: inspectDeliveries(run, d, records.has(d.admit.dispatchId)),
68
162
  name: d.admit.name,
69
163
  dispatchId: d.admit.dispatchId,
70
164
  rung: d.admit.rung,
@@ -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
+ }