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.
- package/README.md +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- 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(
|
|
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
|
-
|
|
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
|
|
83
|
-
*
|
|
84
|
-
*
|
|
85
|
-
* usable rung never asks
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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
|
-
|
|
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 (
|
|
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,
|
|
114
|
-
"
|
|
115
|
-
|
|
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
|
-
|
|
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.
|
|
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) =>
|
|
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
|
|
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
|
+
}
|