catherd-cli 1.3.0 → 1.4.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 +75 -36
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +44 -38
- package/plugin/skills/catherd-setup/SKILL.md +22 -6
- package/src/adapters/access.ts +7 -1
- package/src/adapters/cli.ts +1 -64
- package/src/cli.ts +48 -19
- package/src/domain/host.ts +17 -0
- package/src/domain/notice.ts +5 -2
- package/src/domain/profile-rules.ts +17 -0
- package/src/domain/profile.ts +32 -5
- package/src/domain/record.ts +1 -0
- package/src/entry/catalog-command.ts +4 -2
- package/src/entry/deps.ts +6 -3
- package/src/entry/doctor-command.ts +11 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +18 -7
- package/src/entry/lock-command.ts +7 -2
- package/src/entry/mcp/handshake.ts +3 -1
- package/src/entry/mcp/run-tools.ts +5 -2
- package/src/entry/mcp/server.ts +129 -40
- package/src/entry/mcp/setup-tools.ts +7 -2
- package/src/entry/profile-command.ts +108 -12
- package/src/entry/runs-command.ts +81 -5
- package/src/entry/tui/effects.ts +19 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +19 -6
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +2 -2
- package/src/entry/tui/views/save-dialog.tsx +2 -2
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +83 -0
- package/src/infra/dispatch-dir.ts +1 -0
- package/src/infra/env.ts +4 -1
- package/src/infra/host-context.ts +58 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/supervisor.ts +3 -2
- package/src/services/admission.ts +7 -3
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/dispatch-service.ts +17 -7
- package/src/services/dispatches.ts +6 -1
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor.ts +105 -33
- package/src/services/finalize.ts +1 -1
- package/src/services/lane-service.ts +1 -0
- package/src/services/notifier.ts +322 -70
- package/src/services/peek.ts +46 -9
- package/src/services/ports.ts +8 -2
- package/src/services/profile-service.ts +116 -30
- package/src/services/profile-store.ts +23 -6
- package/src/services/routing-service.ts +6 -1
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-service.ts +2 -0
- package/src/services/run-store.ts +10 -4
- package/src/services/runs-page.ts +3 -2
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +32 -16
- package/src/services/setup.ts +31 -10
- package/src/services/summary.ts +40 -5
- package/src/services/treat-likes.ts +22 -9
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import type { HostContext, OrchestrationHost } from "../domain/host.ts";
|
|
2
|
+
import { agentFiles } from "../domain/agents.ts";
|
|
1
3
|
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
2
4
|
import { join } from "node:path";
|
|
3
5
|
import { isDeepStrictEqual } from "node:util";
|
|
@@ -9,18 +11,19 @@ import {
|
|
|
9
11
|
assertProfileName,
|
|
10
12
|
type Change,
|
|
11
13
|
defaultProfileDoc,
|
|
14
|
+
hostDefaultsDoc,
|
|
12
15
|
diffProfiles,
|
|
13
16
|
type Profile,
|
|
14
17
|
type ProfileDoc,
|
|
15
18
|
type ProfilePatch,
|
|
16
19
|
resolveProfile,
|
|
17
20
|
} from "../domain/profile.ts";
|
|
18
|
-
import { repairs, type Validation } from "../domain/profile-rules.ts";
|
|
21
|
+
import { nativeClaudeIssue, repairs, type Validation } from "../domain/profile-rules.ts";
|
|
19
22
|
import { ROLES } from "../domain/roles.ts";
|
|
20
23
|
import { withFileLockSync } from "../infra/filelock.ts";
|
|
21
24
|
import { configDir } from "../infra/paths.ts";
|
|
22
25
|
import { ensurePrivateDir, writeJsonAtomic, writeTextAtomic } from "../infra/store.ts";
|
|
23
|
-
import { apply, plan, type Synced } from "./agent-links.ts";
|
|
26
|
+
import { apply, plan, pruneProfileAgents, type Synced } from "./agent-links.ts";
|
|
24
27
|
import { loadCatalog } from "./catalog-service.ts";
|
|
25
28
|
import type { ProfilePort, ProfileSaved, ProfileView } from "./ports.ts";
|
|
26
29
|
import {
|
|
@@ -54,11 +57,18 @@ const locked = withProfilesLock;
|
|
|
54
57
|
const writeDoc = (name: string, doc: ProfileDoc) => writeJsonAtomic(profileFile(name), { ...doc, name });
|
|
55
58
|
|
|
56
59
|
/** Writes `doc` as profile `name` and relinks; when the relink refuses, puts the profile back as it was. */
|
|
57
|
-
|
|
60
|
+
/** `repairing`: a saved repair with errors still open, whose remaining native roles are among those errors. */
|
|
61
|
+
function saveAndLink(name: string, doc: ProfileDoc, host: OrchestrationHost, repairing = false): Synced {
|
|
58
62
|
const before = existsSync(profileFile(name)) ? readFileSync(profileFile(name), "utf8") : null;
|
|
63
|
+
const nativeOn = (h: OrchestrationHost) => agentFiles(getProfile(name, h), "").length > 0;
|
|
64
|
+
const hadNative = before !== null && nativeOn(host);
|
|
65
|
+
// omitted defaults are native only on claude-code: the artifacts that resolution made count too
|
|
66
|
+
const hadClaudeNative = before !== null && host !== "claude-code" && nativeOn("claude-code");
|
|
59
67
|
writeDoc(name, doc);
|
|
60
68
|
try {
|
|
61
|
-
|
|
69
|
+
// a profile left with no native role on any host owns no agents; one still native there keeps them
|
|
70
|
+
const allowPrune = host === "claude-code" || hadNative || (hadClaudeNative && !nativeOn("claude-code"));
|
|
71
|
+
return syncFor(name, host, [name], allowPrune, repairing);
|
|
62
72
|
} catch (e) {
|
|
63
73
|
if (before === null) rmSync(profileFile(name), { force: true });
|
|
64
74
|
else writeTextAtomic(profileFile(name), before);
|
|
@@ -66,8 +76,34 @@ function saveAndLink(name: string, doc: ProfileDoc): Synced {
|
|
|
66
76
|
}
|
|
67
77
|
}
|
|
68
78
|
|
|
69
|
-
const validate = (doc: ProfileDoc, name: string): Validation =>
|
|
70
|
-
|
|
79
|
+
const validate = (doc: ProfileDoc, name: string, host: OrchestrationHost): Validation => {
|
|
80
|
+
try {
|
|
81
|
+
return validateHere(resolveProfile(doc, name, host), loadCatalog({ timings: false }), doc, host);
|
|
82
|
+
} catch (e) {
|
|
83
|
+
if (!(e instanceof CatherdError)) throw e;
|
|
84
|
+
return { errors: [{ path: "roles", message: e.message, fix: e.fix }], warnings: [] };
|
|
85
|
+
}
|
|
86
|
+
};
|
|
87
|
+
function nativeAgents(name: string, host: OrchestrationHost) {
|
|
88
|
+
const files = agentFiles(getProfile(name, host), "");
|
|
89
|
+
const first = files[0];
|
|
90
|
+
const issue = first && nativeClaudeIssue(first.rung, host, `roles.${first.role}.rungs`);
|
|
91
|
+
if (issue) throw new CatherdError("E_CONFIG_INVALID", issue.message, { fix: issue.fix });
|
|
92
|
+
return files;
|
|
93
|
+
}
|
|
94
|
+
function syncFor(
|
|
95
|
+
name: string,
|
|
96
|
+
host: OrchestrationHost,
|
|
97
|
+
extra: string[] = [],
|
|
98
|
+
allowPrune = host === "claude-code",
|
|
99
|
+
repairing = false,
|
|
100
|
+
): Synced {
|
|
101
|
+
// a partial repair keeps its open native-role errors: link and prune nothing until they are fixed
|
|
102
|
+
if (repairing && agentFiles(getProfile(name, host), "").some((f) => nativeClaudeIssue(f.rung, host, "")))
|
|
103
|
+
return { linked: [], pruned: [], newSessionNeededFor: [] };
|
|
104
|
+
if (nativeAgents(name, host).length) return apply(plan(host, extra));
|
|
105
|
+
return allowPrune ? pruneProfileAgents(name) : { linked: [], pruned: [], newSessionNeededFor: [] };
|
|
106
|
+
}
|
|
71
107
|
|
|
72
108
|
/** What a save returns: the port's ProfileSaved (one type for the CLI, the TUI and the MCP tools). */
|
|
73
109
|
export type Saved = ProfileSaved;
|
|
@@ -95,7 +131,7 @@ export const CHANGED_ON_DISK = "profile";
|
|
|
95
131
|
export function patchProfile(
|
|
96
132
|
name: string | undefined,
|
|
97
133
|
patch: ProfilePatch,
|
|
98
|
-
opts: { expect?: ProfileDoc }
|
|
134
|
+
opts: { host: OrchestrationHost; expect?: ProfileDoc },
|
|
99
135
|
): Saved {
|
|
100
136
|
return locked(() => {
|
|
101
137
|
const n = assertProfileName(name ?? activeName());
|
|
@@ -111,17 +147,18 @@ export function patchProfile(
|
|
|
111
147
|
],
|
|
112
148
|
warnings: [],
|
|
113
149
|
});
|
|
150
|
+
const host = opts.host;
|
|
114
151
|
const after = applyPatch(before, patch);
|
|
115
|
-
const
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
const diff = diffProfiles(resolveProfile(before, n),
|
|
119
|
-
return { saved: true, ...v, diff, ...saveAndLink(n, after) };
|
|
152
|
+
const v = validate(after, n, host);
|
|
153
|
+
if (v.errors.length && !repairs(profileExists(n) ? validate(before, n, host) : null, v))
|
|
154
|
+
return unsaved(v);
|
|
155
|
+
const diff = diffProfiles(resolveProfile(before, n, host), resolveProfile(after, n, host));
|
|
156
|
+
return { saved: true, ...v, diff, ...saveAndLink(n, after, host, v.errors.length > 0) };
|
|
120
157
|
});
|
|
121
158
|
}
|
|
122
159
|
|
|
123
160
|
/** Spec §7.3 `create` and `copy`: a new profile from `from` (default: the default profile). */
|
|
124
|
-
export function createProfile(name: string, from
|
|
161
|
+
export function createProfile(name: string, from: string | undefined, host: OrchestrationHost): Saved {
|
|
125
162
|
return locked(() => {
|
|
126
163
|
// `default` exists without a file: resetProfile is the only way to write it
|
|
127
164
|
if (profileExists(assertProfileName(name)))
|
|
@@ -133,20 +170,20 @@ export function createProfile(name: string, from?: string): Saved {
|
|
|
133
170
|
fix: "catherd profile list",
|
|
134
171
|
});
|
|
135
172
|
const doc = from === undefined ? defaultProfileDoc(name) : readProfileDoc(from);
|
|
136
|
-
const v = validate(doc, name);
|
|
173
|
+
const v = validate(doc, name, host);
|
|
137
174
|
if (v.errors.length) return unsaved(v);
|
|
138
|
-
return { saved: true, ...v, diff: [], ...saveAndLink(name, doc) };
|
|
175
|
+
return { saved: true, ...v, diff: [], ...saveAndLink(name, doc, host) };
|
|
139
176
|
});
|
|
140
177
|
}
|
|
141
178
|
|
|
142
179
|
/** Writes the default profile under `name`, replacing what is there (`catherd init` when asked to). */
|
|
143
|
-
export function resetProfile(name: string): Saved {
|
|
180
|
+
export function resetProfile(name: string, host: OrchestrationHost): Saved {
|
|
144
181
|
return locked(() => {
|
|
145
182
|
assertProfileName(name);
|
|
146
183
|
const doc = defaultProfileDoc(name);
|
|
147
|
-
const v = validate(doc, name);
|
|
184
|
+
const v = validate(doc, name, host);
|
|
148
185
|
if (v.errors.length) return unsaved(v);
|
|
149
|
-
return { saved: true, ...v, diff: [], ...saveAndLink(name, doc) };
|
|
186
|
+
return { saved: true, ...v, diff: [], ...saveAndLink(name, doc, host) };
|
|
150
187
|
});
|
|
151
188
|
}
|
|
152
189
|
|
|
@@ -181,7 +218,7 @@ export function deleteProfile(name: string): Synced {
|
|
|
181
218
|
bindings: Object.fromEntries(Object.entries(projects.bindings).filter(([r]) => !gone.has(r))),
|
|
182
219
|
});
|
|
183
220
|
try {
|
|
184
|
-
return
|
|
221
|
+
return pruneProfileAgents(name);
|
|
185
222
|
} catch (e) {
|
|
186
223
|
writeTextAtomic(profileFile(name), text);
|
|
187
224
|
if (gone.size) writeJsonAtomic(projectsFile(), projects);
|
|
@@ -193,7 +230,8 @@ export function deleteProfile(name: string): Synced {
|
|
|
193
230
|
/** Spec §7.3 `activate(name, repo?)`: the active profile, or the one bound to a repo; relinks the agents. */
|
|
194
231
|
export function activate(
|
|
195
232
|
name: string,
|
|
196
|
-
repo: string | null
|
|
233
|
+
repo: string | null,
|
|
234
|
+
host: OrchestrationHost,
|
|
197
235
|
): Synced & { active: string; repo: string | null } {
|
|
198
236
|
return locked(() => {
|
|
199
237
|
assertProfileName(name);
|
|
@@ -201,6 +239,7 @@ export function activate(
|
|
|
201
239
|
throw new CatherdError("E_INPUT_INVALID", `no profile named "${name}"`, {
|
|
202
240
|
fix: "catherd profile list",
|
|
203
241
|
});
|
|
242
|
+
nativeAgents(name, host);
|
|
204
243
|
const config = readConfig();
|
|
205
244
|
const projects = readProjects();
|
|
206
245
|
const write = () =>
|
|
@@ -213,7 +252,7 @@ export function activate(
|
|
|
213
252
|
});
|
|
214
253
|
write();
|
|
215
254
|
try {
|
|
216
|
-
return { active: name, repo, ...
|
|
255
|
+
return { active: name, repo, ...syncFor(name, host, [name]) };
|
|
217
256
|
} catch (e) {
|
|
218
257
|
if (repo === null) writeJsonAtomic(configFile(), config);
|
|
219
258
|
else writeJsonAtomic(projectsFile(), projects);
|
|
@@ -223,7 +262,7 @@ export function activate(
|
|
|
223
262
|
}
|
|
224
263
|
|
|
225
264
|
/** `catherd profile use --repo --clear`: removes `repo`'s binding, so it runs on the active profile again. */
|
|
226
|
-
export function unbind(repo: string): Synced & { repo: string; was: string } {
|
|
265
|
+
export function unbind(repo: string, host: OrchestrationHost): Synced & { repo: string; was: string } {
|
|
227
266
|
return locked(() => {
|
|
228
267
|
const projects = readProjects();
|
|
229
268
|
const was = projects.bindings[repo];
|
|
@@ -235,7 +274,11 @@ export function unbind(repo: string): Synced & { repo: string; was: string } {
|
|
|
235
274
|
delete bindings[repo];
|
|
236
275
|
writeJsonAtomic(projectsFile(), { ...projects, schema: 1, bindings });
|
|
237
276
|
try {
|
|
238
|
-
|
|
277
|
+
// a binding to a deleted profile is what doctor sends here to clear: there is nothing of it to relink
|
|
278
|
+
const synced = profileExists(was)
|
|
279
|
+
? syncFor(was, host)
|
|
280
|
+
: { linked: [], pruned: [], newSessionNeededFor: [] };
|
|
281
|
+
return { repo, was, ...synced };
|
|
239
282
|
} catch (e) {
|
|
240
283
|
writeJsonAtomic(projectsFile(), projects);
|
|
241
284
|
throw e;
|
|
@@ -243,8 +286,8 @@ export function unbind(repo: string): Synced & { repo: string; was: string } {
|
|
|
243
286
|
});
|
|
244
287
|
}
|
|
245
288
|
|
|
246
|
-
export function diffNamed(a: string, b: string): Change[] {
|
|
247
|
-
return diffProfiles(getProfile(a), getProfile(b));
|
|
289
|
+
export function diffNamed(a: string, b: string, host: OrchestrationHost): Change[] {
|
|
290
|
+
return diffProfiles(getProfile(a, host), getProfile(b, host));
|
|
248
291
|
}
|
|
249
292
|
|
|
250
293
|
function viewOf(p: Profile): ProfileView {
|
|
@@ -267,13 +310,16 @@ function viewOf(p: Profile): ProfileView {
|
|
|
267
310
|
}
|
|
268
311
|
|
|
269
312
|
/** The ProfilePort the run engine and the MCP tools use (spec §7.3: the single writer). */
|
|
270
|
-
export function profileService(): ProfilePort {
|
|
313
|
+
export function profileService(host: () => HostContext): ProfilePort {
|
|
271
314
|
// without a name, each tool acts on the profile `repo` runs on (the active one outside a repo)
|
|
272
315
|
return {
|
|
273
|
-
|
|
316
|
+
withHost: profileService,
|
|
317
|
+
raw: (name, repo = null) => readProfileDoc(name ?? activeName(repo)),
|
|
318
|
+
budgetFor: (repo) => readProfileDoc(activeName(repo)).budget ?? {},
|
|
319
|
+
forRepo: (repo) => viewOf(profileFor(repo, host().host)),
|
|
274
320
|
get(name, repo = null) {
|
|
275
321
|
const here = activeName(repo);
|
|
276
|
-
const p = getProfile(name === undefined ? here : requireProfile(name));
|
|
322
|
+
const p = getProfile(name === undefined ? here : requireProfile(name), host().host);
|
|
277
323
|
return {
|
|
278
324
|
active: activeName(),
|
|
279
325
|
here,
|
|
@@ -283,12 +329,52 @@ export function profileService(): ProfilePort {
|
|
|
283
329
|
};
|
|
284
330
|
},
|
|
285
331
|
validate(name, repo = null) {
|
|
286
|
-
const v = validateNamed(
|
|
332
|
+
const v = validateNamed(
|
|
333
|
+
name === undefined ? activeName(repo) : requireProfile(name),
|
|
334
|
+
repo,
|
|
335
|
+
host().host,
|
|
336
|
+
);
|
|
287
337
|
return { valid: v.errors.length === 0, ...v };
|
|
288
338
|
},
|
|
289
339
|
// a name that does not exist yet starts from the default profile
|
|
290
|
-
set: (name, patch, repo = null) => patchProfile(name ?? activeName(repo), patch),
|
|
340
|
+
set: (name, patch, repo = null) => patchProfile(name ?? activeName(repo), patch, { host: host().host }),
|
|
291
341
|
agentFor: (repo, role, rung) =>
|
|
292
342
|
parseRung(rung).backend === "claude" ? agentName(activeName(repo), role, rung) : null,
|
|
293
343
|
};
|
|
294
344
|
}
|
|
345
|
+
|
|
346
|
+
export function resetHostDefaults(
|
|
347
|
+
name: string,
|
|
348
|
+
host: OrchestrationHost,
|
|
349
|
+
opts:
|
|
350
|
+
| { preview: true; expect?: ProfileDoc }
|
|
351
|
+
| { preview: false; expect: ProfileDoc; reviewed: { profile: string; host: OrchestrationHost } },
|
|
352
|
+
): Saved & { expect: ProfileDoc; profile: string; host: OrchestrationHost } {
|
|
353
|
+
return locked(() => {
|
|
354
|
+
const refuse = (message: string, expect: ProfileDoc) => ({
|
|
355
|
+
...unsaved({
|
|
356
|
+
errors: [{ path: CHANGED_ON_DISK, message, fix: "preview again and review the changes" }],
|
|
357
|
+
warnings: [],
|
|
358
|
+
}),
|
|
359
|
+
expect,
|
|
360
|
+
profile: name,
|
|
361
|
+
host,
|
|
362
|
+
});
|
|
363
|
+
// the reset is host-specific: a preview approves one profile on one host, never another
|
|
364
|
+
if (!opts.preview && (opts.reviewed.profile !== name || opts.reviewed.host !== host))
|
|
365
|
+
return refuse(
|
|
366
|
+
`the preview was for profile "${opts.reviewed.profile}" on host ${opts.reviewed.host}, not "${name}" on ${host}`,
|
|
367
|
+
opts.expect,
|
|
368
|
+
);
|
|
369
|
+
const before = readProfileDoc(name);
|
|
370
|
+
if (!opts.preview && !isDeepStrictEqual(before, opts.expect))
|
|
371
|
+
return refuse(`profile "${name}" changed on disk since it was shown`, before);
|
|
372
|
+
const after = hostDefaultsDoc(before);
|
|
373
|
+
const v = validate(after, name, host);
|
|
374
|
+
if (v.errors.length) return { ...unsaved(v), expect: before, profile: name, host };
|
|
375
|
+
const diff = diffProfiles(resolveProfile(before, name, host), resolveProfile(after, name, host));
|
|
376
|
+
return opts.preview
|
|
377
|
+
? { ...unsaved(v), diff, expect: before, profile: name, host }
|
|
378
|
+
: { saved: true, ...v, diff, ...saveAndLink(name, after, host), expect: before, profile: name, host };
|
|
379
|
+
});
|
|
380
|
+
}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { OrchestrationHost } from "../domain/host.ts";
|
|
1
2
|
import { existsSync, readdirSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { z } from "zod";
|
|
@@ -94,7 +95,8 @@ export function readProfileDoc(name: string): ProfileDoc {
|
|
|
94
95
|
throw new CatherdError("E_CONFIG_INVALID", `no profile named "${name}"`, { fix: "catherd profile list" });
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
export const getProfile = (name: string): Profile =>
|
|
98
|
+
export const getProfile = (name: string, host: OrchestrationHost): Profile =>
|
|
99
|
+
resolveProfile(readProfileDoc(name), name, host);
|
|
98
100
|
|
|
99
101
|
/** A profile name the user typed: refused as bad input (exit 2) when no such profile exists. */
|
|
100
102
|
export function requireProfile(name: string): string {
|
|
@@ -109,7 +111,8 @@ export function activeName(repo: string | null = null): string {
|
|
|
109
111
|
return bound ?? readConfig().activeProfile ?? "default";
|
|
110
112
|
}
|
|
111
113
|
|
|
112
|
-
export const profileFor = (repo: string | null): Profile =>
|
|
114
|
+
export const profileFor = (repo: string | null, host: OrchestrationHost): Profile =>
|
|
115
|
+
getProfile(activeName(repo), host);
|
|
113
116
|
|
|
114
117
|
/** The backends catherd can run: every registered adapter, and the native `claude` path. */
|
|
115
118
|
export const runnableBackends = (): string[] => ["claude", ...ADAPTER_IDS.filter((id) => adapterFor(id))];
|
|
@@ -212,8 +215,13 @@ export function budgetUsdWarnings(p: Profile): Issue[] {
|
|
|
212
215
|
* Spec §7.1 validation on this machine: the backends catherd can run here, the keys isolation needs, the
|
|
213
216
|
* accesses a backend holds only when isolated, and the backends a dollar budget cannot see.
|
|
214
217
|
*/
|
|
215
|
-
export function validateHere(
|
|
216
|
-
|
|
218
|
+
export function validateHere(
|
|
219
|
+
p: Profile,
|
|
220
|
+
c: Catalog,
|
|
221
|
+
doc?: ProfileDoc,
|
|
222
|
+
host: OrchestrationHost = "unknown",
|
|
223
|
+
): Validation {
|
|
224
|
+
const v = validateProfile(p, c, runnableBackends(), doc, host);
|
|
217
225
|
return {
|
|
218
226
|
errors: [...v.errors, ...isolationKeyErrors(p), ...isolatedOnlyErrors(p)],
|
|
219
227
|
warnings: [...v.warnings, ...budgetUsdWarnings(p)],
|
|
@@ -221,11 +229,20 @@ export function validateHere(p: Profile, c: Catalog, doc?: ProfileDoc): Validati
|
|
|
221
229
|
}
|
|
222
230
|
|
|
223
231
|
/** `repo`: the git toplevel whose listing route reads (opencode lists its models per repository). */
|
|
224
|
-
export function validateNamed(
|
|
232
|
+
export function validateNamed(
|
|
233
|
+
name: string | undefined,
|
|
234
|
+
repo: string | null,
|
|
235
|
+
host: OrchestrationHost,
|
|
236
|
+
): Validation {
|
|
225
237
|
const n = name ?? activeName(repo);
|
|
226
238
|
const doc = readProfileDoc(n);
|
|
227
239
|
const catalog = loadCatalog({ timings: false, ...(repo === null ? {} : { repo }) });
|
|
228
|
-
|
|
240
|
+
try {
|
|
241
|
+
return validateHere(resolveProfile(doc, n, host), catalog, doc, host);
|
|
242
|
+
} catch (e) {
|
|
243
|
+
if (!(e instanceof CatherdError)) throw e;
|
|
244
|
+
return { errors: [{ path: "roles", message: e.message, fix: e.fix }], warnings: [] };
|
|
245
|
+
}
|
|
229
246
|
}
|
|
230
247
|
|
|
231
248
|
/** Spec D10: how strongly the backend holds a role to its access mode. */
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertNativeHost } from "./backends.ts";
|
|
1
2
|
import { BUDGET_CHEAP_AT } from "../domain/budget.ts";
|
|
2
3
|
import { errorMessage } from "../domain/errors.ts";
|
|
3
4
|
import {
|
|
@@ -171,7 +172,11 @@ async function verdict<T extends string>(
|
|
|
171
172
|
/** The routing port over the 1.0 catalog and Jev (spec §5); `o` lets tests inject Jev's transport. */
|
|
172
173
|
export function routingService(o: RoutingOpts = {}): RoutingPort {
|
|
173
174
|
return {
|
|
174
|
-
route: (req) =>
|
|
175
|
+
route: async (req) => {
|
|
176
|
+
const result = await route(req, o);
|
|
177
|
+
assertNativeHost(result.rung, req.host ?? "unknown");
|
|
178
|
+
return result;
|
|
179
|
+
},
|
|
175
180
|
finding: (runDir, laneText, finding, use) =>
|
|
176
181
|
verdict(
|
|
177
182
|
runDir,
|
|
@@ -1,15 +1,107 @@
|
|
|
1
|
-
import { closeSync, fstatSync, openSync, readSync } from "node:fs";
|
|
1
|
+
import { closeSync, existsSync, fstatSync, openSync, readSync } from "node:fs";
|
|
2
|
+
import { sessionKey, type HostContext, type HostSessionRef } from "../domain/host.ts";
|
|
3
|
+
import { deliveryState, readDelivery, type DeliveryState } from "../infra/delivery.ts";
|
|
2
4
|
import type { ExitInfo, RunRecord } from "../domain/record.ts";
|
|
3
|
-
import { dispatchPaths, readExit } from "../infra/dispatch-dir.ts";
|
|
5
|
+
import { awaitsCollect, dispatchPaths, readExit } from "../infra/dispatch-dir.ts";
|
|
4
6
|
import { redact } from "../infra/log.ts";
|
|
5
|
-
import { listDispatches } from "./dispatches.ts";
|
|
7
|
+
import { type Dispatch, listDispatches } from "./dispatches.ts";
|
|
6
8
|
import { registerSavedSecrets } from "./jev-service.ts";
|
|
9
|
+
import { runOwner } from "./sessions.ts";
|
|
7
10
|
import { readRecords, type Run } from "./run-store.ts";
|
|
8
11
|
|
|
9
12
|
/** How many trailing lines of stderr, events and the supervisor log `runs show --debug` prints. */
|
|
10
13
|
export const TAIL_LINES = 20;
|
|
11
14
|
|
|
12
|
-
export
|
|
15
|
+
export function inspectionHost(host: HostContext): HostContext {
|
|
16
|
+
const target = host.session;
|
|
17
|
+
return {
|
|
18
|
+
host: host.host,
|
|
19
|
+
conflict: host.conflict,
|
|
20
|
+
session: target
|
|
21
|
+
? {
|
|
22
|
+
host: target.host,
|
|
23
|
+
sessionId: target.sessionId,
|
|
24
|
+
hostSessionId: target.hostSessionId,
|
|
25
|
+
name: target.name,
|
|
26
|
+
}
|
|
27
|
+
: null,
|
|
28
|
+
};
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface DeliveryInspection {
|
|
32
|
+
eventId: string;
|
|
33
|
+
delivery: DeliveryState;
|
|
34
|
+
receipt: { msgId: string; target: HostSessionRef; at: string } | null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function inspectDelivery(
|
|
38
|
+
run: Run,
|
|
39
|
+
d: Dispatch,
|
|
40
|
+
recorded: boolean,
|
|
41
|
+
eventId = JSON.stringify([
|
|
42
|
+
run.id,
|
|
43
|
+
d.admit.dispatchId,
|
|
44
|
+
!recorded && existsSync(dispatchPaths(d.dir).stall) ? "stalled" : "finished",
|
|
45
|
+
]),
|
|
46
|
+
): DeliveryInspection {
|
|
47
|
+
const owner = runOwner(run);
|
|
48
|
+
const target = owner
|
|
49
|
+
? { host: owner.host, sessionId: owner.sessionId, hostSessionId: null, name: null }
|
|
50
|
+
: null;
|
|
51
|
+
const finished = eventId === JSON.stringify([run.id, d.admit.dispatchId, "finished"]);
|
|
52
|
+
const collected = finished && recorded && !awaitsCollect(d.dir);
|
|
53
|
+
try {
|
|
54
|
+
const receipt = target
|
|
55
|
+
? readDelivery(d.dir).findLast(
|
|
56
|
+
(a) =>
|
|
57
|
+
a.status === "accepted" &&
|
|
58
|
+
a.eventIds.includes(eventId) &&
|
|
59
|
+
sessionKey(a.target) === sessionKey(target),
|
|
60
|
+
)
|
|
61
|
+
: undefined;
|
|
62
|
+
return {
|
|
63
|
+
eventId,
|
|
64
|
+
delivery: collected
|
|
65
|
+
? "collected"
|
|
66
|
+
: (recorded || !finished) && target
|
|
67
|
+
? deliveryState(d.dir, target, eventId)
|
|
68
|
+
: "pending",
|
|
69
|
+
receipt: receipt?.msgId
|
|
70
|
+
? {
|
|
71
|
+
msgId: receipt.msgId,
|
|
72
|
+
at: receipt.at,
|
|
73
|
+
target: {
|
|
74
|
+
host: receipt.target.host,
|
|
75
|
+
sessionId: receipt.target.sessionId,
|
|
76
|
+
hostSessionId: receipt.target.hostSessionId,
|
|
77
|
+
name: receipt.target.name,
|
|
78
|
+
},
|
|
79
|
+
}
|
|
80
|
+
: null,
|
|
81
|
+
};
|
|
82
|
+
} catch {
|
|
83
|
+
return { eventId, delivery: collected ? "collected" : "ambiguous", receipt: null };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Stored stalled and finished events are separate; only finished records can be collected. A stall the role
|
|
89
|
+
* outlived that is still pending for the current owner can never go out (the notifier drops it and retry
|
|
90
|
+
* refuses it), so it is not shown; one with an outcome for this owner (accepted, ambiguous) still is.
|
|
91
|
+
*/
|
|
92
|
+
export function inspectDeliveries(run: Run, d: Dispatch, recorded: boolean): DeliveryInspection[] {
|
|
93
|
+
const exited = existsSync(dispatchPaths(d.dir).exit);
|
|
94
|
+
return [...(recorded ? ["finished"] : []), ...(existsSync(dispatchPaths(d.dir).stall) ? ["stalled"] : [])]
|
|
95
|
+
.map((kind) => ({
|
|
96
|
+
kind,
|
|
97
|
+
...inspectDelivery(run, d, recorded, JSON.stringify([run.id, d.admit.dispatchId, kind])),
|
|
98
|
+
}))
|
|
99
|
+
.filter((e) => !(e.kind === "stalled" && exited && e.delivery === "pending"))
|
|
100
|
+
.map(({ kind: _kind, ...e }) => e);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export interface DispatchDebug extends DeliveryInspection {
|
|
104
|
+
deliveries: DeliveryInspection[];
|
|
13
105
|
name: string;
|
|
14
106
|
dispatchId: string;
|
|
15
107
|
rung: string;
|
|
@@ -65,6 +157,8 @@ export function runDebug(run: Run, name?: string): DispatchDebug[] {
|
|
|
65
157
|
.map((d) => {
|
|
66
158
|
const p = dispatchPaths(d.dir);
|
|
67
159
|
return redact({
|
|
160
|
+
...inspectDelivery(run, d, records.has(d.admit.dispatchId)),
|
|
161
|
+
deliveries: inspectDeliveries(run, d, records.has(d.admit.dispatchId)),
|
|
68
162
|
name: d.admit.name,
|
|
69
163
|
dispatchId: d.admit.dispatchId,
|
|
70
164
|
rung: d.admit.rung,
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
recordHints,
|
|
16
16
|
} from "./dispatches.ts";
|
|
17
17
|
import type { Deps } from "./ports.ts";
|
|
18
|
+
import { assertNativeHost } from "./backends.ts";
|
|
18
19
|
import {
|
|
19
20
|
type AgentRun,
|
|
20
21
|
appendAgentRun,
|
|
@@ -167,6 +168,7 @@ export function recordAgentRun(
|
|
|
167
168
|
throw new CatherdError("E_ADMIT_RUNG", `${i.rung} is not a native Claude rung`, {
|
|
168
169
|
fix: "record_agent_run is for Agent subagents; dispatch records every other run itself",
|
|
169
170
|
});
|
|
171
|
+
assertNativeHost(i.rung, deps.host.conflict ? "unknown" : deps.host.host);
|
|
170
172
|
const row: AgentRun = {
|
|
171
173
|
at: new Date(deps.now()).toISOString(),
|
|
172
174
|
name: i.name,
|
|
@@ -21,7 +21,8 @@ import {
|
|
|
21
21
|
} from "../infra/store.ts";
|
|
22
22
|
|
|
23
23
|
/** Spec §3.3: the Claude Code session that started a run; absent on 1.0 runs and outside Claude Code. */
|
|
24
|
-
const StartedBySchema = z.
|
|
24
|
+
const StartedBySchema = z.looseObject({
|
|
25
|
+
host: z.enum(["claude-code", "codex"]).default("claude-code"),
|
|
25
26
|
sessionId: z.string(),
|
|
26
27
|
hostSessionId: z.string().nullable(),
|
|
27
28
|
name: z.string().nullable(),
|
|
@@ -82,7 +83,12 @@ export function createRun(o: {
|
|
|
82
83
|
aLines: string[];
|
|
83
84
|
version: string;
|
|
84
85
|
now?: Date;
|
|
85
|
-
startedBy?:
|
|
86
|
+
startedBy?: {
|
|
87
|
+
host?: StartedBy["host"];
|
|
88
|
+
sessionId: string;
|
|
89
|
+
hostSessionId: string | null;
|
|
90
|
+
name: string | null;
|
|
91
|
+
} | null;
|
|
86
92
|
}): Run {
|
|
87
93
|
const now = o.now ?? new Date();
|
|
88
94
|
const root = runsDir(o.repo);
|
|
@@ -123,7 +129,7 @@ export function createRun(o: {
|
|
|
123
129
|
aLines: o.aLines,
|
|
124
130
|
createdAt: now.toISOString(),
|
|
125
131
|
catherdVersion: o.version,
|
|
126
|
-
...(o.startedBy ? { startedBy: o.startedBy } : {}),
|
|
132
|
+
...(o.startedBy ? { startedBy: StartedBySchema.parse(o.startedBy) } : {}),
|
|
127
133
|
};
|
|
128
134
|
writeJsonAtomic(p.meta, meta);
|
|
129
135
|
return { id, dir, meta };
|
|
@@ -196,7 +202,7 @@ export function readRecords(run: Run): { records: RunRecord[]; corrupt: number }
|
|
|
196
202
|
if (!r.success) bad++;
|
|
197
203
|
else if (!seen.has(r.data.dispatchId)) {
|
|
198
204
|
seen.add(r.data.dispatchId);
|
|
199
|
-
records.push(r.data);
|
|
205
|
+
records.push(r.data.sessionId && !r.data.host ? { ...r.data, host: "claude-code" } : r.data);
|
|
200
206
|
}
|
|
201
207
|
}
|
|
202
208
|
return { records, corrupt: bad };
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionKey } from "../domain/host.ts";
|
|
1
2
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
2
3
|
import { join } from "node:path";
|
|
3
4
|
import { budgetStatus } from "../domain/budget.ts";
|
|
@@ -98,7 +99,7 @@ export interface MilestoneDetail {
|
|
|
98
99
|
digest: string | null;
|
|
99
100
|
}
|
|
100
101
|
|
|
101
|
-
const keyOf = (g: SessionGroup): string | null => g.session
|
|
102
|
+
const keyOf = (g: SessionGroup): string | null => (g.session ? sessionKey(g.session) : null);
|
|
102
103
|
|
|
103
104
|
/** The runs that live on in a session now (it owns them): each run's live roles and landings count once. */
|
|
104
105
|
const ownRuns = (g: SessionGroup): Run[] => g.runs.filter((x) => x.current).map((x) => x.run);
|
|
@@ -174,7 +175,7 @@ function sessionRun(deps: Deps, x: SessionGroup["runs"][number]): SessionRun {
|
|
|
174
175
|
try {
|
|
175
176
|
const b = budgetStatus(
|
|
176
177
|
spendOf(run, readRecords(run).records, liveDispatches(run, deps.now()), deps.now()),
|
|
177
|
-
deps.profiles.forRepo(run.meta.repo).budget,
|
|
178
|
+
deps.profiles.budgetFor?.(run.meta.repo) ?? deps.profiles.forRepo(run.meta.repo).budget,
|
|
178
179
|
);
|
|
179
180
|
budget = b?.fraction ?? null;
|
|
180
181
|
} catch {
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { sessionKey, type KnownHost } from "../domain/host.ts";
|
|
1
2
|
import { statSync } from "node:fs";
|
|
2
3
|
import { liveSessionFile, readSessionFiles, type SessionFile } from "../infra/claude-session.ts";
|
|
3
4
|
import { type Run, runPaths } from "./run-store.ts";
|
|
@@ -14,6 +15,7 @@ import { readSessionRows } from "./sessions.ts";
|
|
|
14
15
|
|
|
15
16
|
/** A session as the runs page shows it: its name read live while it runs, else the last one recorded. */
|
|
16
17
|
export interface RunSession {
|
|
18
|
+
host: KnownHost;
|
|
17
19
|
sessionId: string;
|
|
18
20
|
hostSessionId: string | null;
|
|
19
21
|
name: string;
|
|
@@ -56,10 +58,11 @@ export function runActivity(run: Run): number {
|
|
|
56
58
|
/** The sessions each run has had, oldest first: its starter, then each one that took it over. */
|
|
57
59
|
function trailOf(
|
|
58
60
|
run: Run,
|
|
59
|
-
): { sessionId: string; hostSessionId: string | null; name: string | null; at: string }[] {
|
|
61
|
+
): { host: KnownHost; sessionId: string; hostSessionId: string | null; name: string | null; at: string }[] {
|
|
60
62
|
const rows = readSessionRows(run);
|
|
61
63
|
const s = run.meta.startedBy;
|
|
62
|
-
if (s && !rows.some((r) => r
|
|
64
|
+
if (s && !rows.some((r) => sessionKey(r) === sessionKey(s)))
|
|
65
|
+
return [{ ...s, at: run.meta.createdAt }, ...rows];
|
|
63
66
|
return rows;
|
|
64
67
|
}
|
|
65
68
|
|
|
@@ -70,21 +73,23 @@ export function groupRuns(runs: Run[], files: SessionFile[] = readSessionFiles()
|
|
|
70
73
|
const trails = new Map(runs.map((r) => [r.id, trailOf(r)]));
|
|
71
74
|
for (const t of trails.values())
|
|
72
75
|
for (const row of t) {
|
|
73
|
-
const was = recorded.get(row
|
|
76
|
+
const was = recorded.get(sessionKey(row));
|
|
74
77
|
if (!was || row.at >= was.at)
|
|
75
|
-
recorded.set(row
|
|
78
|
+
recorded.set(sessionKey(row), {
|
|
76
79
|
name: row.name ?? was?.name ?? null,
|
|
77
80
|
host: row.hostSessionId ?? was?.host ?? null,
|
|
78
81
|
at: row.at,
|
|
79
82
|
});
|
|
80
83
|
}
|
|
81
84
|
const sessionOf = (id: string): RunSession => {
|
|
82
|
-
const
|
|
85
|
+
const [host, sessionId] = JSON.parse(id) as [KnownHost, string];
|
|
86
|
+
const live = host === "claude-code" ? liveSessionFile(sessionId, files) : null;
|
|
83
87
|
const rec = recorded.get(id);
|
|
84
88
|
return {
|
|
85
|
-
|
|
89
|
+
host,
|
|
90
|
+
sessionId,
|
|
86
91
|
hostSessionId: live?.hostSessionId ?? rec?.host ?? null,
|
|
87
|
-
name: live?.name ?? rec?.name ?? `session ${
|
|
92
|
+
name: live?.name ?? rec?.name ?? `session ${sessionId.slice(0, 8)}`,
|
|
88
93
|
live: live !== null,
|
|
89
94
|
};
|
|
90
95
|
};
|
|
@@ -101,13 +106,13 @@ export function groupRuns(runs: Run[], files: SessionFile[] = readSessionFiles()
|
|
|
101
106
|
for (const run of runs) {
|
|
102
107
|
const at = runActivity(run);
|
|
103
108
|
const trail = trails.get(run.id) ?? [];
|
|
104
|
-
const starter = run.meta.startedBy
|
|
109
|
+
const starter = run.meta.startedBy ? sessionKey(run.meta.startedBy) : null;
|
|
105
110
|
// the run lives on in its current owner: the last session that took it (a run can come back to its starter)
|
|
106
|
-
const owner = trail.at(-1)
|
|
111
|
+
const owner = trail.at(-1) ? sessionKey(trail.at(-1)!) : starter;
|
|
107
112
|
const moved = owner !== starter && owner !== null;
|
|
108
113
|
// a run that lives on elsewhere counts, in a session it left, from when that session last took it
|
|
109
114
|
const tookAt = (id: string | null): number =>
|
|
110
|
-
Date.parse(trail.findLast((t) => t
|
|
115
|
+
Date.parse(trail.findLast((t) => sessionKey(t) === id)?.at ?? run.meta.createdAt) || 0;
|
|
111
116
|
add(
|
|
112
117
|
starter,
|
|
113
118
|
{
|
|
@@ -118,7 +123,7 @@ export function groupRuns(runs: Run[], files: SessionFile[] = readSessionFiles()
|
|
|
118
123
|
},
|
|
119
124
|
moved ? tookAt(starter) : at,
|
|
120
125
|
);
|
|
121
|
-
for (const id of new Set(trail.map(
|
|
126
|
+
for (const id of new Set(trail.map(sessionKey)))
|
|
122
127
|
if (id !== starter)
|
|
123
128
|
add(
|
|
124
129
|
id,
|
|
@@ -146,7 +151,7 @@ export function sessionFacts(
|
|
|
146
151
|
run: Run,
|
|
147
152
|
files: SessionFile[] = readSessionFiles(),
|
|
148
153
|
): { session: RunSession | null; continuedIn: string | null } {
|
|
149
|
-
const starter = run.meta.startedBy
|
|
150
|
-
const own = groupRuns([run], files).find((g) => (g.session
|
|
154
|
+
const starter = run.meta.startedBy ? sessionKey(run.meta.startedBy) : null;
|
|
155
|
+
const own = groupRuns([run], files).find((g) => (g.session ? sessionKey(g.session) : null) === starter);
|
|
151
156
|
return { session: own?.session ?? null, continuedIn: own?.runs[0]?.continuedIn ?? null };
|
|
152
157
|
}
|