catherd-cli 1.3.0 → 1.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +144 -42
- package/catalog/ATTRIBUTION.md +12 -4
- package/catalog/models.json +8 -0
- package/package.json +1 -1
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +11 -0
- package/plugin/.mcp-codex.json +10 -0
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/skills/catherd/SKILL.md +150 -79
- package/plugin/skills/catherd-setup/SKILL.md +24 -8
- package/src/adapters/access.ts +7 -1
- package/src/adapters/antigravity/index.ts +18 -3
- package/src/adapters/backend.ts +18 -2
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/cli.ts +1 -64
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/index.ts +7 -1
- package/src/adapters/grok/home.ts +75 -20
- package/src/adapters/grok/index.ts +27 -8
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +53 -19
- package/src/domain/agents.ts +3 -1
- package/src/domain/brief.ts +91 -0
- package/src/domain/calibration.ts +7 -0
- package/src/domain/catalog.ts +12 -1
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +8 -3
- package/src/domain/errors.ts +4 -0
- package/src/domain/failover.ts +17 -5
- package/src/domain/gate-brief.ts +52 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/host.ts +17 -0
- package/src/domain/lane.ts +99 -3
- package/src/domain/notice.ts +14 -4
- package/src/domain/profile-rules.ts +129 -16
- package/src/domain/profile.ts +56 -5
- package/src/domain/record.ts +17 -2
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +56 -16
- package/src/domain/role-scope.ts +106 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/route.ts +73 -0
- package/src/domain/select.ts +177 -25
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/catalog-command.ts +18 -8
- package/src/entry/cli-kit.ts +17 -1
- package/src/entry/deps.ts +8 -3
- package/src/entry/doctor-command.ts +29 -2
- package/src/entry/host-arg.ts +56 -0
- package/src/entry/init-command.ts +20 -8
- package/src/entry/knowledge-command.ts +78 -3
- package/src/entry/lock-command.ts +111 -10
- package/src/entry/mcp/dispatch-tools.ts +3 -3
- package/src/entry/mcp/handshake.ts +8 -2
- package/src/entry/mcp/lane-tools.ts +55 -8
- package/src/entry/mcp/protocol-tools.ts +10 -4
- package/src/entry/mcp/push-tools.ts +16 -0
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +116 -0
- package/src/entry/mcp/run-tools.ts +25 -6
- package/src/entry/mcp/server.ts +224 -44
- package/src/entry/mcp/setup-tools.ts +11 -5
- package/src/entry/mcp/workspace-tools.ts +142 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +131 -16
- package/src/entry/role-cli-command.ts +115 -0
- package/src/entry/runs-command.ts +157 -11
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/tui/effects.ts +23 -8
- package/src/entry/tui/fixtures.ts +33 -10
- package/src/entry/tui/run.tsx +10 -4
- package/src/entry/tui/state.ts +56 -16
- package/src/entry/tui/stories.ts +11 -4
- package/src/entry/tui/views/profile-actions.ts +4 -2
- package/src/entry/tui/views/profiles.tsx +3 -3
- package/src/entry/tui/views/runs.tsx +66 -20
- package/src/entry/tui/views/save-dialog.tsx +8 -4
- package/src/entry/tui/views/status.tsx +6 -2
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/cli.ts +65 -0
- package/src/infra/codex-queue.ts +165 -0
- package/src/infra/delivery.ts +86 -0
- package/src/infra/dispatch-dir.ts +3 -0
- package/src/infra/env.ts +15 -2
- package/src/infra/git.ts +6 -0
- package/src/infra/heavy-lock.ts +93 -4
- package/src/infra/host-context.ts +58 -0
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/paths.ts +35 -0
- package/src/infra/peer-inbox.ts +4 -1
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/sources/arena.ts +11 -3
- package/src/infra/sources/artificial-analysis.ts +8 -1
- package/src/infra/sources/cache.ts +2 -0
- package/src/infra/supervisor.ts +110 -18
- package/src/services/admission.ts +117 -26
- package/src/services/agent-links.ts +30 -5
- package/src/services/backends.ts +7 -0
- package/src/services/budget.ts +26 -6
- package/src/services/catalog-service.ts +20 -12
- package/src/services/credentials.ts +11 -6
- package/src/services/dispatch-service.ts +220 -22
- package/src/services/dispatches.ts +36 -3
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +30 -7
- package/src/services/doctor-checks.ts +20 -5
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-push.ts +69 -90
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +172 -38
- package/src/services/end-push.ts +157 -0
- package/src/services/finalize.ts +13 -3
- package/src/services/gate-env.ts +218 -0
- package/src/services/gate-service.ts +216 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +258 -52
- package/src/services/milestones.ts +137 -26
- package/src/services/notifier.ts +377 -72
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +122 -23
- package/src/services/ports.ts +39 -4
- package/src/services/preflight.ts +153 -19
- package/src/services/profile-service.ts +117 -30
- package/src/services/profile-store.ts +31 -7
- package/src/services/protocol.ts +119 -25
- package/src/services/provenance.ts +16 -12
- package/src/services/questions.ts +15 -0
- package/src/services/reconcile.ts +22 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +150 -37
- package/src/services/run-debug.ts +98 -4
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-service.ts +153 -19
- package/src/services/run-store.ts +119 -19
- package/src/services/runs-page.ts +45 -9
- package/src/services/scratch.ts +60 -0
- package/src/services/session-view.ts +18 -13
- package/src/services/sessions.ts +34 -16
- package/src/services/setup.ts +31 -10
- package/src/services/source-derive.ts +28 -5
- package/src/services/source-sync.ts +9 -2
- package/src/services/standins.ts +40 -6
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +86 -11
- package/src/services/treat-likes.ts +41 -12
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +267 -0
- package/src/services/workspace-service.ts +254 -0
- package/src/services/workspace-store.ts +95 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import { join } from "node:path";
|
|
2
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
3
|
+
import type { Span } from "../domain/util.ts";
|
|
4
|
+
import { dataDir } from "../infra/paths.ts";
|
|
5
|
+
import { appendJsonl, ensureJsonlHeader, readJsonl } from "../infra/store.ts";
|
|
6
|
+
/** What a pause is checked against: a run, or a workspace step about to start one. */
|
|
7
|
+
type Covered = { meta: { workspace?: { id: string; step: string } } };
|
|
8
|
+
import { findWorkspace, workspaceDirectory } from "./workspace-store.ts";
|
|
9
|
+
|
|
10
|
+
// Spec 1.5 plan 25, "Group pause": one environment blocker (a VPN, Docker down) pauses every run on the
|
|
11
|
+
// machine, or every child of a workspace, at once: admission refuses with the reason until it is resumed.
|
|
12
|
+
|
|
13
|
+
/** One row per pause or resume, oldest first; `reason` null is a resume. */
|
|
14
|
+
interface PauseRow {
|
|
15
|
+
at: string;
|
|
16
|
+
reason: string | null;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** A pause in force: whose (the machine, or one workspace), why and since when. */
|
|
20
|
+
export interface Pause {
|
|
21
|
+
scope: "machine" | "workspace";
|
|
22
|
+
/** the workspace id, for a workspace pause */
|
|
23
|
+
workspace?: string;
|
|
24
|
+
reason: string;
|
|
25
|
+
since: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** A stretch of time something was paused; `to` null while it still is. */
|
|
29
|
+
export interface PauseInterval {
|
|
30
|
+
from: string;
|
|
31
|
+
to: string | null;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export const machinePauseFile = (): string => join(dataDir(), "pauses.jsonl");
|
|
35
|
+
export const workspacePauseFile = (id: string): string => join(workspaceDirectory(id), "pauses.jsonl");
|
|
36
|
+
|
|
37
|
+
const rows = (file: string): PauseRow[] =>
|
|
38
|
+
readJsonl<PauseRow>(file).rows.filter(
|
|
39
|
+
(r) => typeof r?.at === "string" && (r.reason === null || typeof r.reason === "string"),
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
/** The pause in force in `file`, or null: its last row, when that row pauses. */
|
|
43
|
+
function current(file: string): { reason: string; since: string } | null {
|
|
44
|
+
const last = rows(file).at(-1);
|
|
45
|
+
return last && last.reason !== null ? { reason: last.reason, since: last.at } : null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Every paused stretch `file` records, oldest first. */
|
|
49
|
+
export function pauseIntervals(file: string): PauseInterval[] {
|
|
50
|
+
const out: PauseInterval[] = [];
|
|
51
|
+
for (const r of rows(file)) {
|
|
52
|
+
const open = out.at(-1);
|
|
53
|
+
if (r.reason !== null && (!open || open.to !== null)) out.push({ from: r.at, to: null });
|
|
54
|
+
else if (r.reason === null && open && open.to === null) open.to = r.at;
|
|
55
|
+
}
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function append(file: string, row: PauseRow): void {
|
|
60
|
+
ensureJsonlHeader(file, "pauses");
|
|
61
|
+
appendJsonl(file, row);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const PAUSE_HINT = "push the reason to the owner once (PushNotification); every run it covers waits on it";
|
|
65
|
+
|
|
66
|
+
/** `catherd pause --machine <reason>`: every dispatch on this machine is refused until resumed. */
|
|
67
|
+
export function pauseMachine(now: number, reason: string): { pause: Pause; hints: string[] } {
|
|
68
|
+
if (!reason.trim())
|
|
69
|
+
throw new CatherdError("E_INPUT_INVALID", "a pause needs a reason", {
|
|
70
|
+
fix: 'catherd pause --machine "the VPN takes the default route"',
|
|
71
|
+
});
|
|
72
|
+
const at = new Date(now).toISOString();
|
|
73
|
+
append(machinePauseFile(), { at, reason });
|
|
74
|
+
return { pause: { scope: "machine", reason, since: at }, hints: [PAUSE_HINT] };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** `catherd resume --machine`: lifts the machine pause; the previous one, or null when none was in force. */
|
|
78
|
+
export function resumeMachine(now: number): { resumed: Pause | null } {
|
|
79
|
+
const was = current(machinePauseFile());
|
|
80
|
+
if (was) append(machinePauseFile(), { at: new Date(now).toISOString(), reason: null });
|
|
81
|
+
return { resumed: was ? { scope: "machine", ...was } : null };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `workspace_pause(workspace, reason)`: every child of the workspace is refused admission until resumed. */
|
|
85
|
+
export function pauseWorkspace(
|
|
86
|
+
now: number,
|
|
87
|
+
i: { workspace: string; reason: string },
|
|
88
|
+
): { pause: Pause; hints: string[] } {
|
|
89
|
+
const workspace = findWorkspace(i.workspace);
|
|
90
|
+
if (!i.reason.trim())
|
|
91
|
+
throw new CatherdError("E_INPUT_INVALID", "a pause needs a reason", {
|
|
92
|
+
fix: 'workspace_pause(workspace, "the VPN takes the default route")',
|
|
93
|
+
});
|
|
94
|
+
const at = new Date(now).toISOString();
|
|
95
|
+
append(workspacePauseFile(workspace.id), { at, reason: i.reason });
|
|
96
|
+
return {
|
|
97
|
+
pause: { scope: "workspace", workspace: workspace.id, reason: i.reason, since: at },
|
|
98
|
+
hints: [PAUSE_HINT],
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** `workspace_resume(workspace)`: lifts the workspace's pause; the previous one, or null. */
|
|
103
|
+
export function resumeWorkspace(now: number, i: { workspace: string }): { resumed: Pause | null } {
|
|
104
|
+
const workspace = findWorkspace(i.workspace);
|
|
105
|
+
const file = workspacePauseFile(workspace.id);
|
|
106
|
+
const was = current(file);
|
|
107
|
+
if (was) append(file, { at: new Date(now).toISOString(), reason: null });
|
|
108
|
+
return { resumed: was ? { scope: "workspace", workspace: workspace.id, ...was } : null };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** The pauses in force over `run`: the machine's, then its workspace's. */
|
|
112
|
+
export function pausesFor(run: Covered | null): Pause[] {
|
|
113
|
+
const out: Pause[] = [];
|
|
114
|
+
const machine = current(machinePauseFile());
|
|
115
|
+
if (machine) out.push({ scope: "machine", ...machine });
|
|
116
|
+
const id = run?.meta.workspace?.id;
|
|
117
|
+
const ws = id ? current(workspacePauseFile(id)) : null;
|
|
118
|
+
if (id && ws) out.push({ scope: "workspace", workspace: id, ...ws });
|
|
119
|
+
return out;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Every stretch the machine, or the run's workspace, was paused, in epoch ms: `land` leaves them out. */
|
|
123
|
+
export function pauseSpans(run: Covered): Span[] {
|
|
124
|
+
const id = run.meta.workspace?.id;
|
|
125
|
+
const files = [machinePauseFile(), ...(id ? [workspacePauseFile(id)] : [])];
|
|
126
|
+
return files.flatMap((f) =>
|
|
127
|
+
pauseIntervals(f).map((p) => ({ from: Date.parse(p.from), to: p.to === null ? null : Date.parse(p.to) })),
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** The pauses in force over any of `runs`, each once, the machine's first. */
|
|
132
|
+
export function pausesOver(runs: Covered[]): Pause[] {
|
|
133
|
+
const seen = new Set<string>();
|
|
134
|
+
const out: Pause[] = [];
|
|
135
|
+
for (const p of [pausesFor(null), ...runs.map(pausesFor)].flat()) {
|
|
136
|
+
const key = `${p.scope} ${p.workspace ?? ""}`;
|
|
137
|
+
if (!seen.has(key)) {
|
|
138
|
+
seen.add(key);
|
|
139
|
+
out.push(p);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
return out;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const describe = (p: Pause): string =>
|
|
146
|
+
`${p.scope === "machine" ? "the machine" : `workspace ${p.workspace}`} is paused since ${p.since}: ${p.reason}`;
|
|
147
|
+
|
|
148
|
+
/** One line per pause, for status and the admission refusal. */
|
|
149
|
+
export const pauseLine = describe;
|
|
150
|
+
|
|
151
|
+
/** Admission refuses while a pause covers the run, naming its reason and how to lift it. */
|
|
152
|
+
export function assertNotPaused(run: Covered): void {
|
|
153
|
+
const [p] = pausesFor(run);
|
|
154
|
+
if (!p) return;
|
|
155
|
+
throw new CatherdError("E_ADMIT_PAUSED", describe(p), {
|
|
156
|
+
fix:
|
|
157
|
+
p.scope === "machine"
|
|
158
|
+
? "when the blocker is gone, the owner runs: catherd resume --machine"
|
|
159
|
+
: `when the blocker is gone, call workspace_resume(${p.workspace})`,
|
|
160
|
+
});
|
|
161
|
+
}
|
package/src/services/peek.ts
CHANGED
|
@@ -1,5 +1,9 @@
|
|
|
1
|
+
import type { HostContext, HostSessionRef, KnownHost } from "../domain/host.ts";
|
|
1
2
|
import { assertId } from "../domain/ids.ts";
|
|
2
3
|
import { noticeHeader } from "../domain/notice.ts";
|
|
4
|
+
import { knownQueueCapability, type QueueCapability } from "../infra/codex-queue.ts";
|
|
5
|
+
import type { DeliveryState } from "../infra/delivery.ts";
|
|
6
|
+
import { inspectionHost, inspectDelivery, inspectDeliveries, type DeliveryInspection } from "./run-debug.ts";
|
|
3
7
|
import { awaitsCollect } from "../infra/dispatch-dir.ts";
|
|
4
8
|
import { claim } from "./dispatch-service.ts";
|
|
5
9
|
import { type Dispatch, type DispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
|
|
@@ -8,7 +12,7 @@ import { finishedNotice } from "./notifier.ts";
|
|
|
8
12
|
import type { Deps } from "./ports.ts";
|
|
9
13
|
import { type Reentry, reentry } from "./reentry.ts";
|
|
10
14
|
import { findRun, listRuns, readAgentRuns, readRecords, type Run } from "./run-store.ts";
|
|
11
|
-
import {
|
|
15
|
+
import { ownsRun, runOwner } from "./sessions.ts";
|
|
12
16
|
import { readNotes } from "./state.ts";
|
|
13
17
|
|
|
14
18
|
export interface PeekRole {
|
|
@@ -28,13 +32,62 @@ export interface PeekRun extends Reentry {
|
|
|
28
32
|
title: string;
|
|
29
33
|
/** the session that owns the run now, null when none has */
|
|
30
34
|
owner: string | null;
|
|
35
|
+
ownerHost: KnownHost | null;
|
|
31
36
|
live: PeekRole[];
|
|
37
|
+
/** Stalled notices are separate from unread finished role results. */
|
|
38
|
+
delivery: (DeliveryInspection & { name: string; dispatchId: string })[];
|
|
32
39
|
/** each finished record not yet read, as the first line of its message */
|
|
33
|
-
unread: {
|
|
40
|
+
unread: {
|
|
41
|
+
name: string;
|
|
42
|
+
dispatchId: string;
|
|
43
|
+
header: string;
|
|
44
|
+
eventId: string;
|
|
45
|
+
delivery: Exclude<DeliveryState, "collected">;
|
|
46
|
+
receipt: { msgId: string; target: HostSessionRef; at: string } | null;
|
|
47
|
+
}[];
|
|
34
48
|
/** the latest native Claude run recorded with record_agent_run */
|
|
35
49
|
native: { name: string; role: string; rung: string; status: string; at: string } | null;
|
|
36
50
|
/** the run's next step (state.md's last line) */
|
|
37
51
|
next: string;
|
|
52
|
+
/** plan 22, goal mode: whether anything in this run is the coordinator's to do now, and why (not) */
|
|
53
|
+
actionable: boolean;
|
|
54
|
+
reason: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Plan 22, goal mode: whether the run holds anything for the coordinator. An unread record is its to read; with
|
|
59
|
+
* roles live, a protocol step one of them is doing (lanes running, the reviewer or verifier step with that role
|
|
60
|
+
* live, the plan while an architect or researcher runs) or the owner's question is nobody's to act on, so the
|
|
61
|
+
* coordinator ends its turn with no tool call and the next catherd message wakes it.
|
|
62
|
+
*/
|
|
63
|
+
export function actionability(
|
|
64
|
+
live: { name: string; role: string }[],
|
|
65
|
+
unread: { name: string }[],
|
|
66
|
+
protocolNext: string,
|
|
67
|
+
): { actionable: boolean; reason: string } {
|
|
68
|
+
if (unread.length)
|
|
69
|
+
return {
|
|
70
|
+
actionable: true,
|
|
71
|
+
reason: `unread: ${unread.map((u) => `result(run, "${u.name}")`).join(", ")}`,
|
|
72
|
+
};
|
|
73
|
+
if (protocolNext.endsWith(" parked: wait for the owner"))
|
|
74
|
+
return {
|
|
75
|
+
actionable: false,
|
|
76
|
+
reason: `${protocolNext.replace(/: wait for the owner$/, "")}: the owner's answer comes first; end the turn with no tool call`,
|
|
77
|
+
};
|
|
78
|
+
const running = (role: string) => live.some((l) => l.role === role);
|
|
79
|
+
const covered =
|
|
80
|
+
/: lanes running \(/.test(protocolNext) ||
|
|
81
|
+
(protocolNext.endsWith(": reviewer") && running("reviewer")) ||
|
|
82
|
+
(protocolNext.endsWith(": verifier") && running("verifier")) ||
|
|
83
|
+
(protocolNext.startsWith("plan:") && (running("architect") || running("researcher"))) ||
|
|
84
|
+
(protocolNext.startsWith("finish:") && (running("verifier") || running("writer")));
|
|
85
|
+
if (live.length && covered)
|
|
86
|
+
return {
|
|
87
|
+
actionable: false,
|
|
88
|
+
reason: `only roles are live (${live.map((l) => l.name).join(", ")}): their results arrive as catherd messages; end the turn with no tool call`,
|
|
89
|
+
};
|
|
90
|
+
return { actionable: true, reason: protocolNext };
|
|
38
91
|
}
|
|
39
92
|
|
|
40
93
|
function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
@@ -43,36 +96,64 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
|
43
96
|
const records = new Map(readRecords(run).records.map((r) => [r.dispatchId, r]));
|
|
44
97
|
const agents = readAgentRuns(run).filter((a) => name === undefined || a.name === name);
|
|
45
98
|
const native = agents.at(-1);
|
|
46
|
-
const
|
|
99
|
+
const notes = readNotes(run);
|
|
100
|
+
const re = reentry(run, now, notes);
|
|
101
|
+
const live = liveDispatches(run, now);
|
|
102
|
+
// the whole run decides it, whatever `name` narrows the view to
|
|
103
|
+
const act = actionability(
|
|
104
|
+
live.map((d) => ({ name: d.admit.name, role: d.admit.role })),
|
|
105
|
+
listDispatches(run).flatMap((d) =>
|
|
106
|
+
records.has(d.admit.dispatchId) && awaitsCollect(d.dir) ? [{ name: d.admit.name }] : [],
|
|
107
|
+
),
|
|
108
|
+
re.protocol.next,
|
|
109
|
+
);
|
|
47
110
|
return {
|
|
48
|
-
questions:
|
|
111
|
+
questions: re.questions,
|
|
49
112
|
run: run.id,
|
|
50
113
|
title: run.meta.title,
|
|
51
114
|
owner: runOwner(run)?.sessionId ?? null,
|
|
52
|
-
|
|
115
|
+
ownerHost: runOwner(run)?.host ?? null,
|
|
116
|
+
live: live.filter(mine).map((d) => ({
|
|
117
|
+
name: d.admit.name,
|
|
118
|
+
role: d.admit.role,
|
|
119
|
+
rung: d.admit.rung,
|
|
120
|
+
state: d.state,
|
|
121
|
+
secs: Math.max(0, Math.round((now - Date.parse(d.admit.admittedAt)) / 1000)),
|
|
122
|
+
lastEvent: lastActivity(d),
|
|
123
|
+
})),
|
|
124
|
+
delivery: listDispatches(run)
|
|
53
125
|
.filter(mine)
|
|
54
|
-
.
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
})),
|
|
126
|
+
.flatMap((d) =>
|
|
127
|
+
inspectDeliveries(run, d, false).map((inspection) => ({
|
|
128
|
+
name: d.admit.name,
|
|
129
|
+
dispatchId: d.admit.dispatchId,
|
|
130
|
+
...inspection,
|
|
131
|
+
})),
|
|
132
|
+
),
|
|
62
133
|
unread: listDispatches(run)
|
|
63
134
|
.filter(mine)
|
|
64
135
|
.flatMap((d) => {
|
|
65
136
|
const r = records.get(d.admit.dispatchId);
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
137
|
+
if (!r || !awaitsCollect(d.dir)) return [];
|
|
138
|
+
const delivery = inspectDelivery(run, d, true);
|
|
139
|
+
if (delivery.delivery === "collected") return [];
|
|
140
|
+
return [
|
|
141
|
+
{
|
|
142
|
+
...delivery,
|
|
143
|
+
delivery: delivery.delivery,
|
|
144
|
+
name: r.name,
|
|
145
|
+
dispatchId: r.dispatchId,
|
|
146
|
+
header: noticeHeader(finishedNotice(run, d, r)),
|
|
147
|
+
},
|
|
148
|
+
];
|
|
69
149
|
}),
|
|
70
150
|
native: native
|
|
71
151
|
? { name: native.name, role: native.role, rung: native.rung, status: native.status, at: native.at }
|
|
72
152
|
: null,
|
|
73
|
-
next:
|
|
74
|
-
protocol:
|
|
75
|
-
verifier:
|
|
153
|
+
next: notes.next,
|
|
154
|
+
protocol: re.protocol,
|
|
155
|
+
verifier: re.verifier,
|
|
156
|
+
...act,
|
|
76
157
|
};
|
|
77
158
|
}
|
|
78
159
|
|
|
@@ -84,7 +165,15 @@ function peekRun(deps: Deps, run: Run, name: string | undefined): PeekRun {
|
|
|
84
165
|
export async function peek(
|
|
85
166
|
deps: Deps,
|
|
86
167
|
i: { run?: string; name?: string },
|
|
87
|
-
): Promise<{
|
|
168
|
+
): Promise<{
|
|
169
|
+
/** plan 22, goal mode: false when no run holds anything for the coordinator; `reason` says why either way */
|
|
170
|
+
actionable: boolean;
|
|
171
|
+
reason: string;
|
|
172
|
+
runs: PeekRun[];
|
|
173
|
+
hints: string[];
|
|
174
|
+
host: HostContext;
|
|
175
|
+
queue: QueueCapability | null;
|
|
176
|
+
}> {
|
|
88
177
|
if (i.name !== undefined) assertId("role name", i.name);
|
|
89
178
|
let runs: Run[];
|
|
90
179
|
if (i.run) {
|
|
@@ -94,10 +183,20 @@ export async function peek(
|
|
|
94
183
|
runs = [run];
|
|
95
184
|
} else {
|
|
96
185
|
const all = listRuns().runs;
|
|
97
|
-
const
|
|
98
|
-
const owned = me ? all.filter((r) => runOwner(r)?.sessionId === me.sessionId) : [];
|
|
186
|
+
const owned = all.filter((r) => ownsRun(deps, r));
|
|
99
187
|
runs = owned.length ? owned : all.slice(0, 1);
|
|
100
188
|
}
|
|
101
189
|
const hints = runs.length === 0 ? ["no runs yet: run_start(repo, title, a_lines) starts one"] : [];
|
|
102
|
-
|
|
190
|
+
const views = runs.map((r) => peekRun(deps, r, i.name));
|
|
191
|
+
const doing = views.filter((v) => v.actionable);
|
|
192
|
+
const shown = (vs: PeekRun[]) =>
|
|
193
|
+
vs.map((v) => (views.length > 1 ? `${v.title}: ${v.reason}` : v.reason)).join("; ");
|
|
194
|
+
return {
|
|
195
|
+
actionable: views.length === 0 || doing.length > 0,
|
|
196
|
+
reason: views.length === 0 ? "no runs yet" : shown(doing.length ? doing : views),
|
|
197
|
+
runs: views,
|
|
198
|
+
hints,
|
|
199
|
+
host: inspectionHost(deps.host),
|
|
200
|
+
queue: deps.host.host === "codex" && !deps.host.conflict ? knownQueueCapability(process.env) : null,
|
|
201
|
+
};
|
|
103
202
|
}
|
package/src/services/ports.ts
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { HostContext } from "../domain/host.ts";
|
|
1
2
|
import type { Provenance } from "./provenance.ts";
|
|
2
3
|
import type { Budget } from "../domain/budget.ts";
|
|
3
4
|
import type { SessionEnv } from "../infra/claude-session.ts";
|
|
@@ -6,7 +7,8 @@ import type { Verdict } from "../domain/jev.ts";
|
|
|
6
7
|
import type { Difficulty, Kind } from "../domain/lane.ts";
|
|
7
8
|
import type { Access } from "../domain/record.ts";
|
|
8
9
|
import type { Role } from "../domain/roles.ts";
|
|
9
|
-
import type {
|
|
10
|
+
import type { RoleScope } from "../domain/role-scope.ts";
|
|
11
|
+
import type { Change, ProfileDoc, ProfilePatch } from "../domain/profile.ts";
|
|
10
12
|
import type { Issue } from "../domain/profile-rules.ts";
|
|
11
13
|
import type { RouteJev, RouteSource } from "../domain/route.ts";
|
|
12
14
|
import type { SyncReport } from "./source-sync.ts";
|
|
@@ -17,7 +19,18 @@ export interface ProfileView {
|
|
|
17
19
|
objective: "cost" | "speed";
|
|
18
20
|
/** `defaultRung`, when set, is where a lane starts without a kind and difficulty (spec §5.4) */
|
|
19
21
|
roles: Partial<
|
|
20
|
-
Record<
|
|
22
|
+
Record<
|
|
23
|
+
Role,
|
|
24
|
+
{
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
access: Access;
|
|
27
|
+
rungs: string[];
|
|
28
|
+
defaultRung?: string;
|
|
29
|
+
network?: false;
|
|
30
|
+
/** plan 23: the role's own timeouts over the profile's */
|
|
31
|
+
timeouts?: { idleMin?: number; wallMin?: number };
|
|
32
|
+
}
|
|
33
|
+
>
|
|
21
34
|
>;
|
|
22
35
|
/** per billing key (spec §7.1); a missing key bills as DEFAULT_BILLING */
|
|
23
36
|
billing: Partial<Record<string, BillingMode>>;
|
|
@@ -46,6 +59,9 @@ export interface ProfileSaved {
|
|
|
46
59
|
}
|
|
47
60
|
|
|
48
61
|
export interface ProfilePort {
|
|
62
|
+
withHost?(host: () => HostContext): ProfilePort;
|
|
63
|
+
budgetFor?(repo: string | null): Budget;
|
|
64
|
+
raw?(name?: string, repo?: string | null): ProfileDoc;
|
|
49
65
|
/** The profile bound to `repo` (a git toplevel), else the active one; null asks for the active one. */
|
|
50
66
|
forRepo(repo: string | null): ProfileView;
|
|
51
67
|
/** Without a name (get, validate, set): the profile `repo`, a repository's toplevel, runs on. */
|
|
@@ -69,6 +85,7 @@ export interface ProfilePort {
|
|
|
69
85
|
}
|
|
70
86
|
|
|
71
87
|
export interface RouteRequest {
|
|
88
|
+
host?: HostContext["host"];
|
|
72
89
|
runDir: string;
|
|
73
90
|
repo: string;
|
|
74
91
|
profile: ProfileView;
|
|
@@ -77,6 +94,8 @@ export interface RouteRequest {
|
|
|
77
94
|
lane: string | null;
|
|
78
95
|
laneText: string | null;
|
|
79
96
|
spentFraction: number;
|
|
97
|
+
/** spec 1.5 plan 24: the run's dispatches so far per quota, for a tie between rungs of equal scores */
|
|
98
|
+
usage?: Record<string, number>;
|
|
80
99
|
}
|
|
81
100
|
|
|
82
101
|
export interface RouteAnswer {
|
|
@@ -88,6 +107,14 @@ export interface RouteAnswer {
|
|
|
88
107
|
/** the Jev question set asked, and its summed probabilities, for outcomes.jsonl (spec §5.6) */
|
|
89
108
|
questionSet: string | null;
|
|
90
109
|
jev: RouteJev | null;
|
|
110
|
+
/** spec 1.5 plan 24: where Jev disagreed with a declared lane header (`Jev said logic/hard`), else null */
|
|
111
|
+
jevSaid: string | null;
|
|
112
|
+
/** spec 1.5 plan 24: no rung clears the lane's bar, and the closest one */
|
|
113
|
+
noClear?: string;
|
|
114
|
+
/** spec 1.5 plan 24: rungs of equal scores on other quotas competed for the start, and what decided */
|
|
115
|
+
tie?: string;
|
|
116
|
+
/** spec 1.5 plan 24: the decision in one line, for the coordinator */
|
|
117
|
+
why: string;
|
|
91
118
|
/** spec 1.2 §5.3, §8: the chosen rung's thresholds, values, sources, facts and run evidence */
|
|
92
119
|
provenance?: Provenance;
|
|
93
120
|
}
|
|
@@ -106,6 +133,8 @@ export interface CatalogFilter {
|
|
|
106
133
|
|
|
107
134
|
export interface RoutingPort {
|
|
108
135
|
route(req: RouteRequest): Promise<RouteAnswer>;
|
|
136
|
+
/** spec 1.5 plan 24: the lanes of one role in one call; Jev is asked about all of them at once */
|
|
137
|
+
routeMany(reqs: RouteRequest[]): Promise<RouteAnswer[]>;
|
|
109
138
|
/** `use` is the repo profile's `jev.use`: "off" answers with the rule's default and never asks Jev */
|
|
110
139
|
finding(
|
|
111
140
|
runDir: string,
|
|
@@ -128,14 +157,20 @@ export interface RoutingPort {
|
|
|
128
157
|
|
|
129
158
|
/** Everything a service needs from outside it; the entry layer builds one, tests build fakes. */
|
|
130
159
|
export interface Deps {
|
|
160
|
+
host: HostContext;
|
|
131
161
|
profiles: ProfilePort;
|
|
132
162
|
routing: RoutingPort;
|
|
133
163
|
version: string;
|
|
134
164
|
/** how often the supervisor and the dispatch watchers poll, in ms */
|
|
135
165
|
pollMs: number;
|
|
136
|
-
/**
|
|
166
|
+
/** Claude transport credentials and registry refresh; host identity lives in `host`. */
|
|
137
167
|
session: SessionEnv | null;
|
|
138
168
|
now: () => number;
|
|
139
169
|
/** spec 1.2 §3.2 `catalog_sync`; default: the real sync (tests inject one that never reaches the network) */
|
|
140
|
-
sync?: (o: { force: boolean }) => Promise<SyncReport>;
|
|
170
|
+
sync?: (o: { force: boolean; wait?: boolean }) => Promise<SyncReport>;
|
|
171
|
+
/**
|
|
172
|
+
* Spec 1.5 plan 21: the role this process works for (CATHERD_ROLE, or a role's scratch TMPDIR); null or
|
|
173
|
+
* absent for the orchestrator's own server. A role never claims a run and never reaches a coordinator tool.
|
|
174
|
+
*/
|
|
175
|
+
role?: RoleScope | null;
|
|
141
176
|
}
|