catherd-cli 1.4.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 +93 -30
- 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 +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +110 -45
- package/plugin/skills/catherd-setup/SKILL.md +2 -2
- 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/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 +5 -0
- 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/lane.ts +99 -3
- package/src/domain/notice.ts +9 -2
- package/src/domain/profile-rules.ts +112 -16
- package/src/domain/profile.ts +24 -0
- package/src/domain/record.ts +16 -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 +14 -6
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +2 -1
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +104 -8
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +6 -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 +20 -4
- package/src/entry/mcp/server.ts +98 -7
- package/src/entry/mcp/setup-tools.ts +4 -3
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +23 -4
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +78 -8
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +4 -0
- package/src/entry/tui/state.ts +37 -10
- package/src/entry/tui/views/profiles.tsx +1 -1
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +6 -2
- 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/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -1
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- 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/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 +107 -16
- package/src/services/admission.ts +110 -23
- 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 +204 -16
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +68 -6
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +12 -2
- 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 +257 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +56 -3
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/ports.ts +31 -2
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +1 -0
- package/src/services/profile-store.ts +10 -3
- 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 +144 -36
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +151 -19
- package/src/services/run-store.ts +109 -15
- package/src/services/runs-page.ts +42 -7
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- 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 +49 -9
- package/src/services/treat-likes.ts +19 -3
- 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
package/src/services/peek.ts
CHANGED
|
@@ -49,6 +49,45 @@ export interface PeekRun extends Reentry {
|
|
|
49
49
|
native: { name: string; role: string; rung: string; status: string; at: string } | null;
|
|
50
50
|
/** the run's next step (state.md's last line) */
|
|
51
51
|
next: string;
|
|
52
|
+
/** plan 22, goal mode: whether anything in this run is the coordinator's to do now, and why (not) */
|
|
53
|
+
actionable: boolean;
|
|
54
|
+
reason: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Plan 22, goal mode: whether the run holds anything for the coordinator. An unread record is its to read; with
|
|
59
|
+
* roles live, a protocol step one of them is doing (lanes running, the reviewer or verifier step with that role
|
|
60
|
+
* live, the plan while an architect or researcher runs) or the owner's question is nobody's to act on, so the
|
|
61
|
+
* coordinator ends its turn with no tool call and the next catherd message wakes it.
|
|
62
|
+
*/
|
|
63
|
+
export function actionability(
|
|
64
|
+
live: { name: string; role: string }[],
|
|
65
|
+
unread: { name: string }[],
|
|
66
|
+
protocolNext: string,
|
|
67
|
+
): { actionable: boolean; reason: string } {
|
|
68
|
+
if (unread.length)
|
|
69
|
+
return {
|
|
70
|
+
actionable: true,
|
|
71
|
+
reason: `unread: ${unread.map((u) => `result(run, "${u.name}")`).join(", ")}`,
|
|
72
|
+
};
|
|
73
|
+
if (protocolNext.endsWith(" parked: wait for the owner"))
|
|
74
|
+
return {
|
|
75
|
+
actionable: false,
|
|
76
|
+
reason: `${protocolNext.replace(/: wait for the owner$/, "")}: the owner's answer comes first; end the turn with no tool call`,
|
|
77
|
+
};
|
|
78
|
+
const running = (role: string) => live.some((l) => l.role === role);
|
|
79
|
+
const covered =
|
|
80
|
+
/: lanes running \(/.test(protocolNext) ||
|
|
81
|
+
(protocolNext.endsWith(": reviewer") && running("reviewer")) ||
|
|
82
|
+
(protocolNext.endsWith(": verifier") && running("verifier")) ||
|
|
83
|
+
(protocolNext.startsWith("plan:") && (running("architect") || running("researcher"))) ||
|
|
84
|
+
(protocolNext.startsWith("finish:") && (running("verifier") || running("writer")));
|
|
85
|
+
if (live.length && covered)
|
|
86
|
+
return {
|
|
87
|
+
actionable: false,
|
|
88
|
+
reason: `only roles are live (${live.map((l) => l.name).join(", ")}): their results arrive as catherd messages; end the turn with no tool call`,
|
|
89
|
+
};
|
|
90
|
+
return { actionable: true, reason: protocolNext };
|
|
52
91
|
}
|
|
53
92
|
|
|
54
93
|
function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
@@ -57,23 +96,31 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
|
57
96
|
const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
|
|
58
97
|
const agents = readAgentRuns(run).filter((a) => name === undefined || a.name === name);
|
|
59
98
|
const native = agents.at(-1);
|
|
60
|
-
const
|
|
99
|
+
const notes = readNotes(run);
|
|
100
|
+
const re = reentry(run, now, notes);
|
|
101
|
+
const live = liveDispatches(run, now);
|
|
102
|
+
// the whole run decides it, whatever `name` narrows the view to
|
|
103
|
+
const act = actionability(
|
|
104
|
+
live.map((d) => ({ name: d.admit.name, role: d.admit.role })),
|
|
105
|
+
listDispatches(run).flatMap((d) =>
|
|
106
|
+
records.has(d.admit.dispatchId) && awaitsCollect(d.dir) ? [{ name: d.admit.name }] : [],
|
|
107
|
+
),
|
|
108
|
+
re.protocol.next,
|
|
109
|
+
);
|
|
61
110
|
return {
|
|
62
|
-
questions:
|
|
111
|
+
questions: re.questions,
|
|
63
112
|
run: run.id,
|
|
64
113
|
title: run.meta.title,
|
|
65
114
|
owner: runOwner(run)?.sessionId ?? null,
|
|
66
115
|
ownerHost: runOwner(run)?.host ?? null,
|
|
67
|
-
live:
|
|
68
|
-
.
|
|
69
|
-
.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
lastEvent: lastActivity(d),
|
|
76
|
-
})),
|
|
116
|
+
live: live.filter(mine).map((d) => ({
|
|
117
|
+
name: d.admit.name,
|
|
118
|
+
role: d.admit.role,
|
|
119
|
+
rung: d.admit.rung,
|
|
120
|
+
state: d.state,
|
|
121
|
+
secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
|
|
122
|
+
lastEvent: lastActivity(d),
|
|
123
|
+
})),
|
|
77
124
|
delivery: listDispatches(run)
|
|
78
125
|
.filter(mine)
|
|
79
126
|
.flatMap((d) =>
|
|
@@ -103,9 +150,10 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
|
103
150
|
native: native
|
|
104
151
|
? { name: native.name, role: native.role, rung: native.rung, status: native.status, at: native.at }
|
|
105
152
|
: null,
|
|
106
|
-
next:
|
|
107
|
-
protocol:
|
|
108
|
-
verifier:
|
|
153
|
+
next: notes.next,
|
|
154
|
+
protocol: re.protocol,
|
|
155
|
+
verifier: re.verifier,
|
|
156
|
+
...act,
|
|
109
157
|
};
|
|
110
158
|
}
|
|
111
159
|
|
|
@@ -117,7 +165,15 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
|
117
165
|
export async function peek(
|
|
118
166
|
deps: Deps,
|
|
119
167
|
i: { run?: string; name?: string },
|
|
120
|
-
): Promise<{
|
|
168
|
+
): Promise<{
|
|
169
|
+
/** plan 22, goal mode: false when no run holds anything for the coordinator; `reason` says why either way */
|
|
170
|
+
actionable: boolean;
|
|
171
|
+
reason: string;
|
|
172
|
+
runs: PeekRun[];
|
|
173
|
+
hints: string[];
|
|
174
|
+
host: HostContext;
|
|
175
|
+
queue: QueueCapability | null;
|
|
176
|
+
}> {
|
|
121
177
|
if (i.name !== undefined) assertId("role name", i.name);
|
|
122
178
|
let runs: Run[];
|
|
123
179
|
if (i.run) {
|
|
@@ -131,8 +187,14 @@ export async function peek(
|
|
|
131
187
|
runs = owned.length ? owned : all.slice(0, 1);
|
|
132
188
|
}
|
|
133
189
|
const hints = runs.length === 0 ? ["no runs yet: run_start(repo, title, a_lines) starts one"] : [];
|
|
190
|
+
const views = runs.map((r) => peekRun(deps, r, i.name));
|
|
191
|
+
const doing = views.filter((v) => v.actionable);
|
|
192
|
+
const shown = (vs: PeekRun[]) =>
|
|
193
|
+
vs.map((v) => (views.length > 1 ? `${v.title}: ${v.reason}` : v.reason)).join("; ");
|
|
134
194
|
return {
|
|
135
|
-
|
|
195
|
+
actionable: views.length === 0 || doing.length > 0,
|
|
196
|
+
reason: views.length === 0 ? "no runs yet" : shown(doing.length ? doing : views),
|
|
197
|
+
runs: views,
|
|
136
198
|
hints,
|
|
137
199
|
host: inspectionHost(deps.host),
|
|
138
200
|
queue: deps.host.host === "codex" && !deps.host.conflict ? knownQueueCapability(process.env) : null,
|
package/src/services/ports.ts
CHANGED
|
@@ -7,6 +7,7 @@ import type { Verdict } from "../domain/jev.ts";
|
|
|
7
7
|
import type { Difficulty, Kind } from "../domain/lane.ts";
|
|
8
8
|
import type { Access } from "../domain/record.ts";
|
|
9
9
|
import type { Role } from "../domain/roles.ts";
|
|
10
|
+
import type { RoleScope } from "../domain/role-scope.ts";
|
|
10
11
|
import type { Change, ProfileDoc, ProfilePatch } from "../domain/profile.ts";
|
|
11
12
|
import type { Issue } from "../domain/profile-rules.ts";
|
|
12
13
|
import type { RouteJev, RouteSource } from "../domain/route.ts";
|
|
@@ -18,7 +19,18 @@ export interface ProfileView {
|
|
|
18
19
|
objective: "cost" | "speed";
|
|
19
20
|
/** `defaultRung`, when set, is where a lane starts without a kind and difficulty (spec §5.4) */
|
|
20
21
|
roles: Partial<
|
|
21
|
-
Record<
|
|
22
|
+
Record<
|
|
23
|
+
Role,
|
|
24
|
+
{
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
access: Access;
|
|
27
|
+
rungs: string[];
|
|
28
|
+
defaultRung?: string;
|
|
29
|
+
network?: false;
|
|
30
|
+
/** plan 23: the role's own timeouts over the profile's */
|
|
31
|
+
timeouts?: { idleMin?: number; wallMin?: number };
|
|
32
|
+
}
|
|
33
|
+
>
|
|
22
34
|
>;
|
|
23
35
|
/** per billing key (spec §7.1); a missing key bills as DEFAULT_BILLING */
|
|
24
36
|
billing: Partial<Record<string, BillingMode>>;
|
|
@@ -82,6 +94,8 @@ export interface RouteRequest {
|
|
|
82
94
|
lane: string | null;
|
|
83
95
|
laneText: string | null;
|
|
84
96
|
spentFraction: number;
|
|
97
|
+
/** spec 1.5 plan 24: the run's dispatches so far per quota, for a tie between rungs of equal scores */
|
|
98
|
+
usage?: Record<string, number>;
|
|
85
99
|
}
|
|
86
100
|
|
|
87
101
|
export interface RouteAnswer {
|
|
@@ -93,6 +107,14 @@ export interface RouteAnswer {
|
|
|
93
107
|
/** the Jev question set asked, and its summed probabilities, for outcomes.jsonl (spec §5.6) */
|
|
94
108
|
questionSet: string | null;
|
|
95
109
|
jev: RouteJev | null;
|
|
110
|
+
/** spec 1.5 plan 24: where Jev disagreed with a declared lane header (`Jev said logic/hard`), else null */
|
|
111
|
+
jevSaid: string | null;
|
|
112
|
+
/** spec 1.5 plan 24: no rung clears the lane's bar, and the closest one */
|
|
113
|
+
noClear?: string;
|
|
114
|
+
/** spec 1.5 plan 24: rungs of equal scores on other quotas competed for the start, and what decided */
|
|
115
|
+
tie?: string;
|
|
116
|
+
/** spec 1.5 plan 24: the decision in one line, for the coordinator */
|
|
117
|
+
why: string;
|
|
96
118
|
/** spec 1.2 §5.3, §8: the chosen rung's thresholds, values, sources, facts and run evidence */
|
|
97
119
|
provenance?: Provenance;
|
|
98
120
|
}
|
|
@@ -111,6 +133,8 @@ export interface CatalogFilter {
|
|
|
111
133
|
|
|
112
134
|
export interface RoutingPort {
|
|
113
135
|
route(req: RouteRequest): Promise<RouteAnswer>;
|
|
136
|
+
/** spec 1.5 plan 24: the lanes of one role in one call; Jev is asked about all of them at once */
|
|
137
|
+
routeMany(reqs: RouteRequest[]): Promise<RouteAnswer[]>;
|
|
114
138
|
/** `use` is the repo profile's `jev.use`: "off" answers with the rule's default and never asks Jev */
|
|
115
139
|
finding(
|
|
116
140
|
runDir: string,
|
|
@@ -143,5 +167,10 @@ export interface Deps {
|
|
|
143
167
|
session: SessionEnv | null;
|
|
144
168
|
now: () => number;
|
|
145
169
|
/** spec 1.2 §3.2 `catalog_sync`; default: the real sync (tests inject one that never reaches the network) */
|
|
146
|
-
sync?: (o: { force: boolean }) => Promise<SyncReport>;
|
|
170
|
+
sync?: (o: { force: boolean; wait?: boolean }) => Promise<SyncReport>;
|
|
171
|
+
/**
|
|
172
|
+
* Spec 1.5 plan 21: the role this process works for (CATHERD_ROLE, or a role's scratch TMPDIR); null or
|
|
173
|
+
* absent for the orchestrator's own server. A role never claims a run and never reaches a coordinator tool.
|
|
174
|
+
*/
|
|
175
|
+
role?: RoleScope | null;
|
|
147
176
|
}
|
|
@@ -1,16 +1,19 @@
|
|
|
1
1
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
2
|
import { join } from "node:path";
|
|
3
3
|
import { CatherdError, errorMessage } from "../domain/errors.ts";
|
|
4
|
-
import { assertLaneHeader, LANE_HEADER_FIX, parseLaneHeader } from "../domain/lane.ts";
|
|
4
|
+
import { assertLaneHeader, LANE_HEADER_FIX, onlyAllowedHits, parseLaneHeader } from "../domain/lane.ts";
|
|
5
5
|
import { checkEnv } from "../infra/env.ts";
|
|
6
|
-
import { heavySlots,
|
|
6
|
+
import { heavySlots, withHeavySlotWithin } from "../infra/heavy-lock.ts";
|
|
7
|
+
import { loginEnv } from "../infra/login-env.ts";
|
|
7
8
|
import { killGroup } from "../infra/proc.ts";
|
|
9
|
+
import { readGateEnv, resolveGateEnv } from "./gate-env.ts";
|
|
10
|
+
import { landedMilestones } from "./milestones.ts";
|
|
8
11
|
import type { Deps } from "./ports.ts";
|
|
9
12
|
import { TAIL_LINES } from "./run-debug.ts";
|
|
10
13
|
import { findRun, type Run, runPaths } from "./run-store.ts";
|
|
11
14
|
|
|
12
|
-
/** Spec §4.7. Only `cannot-start` blocks the run. */
|
|
13
|
-
export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start";
|
|
15
|
+
/** Spec §4.7. Only `cannot-start` blocks the run; `lock-busy` (plan 23) means: run preflight again. */
|
|
16
|
+
export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start" | "lock-busy";
|
|
14
17
|
|
|
15
18
|
interface PreflightResult {
|
|
16
19
|
lane: string;
|
|
@@ -28,6 +31,8 @@ export type PreflightReport =
|
|
|
28
31
|
commands: { lane: string; check: string | null }[];
|
|
29
32
|
results: PreflightResult[];
|
|
30
33
|
blocked: boolean;
|
|
34
|
+
/** plan 23: lanes whose fast check runs no linter while the repo has one */
|
|
35
|
+
warnings: string[];
|
|
31
36
|
};
|
|
32
37
|
|
|
33
38
|
const CHECK_TIMEOUT_MS = 120_000;
|
|
@@ -47,6 +52,8 @@ interface LaneCheck {
|
|
|
47
52
|
lane: string;
|
|
48
53
|
check: string | null;
|
|
49
54
|
owns: string[];
|
|
55
|
+
/** spec 1.5: the Allow: exceptions of the lane's absence check */
|
|
56
|
+
allow: string[];
|
|
50
57
|
problem: string | null;
|
|
51
58
|
/** spec 1.1 §6: why its Kind: or Difficulty: line is refused */
|
|
52
59
|
invalid: string | null;
|
|
@@ -73,15 +80,19 @@ function laneChecks(run: Run): LaneCheck[] {
|
|
|
73
80
|
lane,
|
|
74
81
|
check: h.fastCheck,
|
|
75
82
|
owns: h.owns,
|
|
83
|
+
allow: h.allow,
|
|
76
84
|
problem: h.fastCheck ? null : `lanes/${f} has no Fast check: line`,
|
|
77
85
|
invalid,
|
|
78
86
|
};
|
|
79
87
|
} catch (e) {
|
|
80
|
-
return { lane, check: null, owns: [], problem: (e as Error).message, invalid };
|
|
88
|
+
return { lane, check: null, owns: [], allow: [], problem: (e as Error).message, invalid };
|
|
81
89
|
}
|
|
82
90
|
});
|
|
83
91
|
}
|
|
84
92
|
|
|
93
|
+
/** How many output lines a check's Allow: exceptions are matched against; past it, the check fails as before. */
|
|
94
|
+
const ALLOW_LINES = 1_000;
|
|
95
|
+
|
|
85
96
|
/** How long the pipes may stay open after the check's own process exits. */
|
|
86
97
|
export const DRAIN_MS = 500;
|
|
87
98
|
|
|
@@ -116,10 +127,11 @@ export async function runCheck(
|
|
|
116
127
|
repo: string,
|
|
117
128
|
check: string,
|
|
118
129
|
timeoutMs: number,
|
|
119
|
-
|
|
130
|
+
env: Record<string, string> = checkEnv(process.env, repo),
|
|
131
|
+
): Promise<{ code: number | null; timedOut: boolean; tail: string[]; lines: string[] }> {
|
|
120
132
|
const p = Bun.spawn(["sh", "-c", check], {
|
|
121
133
|
cwd: repo,
|
|
122
|
-
env
|
|
134
|
+
env,
|
|
123
135
|
stdin: "ignore",
|
|
124
136
|
stdout: "pipe",
|
|
125
137
|
stderr: "pipe",
|
|
@@ -141,11 +153,10 @@ export async function runCheck(
|
|
|
141
153
|
Bun.sleep(DRAIN_MS).then(() => false),
|
|
142
154
|
]);
|
|
143
155
|
if (!drained) killGroup(p.pid, "SIGKILL"); // leftovers still in the check's group
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
return { code: timedOut ? null : code, timedOut, tail };
|
|
156
|
+
// every line, up to a bound, for a lane's Allow: exceptions; the tail is what the report shows
|
|
157
|
+
const all = `${out.text()}${err.text()}`.split("\n").filter((l) => l.trim());
|
|
158
|
+
const lines = all.length <= ALLOW_LINES ? all : [];
|
|
159
|
+
return { code: timedOut ? null : code, timedOut, tail: all.slice(-TAIL_LINES), lines };
|
|
149
160
|
} finally {
|
|
150
161
|
clearTimeout(timer);
|
|
151
162
|
out.stop();
|
|
@@ -153,10 +164,100 @@ export async function runCheck(
|
|
|
153
164
|
}
|
|
154
165
|
}
|
|
155
166
|
|
|
156
|
-
|
|
167
|
+
/**
|
|
168
|
+
* Plan 23: a check that failed on the machine, not on the code: no Docker (or none at DOCKER_HOST), no DNS, a
|
|
169
|
+
* permission the sandbox or the OS denied. Such a check cannot start; it never fails as expected.
|
|
170
|
+
*/
|
|
171
|
+
const ENVIRONMENT = [
|
|
172
|
+
/command not found/i,
|
|
173
|
+
/Cannot connect to the Docker daemon/i,
|
|
174
|
+
/rootless Docker not found/i,
|
|
175
|
+
/Could not find a valid Docker environment/i,
|
|
176
|
+
/docker\.sock: connect: no such file or directory/i,
|
|
177
|
+
/Temporary failure in name resolution|Could not resolve host|no such host|Name or service not known|nodename nor servname|getaddrinfo (ENOTFOUND|EAI_AGAIN)/i,
|
|
178
|
+
/permission denied|operation not permitted|\bEACCES\b|\bEPERM\b/i,
|
|
179
|
+
];
|
|
180
|
+
|
|
181
|
+
/** pnpm exits 0 on a `--filter` that matches no package: the lane creates it, so there is nothing to run yet. */
|
|
182
|
+
const EMPTY_FILTER = /No projects matched the filters/i;
|
|
183
|
+
|
|
184
|
+
/** The first tail line an ENVIRONMENT pattern matches, or null. */
|
|
185
|
+
export const environmentLine = (tail: string[]): string | null =>
|
|
186
|
+
tail.find((l) => ENVIRONMENT.some((re) => re.test(l))) ?? null;
|
|
187
|
+
|
|
188
|
+
/** One `pnpm … --filter …` command, nothing chained, piped or backgrounded beside it. */
|
|
189
|
+
const SINGLE_PNPM_FILTER = /^\s*pnpm\s(?:[^;&|\n`$()<>]*\s)?(?:--filter|-F)(?:[\s=])[^;&|\n`$()<>]*$/;
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* An unmatched pnpm filter is "skipped" only when it is provably the check's sole failure: the whole check
|
|
193
|
+
* exited 0, or the check is a single pnpm --filter command. In a compound check (`pnpm --filter new test;
|
|
194
|
+
* docker compose up`) a later command's failure decides.
|
|
195
|
+
*/
|
|
196
|
+
export function classify(
|
|
197
|
+
r: { code: number | null; timedOut: boolean; tail: string[] },
|
|
198
|
+
command?: string,
|
|
199
|
+
): PreflightOutcome {
|
|
157
200
|
if (r.timedOut || r.code === 126 || r.code === 127) return "cannot-start";
|
|
201
|
+
const soleFailure = r.code === 0 || (command !== undefined && SINGLE_PNPM_FILTER.test(command));
|
|
202
|
+
if (soleFailure && r.tail.some((l) => EMPTY_FILTER.test(l))) return "skipped";
|
|
158
203
|
if (r.code === 0) return "pass";
|
|
159
|
-
return r.tail
|
|
204
|
+
return environmentLine(r.tail) !== null ? "cannot-start" : "fails-as-expected";
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** Why a classified result says what it says, for its note. */
|
|
208
|
+
function noteOf(
|
|
209
|
+
r: { code: number | null; timedOut: boolean; tail: string[] },
|
|
210
|
+
outcome: PreflightOutcome,
|
|
211
|
+
timeoutMs: number,
|
|
212
|
+
): string | null {
|
|
213
|
+
if (r.timedOut) return `timed out after ${timeoutMs / 1000} s`;
|
|
214
|
+
if (outcome === "skipped") return "the pnpm filter matches no package yet; the lane creates it";
|
|
215
|
+
const env = outcome === "cannot-start" ? environmentLine(r.tail) : null;
|
|
216
|
+
return env ? `environment: ${env.trim()}` : null;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/** How long one check waits for a heavy slot before it reports lock-busy; tests shorten it. */
|
|
220
|
+
export const preflightLimits = { lockWaitMs: 60_000 };
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Plan 23: a check runs in the user's login environment (DOCKER_HOST and the rest a login shell sets), through
|
|
224
|
+
* the allowlist, plus the repo's gate environment. A login PATH comes first, with the server's own after it.
|
|
225
|
+
*/
|
|
226
|
+
export async function checkEnvFor(repo: string): Promise<Record<string, string>> {
|
|
227
|
+
const login = loginEnv();
|
|
228
|
+
const base: Record<string, string | undefined> = { ...process.env, ...login };
|
|
229
|
+
const path = [
|
|
230
|
+
...new Set([...(login.PATH ?? "").split(":"), ...(process.env.PATH ?? "").split(":")].filter(Boolean)),
|
|
231
|
+
];
|
|
232
|
+
if (path.length) base.PATH = path.join(":");
|
|
233
|
+
return { ...checkEnv(base, repo), ...resolveGateEnv(await readGateEnv(repo), base).env };
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/** What a fast check names when it lints: a lint script, or a linter by name. */
|
|
237
|
+
const LINT_STEP = /\b(lint|eslint|biome|oxlint|golangci-lint|ruff|clippy|vet|check)\b/i;
|
|
238
|
+
|
|
239
|
+
/** Root files that say the repo has a linter, by the linter they configure. */
|
|
240
|
+
const LINT_CONFIGS: [RegExp, string][] = [
|
|
241
|
+
[/^\.golangci\.(ya?ml|toml|json)$/, "golangci-lint"],
|
|
242
|
+
[/^(biome\.jsonc?)$/, "biome"],
|
|
243
|
+
[/^(eslint\.config\.[cm]?[jt]s|\.eslintrc(\.[a-z]+)?)$/, "eslint"],
|
|
244
|
+
[/^\.?oxlintrc\.json$/, "oxlint"],
|
|
245
|
+
[/^\.?ruff\.toml$/, "ruff"],
|
|
246
|
+
];
|
|
247
|
+
|
|
248
|
+
/** The linter the repo has, by name, or null: a root lint config, or a `lint` script in package.json. */
|
|
249
|
+
export function repoLinter(repo: string): string | null {
|
|
250
|
+
for (const name of existsSync(repo) ? readdirSync(repo) : [])
|
|
251
|
+
for (const [re, linter] of LINT_CONFIGS) if (re.test(name)) return linter;
|
|
252
|
+
try {
|
|
253
|
+
const pkg = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")) as {
|
|
254
|
+
scripts?: Record<string, unknown>;
|
|
255
|
+
};
|
|
256
|
+
if (typeof pkg.scripts?.lint === "string") return "the lint script";
|
|
257
|
+
} catch {
|
|
258
|
+
// no package.json, or one that does not parse: no lint script
|
|
259
|
+
}
|
|
260
|
+
return null;
|
|
160
261
|
}
|
|
161
262
|
|
|
162
263
|
/**
|
|
@@ -165,11 +266,16 @@ export function classify(r: { code: number | null; timedOut: boolean; tail: stri
|
|
|
165
266
|
*/
|
|
166
267
|
export async function preflight(
|
|
167
268
|
deps: Deps,
|
|
168
|
-
i: { run: string; confirmed?: boolean; timeoutMs?: number },
|
|
269
|
+
i: { run: string; confirmed?: boolean; timeoutMs?: number; milestone?: string },
|
|
169
270
|
): Promise<PreflightReport> {
|
|
170
271
|
const run = findRun(i.run);
|
|
171
272
|
const profile = deps.profiles.forRepo(run.meta.repo);
|
|
172
|
-
|
|
273
|
+
// plan 23: one milestone's lanes, or by default every lane whose milestone has not landed
|
|
274
|
+
const landed = new Set(landedMilestones(run));
|
|
275
|
+
const milestoneOf = (lane: string) => lane.split(".")[0] as string;
|
|
276
|
+
const lanes = laneChecks(run).filter((l) =>
|
|
277
|
+
i.milestone ? milestoneOf(l.lane) === i.milestone : !landed.has(milestoneOf(l.lane)),
|
|
278
|
+
);
|
|
173
279
|
// spec 1.1 §6: refuse before running anything, naming every lane whose header the catalog cannot route
|
|
174
280
|
const invalid = lanes.flatMap((l) => (l.invalid ? [l.invalid] : []));
|
|
175
281
|
if (invalid.length) throw new CatherdError("E_LANE_INVALID", invalid.join("; "), { fix: LANE_HEADER_FIX });
|
|
@@ -177,6 +283,8 @@ export async function preflight(
|
|
|
177
283
|
if (profile.preflight.confirm && !i.confirmed) return { needsConfirmation: true, commands };
|
|
178
284
|
const timeoutMs = i.timeoutMs ?? CHECK_TIMEOUT_MS;
|
|
179
285
|
const results: PreflightResult[] = [];
|
|
286
|
+
// the login env is captured once per server; the gate env is read once per preflight
|
|
287
|
+
let env: Record<string, string> | undefined;
|
|
180
288
|
for (const l of lanes) {
|
|
181
289
|
const check = l.check;
|
|
182
290
|
if (!check) {
|
|
@@ -202,20 +310,46 @@ export async function preflight(
|
|
|
202
310
|
results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note });
|
|
203
311
|
continue;
|
|
204
312
|
}
|
|
205
|
-
|
|
313
|
+
env ??= await checkEnvFor(run.meta.repo);
|
|
314
|
+
const checkEnvironment = env;
|
|
315
|
+
// plan 23: each check waits for its slot within its own budget, and says lock-busy past it
|
|
316
|
+
const slot = await withHeavySlotWithin(heavySlots(profile.heavy), preflightLimits.lockWaitMs, () =>
|
|
317
|
+
runCheck(run.meta.repo, check, timeoutMs, checkEnvironment),
|
|
318
|
+
);
|
|
319
|
+
if (slot.busy) {
|
|
320
|
+
const note = `every heavy slot stayed busy for ${preflightLimits.lockWaitMs / 1000} s (other lanes' checks); run preflight again when they finish`;
|
|
321
|
+
results.push({ lane: l.lane, check, outcome: "lock-busy", exitCode: null, tail: [], note });
|
|
322
|
+
continue;
|
|
323
|
+
}
|
|
324
|
+
const r = slot.value;
|
|
325
|
+
// spec 1.5: an absence check whose every hit is one of the lane's Allow: exceptions has passed
|
|
326
|
+
const classified = classify(r, check);
|
|
327
|
+
const allowed = classified === "fails-as-expected" && onlyAllowedHits(r.lines, l.allow);
|
|
328
|
+
const outcome = allowed ? "pass" : classified;
|
|
206
329
|
results.push({
|
|
207
330
|
lane: l.lane,
|
|
208
331
|
check,
|
|
209
|
-
outcome
|
|
332
|
+
outcome,
|
|
210
333
|
exitCode: r.code,
|
|
211
334
|
tail: r.tail,
|
|
212
|
-
note:
|
|
335
|
+
note: allowed ? `every hit is an Allow: exception (${r.lines.length})` : noteOf(r, outcome, timeoutMs),
|
|
213
336
|
});
|
|
214
337
|
}
|
|
338
|
+
// plan 23: lint reaches the worker only through its fast check
|
|
339
|
+
const linter = repoLinter(run.meta.repo);
|
|
340
|
+
const warnings = linter
|
|
341
|
+
? lanes
|
|
342
|
+
.filter((l) => l.check && !LINT_STEP.test(l.check))
|
|
343
|
+
.map(
|
|
344
|
+
(l) =>
|
|
345
|
+
`${l.lane}: its fast check runs no linter, and the repo has one (${linter}): add the linter of every package the lane touches to its Fast check: line`,
|
|
346
|
+
)
|
|
347
|
+
: [];
|
|
215
348
|
return {
|
|
216
349
|
needsConfirmation: false,
|
|
217
350
|
commands,
|
|
218
351
|
results,
|
|
219
352
|
blocked: results.some((r) => r.outcome === "cannot-start"),
|
|
353
|
+
warnings,
|
|
220
354
|
};
|
|
221
355
|
}
|
|
@@ -15,7 +15,12 @@ import {
|
|
|
15
15
|
ProfileDocSchema,
|
|
16
16
|
resolveProfile,
|
|
17
17
|
} from "../domain/profile.ts";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
type Issue,
|
|
20
|
+
type ValidateOptions,
|
|
21
|
+
type Validation,
|
|
22
|
+
validateProfile,
|
|
23
|
+
} from "../domain/profile-rules.ts";
|
|
19
24
|
import type { Catalog } from "../domain/catalog.ts";
|
|
20
25
|
import type { Access } from "../domain/record.ts";
|
|
21
26
|
import { ROLES, type Role } from "../domain/roles.ts";
|
|
@@ -220,8 +225,9 @@ export function validateHere(
|
|
|
220
225
|
c: Catalog,
|
|
221
226
|
doc?: ProfileDoc,
|
|
222
227
|
host: OrchestrationHost = "unknown",
|
|
228
|
+
o: ValidateOptions = {},
|
|
223
229
|
): Validation {
|
|
224
|
-
const v = validateProfile(p, c, runnableBackends(), doc, host);
|
|
230
|
+
const v = validateProfile(p, c, runnableBackends(), doc, host, o);
|
|
225
231
|
return {
|
|
226
232
|
errors: [...v.errors, ...isolationKeyErrors(p), ...isolatedOnlyErrors(p)],
|
|
227
233
|
warnings: [...v.warnings, ...budgetUsdWarnings(p)],
|
|
@@ -233,12 +239,13 @@ export function validateNamed(
|
|
|
233
239
|
name: string | undefined,
|
|
234
240
|
repo: string | null,
|
|
235
241
|
host: OrchestrationHost,
|
|
242
|
+
o: ValidateOptions = {},
|
|
236
243
|
): Validation {
|
|
237
244
|
const n = name ?? activeName(repo);
|
|
238
245
|
const doc = readProfileDoc(n);
|
|
239
246
|
const catalog = loadCatalog({ timings: false, ...(repo === null ? {} : { repo }) });
|
|
240
247
|
try {
|
|
241
|
-
return validateHere(resolveProfile(doc, n, host), catalog, doc, host);
|
|
248
|
+
return validateHere(resolveProfile(doc, n, host), catalog, doc, host, o);
|
|
242
249
|
} catch (e) {
|
|
243
250
|
if (!(e instanceof CatherdError)) throw e;
|
|
244
251
|
return { errors: [{ path: "roles", message: e.message, fix: e.fix }], warnings: [] };
|