catherd-cli 1.4.0 → 2.0.0-beta.20261022113309
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -169
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -239
- package/package.json +6 -8
- 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/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +97 -356
- package/plugin/skills/catherd-setup/SKILL.md +21 -20
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +19 -4
- 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/home.ts +1 -1
- package/src/adapters/cursor/index.ts +10 -4
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +27 -27
- package/src/domain/agents.ts +3 -1
- package/src/domain/bars.ts +33 -107
- package/src/domain/blocked.ts +72 -0
- package/src/domain/branch.ts +51 -0
- package/src/domain/brief.ts +106 -0
- package/src/domain/catalog.ts +69 -313
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +19 -29
- package/src/domain/errors.ts +6 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -75
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +53 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/ids.ts +13 -2
- package/src/domain/jev.ts +47 -2
- package/src/domain/judge.ts +124 -0
- package/src/domain/lane.ts +118 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +32 -8
- package/src/domain/notify.ts +32 -0
- package/src/domain/plan-doc.ts +198 -0
- package/src/domain/plan-lint.ts +141 -0
- package/src/domain/profile-rules.ts +175 -138
- package/src/domain/profile.ts +140 -63
- package/src/domain/record.ts +68 -3
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +182 -56
- package/src/domain/role-scope.ts +76 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +90 -0
- package/src/domain/rules.ts +33 -0
- package/src/domain/run-events.ts +298 -0
- package/src/domain/run-machine.ts +668 -0
- package/src/domain/run-state.ts +1310 -0
- package/src/domain/scorecard.ts +197 -0
- package/src/domain/select.ts +229 -72
- package/src/domain/slopalytics.ts +72 -0
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +24 -165
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +18 -2
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +20 -69
- package/src/entry/knowledge-command.ts +121 -5
- package/src/entry/lock-command.ts +103 -8
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +134 -0
- package/src/entry/mcp/server.ts +85 -21
- package/src/entry/mcp/session-tools.ts +149 -0
- package/src/entry/panel/openapi.ts +189 -0
- package/src/entry/panel/server.ts +410 -0
- package/src/entry/panel-command.ts +68 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +33 -18
- package/src/entry/role-cli-command.ts +159 -0
- package/src/entry/runs-command.ts +81 -27
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/entry/supervise-command.ts +30 -3
- 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 -3
- package/src/infra/git.ts +70 -0
- package/src/infra/heavy-lock.ts +133 -6
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/launch.ts +63 -7
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/memory.ts +45 -0
- package/src/infra/ntfy.ts +40 -0
- package/src/infra/open-url.ts +42 -0
- package/src/infra/paths.ts +37 -2
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +133 -31
- package/src/services/backends.ts +4 -6
- package/src/services/budget.ts +26 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -254
- package/src/services/credentials.ts +15 -26
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +209 -17
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +72 -15
- package/src/services/end-push.ts +157 -0
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +24 -2
- package/src/services/gate-env.ts +217 -0
- package/src/services/gate-service.ts +231 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +291 -55
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +167 -29
- package/src/services/notifier.ts +124 -4
- package/src/services/notify-service.ts +26 -0
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +60 -5
- package/src/services/preflight.ts +232 -24
- package/src/services/profile-service.ts +31 -3
- package/src/services/profile-store.ts +18 -6
- package/src/services/protocol.ts +123 -25
- package/src/services/provenance.ts +34 -89
- package/src/services/questions.ts +24 -0
- package/src/services/reconcile.ts +33 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +161 -42
- package/src/services/run-briefs.ts +477 -0
- package/src/services/run-client.ts +217 -0
- package/src/services/run-effects.ts +837 -0
- package/src/services/run-events-store.ts +174 -0
- package/src/services/run-evidence.ts +1 -1
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +204 -23
- package/src/services/run-store.ts +159 -18
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +76 -12
- package/src/services/tree-path.ts +27 -0
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +259 -0
- package/src/services/workspace-service.ts +259 -0
- package/src/services/workspace-store.ts +95 -0
- package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js +1 -0
- package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
- package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js +1 -0
- package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-B6GiwDkh.js +11 -0
- package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
- package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
- package/web/dist/assets/index-C5IbylvM.css +2 -0
- package/web/dist/assets/index-C5IbylvM.css.br +0 -0
- package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
- package/web/dist/assets/label-DFSYBc62.js +1 -0
- package/web/dist/assets/label-DFSYBc62.js.br +0 -0
- package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
- package/web/dist/assets/link-D8EcKZUt.js +1 -0
- package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
- package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
- package/web/dist/assets/login-DaN5HPPW.js +1 -0
- package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
- package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
- package/web/dist/assets/query-Cu--ccT3.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
- package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
- package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
- package/web/dist/assets/table-BOxXhqS4.js +1 -0
- package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
- package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
- package/web/dist/assets/time--9kSQbbp.js +1 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
- package/web/dist/index.html +15 -0
- package/catalog/ATTRIBUTION.md +0 -36
- package/catalog/scores.json +0 -3202
- package/catalog/sources.json +0 -97
- package/src/adapters/antigravity/events.ts +0 -131
- package/src/adapters/antigravity/home.ts +0 -46
- package/src/adapters/antigravity/index.ts +0 -320
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -132
- package/src/adapters/grok/index.ts +0 -339
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -125
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -84
- package/src/entry/mcp/protocol-tools.ts +0 -57
- package/src/entry/mcp/run-tools.ts +0 -148
- package/src/entry/mcp/setup-tools.ts +0 -108
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -381
- package/src/entry/tui/fixtures.ts +0 -637
- package/src/entry/tui/profile-edits.ts +0 -146
- package/src/entry/tui/profile-tree.ts +0 -429
- package/src/entry/tui/providers/app.tsx +0 -162
- package/src/entry/tui/providers/boundary.tsx +0 -57
- package/src/entry/tui/providers/data.tsx +0 -160
- package/src/entry/tui/providers/keymap.tsx +0 -166
- package/src/entry/tui/providers/root.tsx +0 -45
- package/src/entry/tui/providers/theme.tsx +0 -17
- package/src/entry/tui/providers/toast.tsx +0 -77
- package/src/entry/tui/run.tsx +0 -114
- package/src/entry/tui/state.ts +0 -373
- package/src/entry/tui/stories.ts +0 -114
- package/src/entry/tui/text.ts +0 -130
- package/src/entry/tui/theme.ts +0 -140
- package/src/entry/tui/views/app.tsx +0 -208
- package/src/entry/tui/views/dialogs.tsx +0 -41
- package/src/entry/tui/views/profile-actions.ts +0 -331
- package/src/entry/tui/views/profiles.tsx +0 -520
- package/src/entry/tui/views/runs.tsx +0 -573
- package/src/entry/tui/views/save-dialog.tsx +0 -340
- package/src/entry/tui/views/status.tsx +0 -209
- package/src/entry/tui/widgets/chrome.tsx +0 -114
- package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
- package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
- package/src/entry/tui/widgets/dialog-select.tsx +0 -212
- package/src/entry/tui/widgets/dialog.tsx +0 -68
- package/src/entry/tui/widgets/line.tsx +0 -92
- package/src/entry/tui/widgets/list.tsx +0 -197
- package/src/entry/tui/widgets/spinner.tsx +0 -49
- package/src/entry/tui/widgets/toast.tsx +0 -62
- package/src/infra/sources/arena.ts +0 -87
- package/src/infra/sources/artificial-analysis.ts +0 -112
- package/src/infra/sources/cache.ts +0 -124
- package/src/infra/sources/csv.ts +0 -38
- package/src/infra/sources/epoch.ts +0 -82
- package/src/infra/sources/http.ts +0 -75
- package/src/infra/sources/litellm.ts +0 -58
- package/src/infra/sources/models-dev.ts +0 -59
- package/src/infra/sources/openrouter-endpoints.ts +0 -52
- package/src/infra/sources/openrouter-models.ts +0 -60
- package/src/infra/sources/rows.ts +0 -38
- package/src/infra/sources/vectara.ts +0 -69
- package/src/infra/sources/zip.ts +0 -48
- package/src/services/catalog-refresh.ts +0 -201
- package/src/services/doctor-sources.ts +0 -76
- package/src/services/runs-page.ts +0 -255
- package/src/services/source-derive.ts +0 -374
- package/src/services/source-sync.ts +0 -305
- package/src/services/standins.ts +0 -204
- package/src/services/treat-likes.ts +0 -97
package/src/services/ports.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
|
+
import type { BlockedReason } from "../domain/blocked.ts";
|
|
1
2
|
import type { HostContext } from "../domain/host.ts";
|
|
2
3
|
import type { Provenance } from "./provenance.ts";
|
|
4
|
+
import type { Bars } from "../domain/bars.ts";
|
|
3
5
|
import type { Budget } from "../domain/budget.ts";
|
|
4
6
|
import type { SessionEnv } from "../infra/claude-session.ts";
|
|
5
7
|
import type { BillingMode } from "../domain/cost.ts";
|
|
@@ -7,10 +9,12 @@ import type { Verdict } from "../domain/jev.ts";
|
|
|
7
9
|
import type { Difficulty, Kind } from "../domain/lane.ts";
|
|
8
10
|
import type { Access } from "../domain/record.ts";
|
|
9
11
|
import type { Role } from "../domain/roles.ts";
|
|
12
|
+
import type { RoleScope } from "../domain/role-scope.ts";
|
|
10
13
|
import type { Change, ProfileDoc, ProfilePatch } from "../domain/profile.ts";
|
|
11
14
|
import type { Issue } from "../domain/profile-rules.ts";
|
|
12
15
|
import type { RouteJev, RouteSource } from "../domain/route.ts";
|
|
13
|
-
import type {
|
|
16
|
+
import type { IndexFile } from "../domain/slopalytics.ts";
|
|
17
|
+
import type { IndexStatus } from "./catalog-service.ts";
|
|
14
18
|
|
|
15
19
|
/** What the run lifecycle reads from a profile. Rungs are `backend:model#effort`; a role's in ladder order. */
|
|
16
20
|
export interface ProfileView {
|
|
@@ -18,7 +22,20 @@ export interface ProfileView {
|
|
|
18
22
|
objective: "cost" | "speed";
|
|
19
23
|
/** `defaultRung`, when set, is where a lane starts without a kind and difficulty (spec §5.4) */
|
|
20
24
|
roles: Partial<
|
|
21
|
-
Record<
|
|
25
|
+
Record<
|
|
26
|
+
Role,
|
|
27
|
+
{
|
|
28
|
+
enabled: boolean;
|
|
29
|
+
access: Access;
|
|
30
|
+
rungs: string[];
|
|
31
|
+
defaultRung?: string;
|
|
32
|
+
network?: false;
|
|
33
|
+
/** plan 23: the role's own timeouts over the profile's */
|
|
34
|
+
timeouts?: { idleMin?: number; wallMin?: number };
|
|
35
|
+
/** plan 33: the role's own bar percentiles over the profile's */
|
|
36
|
+
bars?: Bars;
|
|
37
|
+
}
|
|
38
|
+
>
|
|
22
39
|
>;
|
|
23
40
|
/** per billing key (spec §7.1); a missing key bills as DEFAULT_BILLING */
|
|
24
41
|
billing: Partial<Record<string, BillingMode>>;
|
|
@@ -27,11 +44,17 @@ export interface ProfileView {
|
|
|
27
44
|
isolated: Partial<Record<string, boolean>>;
|
|
28
45
|
/** rung → its stand-in on a usage limit (spec §4.5) */
|
|
29
46
|
failover: Record<string, string>;
|
|
47
|
+
/** `<backend>:<model>` → the highest effort routing starts it at (plan 33) */
|
|
48
|
+
maxEffort?: Record<string, string>;
|
|
49
|
+
/** plan 33: bar percentiles per difficulty (0 to 100) over the shipped ones */
|
|
50
|
+
bars?: Bars;
|
|
30
51
|
budget: Budget;
|
|
31
52
|
timeouts: { idleMin: number; wallMin: number };
|
|
32
53
|
preflight: { confirm: boolean };
|
|
33
54
|
heavy: number | "cpus/2";
|
|
34
55
|
notify: string[];
|
|
56
|
+
/** plan 35: the memory numbers that cap milestones side by side, defaults filled */
|
|
57
|
+
limits: { memory: { reserveMb: number; perMilestoneMb: number; floorMb: number } };
|
|
35
58
|
}
|
|
36
59
|
|
|
37
60
|
/** What a profile write returns (spec §7.3): whether it saved, why not, what changed, and the agents it touched. */
|
|
@@ -70,6 +93,10 @@ export interface ProfilePort {
|
|
|
70
93
|
set(name: string | undefined, patch: ProfilePatch, repo?: string | null): ProfileSaved;
|
|
71
94
|
/** The native agent that runs `rung` for `role` under `repo`'s profile; null unless it is a `claude:` rung. */
|
|
72
95
|
agentFor(repo: string | null, role: Role, rung: string): string | null;
|
|
96
|
+
/** The ntfy topic url of the phone messages: config's `notify.url`, a random ntfy.sh topic made once. */
|
|
97
|
+
notifyUrl(): string;
|
|
98
|
+
/** Replaces it; an http(s) url only. */
|
|
99
|
+
setNotifyUrl(url: string): void;
|
|
73
100
|
}
|
|
74
101
|
|
|
75
102
|
export interface RouteRequest {
|
|
@@ -82,6 +109,8 @@ export interface RouteRequest {
|
|
|
82
109
|
lane: string | null;
|
|
83
110
|
laneText: string | null;
|
|
84
111
|
spentFraction: number;
|
|
112
|
+
/** spec 1.5 plan 24: the run's dispatches so far per quota, for a tie between rungs of equal scores */
|
|
113
|
+
usage?: Record<string, number>;
|
|
85
114
|
}
|
|
86
115
|
|
|
87
116
|
export interface RouteAnswer {
|
|
@@ -93,6 +122,14 @@ export interface RouteAnswer {
|
|
|
93
122
|
/** the Jev question set asked, and its summed probabilities, for outcomes.jsonl (spec §5.6) */
|
|
94
123
|
questionSet: string | null;
|
|
95
124
|
jev: RouteJev | null;
|
|
125
|
+
/** spec 1.5 plan 24: where Jev disagreed with a declared lane header (`Jev said logic/hard`), else null */
|
|
126
|
+
jevSaid: string | null;
|
|
127
|
+
/** spec 1.5 plan 24: no rung clears the lane's bar, and the closest one */
|
|
128
|
+
noClear?: string;
|
|
129
|
+
/** spec 1.5 plan 24: rungs of equal scores on other quotas competed for the start, and what decided */
|
|
130
|
+
tie?: string;
|
|
131
|
+
/** spec 1.5 plan 24: the decision in one line, for the coordinator */
|
|
132
|
+
why: string;
|
|
96
133
|
/** spec 1.2 §5.3, §8: the chosen rung's thresholds, values, sources, facts and run evidence */
|
|
97
134
|
provenance?: Provenance;
|
|
98
135
|
}
|
|
@@ -103,7 +140,6 @@ export interface CatalogFilter {
|
|
|
103
140
|
role?: Role;
|
|
104
141
|
backend?: string;
|
|
105
142
|
text?: string;
|
|
106
|
-
scoredOnly: boolean;
|
|
107
143
|
limit: number;
|
|
108
144
|
/** the git toplevel whose per-repository listings (opencode's) to read; none reads the global ones */
|
|
109
145
|
repo?: string;
|
|
@@ -111,6 +147,8 @@ export interface CatalogFilter {
|
|
|
111
147
|
|
|
112
148
|
export interface RoutingPort {
|
|
113
149
|
route(req: RouteRequest): Promise<RouteAnswer>;
|
|
150
|
+
/** spec 1.5 plan 24: the lanes of one role in one call; Jev is asked about all of them at once */
|
|
151
|
+
routeMany(reqs: RouteRequest[]): Promise<RouteAnswer[]>;
|
|
114
152
|
/** `use` is the repo profile's `jev.use`: "off" answers with the rule's default and never asks Jev */
|
|
115
153
|
finding(
|
|
116
154
|
runDir: string,
|
|
@@ -124,6 +162,18 @@ export interface RoutingPort {
|
|
|
124
162
|
after: string,
|
|
125
163
|
use: "auto" | "off",
|
|
126
164
|
): Promise<Verdict<"yes" | "no">>;
|
|
165
|
+
/** plan 30b: why a role stopped that gave no reason; `other` and an unsure answer are the rule's fallback */
|
|
166
|
+
stopCause(
|
|
167
|
+
runDir: string,
|
|
168
|
+
status: string,
|
|
169
|
+
reply: string,
|
|
170
|
+
use: "auto" | "off",
|
|
171
|
+
): Promise<Verdict<BlockedReason | "other">>;
|
|
172
|
+
/**
|
|
173
|
+
* A run's start check: refreshes the index if it is due, reads it once, and names the enabled roles of `view`
|
|
174
|
+
* that no indexed pair can fill; `index` is what the run pins (null when there is none).
|
|
175
|
+
*/
|
|
176
|
+
checkStart(view: ProfileView, repo: string): Promise<{ unusable: Role[]; index: IndexFile | null }>;
|
|
127
177
|
/** `billing` prices the rungs (spec §5.3); absent keys bill as DEFAULT_BILLING */
|
|
128
178
|
catalog(
|
|
129
179
|
filter: CatalogFilter,
|
|
@@ -142,6 +192,11 @@ export interface Deps {
|
|
|
142
192
|
/** Claude transport credentials and registry refresh; host identity lives in `host`. */
|
|
143
193
|
session: SessionEnv | null;
|
|
144
194
|
now: () => number;
|
|
145
|
-
/**
|
|
146
|
-
sync?: (o: { force: boolean }) => Promise<
|
|
195
|
+
/** `catalog_sync`; default: the real refresh (tests inject one that never reaches the network) */
|
|
196
|
+
sync?: (o: { force: boolean }) => Promise<IndexStatus>;
|
|
197
|
+
/**
|
|
198
|
+
* Spec 1.5 plan 21: the role this process works for (CATHERD_ROLE, or a role's scratch TMPDIR); null or
|
|
199
|
+
* absent for the orchestrator's own server. A role never claims a run and never reaches a coordinator tool.
|
|
200
|
+
*/
|
|
201
|
+
role?: RoleScope | null;
|
|
147
202
|
}
|
|
@@ -1,16 +1,24 @@
|
|
|
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
|
+
import { coverageProblems } from "../domain/plan-doc.ts";
|
|
6
|
+
import { lintLanes } from "../domain/plan-lint.ts";
|
|
5
7
|
import { checkEnv } from "../infra/env.ts";
|
|
6
|
-
import { heavySlots,
|
|
8
|
+
import { heavySlots, withHeavySlotWithin } from "../infra/heavy-lock.ts";
|
|
9
|
+
import { loginEnv } from "../infra/login-env.ts";
|
|
7
10
|
import { killGroup } from "../infra/proc.ts";
|
|
11
|
+
import { readGateEnv, resolveGateEnv } from "./gate-env.ts";
|
|
12
|
+
import { landedMilestones } from "./milestones.ts";
|
|
13
|
+
import { planDocs } from "./plan-input.ts";
|
|
8
14
|
import type { Deps } from "./ports.ts";
|
|
15
|
+
import { isDaemonRun, replayRun } from "./run-events-store.ts";
|
|
9
16
|
import { TAIL_LINES } from "./run-debug.ts";
|
|
10
17
|
import { findRun, type Run, runPaths } from "./run-store.ts";
|
|
18
|
+
import { treeOf } from "./tree-path.ts";
|
|
11
19
|
|
|
12
|
-
/** Spec §4.7. Only `cannot-start` blocks the run. */
|
|
13
|
-
export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start";
|
|
20
|
+
/** Spec §4.7. Only `cannot-start` blocks the run; `lock-busy` (plan 23) means: run preflight again. */
|
|
21
|
+
export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start" | "lock-busy";
|
|
14
22
|
|
|
15
23
|
interface PreflightResult {
|
|
16
24
|
lane: string;
|
|
@@ -22,12 +30,21 @@ interface PreflightResult {
|
|
|
22
30
|
}
|
|
23
31
|
|
|
24
32
|
export type PreflightReport =
|
|
25
|
-
| {
|
|
33
|
+
| {
|
|
34
|
+
needsConfirmation: true;
|
|
35
|
+
commands: { lane: string; check: string | null }[];
|
|
36
|
+
/** plan 34: the plan lint's warnings */
|
|
37
|
+
lint: string[];
|
|
38
|
+
}
|
|
26
39
|
| {
|
|
27
40
|
needsConfirmation: false;
|
|
28
41
|
commands: { lane: string; check: string | null }[];
|
|
29
42
|
results: PreflightResult[];
|
|
30
43
|
blocked: boolean;
|
|
44
|
+
/** plan 23: lanes whose fast check runs no linter while the repo has one */
|
|
45
|
+
warnings: string[];
|
|
46
|
+
/** plan 34: the plan lint's warnings */
|
|
47
|
+
lint: string[];
|
|
31
48
|
};
|
|
32
49
|
|
|
33
50
|
const CHECK_TIMEOUT_MS = 120_000;
|
|
@@ -47,9 +64,15 @@ interface LaneCheck {
|
|
|
47
64
|
lane: string;
|
|
48
65
|
check: string | null;
|
|
49
66
|
owns: string[];
|
|
67
|
+
/** spec 1.5: the Allow: exceptions of the lane's absence check */
|
|
68
|
+
allow: string[];
|
|
50
69
|
problem: string | null;
|
|
51
70
|
/** spec 1.1 §6: why its Kind: or Difficulty: line is refused */
|
|
52
71
|
invalid: string | null;
|
|
72
|
+
/** plan 32: the plan tasks it names on its `Tasks:` line */
|
|
73
|
+
tasks: string[];
|
|
74
|
+
/** plan 34: the lane file's text; null when it cannot be read */
|
|
75
|
+
text: string | null;
|
|
53
76
|
}
|
|
54
77
|
|
|
55
78
|
function laneChecks(run: Run): LaneCheck[] {
|
|
@@ -73,15 +96,30 @@ function laneChecks(run: Run): LaneCheck[] {
|
|
|
73
96
|
lane,
|
|
74
97
|
check: h.fastCheck,
|
|
75
98
|
owns: h.owns,
|
|
99
|
+
allow: h.allow,
|
|
76
100
|
problem: h.fastCheck ? null : `lanes/${f} has no Fast check: line`,
|
|
77
101
|
invalid,
|
|
102
|
+
tasks: h.tasks,
|
|
103
|
+
text,
|
|
78
104
|
};
|
|
79
105
|
} catch (e) {
|
|
80
|
-
return {
|
|
106
|
+
return {
|
|
107
|
+
lane,
|
|
108
|
+
check: null,
|
|
109
|
+
owns: [],
|
|
110
|
+
allow: [],
|
|
111
|
+
problem: (e as Error).message,
|
|
112
|
+
invalid,
|
|
113
|
+
tasks: [],
|
|
114
|
+
text: null,
|
|
115
|
+
};
|
|
81
116
|
}
|
|
82
117
|
});
|
|
83
118
|
}
|
|
84
119
|
|
|
120
|
+
/** How many output lines a check's Allow: exceptions are matched against; past it, the check fails as before. */
|
|
121
|
+
const ALLOW_LINES = 1_000;
|
|
122
|
+
|
|
85
123
|
/** How long the pipes may stay open after the check's own process exits. */
|
|
86
124
|
export const DRAIN_MS = 500;
|
|
87
125
|
|
|
@@ -116,10 +154,11 @@ export async function runCheck(
|
|
|
116
154
|
repo: string,
|
|
117
155
|
check: string,
|
|
118
156
|
timeoutMs: number,
|
|
119
|
-
|
|
157
|
+
env: Record<string, string> = checkEnv(process.env, repo),
|
|
158
|
+
): Promise<{ code: number | null; timedOut: boolean; tail: string[]; lines: string[] }> {
|
|
120
159
|
const p = Bun.spawn(["sh", "-c", check], {
|
|
121
160
|
cwd: repo,
|
|
122
|
-
env
|
|
161
|
+
env,
|
|
123
162
|
stdin: "ignore",
|
|
124
163
|
stdout: "pipe",
|
|
125
164
|
stderr: "pipe",
|
|
@@ -141,11 +180,10 @@ export async function runCheck(
|
|
|
141
180
|
Bun.sleep(DRAIN_MS).then(() => false),
|
|
142
181
|
]);
|
|
143
182
|
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 };
|
|
183
|
+
// every line, up to a bound, for a lane's Allow: exceptions; the tail is what the report shows
|
|
184
|
+
const all = `${out.text()}${err.text()}`.split("\n").filter((l) => l.trim());
|
|
185
|
+
const lines = all.length <= ALLOW_LINES ? all : [];
|
|
186
|
+
return { code: timedOut ? null : code, timedOut, tail: all.slice(-TAIL_LINES), lines };
|
|
149
187
|
} finally {
|
|
150
188
|
clearTimeout(timer);
|
|
151
189
|
out.stop();
|
|
@@ -153,10 +191,100 @@ export async function runCheck(
|
|
|
153
191
|
}
|
|
154
192
|
}
|
|
155
193
|
|
|
156
|
-
|
|
194
|
+
/**
|
|
195
|
+
* Plan 23: a check that failed on the machine, not on the code: no Docker (or none at DOCKER_HOST), no DNS, a
|
|
196
|
+
* permission the sandbox or the OS denied. Such a check cannot start; it never fails as expected.
|
|
197
|
+
*/
|
|
198
|
+
const ENVIRONMENT = [
|
|
199
|
+
/command not found/i,
|
|
200
|
+
/Cannot connect to the Docker daemon/i,
|
|
201
|
+
/rootless Docker not found/i,
|
|
202
|
+
/Could not find a valid Docker environment/i,
|
|
203
|
+
/docker\.sock: connect: no such file or directory/i,
|
|
204
|
+
/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,
|
|
205
|
+
/permission denied|operation not permitted|\bEACCES\b|\bEPERM\b/i,
|
|
206
|
+
];
|
|
207
|
+
|
|
208
|
+
/** pnpm exits 0 on a `--filter` that matches no package: the lane creates it, so there is nothing to run yet. */
|
|
209
|
+
const EMPTY_FILTER = /No projects matched the filters/i;
|
|
210
|
+
|
|
211
|
+
/** The first tail line an ENVIRONMENT pattern matches, or null. */
|
|
212
|
+
export const environmentLine = (tail: string[]): string | null =>
|
|
213
|
+
tail.find((l) => ENVIRONMENT.some((re) => re.test(l))) ?? null;
|
|
214
|
+
|
|
215
|
+
/** One `pnpm … --filter …` command, nothing chained, piped or backgrounded beside it. */
|
|
216
|
+
const SINGLE_PNPM_FILTER = /^\s*pnpm\s(?:[^;&|\n`$()<>]*\s)?(?:--filter|-F)(?:[\s=])[^;&|\n`$()<>]*$/;
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* An unmatched pnpm filter is "skipped" only when it is provably the check's sole failure: the whole check
|
|
220
|
+
* exited 0, or the check is a single pnpm --filter command. In a compound check (`pnpm --filter new test;
|
|
221
|
+
* docker compose up`) a later command's failure decides.
|
|
222
|
+
*/
|
|
223
|
+
export function classify(
|
|
224
|
+
r: { code: number | null; timedOut: boolean; tail: string[] },
|
|
225
|
+
command?: string,
|
|
226
|
+
): PreflightOutcome {
|
|
157
227
|
if (r.timedOut || r.code === 126 || r.code === 127) return "cannot-start";
|
|
228
|
+
const soleFailure = r.code === 0 || (command !== undefined && SINGLE_PNPM_FILTER.test(command));
|
|
229
|
+
if (soleFailure && r.tail.some((l) => EMPTY_FILTER.test(l))) return "skipped";
|
|
158
230
|
if (r.code === 0) return "pass";
|
|
159
|
-
return r.tail
|
|
231
|
+
return environmentLine(r.tail) !== null ? "cannot-start" : "fails-as-expected";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Why a classified result says what it says, for its note. */
|
|
235
|
+
function noteOf(
|
|
236
|
+
r: { code: number | null; timedOut: boolean; tail: string[] },
|
|
237
|
+
outcome: PreflightOutcome,
|
|
238
|
+
timeoutMs: number,
|
|
239
|
+
): string | null {
|
|
240
|
+
if (r.timedOut) return `timed out after ${timeoutMs / 1000} s`;
|
|
241
|
+
if (outcome === "skipped") return "the pnpm filter matches no package yet; the lane creates it";
|
|
242
|
+
const env = outcome === "cannot-start" ? environmentLine(r.tail) : null;
|
|
243
|
+
return env ? `environment: ${env.trim()}` : null;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
/** How long one check waits for a heavy slot before it reports lock-busy; tests shorten it. */
|
|
247
|
+
export const preflightLimits = { lockWaitMs: 60_000 };
|
|
248
|
+
|
|
249
|
+
/**
|
|
250
|
+
* Plan 23: a check runs in the user's login environment (DOCKER_HOST and the rest a login shell sets), through
|
|
251
|
+
* the allowlist, plus the repo's gate environment. A login PATH comes first, with the server's own after it.
|
|
252
|
+
*/
|
|
253
|
+
export async function checkEnvFor(repo: string): Promise<Record<string, string>> {
|
|
254
|
+
const login = loginEnv();
|
|
255
|
+
const base: Record<string, string | undefined> = { ...process.env, ...login };
|
|
256
|
+
const path = [
|
|
257
|
+
...new Set([...(login.PATH ?? "").split(":"), ...(process.env.PATH ?? "").split(":")].filter(Boolean)),
|
|
258
|
+
];
|
|
259
|
+
if (path.length) base.PATH = path.join(":");
|
|
260
|
+
return { ...checkEnv(base, repo), ...resolveGateEnv(await readGateEnv(repo), base).env };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** What a fast check names when it lints: a lint script, or a linter by name. */
|
|
264
|
+
const LINT_STEP = /\b(lint|eslint|biome|oxlint|golangci-lint|ruff|clippy|vet|check)\b/i;
|
|
265
|
+
|
|
266
|
+
/** Root files that say the repo has a linter, by the linter they configure. */
|
|
267
|
+
const LINT_CONFIGS: [RegExp, string][] = [
|
|
268
|
+
[/^\.golangci\.(ya?ml|toml|json)$/, "golangci-lint"],
|
|
269
|
+
[/^(biome\.jsonc?)$/, "biome"],
|
|
270
|
+
[/^(eslint\.config\.[cm]?[jt]s|\.eslintrc(\.[a-z]+)?)$/, "eslint"],
|
|
271
|
+
[/^\.?oxlintrc\.json$/, "oxlint"],
|
|
272
|
+
[/^\.?ruff\.toml$/, "ruff"],
|
|
273
|
+
];
|
|
274
|
+
|
|
275
|
+
/** The linter the repo has, by name, or null: a root lint config, or a `lint` script in package.json. */
|
|
276
|
+
export function repoLinter(repo: string): string | null {
|
|
277
|
+
for (const name of existsSync(repo) ? readdirSync(repo) : [])
|
|
278
|
+
for (const [re, linter] of LINT_CONFIGS) if (re.test(name)) return linter;
|
|
279
|
+
try {
|
|
280
|
+
const pkg = JSON.parse(readFileSync(join(repo, "package.json"), "utf8")) as {
|
|
281
|
+
scripts?: Record<string, unknown>;
|
|
282
|
+
};
|
|
283
|
+
if (typeof pkg.scripts?.lint === "string") return "the lint script";
|
|
284
|
+
} catch {
|
|
285
|
+
// no package.json, or one that does not parse: no lint script
|
|
286
|
+
}
|
|
287
|
+
return null;
|
|
160
288
|
}
|
|
161
289
|
|
|
162
290
|
/**
|
|
@@ -165,18 +293,60 @@ export function classify(r: { code: number | null; timedOut: boolean; tail: stri
|
|
|
165
293
|
*/
|
|
166
294
|
export async function preflight(
|
|
167
295
|
deps: Deps,
|
|
168
|
-
i: { run: string; confirmed?: boolean; timeoutMs?: number },
|
|
296
|
+
i: { run: string; confirmed?: boolean; timeoutMs?: number; milestone?: string },
|
|
169
297
|
): Promise<PreflightReport> {
|
|
170
298
|
const run = findRun(i.run);
|
|
171
299
|
const profile = deps.profiles.forRepo(run.meta.repo);
|
|
172
|
-
|
|
300
|
+
// plan 23: one milestone's lanes, or by default every lane whose milestone has not landed
|
|
301
|
+
const landed = new Set(landedMilestones(run));
|
|
302
|
+
const milestoneOf = (lane: string) => lane.split(".")[0] as string;
|
|
303
|
+
const lanes = laneChecks(run).filter((l) =>
|
|
304
|
+
i.milestone ? milestoneOf(l.lane) === i.milestone : !landed.has(milestoneOf(l.lane)),
|
|
305
|
+
);
|
|
173
306
|
// spec 1.1 §6: refuse before running anything, naming every lane whose header the catalog cannot route
|
|
174
307
|
const invalid = lanes.flatMap((l) => (l.invalid ? [l.invalid] : []));
|
|
175
308
|
if (invalid.length) throw new CatherdError("E_LANE_INVALID", invalid.join("; "), { fix: LANE_HEADER_FIX });
|
|
176
309
|
const commands = lanes.map((l) => ({ lane: l.lane, check: l.check }));
|
|
177
|
-
|
|
310
|
+
const { warnings: lint, stuck } = lintLanes(
|
|
311
|
+
lanes.flatMap((l) => (l.text === null ? [] : [{ id: l.lane, text: l.text }])),
|
|
312
|
+
laneChecks(run).map((l) => l.lane),
|
|
313
|
+
// a base run's milestone starts after the ones it names are merged, not only landed (plan 35)
|
|
314
|
+
run.meta.base
|
|
315
|
+
? replayRun(run)
|
|
316
|
+
.milestones.filter((m) => m.status === "merged")
|
|
317
|
+
.map((m) => m.id)
|
|
318
|
+
: [...landed],
|
|
319
|
+
);
|
|
320
|
+
// plan 32: a daemon run starts no lane the plan does not back, before the confirm list and before any check
|
|
321
|
+
if (isDaemonRun(run)) {
|
|
322
|
+
const problems = coverageProblems(
|
|
323
|
+
planDocs(run),
|
|
324
|
+
lanes.map((l) => ({ id: l.lane, tasks: l.tasks })),
|
|
325
|
+
);
|
|
326
|
+
if (problems.length)
|
|
327
|
+
return {
|
|
328
|
+
needsConfirmation: false,
|
|
329
|
+
commands,
|
|
330
|
+
results: problems.map((p) => ({
|
|
331
|
+
lane: p.lane || (i.milestone ?? "plan"),
|
|
332
|
+
check: null,
|
|
333
|
+
outcome: "cannot-start" as const,
|
|
334
|
+
exitCode: null,
|
|
335
|
+
tail: [],
|
|
336
|
+
note: p.note,
|
|
337
|
+
})),
|
|
338
|
+
blocked: true,
|
|
339
|
+
warnings: [],
|
|
340
|
+
lint,
|
|
341
|
+
};
|
|
342
|
+
}
|
|
343
|
+
if (profile.preflight.confirm && !i.confirmed) return { needsConfirmation: true, commands, lint };
|
|
178
344
|
const timeoutMs = i.timeoutMs ?? CHECK_TIMEOUT_MS;
|
|
345
|
+
// plan 35: a base run's milestone is checked in its own worktree
|
|
346
|
+
const cwd = treeOf(run, i.milestone ?? null);
|
|
179
347
|
const results: PreflightResult[] = [];
|
|
348
|
+
// the login env is captured once per server; the gate env is read once per preflight
|
|
349
|
+
let env: Record<string, string> | undefined;
|
|
180
350
|
for (const l of lanes) {
|
|
181
351
|
const check = l.check;
|
|
182
352
|
if (!check) {
|
|
@@ -194,28 +364,66 @@ export async function preflight(
|
|
|
194
364
|
results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note: AS_ROOT });
|
|
195
365
|
continue;
|
|
196
366
|
}
|
|
197
|
-
const unborn = l.owns.filter(
|
|
198
|
-
(p) => check.includes(p.replace(/\/$/, "")) && !existsSync(join(run.meta.repo, p)),
|
|
199
|
-
);
|
|
367
|
+
const unborn = l.owns.filter((p) => check.includes(p.replace(/\/$/, "")) && !existsSync(join(cwd, p)));
|
|
200
368
|
if (unborn.length) {
|
|
201
369
|
const note = `${unborn.join(", ")} does not exist yet; the lane creates it`;
|
|
202
370
|
results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note });
|
|
203
371
|
continue;
|
|
204
372
|
}
|
|
205
|
-
|
|
373
|
+
env ??= await checkEnvFor(run.meta.repo);
|
|
374
|
+
const checkEnvironment = env;
|
|
375
|
+
// plan 23: each check waits for its slot within its own budget, and says lock-busy past it
|
|
376
|
+
const slot = await withHeavySlotWithin(
|
|
377
|
+
heavySlots(profile.heavy),
|
|
378
|
+
preflightLimits.lockWaitMs,
|
|
379
|
+
(_slot, tmp) => runCheck(cwd, check, timeoutMs, { ...checkEnvironment, TMPDIR: tmp, GOTMPDIR: tmp }),
|
|
380
|
+
{ tmpBase: process.env.TMPDIR },
|
|
381
|
+
);
|
|
382
|
+
if (slot.busy) {
|
|
383
|
+
const note = `every heavy slot stayed busy for ${preflightLimits.lockWaitMs / 1000} s (other lanes' checks); run preflight again when they finish`;
|
|
384
|
+
results.push({ lane: l.lane, check, outcome: "lock-busy", exitCode: null, tail: [], note });
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
const r = slot.value;
|
|
388
|
+
// spec 1.5: an absence check whose every hit is one of the lane's Allow: exceptions has passed
|
|
389
|
+
const classified = classify(r, check);
|
|
390
|
+
const allowed = classified === "fails-as-expected" && onlyAllowedHits(r.lines, l.allow);
|
|
391
|
+
const outcome = allowed ? "pass" : classified;
|
|
206
392
|
results.push({
|
|
207
393
|
lane: l.lane,
|
|
208
394
|
check,
|
|
209
|
-
outcome
|
|
395
|
+
outcome,
|
|
210
396
|
exitCode: r.code,
|
|
211
397
|
tail: r.tail,
|
|
212
|
-
note:
|
|
398
|
+
note: allowed ? `every hit is an Allow: exception (${r.lines.length})` : noteOf(r, outcome, timeoutMs),
|
|
213
399
|
});
|
|
214
400
|
}
|
|
401
|
+
// plan 34: an After: that can never finish stops the milestone, so the daemon parks it instead of waiting forever
|
|
402
|
+
for (const s of stuck)
|
|
403
|
+
results.push({
|
|
404
|
+
lane: s.lane,
|
|
405
|
+
check: null,
|
|
406
|
+
outcome: "cannot-start",
|
|
407
|
+
exitCode: null,
|
|
408
|
+
tail: [],
|
|
409
|
+
note: s.note,
|
|
410
|
+
});
|
|
411
|
+
// plan 23: lint reaches the worker only through its fast check
|
|
412
|
+
const linter = repoLinter(run.meta.repo);
|
|
413
|
+
const warnings = linter
|
|
414
|
+
? lanes
|
|
415
|
+
.filter((l) => l.check && !LINT_STEP.test(l.check))
|
|
416
|
+
.map(
|
|
417
|
+
(l) =>
|
|
418
|
+
`${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`,
|
|
419
|
+
)
|
|
420
|
+
: [];
|
|
215
421
|
return {
|
|
216
422
|
needsConfirmation: false,
|
|
217
423
|
commands,
|
|
218
424
|
results,
|
|
219
425
|
blocked: results.some((r) => r.outcome === "cannot-start"),
|
|
426
|
+
warnings,
|
|
427
|
+
lint,
|
|
220
428
|
};
|
|
221
429
|
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { HostContext, OrchestrationHost } from "../domain/host.ts";
|
|
2
2
|
import { agentFiles } from "../domain/agents.ts";
|
|
3
|
+
import { type MemoryLimits, memoryLimitsOf } from "../domain/milestone-memory.ts";
|
|
3
4
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
4
5
|
import { join } from "node:path";
|
|
5
6
|
import { isDeepStrictEqual } from "node:util";
|
|
@@ -78,7 +79,7 @@ function saveAndLink(name: string, doc: ProfileDoc, host: OrchestrationHost, rep
|
|
|
78
79
|
|
|
79
80
|
const validate = (doc: ProfileDoc, name: string, host: OrchestrationHost): Validation => {
|
|
80
81
|
try {
|
|
81
|
-
return validateHere(resolveProfile(doc, name, host), loadCatalog(
|
|
82
|
+
return validateHere(resolveProfile(doc, name, host), loadCatalog(), doc, host);
|
|
82
83
|
} catch (e) {
|
|
83
84
|
if (!(e instanceof CatherdError)) throw e;
|
|
84
85
|
return { errors: [{ path: "roles", message: e.message, fix: e.fix }], warnings: [] };
|
|
@@ -105,7 +106,7 @@ function syncFor(
|
|
|
105
106
|
return allowPrune ? pruneProfileAgents(name) : { linked: [], pruned: [], newSessionNeededFor: [] };
|
|
106
107
|
}
|
|
107
108
|
|
|
108
|
-
/** What a save returns: the port's ProfileSaved (one type for the CLI, the
|
|
109
|
+
/** What a save returns: the port's ProfileSaved (one type for the CLI, the panel and the MCP tools). */
|
|
109
110
|
export type Saved = ProfileSaved;
|
|
110
111
|
|
|
111
112
|
const unsaved = (v: Validation): Saved => ({
|
|
@@ -121,7 +122,7 @@ const unsaved = (v: Validation): Saved => ({
|
|
|
121
122
|
export const CHANGED_ON_DISK = "profile";
|
|
122
123
|
|
|
123
124
|
/**
|
|
124
|
-
* Spec §7.3 `patch` (profile_set, `catherd profile set`, the
|
|
125
|
+
* Spec §7.3 `patch` (profile_set, `catherd profile set`, the panel's save): validates first and writes
|
|
125
126
|
* nothing when invalid, unless the save repairs the profile (spec 1.2 §6.2): it removes at least one error
|
|
126
127
|
* the stored profile had and adds none. Then it saves, and the result's `errors` are those still open. A
|
|
127
128
|
* profile that does not exist yet starts from the default profile. With `expect` (the profile as a preview
|
|
@@ -301,14 +302,23 @@ function viewOf(p: Profile): ProfileView {
|
|
|
301
302
|
jev: p.jev,
|
|
302
303
|
isolated: Object.fromEntries(Object.entries(p.harness).map(([k, h]) => [k, h.isolated])),
|
|
303
304
|
failover: p.failover,
|
|
305
|
+
maxEffort: p.maxEffort,
|
|
306
|
+
bars: p.bars,
|
|
304
307
|
budget: p.budget,
|
|
305
308
|
timeouts: p.timeouts,
|
|
306
309
|
preflight: p.preflight,
|
|
307
310
|
heavy: p.lock.heavy,
|
|
308
311
|
notify: p.notify,
|
|
312
|
+
limits: { memory: pickMemory(memoryLimitsOf(p)) },
|
|
309
313
|
};
|
|
310
314
|
}
|
|
311
315
|
|
|
316
|
+
const pickMemory = ({ reserveMb, perMilestoneMb, floorMb }: MemoryLimits) => ({
|
|
317
|
+
reserveMb,
|
|
318
|
+
perMilestoneMb,
|
|
319
|
+
floorMb,
|
|
320
|
+
});
|
|
321
|
+
|
|
312
322
|
/** The ProfilePort the run engine and the MCP tools use (spec §7.3: the single writer). */
|
|
313
323
|
export function profileService(host: () => HostContext): ProfilePort {
|
|
314
324
|
// without a name, each tool acts on the profile `repo` runs on (the active one outside a repo)
|
|
@@ -333,6 +343,7 @@ export function profileService(host: () => HostContext): ProfilePort {
|
|
|
333
343
|
name === undefined ? activeName(repo) : requireProfile(name),
|
|
334
344
|
repo,
|
|
335
345
|
host().host,
|
|
346
|
+
{ reach: true },
|
|
336
347
|
);
|
|
337
348
|
return { valid: v.errors.length === 0, ...v };
|
|
338
349
|
},
|
|
@@ -340,6 +351,23 @@ export function profileService(host: () => HostContext): ProfilePort {
|
|
|
340
351
|
set: (name, patch, repo = null) => patchProfile(name ?? activeName(repo), patch, { host: host().host }),
|
|
341
352
|
agentFor: (repo, role, rung) =>
|
|
342
353
|
parseRung(rung).backend === "claude" ? agentName(activeName(repo), role, rung) : null,
|
|
354
|
+
notifyUrl: () =>
|
|
355
|
+
locked(() => {
|
|
356
|
+
const config = readConfig();
|
|
357
|
+
if (config.notify?.url) return config.notify.url;
|
|
358
|
+
const url = `https://ntfy.sh/catherd-${Buffer.from(crypto.getRandomValues(new Uint8Array(10))).toString("hex")}`;
|
|
359
|
+
writeJsonAtomic(configFile(), { ...config, schema: 1, notify: { ...config.notify, url } });
|
|
360
|
+
return url;
|
|
361
|
+
}),
|
|
362
|
+
setNotifyUrl: (url) =>
|
|
363
|
+
locked(() => {
|
|
364
|
+
if (!/^https?:\/\//.test(url) || !URL.canParse(url))
|
|
365
|
+
throw new CatherdError("E_INPUT_INVALID", `"${url}" is not an http(s) url`, {
|
|
366
|
+
fix: "give the ntfy topic's url, like https://ntfy.sh/<topic>",
|
|
367
|
+
});
|
|
368
|
+
const config = readConfig();
|
|
369
|
+
writeJsonAtomic(configFile(), { ...config, schema: 1, notify: { ...config.notify, url } });
|
|
370
|
+
}),
|
|
343
371
|
};
|
|
344
372
|
}
|
|
345
373
|
|
|
@@ -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";
|
|
@@ -34,7 +39,12 @@ export const projectsFile = (): string => join(configDir(), "projects.json");
|
|
|
34
39
|
export const agentsRoot = (): string => join(configDir(), "agents");
|
|
35
40
|
export const profileFile = (name: string): string => join(profilesDir(), `${name}.json`);
|
|
36
41
|
|
|
37
|
-
const ConfigSchema = z.looseObject({
|
|
42
|
+
const ConfigSchema = z.looseObject({
|
|
43
|
+
schema: z.literal(1),
|
|
44
|
+
activeProfile: z.string().optional(),
|
|
45
|
+
/** where the phone messages go (an ntfy topic), made at the first run */
|
|
46
|
+
notify: z.looseObject({ url: z.string().optional() }).optional(),
|
|
47
|
+
});
|
|
38
48
|
const ProjectsSchema = z.looseObject({
|
|
39
49
|
schema: z.literal(1),
|
|
40
50
|
bindings: z.record(z.string(), z.string()).default({}),
|
|
@@ -144,7 +154,7 @@ export function isolationKeyErrors(
|
|
|
144
154
|
|
|
145
155
|
/**
|
|
146
156
|
* Spec 1.3 §9 Q2: each enabled role whose rung, or a failover stand-in of one, runs natively on a backend that
|
|
147
|
-
* holds the role's access only when isolated (
|
|
157
|
+
* holds the role's access only when isolated (one with no read-only flag).
|
|
148
158
|
*/
|
|
149
159
|
export function isolatedOnlyErrors(p: Profile): Issue[] {
|
|
150
160
|
const out: Issue[] = [];
|
|
@@ -220,8 +230,9 @@ export function validateHere(
|
|
|
220
230
|
c: Catalog,
|
|
221
231
|
doc?: ProfileDoc,
|
|
222
232
|
host: OrchestrationHost = "unknown",
|
|
233
|
+
o: ValidateOptions = {},
|
|
223
234
|
): Validation {
|
|
224
|
-
const v = validateProfile(p, c, runnableBackends(), doc, host);
|
|
235
|
+
const v = validateProfile(p, c, runnableBackends(), doc, host, o);
|
|
225
236
|
return {
|
|
226
237
|
errors: [...v.errors, ...isolationKeyErrors(p), ...isolatedOnlyErrors(p)],
|
|
227
238
|
warnings: [...v.warnings, ...budgetUsdWarnings(p)],
|
|
@@ -233,12 +244,13 @@ export function validateNamed(
|
|
|
233
244
|
name: string | undefined,
|
|
234
245
|
repo: string | null,
|
|
235
246
|
host: OrchestrationHost,
|
|
247
|
+
o: ValidateOptions = {},
|
|
236
248
|
): Validation {
|
|
237
249
|
const n = name ?? activeName(repo);
|
|
238
250
|
const doc = readProfileDoc(n);
|
|
239
|
-
const catalog = loadCatalog(
|
|
251
|
+
const catalog = loadCatalog(repo === null ? {} : { repo });
|
|
240
252
|
try {
|
|
241
|
-
return validateHere(resolveProfile(doc, n, host), catalog, doc, host);
|
|
253
|
+
return validateHere(resolveProfile(doc, n, host), catalog, doc, host, o);
|
|
242
254
|
} catch (e) {
|
|
243
255
|
if (!(e instanceof CatherdError)) throw e;
|
|
244
256
|
return { errors: [{ path: "roles", message: e.message, fix: e.fix }], warnings: [] };
|