catherd-cli 0.2.1 → 1.1.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 +141 -33
- package/THIRD_PARTY_NOTICES.md +37 -0
- package/catalog/jev.json +89 -0
- package/catalog/models.json +236 -0
- package/catalog/scores.json +366 -0
- package/package.json +24 -8
- package/plugin/.claude-plugin/plugin.json +2 -2
- package/plugin/.mcp.json +2 -2
- package/plugin/bin/catherd-mcp +30 -0
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +160 -97
- package/plugin/skills/catherd-setup/SKILL.md +34 -24
- package/src/adapters/access.ts +63 -0
- package/src/adapters/all.ts +8 -0
- package/src/adapters/backend.ts +159 -0
- package/src/adapters/claude-code/events.ts +86 -0
- package/src/adapters/claude-code/index.ts +317 -0
- package/src/adapters/claude-code/models-api.ts +65 -0
- package/src/adapters/claude-code/models.ts +26 -0
- package/src/adapters/cli.ts +74 -0
- package/src/adapters/codex/events.ts +65 -0
- package/src/adapters/codex/home.ts +47 -0
- package/src/adapters/codex/index.ts +282 -0
- package/src/adapters/discovery.ts +87 -0
- package/src/adapters/opencode/agents.ts +84 -0
- package/src/adapters/opencode/events.ts +109 -0
- package/src/adapters/opencode/index.ts +333 -0
- package/src/adapters/registry.ts +14 -0
- package/src/cli.ts +159 -19
- package/src/domain/agents.ts +60 -0
- package/src/domain/budget.ts +45 -0
- package/src/domain/catalog.ts +262 -0
- package/src/domain/changes.ts +52 -0
- package/src/domain/cost.ts +89 -0
- package/src/domain/errors.ts +60 -0
- package/src/domain/failover.ts +119 -0
- package/src/domain/hints.ts +22 -0
- package/src/domain/ids.ts +97 -0
- package/src/domain/jev.ts +283 -0
- package/src/domain/lane.ts +92 -0
- package/src/domain/notice.ts +95 -0
- package/src/domain/profile-rules.ts +239 -0
- package/src/domain/profile.ts +389 -0
- package/src/domain/record.ts +86 -0
- package/src/{profile → domain}/role-prompts.ts +75 -27
- package/src/domain/roles.ts +25 -0
- package/src/domain/route.ts +126 -0
- package/src/domain/runtime.ts +21 -0
- package/src/domain/sanitize.ts +29 -0
- package/src/domain/secrets.ts +57 -0
- package/src/domain/select.ts +136 -0
- package/src/domain/state.ts +49 -0
- package/src/domain/util.ts +18 -0
- package/src/entry/capture-fixtures-command.ts +57 -0
- package/src/entry/catalog-command.ts +106 -0
- package/src/entry/cli-kit.ts +36 -0
- package/src/entry/deps.ts +17 -0
- package/src/entry/doctor-command.ts +51 -0
- package/src/entry/glyphs.ts +34 -0
- package/src/entry/init-command.ts +182 -0
- package/src/entry/lock-command.ts +123 -0
- package/src/{mcp/commands.ts → entry/mcp/command.ts} +5 -1
- package/src/entry/mcp/dispatch-tools.ts +52 -0
- package/src/entry/mcp/handshake.ts +58 -0
- package/src/entry/mcp/lane-tools.ts +84 -0
- package/src/entry/mcp/protocol-tools.ts +57 -0
- package/src/entry/mcp/result.ts +51 -0
- package/src/entry/mcp/run-tools.ts +145 -0
- package/src/entry/mcp/server.ts +88 -0
- package/src/entry/mcp/setup-tools.ts +81 -0
- package/src/entry/profile-command.ts +318 -0
- package/src/entry/prompt.ts +103 -0
- package/src/entry/runs-command.ts +260 -0
- package/src/entry/supervise-bin.ts +18 -0
- package/src/entry/supervise-command.ts +37 -0
- package/src/entry/tui/commands.ts +545 -0
- package/src/entry/tui/effects.ts +312 -0
- package/src/entry/tui/fixtures.ts +585 -0
- package/src/entry/tui/profile-edits.ts +138 -0
- package/src/entry/tui/profile-tree.ts +420 -0
- package/src/entry/tui/providers/app.tsx +162 -0
- package/src/entry/tui/providers/boundary.tsx +57 -0
- package/src/entry/tui/providers/data.tsx +160 -0
- package/src/entry/tui/providers/keymap.tsx +166 -0
- package/src/entry/tui/providers/root.tsx +45 -0
- package/src/entry/tui/providers/theme.tsx +17 -0
- package/src/entry/tui/providers/toast.tsx +77 -0
- package/src/entry/tui/run.tsx +98 -0
- package/src/entry/tui/state.ts +356 -0
- package/src/entry/tui/stories.ts +107 -0
- package/src/entry/tui/text.ts +130 -0
- package/src/entry/tui/theme.ts +140 -0
- package/src/entry/tui/views/app.tsx +208 -0
- package/src/entry/tui/views/dialogs.tsx +41 -0
- package/src/entry/tui/views/profile-actions.ts +321 -0
- package/src/entry/tui/views/profiles.tsx +440 -0
- package/src/entry/tui/views/runs.tsx +573 -0
- package/src/entry/tui/views/save-dialog.tsx +332 -0
- package/src/entry/tui/views/status.tsx +209 -0
- package/src/entry/tui/widgets/chrome.tsx +114 -0
- package/src/entry/tui/widgets/dialog-confirm.tsx +58 -0
- package/src/entry/tui/widgets/dialog-prompt.tsx +52 -0
- package/src/entry/tui/widgets/dialog-select.tsx +212 -0
- package/src/entry/tui/widgets/dialog.tsx +68 -0
- package/src/entry/tui/widgets/line.tsx +92 -0
- package/src/entry/tui/widgets/list.tsx +197 -0
- package/src/entry/tui/widgets/spinner.tsx +49 -0
- package/src/entry/tui/widgets/toast.tsx +62 -0
- package/src/infra/assets.ts +4 -0
- package/src/infra/claude-session.ts +98 -0
- package/src/infra/dispatch-dir.ts +286 -0
- package/src/infra/env.ts +67 -0
- package/src/infra/filelock.ts +159 -0
- package/src/infra/git.ts +91 -0
- package/src/infra/heavy-lock.ts +36 -0
- package/src/infra/jev-client.ts +162 -0
- package/src/infra/launch.ts +25 -0
- package/src/infra/log.ts +84 -0
- package/src/infra/paths.ts +38 -0
- package/src/infra/peer-inbox.ts +92 -0
- package/src/infra/proc.ts +110 -0
- package/src/infra/store.ts +229 -0
- package/src/infra/supervisor.ts +287 -0
- package/src/infra/version.ts +6 -0
- package/src/services/admission.ts +303 -0
- package/src/services/agent-links.ts +139 -0
- package/src/services/backends.ts +44 -0
- package/src/services/budget.ts +47 -0
- package/src/services/capture.ts +233 -0
- package/src/services/catalog-service.ts +382 -0
- package/src/services/dispatch-service.ts +620 -0
- package/src/services/dispatches.ts +174 -0
- package/src/services/doctor-access.ts +231 -0
- package/src/services/doctor-backends.ts +170 -0
- package/src/services/doctor-checks.ts +148 -0
- package/src/services/doctor-push.ts +109 -0
- package/src/services/doctor.ts +338 -0
- package/src/services/finalize.ts +377 -0
- package/src/services/gate-service.ts +288 -0
- package/src/services/global-install.ts +99 -0
- package/src/services/jev-service.ts +183 -0
- package/src/services/lane-service.ts +390 -0
- package/src/services/milestones.ts +208 -0
- package/src/services/notifier.ts +296 -0
- package/src/services/peek.ts +103 -0
- package/src/services/ports.ts +135 -0
- package/src/services/preflight.ts +221 -0
- package/src/services/profile-service.ts +292 -0
- package/src/services/profile-store.ts +141 -0
- package/src/services/protocol.ts +212 -0
- package/src/services/questions.ts +102 -0
- package/src/services/reconcile.ts +130 -0
- package/src/services/reentry.ts +22 -0
- package/src/services/routing-service.ts +190 -0
- package/src/services/run-debug.ts +79 -0
- package/src/services/run-service.ts +194 -0
- package/src/services/run-store.ts +334 -0
- package/src/services/runs-page.ts +254 -0
- package/src/services/session-view.ts +152 -0
- package/src/services/sessions.ts +92 -0
- package/src/services/setup.ts +87 -0
- package/src/services/state.ts +111 -0
- package/src/services/summary.ts +241 -0
- package/catalog/catalog.json +0 -112
- package/catalog/models-dev.json +0 -56453
- package/src/core/codex-events.ts +0 -49
- package/src/core/codex.ts +0 -166
- package/src/core/harness.ts +0 -89
- package/src/core/lanes.ts +0 -67
- package/src/core/lock.ts +0 -111
- package/src/core/opencode.ts +0 -207
- package/src/core/reconcile.ts +0 -26
- package/src/core/reply.ts +0 -54
- package/src/core/runstore.ts +0 -163
- package/src/core/spawn.ts +0 -89
- package/src/core/status.ts +0 -193
- package/src/files.ts +0 -19
- package/src/mcp/dispatch.ts +0 -154
- package/src/mcp/lane-tools.ts +0 -200
- package/src/mcp/out.ts +0 -6
- package/src/mcp/preflight.ts +0 -84
- package/src/mcp/run-tools.ts +0 -121
- package/src/mcp/runs.ts +0 -118
- package/src/mcp/server.ts +0 -26
- package/src/mcp/setup-tools.ts +0 -175
- package/src/paths.ts +0 -20
- package/src/profile/agents.ts +0 -130
- package/src/profile/profile.ts +0 -228
- package/src/routing/catalog.ts +0 -227
- package/src/routing/commands.ts +0 -22
- package/src/routing/jev.ts +0 -185
- package/src/routing/route.ts +0 -155
- package/src/routing/select.ts +0 -90
- package/src/tui/backends.ts +0 -57
- package/src/tui/commands.ts +0 -53
- package/src/tui/dashboard.tsx +0 -245
- package/src/tui/editor.tsx +0 -214
- package/src/tui/init.tsx +0 -208
- package/src/tui/matrix-model.ts +0 -279
- package/src/tui/matrix.tsx +0 -512
- package/src/tui/profiles.ts +0 -38
- package/src/tui/theme.ts +0 -177
- package/src/tui/ui.tsx +0 -267
- package/src/tui/watch-model.ts +0 -83
- package/src/tui/watch.tsx +0 -236
- package/src/types.ts +0 -184
- package/src/version.ts +0 -7
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { Budget } from "../domain/budget.ts";
|
|
2
|
+
import type { SessionEnv } from "../infra/claude-session.ts";
|
|
3
|
+
import type { BillingMode } from "../domain/cost.ts";
|
|
4
|
+
import type { Verdict } from "../domain/jev.ts";
|
|
5
|
+
import type { Difficulty, Kind } from "../domain/lane.ts";
|
|
6
|
+
import type { Access } from "../domain/record.ts";
|
|
7
|
+
import type { Role } from "../domain/roles.ts";
|
|
8
|
+
import type { Change, ProfilePatch } from "../domain/profile.ts";
|
|
9
|
+
import type { Issue } from "../domain/profile-rules.ts";
|
|
10
|
+
import type { RouteJev, RouteSource } from "../domain/route.ts";
|
|
11
|
+
|
|
12
|
+
/** What the run lifecycle reads from a profile. Rungs are `backend:model#effort`; a role's in ladder order. */
|
|
13
|
+
export interface ProfileView {
|
|
14
|
+
name: string;
|
|
15
|
+
objective: "cost" | "speed";
|
|
16
|
+
/** `defaultRung`, when set, is where a lane starts without a kind and difficulty (spec §5.4) */
|
|
17
|
+
roles: Partial<
|
|
18
|
+
Record<Role, { enabled: boolean; access: Access; rungs: string[]; defaultRung?: string; network?: false }>
|
|
19
|
+
>;
|
|
20
|
+
/** per billing key (spec §7.1); a missing key bills as DEFAULT_BILLING */
|
|
21
|
+
billing: Partial<Record<string, BillingMode>>;
|
|
22
|
+
jev: { use: "auto" | "off" };
|
|
23
|
+
/** per backend id: run its harness isolated (spec §7.1 `harness`) */
|
|
24
|
+
isolated: Partial<Record<string, boolean>>;
|
|
25
|
+
/** rung → its stand-in on a usage limit (spec §4.5) */
|
|
26
|
+
failover: Record<string, string>;
|
|
27
|
+
budget: Budget;
|
|
28
|
+
timeouts: { idleMin: number; wallMin: number };
|
|
29
|
+
preflight: { confirm: boolean };
|
|
30
|
+
heavy: number | "cpus/2";
|
|
31
|
+
notify: string[];
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** What a profile write returns (spec §7.3): whether it saved, why not, what changed, and the agents it touched. */
|
|
35
|
+
export interface ProfileSaved {
|
|
36
|
+
saved: boolean;
|
|
37
|
+
errors: Issue[];
|
|
38
|
+
warnings: Issue[];
|
|
39
|
+
diff: Change[];
|
|
40
|
+
linked: string[];
|
|
41
|
+
pruned: string[];
|
|
42
|
+
/** agents whose link is new or whose file changed: they apply from the next Claude Code session */
|
|
43
|
+
newSessionNeededFor: string[];
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export interface ProfilePort {
|
|
47
|
+
/** The profile bound to `repo` (a git toplevel), else the active one; null asks for the active one. */
|
|
48
|
+
forRepo(repo: string | null): ProfileView;
|
|
49
|
+
/** Without a name (get, validate, set): the profile `repo`, a repository's toplevel, runs on. */
|
|
50
|
+
get(
|
|
51
|
+
name?: string,
|
|
52
|
+
repo?: string | null,
|
|
53
|
+
): {
|
|
54
|
+
/** the global active profile */
|
|
55
|
+
active: string;
|
|
56
|
+
/** the profile `repo` runs on: its binding, else the active one */
|
|
57
|
+
here: string;
|
|
58
|
+
profiles: string[];
|
|
59
|
+
profile: ProfileView;
|
|
60
|
+
/** per role: whether its backend holds it to its access mode (spec D10) */
|
|
61
|
+
enforcement: Partial<Record<Role, "enforced" | "advisory">>;
|
|
62
|
+
};
|
|
63
|
+
validate(name?: string, repo?: string | null): { valid: boolean; errors: Issue[]; warnings: Issue[] };
|
|
64
|
+
set(name: string | undefined, patch: ProfilePatch, repo?: string | null): ProfileSaved;
|
|
65
|
+
/** The native agent that runs `rung` for `role` under `repo`'s profile; null unless it is a `claude:` rung. */
|
|
66
|
+
agentFor(repo: string | null, role: Role, rung: string): string | null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RouteRequest {
|
|
70
|
+
runDir: string;
|
|
71
|
+
repo: string;
|
|
72
|
+
profile: ProfileView;
|
|
73
|
+
role: Role;
|
|
74
|
+
/** the lane's id, for jev.jsonl; null when routing a role without a lane */
|
|
75
|
+
lane: string | null;
|
|
76
|
+
laneText: string | null;
|
|
77
|
+
spentFraction: number;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export interface RouteAnswer {
|
|
81
|
+
rung: string;
|
|
82
|
+
ladder: string[];
|
|
83
|
+
source: RouteSource;
|
|
84
|
+
kind: Kind | null;
|
|
85
|
+
difficulty: Difficulty | null;
|
|
86
|
+
/** the Jev question set asked, and its summed probabilities, for outcomes.jsonl (spec §5.6) */
|
|
87
|
+
questionSet: string | null;
|
|
88
|
+
jev: RouteJev | null;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export type { Verdict };
|
|
92
|
+
|
|
93
|
+
export interface CatalogFilter {
|
|
94
|
+
role?: Role;
|
|
95
|
+
backend?: string;
|
|
96
|
+
text?: string;
|
|
97
|
+
scoredOnly: boolean;
|
|
98
|
+
limit: number;
|
|
99
|
+
/** the git toplevel whose per-repository listings (opencode's) to read; none reads the global ones */
|
|
100
|
+
repo?: string;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface RoutingPort {
|
|
104
|
+
route(req: RouteRequest): Promise<RouteAnswer>;
|
|
105
|
+
/** `use` is the repo profile's `jev.use`: "off" answers with the rule's default and never asks Jev */
|
|
106
|
+
finding(
|
|
107
|
+
runDir: string,
|
|
108
|
+
laneText: string,
|
|
109
|
+
finding: string,
|
|
110
|
+
use: "auto" | "off",
|
|
111
|
+
): Promise<Verdict<"design" | "code" | "unclear">>;
|
|
112
|
+
sameDefect(
|
|
113
|
+
runDir: string,
|
|
114
|
+
before: string,
|
|
115
|
+
after: string,
|
|
116
|
+
use: "auto" | "off",
|
|
117
|
+
): Promise<Verdict<"yes" | "no">>;
|
|
118
|
+
/** `billing` prices the rungs (spec §5.3); absent keys bill as DEFAULT_BILLING */
|
|
119
|
+
catalog(
|
|
120
|
+
filter: CatalogFilter,
|
|
121
|
+
billing?: Partial<Record<string, BillingMode>>,
|
|
122
|
+
): { total: number; models: unknown[] };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** Everything a service needs from outside it; the entry layer builds one, tests build fakes. */
|
|
126
|
+
export interface Deps {
|
|
127
|
+
profiles: ProfilePort;
|
|
128
|
+
routing: RoutingPort;
|
|
129
|
+
version: string;
|
|
130
|
+
/** how often the supervisor and the dispatch watchers poll, in ms */
|
|
131
|
+
pollMs: number;
|
|
132
|
+
/** the Claude Code session this process serves (spec §3.3), from its environment; null outside one */
|
|
133
|
+
session: SessionEnv | null;
|
|
134
|
+
now: () => number;
|
|
135
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CatherdError, errorMessage } from "../domain/errors.ts";
|
|
4
|
+
import { assertLaneHeader, LANE_HEADER_FIX, parseLaneHeader } from "../domain/lane.ts";
|
|
5
|
+
import { checkEnv } from "../infra/env.ts";
|
|
6
|
+
import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
|
|
7
|
+
import { killGroup } from "../infra/proc.ts";
|
|
8
|
+
import type { Deps } from "./ports.ts";
|
|
9
|
+
import { TAIL_LINES } from "./run-debug.ts";
|
|
10
|
+
import { findRun, type Run, runPaths } from "./run-store.ts";
|
|
11
|
+
|
|
12
|
+
/** Spec §4.7. Only `cannot-start` blocks the run. */
|
|
13
|
+
export type PreflightOutcome = "pass" | "fails-as-expected" | "skipped" | "cannot-start";
|
|
14
|
+
|
|
15
|
+
interface PreflightResult {
|
|
16
|
+
lane: string;
|
|
17
|
+
check: string | null;
|
|
18
|
+
outcome: PreflightOutcome;
|
|
19
|
+
exitCode: number | null;
|
|
20
|
+
tail: string[];
|
|
21
|
+
note: string | null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export type PreflightReport =
|
|
25
|
+
| { needsConfirmation: true; commands: { lane: string; check: string | null }[] }
|
|
26
|
+
| {
|
|
27
|
+
needsConfirmation: false;
|
|
28
|
+
commands: { lane: string; check: string | null }[];
|
|
29
|
+
results: PreflightResult[];
|
|
30
|
+
blocked: boolean;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const CHECK_TIMEOUT_MS = 120_000;
|
|
34
|
+
|
|
35
|
+
/** Whose uid preflight runs under; tests stand in for root with it. */
|
|
36
|
+
export const preflightUser = { uid: (): number => process.getuid?.() ?? -1 };
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Spec §10.4: a lane's check never runs as root. A machine that says it is disposable (IS_SANDBOX, the
|
|
40
|
+
* rule claude itself applies to root) is the one exception.
|
|
41
|
+
*/
|
|
42
|
+
const refusesRoot = (): boolean => preflightUser.uid() === 0 && !process.env.IS_SANDBOX;
|
|
43
|
+
const AS_ROOT =
|
|
44
|
+
"not run: catherd runs as root, and preflight never runs a lane's check as root (spec §10.4); run catherd as a normal user, or set IS_SANDBOX=1 on a disposable machine";
|
|
45
|
+
|
|
46
|
+
interface LaneCheck {
|
|
47
|
+
lane: string;
|
|
48
|
+
check: string | null;
|
|
49
|
+
owns: string[];
|
|
50
|
+
problem: string | null;
|
|
51
|
+
/** spec 1.1 §6: why its Kind: or Difficulty: line is refused */
|
|
52
|
+
invalid: string | null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function laneChecks(run: Run): LaneCheck[] {
|
|
56
|
+
const dir = runPaths(run.dir).lanes;
|
|
57
|
+
if (!existsSync(dir)) return [];
|
|
58
|
+
return readdirSync(dir)
|
|
59
|
+
.filter((f) => f.endsWith(".md"))
|
|
60
|
+
.sort()
|
|
61
|
+
.map((f) => {
|
|
62
|
+
const lane = f.slice(0, -".md".length);
|
|
63
|
+
let invalid: string | null = null;
|
|
64
|
+
try {
|
|
65
|
+
const text = readFileSync(join(dir, f), "utf8");
|
|
66
|
+
try {
|
|
67
|
+
assertLaneHeader(text, `lanes/${f}`);
|
|
68
|
+
} catch (e) {
|
|
69
|
+
invalid = errorMessage(e);
|
|
70
|
+
}
|
|
71
|
+
const h = parseLaneHeader(text);
|
|
72
|
+
return {
|
|
73
|
+
lane,
|
|
74
|
+
check: h.fastCheck,
|
|
75
|
+
owns: h.owns,
|
|
76
|
+
problem: h.fastCheck ? null : `lanes/${f} has no Fast check: line`,
|
|
77
|
+
invalid,
|
|
78
|
+
};
|
|
79
|
+
} catch (e) {
|
|
80
|
+
return { lane, check: null, owns: [], problem: (e as Error).message, invalid };
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** How long the pipes may stay open after the check's own process exits. */
|
|
86
|
+
export const DRAIN_MS = 500;
|
|
87
|
+
|
|
88
|
+
/** Reads a pipe chunk by chunk, so what arrived is kept when the reading stops early. */
|
|
89
|
+
export function collect(stream: ReadableStream<Uint8Array>): {
|
|
90
|
+
done: Promise<void>;
|
|
91
|
+
text: () => string;
|
|
92
|
+
stop: () => void;
|
|
93
|
+
} {
|
|
94
|
+
const reader = stream.getReader();
|
|
95
|
+
const decoder = new TextDecoder();
|
|
96
|
+
let text = "";
|
|
97
|
+
const done = (async () => {
|
|
98
|
+
for (;;) {
|
|
99
|
+
const { value, done } = await reader.read();
|
|
100
|
+
if (done) return;
|
|
101
|
+
text += decoder.decode(value, { stream: true });
|
|
102
|
+
}
|
|
103
|
+
})().catch(() => {
|
|
104
|
+
// a pipe that breaks ends the reading; what arrived is kept
|
|
105
|
+
});
|
|
106
|
+
// cancelling a pipe that is already closed has nothing to report
|
|
107
|
+
return { done, text: () => text, stop: () => void reader.cancel().catch(() => {}) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Runs `check` with `sh -c` in its own process group, with an allowlisted env (no credentials), killing the
|
|
112
|
+
* group on timeout. A process the check leaves behind (even in a new session) may hold the pipes open: once
|
|
113
|
+
* the check exits, the pipes get DRAIN_MS to close, then the reading stops with what arrived.
|
|
114
|
+
*/
|
|
115
|
+
export async function runCheck(
|
|
116
|
+
repo: string,
|
|
117
|
+
check: string,
|
|
118
|
+
timeoutMs: number,
|
|
119
|
+
): Promise<{ code: number | null; timedOut: boolean; tail: string[] }> {
|
|
120
|
+
const p = Bun.spawn(["sh", "-c", check], {
|
|
121
|
+
cwd: repo,
|
|
122
|
+
env: checkEnv(process.env, repo),
|
|
123
|
+
stdin: "ignore",
|
|
124
|
+
stdout: "pipe",
|
|
125
|
+
stderr: "pipe",
|
|
126
|
+
detached: true,
|
|
127
|
+
});
|
|
128
|
+
let timedOut = false;
|
|
129
|
+
const timer = setTimeout(() => {
|
|
130
|
+
timedOut = true;
|
|
131
|
+
killGroup(p.pid, "SIGKILL");
|
|
132
|
+
}, timeoutMs);
|
|
133
|
+
const out = collect(p.stdout);
|
|
134
|
+
const err = collect(p.stderr);
|
|
135
|
+
try {
|
|
136
|
+
// the timeout kills the group, so the check's own process always exits
|
|
137
|
+
const code = await p.exited;
|
|
138
|
+
clearTimeout(timer); // the check is done: a slow drain is not a timeout
|
|
139
|
+
const drained = await Promise.race([
|
|
140
|
+
Promise.all([out.done, err.done]).then(() => true),
|
|
141
|
+
Bun.sleep(DRAIN_MS).then(() => false),
|
|
142
|
+
]);
|
|
143
|
+
if (!drained) killGroup(p.pid, "SIGKILL"); // leftovers still in the check's group
|
|
144
|
+
const tail = `${out.text()}${err.text()}`
|
|
145
|
+
.split("\n")
|
|
146
|
+
.filter((l) => l.trim())
|
|
147
|
+
.slice(-TAIL_LINES);
|
|
148
|
+
return { code: timedOut ? null : code, timedOut, tail };
|
|
149
|
+
} finally {
|
|
150
|
+
clearTimeout(timer);
|
|
151
|
+
out.stop();
|
|
152
|
+
err.stop();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function classify(r: { code: number | null; timedOut: boolean; tail: string[] }): PreflightOutcome {
|
|
157
|
+
if (r.timedOut || r.code === 126 || r.code === 127) return "cannot-start";
|
|
158
|
+
if (r.code === 0) return "pass";
|
|
159
|
+
return r.tail.some((l) => /command not found/i.test(l)) ? "cannot-start" : "fails-as-expected";
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Spec §4.7: each lane's fast check once, on the base tree, behind the heavy lock. A check on a file the
|
|
164
|
+
* lane itself creates is skipped. With `profile.preflight.confirm`, the first call only lists the commands.
|
|
165
|
+
*/
|
|
166
|
+
export async function preflight(
|
|
167
|
+
deps: Deps,
|
|
168
|
+
i: { run: string; confirmed?: boolean; timeoutMs?: number },
|
|
169
|
+
): Promise<PreflightReport> {
|
|
170
|
+
const run = findRun(i.run);
|
|
171
|
+
const profile = deps.profiles.forRepo(run.meta.repo);
|
|
172
|
+
const lanes = laneChecks(run);
|
|
173
|
+
// spec 1.1 §6: refuse before running anything, naming every lane whose header the catalog cannot route
|
|
174
|
+
const invalid = lanes.flatMap((l) => (l.invalid ? [l.invalid] : []));
|
|
175
|
+
if (invalid.length) throw new CatherdError("E_LANE_INVALID", invalid.join("; "), { fix: LANE_HEADER_FIX });
|
|
176
|
+
const commands = lanes.map((l) => ({ lane: l.lane, check: l.check }));
|
|
177
|
+
if (profile.preflight.confirm && !i.confirmed) return { needsConfirmation: true, commands };
|
|
178
|
+
const timeoutMs = i.timeoutMs ?? CHECK_TIMEOUT_MS;
|
|
179
|
+
const results: PreflightResult[] = [];
|
|
180
|
+
for (const l of lanes) {
|
|
181
|
+
const check = l.check;
|
|
182
|
+
if (!check) {
|
|
183
|
+
results.push({
|
|
184
|
+
lane: l.lane,
|
|
185
|
+
check,
|
|
186
|
+
outcome: "cannot-start",
|
|
187
|
+
exitCode: null,
|
|
188
|
+
tail: [],
|
|
189
|
+
note: l.problem,
|
|
190
|
+
});
|
|
191
|
+
continue;
|
|
192
|
+
}
|
|
193
|
+
if (refusesRoot()) {
|
|
194
|
+
results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note: AS_ROOT });
|
|
195
|
+
continue;
|
|
196
|
+
}
|
|
197
|
+
const unborn = l.owns.filter(
|
|
198
|
+
(p) => check.includes(p.replace(/\/$/, "")) && !existsSync(join(run.meta.repo, p)),
|
|
199
|
+
);
|
|
200
|
+
if (unborn.length) {
|
|
201
|
+
const note = `${unborn.join(", ")} does not exist yet; the lane creates it`;
|
|
202
|
+
results.push({ lane: l.lane, check, outcome: "skipped", exitCode: null, tail: [], note });
|
|
203
|
+
continue;
|
|
204
|
+
}
|
|
205
|
+
const r = await withHeavySlot(heavySlots(profile.heavy), () => runCheck(run.meta.repo, check, timeoutMs));
|
|
206
|
+
results.push({
|
|
207
|
+
lane: l.lane,
|
|
208
|
+
check,
|
|
209
|
+
outcome: classify(r),
|
|
210
|
+
exitCode: r.code,
|
|
211
|
+
tail: r.tail,
|
|
212
|
+
note: r.timedOut ? `timed out after ${timeoutMs / 1000} s` : null,
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
return {
|
|
216
|
+
needsConfirmation: false,
|
|
217
|
+
commands,
|
|
218
|
+
results,
|
|
219
|
+
blocked: results.some((r) => r.outcome === "cannot-start"),
|
|
220
|
+
};
|
|
221
|
+
}
|
|
@@ -0,0 +1,292 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { isDeepStrictEqual } from "node:util";
|
|
4
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { parseRung } from "../domain/ids.ts";
|
|
6
|
+
import {
|
|
7
|
+
agentName,
|
|
8
|
+
applyPatch,
|
|
9
|
+
assertProfileName,
|
|
10
|
+
type Change,
|
|
11
|
+
defaultProfileDoc,
|
|
12
|
+
diffProfiles,
|
|
13
|
+
type Profile,
|
|
14
|
+
type ProfileDoc,
|
|
15
|
+
type ProfilePatch,
|
|
16
|
+
resolveProfile,
|
|
17
|
+
} from "../domain/profile.ts";
|
|
18
|
+
import { type Validation, validateProfile } from "../domain/profile-rules.ts";
|
|
19
|
+
import { ROLES } from "../domain/roles.ts";
|
|
20
|
+
import { withFileLockSync } from "../infra/filelock.ts";
|
|
21
|
+
import { configDir } from "../infra/paths.ts";
|
|
22
|
+
import { ensurePrivateDir, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
23
|
+
import { apply, plan, type Synced } from "./agent-links.ts";
|
|
24
|
+
import { loadCatalog } from "./catalog-service.ts";
|
|
25
|
+
import type { ProfilePort, ProfileSaved, ProfileView } from "./ports.ts";
|
|
26
|
+
import {
|
|
27
|
+
activeName,
|
|
28
|
+
configFile,
|
|
29
|
+
getProfile,
|
|
30
|
+
listProfiles,
|
|
31
|
+
profileExists,
|
|
32
|
+
profileFile,
|
|
33
|
+
profileFor,
|
|
34
|
+
projectsFile,
|
|
35
|
+
readConfig,
|
|
36
|
+
readProfileDoc,
|
|
37
|
+
readProjects,
|
|
38
|
+
requireProfile,
|
|
39
|
+
roleEnforcement,
|
|
40
|
+
runnableBackends,
|
|
41
|
+
validateNamed,
|
|
42
|
+
} from "./profile-store.ts";
|
|
43
|
+
|
|
44
|
+
// Spec §7.3, the single writer: one lock over profiles, config.json, projects.json, agent files and links.
|
|
45
|
+
// Reads live in profile-store.ts, the agent files and links in agent-links.ts.
|
|
46
|
+
|
|
47
|
+
/** The profiles lock every writer here takes; `init` moves 0.x files aside under it too. */
|
|
48
|
+
export const withProfilesLock = <T>(fn: () => T): T => {
|
|
49
|
+
ensurePrivateDir(configDir());
|
|
50
|
+
return withFileLockSync(join(configDir(), "profiles"), fn);
|
|
51
|
+
};
|
|
52
|
+
const locked = withProfilesLock;
|
|
53
|
+
|
|
54
|
+
const writeDoc = (name: string, doc: ProfileDoc) => writeJsonAtomic(profileFile(name), { ...doc, name });
|
|
55
|
+
|
|
56
|
+
/** Writes `doc` as profile `name` and relinks; when the relink refuses, puts the profile back as it was. */
|
|
57
|
+
function saveAndLink(name: string, doc: ProfileDoc): Synced {
|
|
58
|
+
const before = existsSync(profileFile(name)) ? readFileSync(profileFile(name), "utf8") : null;
|
|
59
|
+
writeDoc(name, doc);
|
|
60
|
+
try {
|
|
61
|
+
return apply(plan([name]));
|
|
62
|
+
} catch (e) {
|
|
63
|
+
if (before === null) rmSync(profileFile(name), { force: true });
|
|
64
|
+
else writeTextAtomic(profileFile(name), before);
|
|
65
|
+
throw e;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const validate = (doc: ProfileDoc, name: string): Validation =>
|
|
70
|
+
validateProfile(resolveProfile(doc, name), loadCatalog({ timings: false }), runnableBackends(), doc);
|
|
71
|
+
|
|
72
|
+
/** What a save returns: the port's ProfileSaved (one type for the CLI, the TUI and the MCP tools). */
|
|
73
|
+
export type Saved = ProfileSaved;
|
|
74
|
+
|
|
75
|
+
const unsaved = (v: Validation): Saved => ({
|
|
76
|
+
saved: false,
|
|
77
|
+
...v,
|
|
78
|
+
diff: [],
|
|
79
|
+
linked: [],
|
|
80
|
+
pruned: [],
|
|
81
|
+
newSessionNeededFor: [],
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/** The issue path of a save refused because the profile is no longer the one its caller was shown. */
|
|
85
|
+
export const CHANGED_ON_DISK = "profile";
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Spec §7.3 `patch` (profile_set, `catherd profile set`): validates first and writes nothing when invalid.
|
|
89
|
+
* A profile that does not exist yet starts from the default profile. With `expect` (the profile as a
|
|
90
|
+
* preview read it), it writes nothing when the profile under the lock is no longer that one: the patch
|
|
91
|
+
* would land on values the preview never showed.
|
|
92
|
+
*/
|
|
93
|
+
export function patchProfile(
|
|
94
|
+
name: string | undefined,
|
|
95
|
+
patch: ProfilePatch,
|
|
96
|
+
opts: { expect?: ProfileDoc } = {},
|
|
97
|
+
): Saved {
|
|
98
|
+
return locked(() => {
|
|
99
|
+
const n = assertProfileName(name ?? activeName());
|
|
100
|
+
const before = profileExists(n) ? readProfileDoc(n) : defaultProfileDoc(n);
|
|
101
|
+
if (opts.expect !== undefined && !isDeepStrictEqual(before, opts.expect))
|
|
102
|
+
return unsaved({
|
|
103
|
+
errors: [
|
|
104
|
+
{
|
|
105
|
+
path: CHANGED_ON_DISK,
|
|
106
|
+
message: `profile "${n}" changed on disk since it was shown`,
|
|
107
|
+
fix: "check the changes and save again",
|
|
108
|
+
},
|
|
109
|
+
],
|
|
110
|
+
warnings: [],
|
|
111
|
+
});
|
|
112
|
+
const after = applyPatch(before, patch);
|
|
113
|
+
const resolved = resolveProfile(after, n);
|
|
114
|
+
const v = validate(after, n);
|
|
115
|
+
if (v.errors.length) return unsaved(v);
|
|
116
|
+
const diff = diffProfiles(resolveProfile(before, n), resolved);
|
|
117
|
+
return { saved: true, ...v, diff, ...saveAndLink(n, after) };
|
|
118
|
+
});
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/** Spec §7.3 `create` and `copy`: a new profile from `from` (default: the default profile). */
|
|
122
|
+
export function createProfile(name: string, from?: string): Saved {
|
|
123
|
+
return locked(() => {
|
|
124
|
+
// `default` exists without a file: resetProfile is the only way to write it
|
|
125
|
+
if (profileExists(assertProfileName(name)))
|
|
126
|
+
throw new CatherdError("E_INPUT_INVALID", `profile "${name}" already exists`, {
|
|
127
|
+
fix: `pick another name, or edit it with catherd profile set --profile ${name}`,
|
|
128
|
+
});
|
|
129
|
+
if (from !== undefined && !profileExists(assertProfileName(from)))
|
|
130
|
+
throw new CatherdError("E_INPUT_INVALID", `no profile named "${from}" to copy`, {
|
|
131
|
+
fix: "catherd profile list",
|
|
132
|
+
});
|
|
133
|
+
const doc = from === undefined ? defaultProfileDoc(name) : readProfileDoc(from);
|
|
134
|
+
const v = validate(doc, name);
|
|
135
|
+
if (v.errors.length) return unsaved(v);
|
|
136
|
+
return { saved: true, ...v, diff: [], ...saveAndLink(name, doc) };
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/** Writes the default profile under `name`, replacing what is there (`catherd init` when asked to). */
|
|
141
|
+
export function resetProfile(name: string): Saved {
|
|
142
|
+
return locked(() => {
|
|
143
|
+
assertProfileName(name);
|
|
144
|
+
const doc = defaultProfileDoc(name);
|
|
145
|
+
const v = validate(doc, name);
|
|
146
|
+
if (v.errors.length) return unsaved(v);
|
|
147
|
+
return { saved: true, ...v, diff: [], ...saveAndLink(name, doc) };
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/**
|
|
152
|
+
* Spec §7.3 `delete`: never the active profile or a repo-bound one; its agent files go with it. A binding
|
|
153
|
+
* whose repo no longer exists does not count: it is pruned with the profile.
|
|
154
|
+
*/
|
|
155
|
+
export function deleteProfile(name: string): Synced {
|
|
156
|
+
return locked(() => {
|
|
157
|
+
assertProfileName(name);
|
|
158
|
+
if (!existsSync(profileFile(name)))
|
|
159
|
+
throw new CatherdError("E_INPUT_INVALID", `profile "${name}" has no file to delete`, {
|
|
160
|
+
fix: "catherd profile list",
|
|
161
|
+
});
|
|
162
|
+
if (name === activeName())
|
|
163
|
+
throw new CatherdError("E_INPUT_INVALID", `"${name}" is the active profile`, {
|
|
164
|
+
fix: "make another profile active first: catherd profile use <name>",
|
|
165
|
+
});
|
|
166
|
+
const projects = readProjects();
|
|
167
|
+
const mine = Object.keys(projects.bindings).filter((repo) => projects.bindings[repo] === name);
|
|
168
|
+
const bound = mine.filter((repo) => existsSync(repo));
|
|
169
|
+
if (bound.length)
|
|
170
|
+
throw new CatherdError("E_INPUT_INVALID", `"${name}" is bound to ${bound.join(", ")}`, {
|
|
171
|
+
fix: "run catherd profile use --repo --clear (or use <name> --repo) inside each of those repos",
|
|
172
|
+
});
|
|
173
|
+
const text = readFileSync(profileFile(name), "utf8");
|
|
174
|
+
rmSync(profileFile(name), { force: true });
|
|
175
|
+
const gone = new Set(mine);
|
|
176
|
+
if (gone.size)
|
|
177
|
+
writeJsonAtomic(projectsFile(), {
|
|
178
|
+
...projects,
|
|
179
|
+
bindings: Object.fromEntries(Object.entries(projects.bindings).filter(([r]) => !gone.has(r))),
|
|
180
|
+
});
|
|
181
|
+
try {
|
|
182
|
+
return apply(plan(), [name]);
|
|
183
|
+
} catch (e) {
|
|
184
|
+
writeTextAtomic(profileFile(name), text);
|
|
185
|
+
if (gone.size) writeJsonAtomic(projectsFile(), projects);
|
|
186
|
+
throw e;
|
|
187
|
+
}
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/** Spec §7.3 `activate(name, repo?)`: the active profile, or the one bound to a repo; relinks the agents. */
|
|
192
|
+
export function activate(
|
|
193
|
+
name: string,
|
|
194
|
+
repo: string | null = null,
|
|
195
|
+
): Synced & { active: string; repo: string | null } {
|
|
196
|
+
return locked(() => {
|
|
197
|
+
assertProfileName(name);
|
|
198
|
+
if (!profileExists(name))
|
|
199
|
+
throw new CatherdError("E_INPUT_INVALID", `no profile named "${name}"`, {
|
|
200
|
+
fix: "catherd profile list",
|
|
201
|
+
});
|
|
202
|
+
const config = readConfig();
|
|
203
|
+
const projects = readProjects();
|
|
204
|
+
const write = () =>
|
|
205
|
+
repo === null
|
|
206
|
+
? writeJsonAtomic(configFile(), { ...config, schema: 1, activeProfile: name })
|
|
207
|
+
: writeJsonAtomic(projectsFile(), {
|
|
208
|
+
...projects,
|
|
209
|
+
schema: 1,
|
|
210
|
+
bindings: { ...projects.bindings, [repo]: name },
|
|
211
|
+
});
|
|
212
|
+
write();
|
|
213
|
+
try {
|
|
214
|
+
return { active: name, repo, ...apply(plan([name])) };
|
|
215
|
+
} catch (e) {
|
|
216
|
+
if (repo === null) writeJsonAtomic(configFile(), config);
|
|
217
|
+
else writeJsonAtomic(projectsFile(), projects);
|
|
218
|
+
throw e;
|
|
219
|
+
}
|
|
220
|
+
});
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/** `catherd profile use --repo --clear`: removes `repo`'s binding, so it runs on the active profile again. */
|
|
224
|
+
export function unbind(repo: string): Synced & { repo: string; was: string } {
|
|
225
|
+
return locked(() => {
|
|
226
|
+
const projects = readProjects();
|
|
227
|
+
const was = projects.bindings[repo];
|
|
228
|
+
if (was === undefined)
|
|
229
|
+
throw new CatherdError("E_INPUT_INVALID", `${repo} is bound to no profile`, {
|
|
230
|
+
fix: "catherd profile list shows the bound repos",
|
|
231
|
+
});
|
|
232
|
+
const bindings = { ...projects.bindings };
|
|
233
|
+
delete bindings[repo];
|
|
234
|
+
writeJsonAtomic(projectsFile(), { ...projects, schema: 1, bindings });
|
|
235
|
+
try {
|
|
236
|
+
return { repo, was, ...apply(plan()) };
|
|
237
|
+
} catch (e) {
|
|
238
|
+
writeJsonAtomic(projectsFile(), projects);
|
|
239
|
+
throw e;
|
|
240
|
+
}
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
export function diffNamed(a: string, b: string): Change[] {
|
|
245
|
+
return diffProfiles(getProfile(a), getProfile(b));
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function viewOf(p: Profile): ProfileView {
|
|
249
|
+
const roles: ProfileView["roles"] = {};
|
|
250
|
+
for (const role of ROLES) roles[role] = { ...p.roles[role], rungs: [...p.roles[role].rungs] };
|
|
251
|
+
return {
|
|
252
|
+
name: p.name,
|
|
253
|
+
objective: p.objective,
|
|
254
|
+
roles,
|
|
255
|
+
billing: p.billing,
|
|
256
|
+
jev: p.jev,
|
|
257
|
+
isolated: Object.fromEntries(Object.entries(p.harness).map(([k, h]) => [k, h.isolated])),
|
|
258
|
+
failover: p.failover,
|
|
259
|
+
budget: p.budget,
|
|
260
|
+
timeouts: p.timeouts,
|
|
261
|
+
preflight: p.preflight,
|
|
262
|
+
heavy: p.lock.heavy,
|
|
263
|
+
notify: p.notify,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/** The ProfilePort the run engine and the MCP tools use (spec §7.3: the single writer). */
|
|
268
|
+
export function profileService(): ProfilePort {
|
|
269
|
+
// without a name, each tool acts on the profile `repo` runs on (the active one outside a repo)
|
|
270
|
+
return {
|
|
271
|
+
forRepo: (repo) => viewOf(profileFor(repo)),
|
|
272
|
+
get(name, repo = null) {
|
|
273
|
+
const here = activeName(repo);
|
|
274
|
+
const p = getProfile(name === undefined ? here : requireProfile(name));
|
|
275
|
+
return {
|
|
276
|
+
active: activeName(),
|
|
277
|
+
here,
|
|
278
|
+
profiles: listProfiles(),
|
|
279
|
+
profile: viewOf(p),
|
|
280
|
+
enforcement: roleEnforcement(p),
|
|
281
|
+
};
|
|
282
|
+
},
|
|
283
|
+
validate(name, repo = null) {
|
|
284
|
+
const v = validateNamed(name === undefined ? activeName(repo) : requireProfile(name), repo);
|
|
285
|
+
return { valid: v.errors.length === 0, ...v };
|
|
286
|
+
},
|
|
287
|
+
// a name that does not exist yet starts from the default profile
|
|
288
|
+
set: (name, patch, repo = null) => patchProfile(name ?? activeName(repo), patch),
|
|
289
|
+
agentFor: (repo, role, rung) =>
|
|
290
|
+
parseRung(rung).backend === "claude" ? agentName(activeName(repo), role, rung) : null,
|
|
291
|
+
};
|
|
292
|
+
}
|