catherd-cli 1.4.0 → 2.0.0-beta.20261022113309
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +188 -169
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -239
- package/package.json +6 -8
- package/plugin/.claude-plugin/plugin.json +1 -1
- package/plugin/.codex-plugin/plugin.json +1 -1
- package/plugin/bin/catherd-mcp +1 -1
- package/plugin/commands/catherd.md +2 -2
- package/plugin/skills/catherd/SKILL.md +97 -356
- package/plugin/skills/catherd-setup/SKILL.md +21 -20
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +19 -4
- package/src/adapters/claude-code/index.ts +126 -14
- package/src/adapters/codex/events.ts +10 -0
- package/src/adapters/codex/index.ts +17 -5
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +10 -4
- package/src/adapters/opencode/index.ts +38 -1
- package/src/cli.ts +27 -27
- package/src/domain/agents.ts +3 -1
- package/src/domain/bars.ts +33 -107
- package/src/domain/blocked.ts +72 -0
- package/src/domain/branch.ts +51 -0
- package/src/domain/brief.ts +106 -0
- package/src/domain/catalog.ts +69 -313
- package/src/domain/changes.ts +23 -1
- package/src/domain/cost.ts +19 -29
- package/src/domain/errors.ts +6 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -75
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +53 -0
- package/src/domain/hints.ts +13 -2
- package/src/domain/ids.ts +13 -2
- package/src/domain/jev.ts +47 -2
- package/src/domain/judge.ts +124 -0
- package/src/domain/lane.ts +118 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +32 -8
- package/src/domain/notify.ts +32 -0
- package/src/domain/plan-doc.ts +198 -0
- package/src/domain/plan-lint.ts +141 -0
- package/src/domain/profile-rules.ts +175 -138
- package/src/domain/profile.ts +140 -63
- package/src/domain/record.ts +68 -3
- package/src/domain/reply.ts +29 -0
- package/src/domain/role-prompts.ts +182 -56
- package/src/domain/role-scope.ts +76 -0
- package/src/domain/role-tools.ts +34 -0
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +90 -0
- package/src/domain/rules.ts +33 -0
- package/src/domain/run-events.ts +298 -0
- package/src/domain/run-machine.ts +668 -0
- package/src/domain/run-state.ts +1310 -0
- package/src/domain/scorecard.ts +197 -0
- package/src/domain/select.ts +229 -72
- package/src/domain/slopalytics.ts +72 -0
- package/src/domain/state.ts +5 -0
- package/src/domain/util.ts +22 -0
- package/src/domain/workspace.ts +130 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +24 -165
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +18 -2
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/deps.ts +2 -0
- package/src/entry/doctor-command.ts +20 -2
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/host-arg.ts +28 -0
- package/src/entry/init-command.ts +20 -69
- package/src/entry/knowledge-command.ts +121 -5
- package/src/entry/lock-command.ts +103 -8
- package/src/entry/mcp/handshake.ts +6 -2
- package/src/entry/mcp/role-bin.ts +20 -0
- package/src/entry/mcp/role-server.ts +134 -0
- package/src/entry/mcp/server.ts +85 -21
- package/src/entry/mcp/session-tools.ts +149 -0
- package/src/entry/panel/openapi.ts +189 -0
- package/src/entry/panel/server.ts +410 -0
- package/src/entry/panel-command.ts +68 -0
- package/src/entry/pause-command.ts +67 -0
- package/src/entry/profile-command.ts +33 -18
- package/src/entry/role-cli-command.ts +159 -0
- package/src/entry/runs-command.ts +81 -27
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/entry/supervise-command.ts +30 -3
- package/src/entry/workspace-command.ts +44 -0
- package/src/infra/boot-lock.ts +25 -0
- package/src/infra/delivery.ts +3 -0
- package/src/infra/dispatch-dir.ts +2 -0
- package/src/infra/env.ts +11 -3
- package/src/infra/git.ts +70 -0
- package/src/infra/heavy-lock.ts +133 -6
- package/src/infra/host-probe.ts +16 -0
- package/src/infra/launch.ts +63 -7
- package/src/infra/lock-activity.ts +88 -0
- package/src/infra/login-env.ts +48 -0
- package/src/infra/memory.ts +45 -0
- package/src/infra/ntfy.ts +40 -0
- package/src/infra/open-url.ts +42 -0
- package/src/infra/paths.ts +37 -2
- package/src/infra/role-mcp.ts +69 -0
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/infra/supervisor.ts +107 -16
- package/src/services/admission.ts +133 -31
- package/src/services/backends.ts +4 -6
- package/src/services/budget.ts +26 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -254
- package/src/services/credentials.ts +15 -26
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +209 -17
- package/src/services/dispatches.ts +30 -2
- package/src/services/doctor-access.ts +8 -3
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor-docker.ts +221 -0
- package/src/services/doctor-role-mcp.ts +65 -0
- package/src/services/doctor.ts +72 -15
- package/src/services/end-push.ts +157 -0
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +24 -2
- package/src/services/gate-env.ts +217 -0
- package/src/services/gate-service.ts +231 -45
- package/src/services/global-install.ts +46 -13
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-edit.ts +154 -0
- package/src/services/lane-service.ts +291 -55
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +167 -29
- package/src/services/notifier.ts +124 -4
- package/src/services/notify-service.ts +26 -0
- package/src/services/orchestrator-wait.ts +82 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/pause.ts +161 -0
- package/src/services/peek.ts +79 -17
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +60 -5
- package/src/services/preflight.ts +232 -24
- package/src/services/profile-service.ts +31 -3
- package/src/services/profile-store.ts +18 -6
- package/src/services/protocol.ts +123 -25
- package/src/services/provenance.ts +34 -89
- package/src/services/questions.ts +24 -0
- package/src/services/reconcile.ts +33 -1
- package/src/services/reentry.ts +7 -6
- package/src/services/role-access.ts +27 -0
- package/src/services/routing-service.ts +161 -42
- package/src/services/run-briefs.ts +477 -0
- package/src/services/run-client.ts +217 -0
- package/src/services/run-effects.ts +837 -0
- package/src/services/run-events-store.ts +174 -0
- package/src/services/run-evidence.ts +1 -1
- package/src/services/run-pin.ts +125 -0
- package/src/services/run-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +204 -23
- package/src/services/run-store.ts +159 -18
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/scratch.ts +60 -0
- package/src/services/sessions.ts +3 -1
- package/src/services/state.ts +21 -0
- package/src/services/summary.ts +76 -12
- package/src/services/tree-path.ts +27 -0
- package/src/services/verifier-brief.ts +23 -0
- package/src/services/verifier-step.ts +48 -0
- package/src/services/workspace-admission.ts +259 -0
- package/src/services/workspace-service.ts +259 -0
- package/src/services/workspace-store.ts +95 -0
- package/web/dist/assets/Brand-PKlUX5TE.js +1 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.br +0 -0
- package/web/dist/assets/Brand-PKlUX5TE.js.gz +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js +1 -0
- package/web/dist/assets/Modal-Dxc5so53.js.br +0 -0
- package/web/dist/assets/Modal-Dxc5so53.js.gz +0 -0
- package/web/dist/assets/Pressable-C8-T6Ed_.js +1 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js +153 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.br +0 -0
- package/web/dist/assets/StatusChip-B5xfI_Mn.js.gz +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js +1 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.br +0 -0
- package/web/dist/assets/_app-Ea5cpyKe.js.gz +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js +1 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.br +0 -0
- package/web/dist/assets/_runId-pTG0fXVo.js.gz +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js +1 -0
- package/web/dist/assets/drawer-Du3cdlok.js.br +0 -0
- package/web/dist/assets/drawer-Du3cdlok.js.gz +0 -0
- package/web/dist/assets/geist-cyrillic-ext-wght-normal-DjL33-gN.woff2 +0 -0
- package/web/dist/assets/geist-cyrillic-wght-normal-BEAKL7Jp.woff2 +0 -0
- package/web/dist/assets/geist-latin-ext-wght-normal-DC-KSUi6.woff2 +0 -0
- package/web/dist/assets/geist-latin-wght-normal-BgDaEnEv.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-ext-wght-normal-X_5orZeX.woff2 +0 -0
- package/web/dist/assets/geist-mono-cyrillic-wght-normal-DiZS0aHC.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-ext-wght-normal-Bwz-egvJ.woff2 +0 -0
- package/web/dist/assets/geist-mono-latin-wght-normal-XN7g48iV.woff2 +0 -0
- package/web/dist/assets/geist-mono-symbols2-wght-normal-CO5SzqOn.woff2 +0 -0
- package/web/dist/assets/geist-mono-vietnamese-wght-normal-DadHysG0.woff2 +0 -0
- package/web/dist/assets/geist-vietnamese-wght-normal-6IgcOCM7.woff2 +0 -0
- package/web/dist/assets/index-B6GiwDkh.js +11 -0
- package/web/dist/assets/index-B6GiwDkh.js.br +0 -0
- package/web/dist/assets/index-B6GiwDkh.js.gz +0 -0
- package/web/dist/assets/index-C5IbylvM.css +2 -0
- package/web/dist/assets/index-C5IbylvM.css.br +0 -0
- package/web/dist/assets/index-C5IbylvM.css.gz +0 -0
- package/web/dist/assets/label-DFSYBc62.js +1 -0
- package/web/dist/assets/label-DFSYBc62.js.br +0 -0
- package/web/dist/assets/label-DFSYBc62.js.gz +0 -0
- package/web/dist/assets/link-D8EcKZUt.js +1 -0
- package/web/dist/assets/link-D8EcKZUt.js.br +0 -0
- package/web/dist/assets/link-D8EcKZUt.js.gz +0 -0
- package/web/dist/assets/login-DaN5HPPW.js +1 -0
- package/web/dist/assets/login-DaN5HPPW.js.br +0 -0
- package/web/dist/assets/login-DaN5HPPW.js.gz +0 -0
- package/web/dist/assets/query-Cu--ccT3.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js +1 -0
- package/web/dist/assets/questions-shoO9OVj.js.br +0 -0
- package/web/dist/assets/questions-shoO9OVj.js.gz +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js +12 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.br +0 -0
- package/web/dist/assets/react-query.gen-Bu2yV-qJ.js.gz +0 -0
- package/web/dist/assets/rolldown-runtime-XKyaHtl9.js +1 -0
- package/web/dist/assets/run-status-C0DRuV7U.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js +1 -0
- package/web/dist/assets/runs-C3rg2zZc.js.br +0 -0
- package/web/dist/assets/runs-C3rg2zZc.js.gz +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js +1 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.br +0 -0
- package/web/dist/assets/setup-5I4Vl9fR.js.gz +0 -0
- package/web/dist/assets/table-BOxXhqS4.js +1 -0
- package/web/dist/assets/table-BOxXhqS4.js.br +0 -0
- package/web/dist/assets/table-BOxXhqS4.js.gz +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js +1 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.br +0 -0
- package/web/dist/assets/textfield-BzRFkxBF.js.gz +0 -0
- package/web/dist/assets/time--9kSQbbp.js +1 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js +5 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.br +0 -0
- package/web/dist/assets/use-css-variable-CpnN9WtA.js.gz +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js +1 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.br +0 -0
- package/web/dist/assets/useMutation-CVPKlR34.js.gz +0 -0
- package/web/dist/index.html +15 -0
- package/catalog/ATTRIBUTION.md +0 -36
- package/catalog/scores.json +0 -3202
- package/catalog/sources.json +0 -97
- package/src/adapters/antigravity/events.ts +0 -131
- package/src/adapters/antigravity/home.ts +0 -46
- package/src/adapters/antigravity/index.ts +0 -320
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -132
- package/src/adapters/grok/index.ts +0 -339
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -125
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -84
- package/src/entry/mcp/protocol-tools.ts +0 -57
- package/src/entry/mcp/run-tools.ts +0 -148
- package/src/entry/mcp/setup-tools.ts +0 -108
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -381
- package/src/entry/tui/fixtures.ts +0 -637
- package/src/entry/tui/profile-edits.ts +0 -146
- package/src/entry/tui/profile-tree.ts +0 -429
- package/src/entry/tui/providers/app.tsx +0 -162
- package/src/entry/tui/providers/boundary.tsx +0 -57
- package/src/entry/tui/providers/data.tsx +0 -160
- package/src/entry/tui/providers/keymap.tsx +0 -166
- package/src/entry/tui/providers/root.tsx +0 -45
- package/src/entry/tui/providers/theme.tsx +0 -17
- package/src/entry/tui/providers/toast.tsx +0 -77
- package/src/entry/tui/run.tsx +0 -114
- package/src/entry/tui/state.ts +0 -373
- package/src/entry/tui/stories.ts +0 -114
- package/src/entry/tui/text.ts +0 -130
- package/src/entry/tui/theme.ts +0 -140
- package/src/entry/tui/views/app.tsx +0 -208
- package/src/entry/tui/views/dialogs.tsx +0 -41
- package/src/entry/tui/views/profile-actions.ts +0 -331
- package/src/entry/tui/views/profiles.tsx +0 -520
- package/src/entry/tui/views/runs.tsx +0 -573
- package/src/entry/tui/views/save-dialog.tsx +0 -340
- package/src/entry/tui/views/status.tsx +0 -209
- package/src/entry/tui/widgets/chrome.tsx +0 -114
- package/src/entry/tui/widgets/dialog-confirm.tsx +0 -58
- package/src/entry/tui/widgets/dialog-prompt.tsx +0 -52
- package/src/entry/tui/widgets/dialog-select.tsx +0 -212
- package/src/entry/tui/widgets/dialog.tsx +0 -68
- package/src/entry/tui/widgets/line.tsx +0 -92
- package/src/entry/tui/widgets/list.tsx +0 -197
- package/src/entry/tui/widgets/spinner.tsx +0 -49
- package/src/entry/tui/widgets/toast.tsx +0 -62
- package/src/infra/sources/arena.ts +0 -87
- package/src/infra/sources/artificial-analysis.ts +0 -112
- package/src/infra/sources/cache.ts +0 -124
- package/src/infra/sources/csv.ts +0 -38
- package/src/infra/sources/epoch.ts +0 -82
- package/src/infra/sources/http.ts +0 -75
- package/src/infra/sources/litellm.ts +0 -58
- package/src/infra/sources/models-dev.ts +0 -59
- package/src/infra/sources/openrouter-endpoints.ts +0 -52
- package/src/infra/sources/openrouter-models.ts +0 -60
- package/src/infra/sources/rows.ts +0 -38
- package/src/infra/sources/vectara.ts +0 -69
- package/src/infra/sources/zip.ts +0 -48
- package/src/services/catalog-refresh.ts +0 -201
- package/src/services/doctor-sources.ts +0 -76
- package/src/services/runs-page.ts +0 -255
- package/src/services/source-derive.ts +0 -374
- package/src/services/source-sync.ts +0 -305
- package/src/services/standins.ts +0 -204
- package/src/services/treat-likes.ts +0 -97
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
4
|
+
import type { Run } from "./run-store.ts";
|
|
5
|
+
|
|
6
|
+
/** `<run dir>/trees/<m>`: where milestone `m` of a base run works. */
|
|
7
|
+
export const treeDir = (run: Run, m: string): string => join(run.dir, "trees", m);
|
|
8
|
+
|
|
9
|
+
/** The marker beside the tree, never inside it, that Setup: finished. */
|
|
10
|
+
export const readyFile = (run: Run, m: string): string => join(run.dir, "trees", `${m}.ready`);
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Where milestone `m` works: its worktree in a base run once it exists, else the repo (a run without base, the
|
|
14
|
+
* run itself, a milestone not claimed yet). A claimed milestone whose folder is gone is an error, never the repo:
|
|
15
|
+
* its commits, checks and merge would land in the owner's own checkout. Kept apart from milestone-tree.ts so the services that only ask
|
|
16
|
+
* where a milestone works do not import the claiming code, which imports them.
|
|
17
|
+
*/
|
|
18
|
+
export function treeOf(run: Run, m: string | null): string {
|
|
19
|
+
if (!run.meta.base || m === null) return run.meta.repo;
|
|
20
|
+
const tree = treeDir(run, m);
|
|
21
|
+
if (existsSync(tree)) return tree;
|
|
22
|
+
if (existsSync(readyFile(run, m)))
|
|
23
|
+
throw new CatherdError("E_TREE", `the worktree of ${m} is gone: ${tree}`, {
|
|
24
|
+
fix: "restore the tree (git worktree add it back on the milestone's branch) or cancel the run",
|
|
25
|
+
});
|
|
26
|
+
return run.meta.repo;
|
|
27
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { withGateNotes } from "../domain/gate-brief.ts";
|
|
2
|
+
import { gateEnvLines, readGateEnv } from "./gate-env.ts";
|
|
3
|
+
import { recordedItems } from "./gate-service.ts";
|
|
4
|
+
import type { Run } from "./run-store.ts";
|
|
5
|
+
|
|
6
|
+
// Plan 23: a dispatched verifier's brief ends with the gate's rules and the run's facts: the milestone's
|
|
7
|
+
// recorded items (reuse their names), the failed ones (a re-check runs them first, each command capped), and
|
|
8
|
+
// the repo's gate environment.
|
|
9
|
+
|
|
10
|
+
/** The milestone a verifier dispatch checks: the first `M<n>` its name names as a word (verifier-M1 → M1). */
|
|
11
|
+
export const milestoneOfName = (name: string): string | null =>
|
|
12
|
+
/(?:^|[^A-Za-z0-9])(M\d+)(?=$|[-._\s])/.exec(name)?.[1] ?? null;
|
|
13
|
+
|
|
14
|
+
/** A verifier's brief with the gate notes, once; any other role's brief as it is. */
|
|
15
|
+
export async function verifierBrief(run: Run, role: string, name: string, brief: string): Promise<string> {
|
|
16
|
+
if (role !== "verifier") return brief;
|
|
17
|
+
const milestone = milestoneOfName(name);
|
|
18
|
+
return withGateNotes(brief, {
|
|
19
|
+
milestone,
|
|
20
|
+
recorded: milestone ? await recordedItems(run, milestone) : [],
|
|
21
|
+
env: gateEnvLines(await readGateEnv(run.meta.repo)),
|
|
22
|
+
});
|
|
23
|
+
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { liveSessionFile, readSessionFiles, type SessionFile } from "../infra/claude-session.ts";
|
|
2
|
+
import { readJsonl } from "../infra/store.ts";
|
|
3
|
+
import { gatesFile, latestVerifierStep, type VerifierStep } from "./gate-service.ts";
|
|
4
|
+
import { readAgentRuns, readRecords, type Run } from "./run-store.ts";
|
|
5
|
+
import { runOwner } from "./sessions.ts";
|
|
6
|
+
|
|
7
|
+
/** What ended a verifier step (plan 22: a step nothing ends shows as live forever). */
|
|
8
|
+
export type StepClosedBy = "gate_pass" | "agent-run" | "record" | "owner-gone";
|
|
9
|
+
|
|
10
|
+
export interface VerifierStepView extends VerifierStep {
|
|
11
|
+
/** seconds since the step began */
|
|
12
|
+
secs: number;
|
|
13
|
+
open: boolean;
|
|
14
|
+
closedBy: StepClosedBy | null;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Plan 22: the verifier's latest step with its age, closed by the first evidence after it: a `gate_pass` of its
|
|
19
|
+
* item in this run, a native verifier's `record_agent_run`, a verifier dispatch's record, or its owner session
|
|
20
|
+
* gone (another session took the run since, or the owner's Claude Code session no longer runs). A Codex owner
|
|
21
|
+
* that still holds the run has no liveness to read: its step stays open until other evidence ends it.
|
|
22
|
+
*/
|
|
23
|
+
export function verifierStepView(
|
|
24
|
+
run: Run,
|
|
25
|
+
now: number,
|
|
26
|
+
files: () => SessionFile[] = readSessionFiles,
|
|
27
|
+
): VerifierStepView | null {
|
|
28
|
+
const step = latestVerifierStep(run);
|
|
29
|
+
if (!step) return null;
|
|
30
|
+
const at = Date.parse(step.at) || 0;
|
|
31
|
+
const after = (t: string | undefined) => (Date.parse(t ?? "") || 0) >= at;
|
|
32
|
+
const passed = readJsonl<{ run?: unknown; item?: unknown; at?: string }>(
|
|
33
|
+
gatesFile(run.meta.repo),
|
|
34
|
+
).rows.some((p) => p?.run === run.id && p.item === step.item && after(p.at));
|
|
35
|
+
const owner = runOwner(run);
|
|
36
|
+
const closedBy: StepClosedBy | null = passed
|
|
37
|
+
? "gate_pass"
|
|
38
|
+
: readAgentRuns(run).some((a) => a.role === "verifier" && after(a.at))
|
|
39
|
+
? "agent-run"
|
|
40
|
+
: readRecords(run).records.some((r) => r.role === "verifier" && after(r.endedAt))
|
|
41
|
+
? "record"
|
|
42
|
+
: owner &&
|
|
43
|
+
((Date.parse(owner.since) || 0) > at ||
|
|
44
|
+
(owner.host === "claude-code" && liveSessionFile(owner.sessionId, files()) === null))
|
|
45
|
+
? "owner-gone"
|
|
46
|
+
: null;
|
|
47
|
+
return { ...step, secs: Math.max(0, Math.floor((now - at) / 1000)), open: closedBy === null, closedBy };
|
|
48
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
|
+
import { budgetStatus, formatBudget, type BudgetStatus, type Spend } from "../domain/budget.ts";
|
|
3
|
+
import { CatherdError, errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
4
|
+
import type { Workspace, WorkspaceStep } from "../domain/workspace.ts";
|
|
5
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
6
|
+
import { gitToplevel, isAncestor } from "../infra/git.ts";
|
|
7
|
+
import { writeTextAtomic } from "../infra/store.ts";
|
|
8
|
+
import { spendOf } from "./budget.ts";
|
|
9
|
+
import { pendingDispatches } from "./dispatches.ts";
|
|
10
|
+
import { landedCommitOf, landedMilestones } from "./milestones.ts";
|
|
11
|
+
import { readRecords, type Run, runPaths } from "./run-store.ts";
|
|
12
|
+
import {
|
|
13
|
+
findWorkspace,
|
|
14
|
+
saveWorkspace,
|
|
15
|
+
WORKSPACE_LOCK_WAIT_MS,
|
|
16
|
+
workspaceChildren,
|
|
17
|
+
workspaceDirectory,
|
|
18
|
+
workspacePaths,
|
|
19
|
+
} from "./workspace-store.ts";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Count each recorded or unrecorded dispatch once; elapsed minutes belong to the parent and start at its first
|
|
23
|
+
* child (#43 finding 4), none before one. With `warnings`, a child whose cost evidence cannot be read is
|
|
24
|
+
* skipped and named there (status); without, it throws E_RUN_CORRUPT (admission against a cap, which cannot
|
|
25
|
+
* prove the cap holds without it).
|
|
26
|
+
*/
|
|
27
|
+
export async function workspaceSpend(
|
|
28
|
+
workspace: Workspace,
|
|
29
|
+
now: number,
|
|
30
|
+
children = workspaceChildren(workspace),
|
|
31
|
+
warnings?: string[],
|
|
32
|
+
): Promise<Spend> {
|
|
33
|
+
const first = Math.min(...children.map((c) => Date.parse(c.meta.createdAt)).filter(Number.isFinite));
|
|
34
|
+
const total: Spend = {
|
|
35
|
+
minutes: Number.isFinite(first) ? Math.max(0, (now - first) / 60_000) : 0,
|
|
36
|
+
tokens: 0,
|
|
37
|
+
usd: 0,
|
|
38
|
+
};
|
|
39
|
+
for (const child of children) {
|
|
40
|
+
try {
|
|
41
|
+
const spent = await withFileLock(runPaths(child.dir).runs, () => {
|
|
42
|
+
const { records, corrupt } = readRecords(child);
|
|
43
|
+
if (corrupt) throw new CatherdError("E_RUN_CORRUPT", `run ${child.id} has unreadable cost records`);
|
|
44
|
+
return spendOf(child, records, pendingDispatches(child, now, records, true), now, true);
|
|
45
|
+
});
|
|
46
|
+
total.tokens += spent.tokens;
|
|
47
|
+
total.usd += spent.usd;
|
|
48
|
+
} catch (e) {
|
|
49
|
+
if (!warnings || !isCatherdError(e) || e.code !== "E_RUN_CORRUPT") throw e;
|
|
50
|
+
warnings.push(`spend leaves out ${child.id}: ${errorMessage(e)}`);
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return total;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** The workspace budget a child run's routing sees; a child whose evidence is unreadable counts nothing. */
|
|
57
|
+
export async function workspaceBudget(run: Run, now: number): Promise<BudgetStatus | null> {
|
|
58
|
+
if (!run.meta.workspace) return null;
|
|
59
|
+
const workspace = findWorkspace(run.meta.workspace.id);
|
|
60
|
+
return budgetStatus(await workspaceSpend(workspace, now, undefined, []), workspace.budget);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Why a dependency keeps a step waiting: the dependency's step id and the reason, in words. */
|
|
64
|
+
export interface Blocker {
|
|
65
|
+
step: string;
|
|
66
|
+
why: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The dependency's uncollected dispatches, read under its records lock: a reason, or null when none. */
|
|
70
|
+
async function uncollected(child: Run, now: number): Promise<string | null> {
|
|
71
|
+
const step = child.meta.workspace?.step ?? child.id;
|
|
72
|
+
try {
|
|
73
|
+
const pending = await withFileLock(runPaths(child.dir).runs, () => {
|
|
74
|
+
const { records, corrupt } = readRecords(child);
|
|
75
|
+
if (corrupt) throw new CatherdError("E_RUN_CORRUPT", `run ${child.id} has unreadable dispatch records`);
|
|
76
|
+
return pendingDispatches(child, now, records, true);
|
|
77
|
+
});
|
|
78
|
+
return pending.length
|
|
79
|
+
? `${step} has ${pending.length} dispatch(es) not collected (${pending.map((d) => d.admit.name).join(", ")})`
|
|
80
|
+
: null;
|
|
81
|
+
} catch (e) {
|
|
82
|
+
if (isCatherdError(e) && e.code === "E_RUN_CORRUPT") return `${step} cannot be read: ${e.message}`;
|
|
83
|
+
throw e;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/** A landed milestone that differs from the step's only by case: `m1` for `M1` (#43 finding 7). */
|
|
88
|
+
const caseTwin = (landed: string[], milestone: string): string | undefined =>
|
|
89
|
+
landed.find((m) => m !== milestone && m.toLowerCase() === milestone.toLowerCase());
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* #43 finding 8: the one dependency rule admission, child start and status share. A dependency releases its
|
|
93
|
+
* dependents once its child has landed the dependency's milestone and every dispatch it admitted is
|
|
94
|
+
* collected. Reads only the dependencies' own evidence (#43 finding 2); an unreadable one blocks, named.
|
|
95
|
+
*/
|
|
96
|
+
export async function dependencyBlockers(
|
|
97
|
+
workspace: Workspace,
|
|
98
|
+
step: WorkspaceStep,
|
|
99
|
+
children: Run[],
|
|
100
|
+
now: number,
|
|
101
|
+
o: { merge?: boolean } = {},
|
|
102
|
+
): Promise<Blocker[]> {
|
|
103
|
+
const out: Blocker[] = [];
|
|
104
|
+
for (const id of step.dependsOn) {
|
|
105
|
+
const before = workspace.steps.find((s) => s.id === id);
|
|
106
|
+
const child = children.find((c) => c.meta.workspace?.step === id);
|
|
107
|
+
if (!before || !child) {
|
|
108
|
+
out.push({ step: id, why: `${id} has not started` });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
const landed = landedMilestones(child);
|
|
112
|
+
if (!landed.includes(before.milestone)) {
|
|
113
|
+
const twin = caseTwin(landed, before.milestone);
|
|
114
|
+
out.push({
|
|
115
|
+
step: id,
|
|
116
|
+
why: `${id} has not landed ${before.milestone}${twin ? ` (it landed ${twin}: the step completes on ${before.milestone})` : ""}`,
|
|
117
|
+
});
|
|
118
|
+
continue;
|
|
119
|
+
}
|
|
120
|
+
const why = (await uncollected(child, now)) ?? (o.merge ? await unmerged(before, child) : null);
|
|
121
|
+
if (why) out.push({ step: id, why });
|
|
122
|
+
}
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* A `release: "merge"` step releases its dependents once its landed commit is an ancestor of its base ref
|
|
128
|
+
* (`git merge-base --is-ancestor`), in the repository as it stands: catherd never fetches or polls.
|
|
129
|
+
*/
|
|
130
|
+
async function unmerged(step: WorkspaceStep, child: Run): Promise<string | null> {
|
|
131
|
+
if (step.release !== "merge" || !step.base) return null;
|
|
132
|
+
const commit = landedCommitOf(child, step.milestone);
|
|
133
|
+
if (!commit) return `${step.id} has no landed commit for ${step.milestone} in its ledger`;
|
|
134
|
+
const merged = await isAncestor(child.meta.repo, commit, step.base);
|
|
135
|
+
if (merged === true) return null;
|
|
136
|
+
if (merged === false)
|
|
137
|
+
return `${step.id}'s ${step.milestone} (${commit.slice(0, 7)}) is not merged into ${step.base} yet: merge it, git fetch, then ask again`;
|
|
138
|
+
return `${step.id}: git cannot tell whether ${commit.slice(0, 7)} is in ${step.base} (does the ref exist in ${child.meta.repo}?)`;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export function withRunAdmission<T>(run: Run, now: () => number, admit: () => Promise<T>): Promise<T> {
|
|
142
|
+
return withWorkspaceAdmission(run, now, () => withFileLock(runPaths(run.dir).admission, admit));
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Parent → child admission → child records; siblings share the actual admission boundary. */
|
|
146
|
+
export async function withWorkspaceAdmission<T>(
|
|
147
|
+
run: Run,
|
|
148
|
+
clock: number | (() => number),
|
|
149
|
+
admit: () => Promise<T>,
|
|
150
|
+
): Promise<T> {
|
|
151
|
+
const link = run.meta.workspace;
|
|
152
|
+
if (!link) return admit();
|
|
153
|
+
const workspace = findWorkspace(link.id);
|
|
154
|
+
// git outside the workspace lock (#43 finding 5): the checkout check needs no sibling to wait
|
|
155
|
+
if ((await gitToplevel(run.meta.repo)) !== run.meta.repo)
|
|
156
|
+
throw new CatherdError(
|
|
157
|
+
"E_IO_PATH",
|
|
158
|
+
`workspace repository of ${link.step} is no longer the captured git root`,
|
|
159
|
+
);
|
|
160
|
+
return withFileLock(
|
|
161
|
+
workspacePaths(workspaceDirectory(workspace.id)).admission,
|
|
162
|
+
async () => {
|
|
163
|
+
const now = typeof clock === "function" ? clock() : clock;
|
|
164
|
+
const children = workspaceChildren(workspace);
|
|
165
|
+
const step = workspace.steps.find((s) => s.id === link.step);
|
|
166
|
+
if (!step || !children.some((c) => c.id === run.id))
|
|
167
|
+
throw new CatherdError("E_INPUT_INVALID", "the run is not a member of this workspace execution", {
|
|
168
|
+
fix: "start the child with workspace_child_start",
|
|
169
|
+
});
|
|
170
|
+
// #43 finding 7: a landed step's child admits again (a post-land fix); its dependents wait for it
|
|
171
|
+
const blockers = await dependencyBlockers(workspace, step, children, now);
|
|
172
|
+
if (blockers.length)
|
|
173
|
+
throw new CatherdError(
|
|
174
|
+
"E_INPUT_INVALID",
|
|
175
|
+
`${step.id} is waiting: ${blockers.map((b) => b.why).join("; ")}`,
|
|
176
|
+
{
|
|
177
|
+
fix: "land the dependency's completion milestone and collect its finished dispatches first",
|
|
178
|
+
},
|
|
179
|
+
);
|
|
180
|
+
await assertWorkspaceBudget(workspace, now, children);
|
|
181
|
+
return admit();
|
|
182
|
+
},
|
|
183
|
+
{ timeoutMs: WORKSPACE_LOCK_WAIT_MS },
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Whether the budget caps anything: without a cap, no sibling's evidence is read (#43 finding 2). */
|
|
188
|
+
const capped = (workspace: Workspace): boolean =>
|
|
189
|
+
Object.values(workspace.budget).some((v) => typeof v === "number");
|
|
190
|
+
|
|
191
|
+
/** Refuses E_RUN_BUDGET once a capped workspace budget is spent; reads every child only when capped. */
|
|
192
|
+
export async function assertWorkspaceBudget(
|
|
193
|
+
workspace: Workspace,
|
|
194
|
+
now: number,
|
|
195
|
+
children: Run[],
|
|
196
|
+
): Promise<void> {
|
|
197
|
+
if (!capped(workspace)) return;
|
|
198
|
+
const budget = budgetStatus(await workspaceSpend(workspace, now, children), workspace.budget);
|
|
199
|
+
if (budget && budget.fraction >= 1)
|
|
200
|
+
throw new CatherdError("E_RUN_BUDGET", `the workspace budget is spent: ${formatBudget(budget)}`, {
|
|
201
|
+
fix: `finish with the work already admitted, or raise it: workspace_budget(${workspace.id}, …)`,
|
|
202
|
+
});
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* `workspace_budget`: sets the caps given (a number) or removes them (null), keeping the others; the owner's
|
|
207
|
+
* way to raise a spent budget without a new workspace (#43 finding 4). Returns the budget and its status.
|
|
208
|
+
*/
|
|
209
|
+
export async function setWorkspaceBudget(
|
|
210
|
+
deps: { now: () => number },
|
|
211
|
+
i: { workspace: string; minutes?: number | null; tokens?: number | null; usd?: number | null },
|
|
212
|
+
): Promise<{ budget: Workspace["budget"]; spend: Spend; status: BudgetStatus | null }> {
|
|
213
|
+
const workspace = findWorkspace(i.workspace);
|
|
214
|
+
return withFileLock(
|
|
215
|
+
workspacePaths(workspaceDirectory(workspace.id)).admission,
|
|
216
|
+
async () => {
|
|
217
|
+
const budget: Workspace["budget"] = { ...findWorkspace(workspace.id).budget };
|
|
218
|
+
for (const key of ["minutes", "tokens", "usd"] as const) {
|
|
219
|
+
const v = i[key];
|
|
220
|
+
if (v === null) delete budget[key];
|
|
221
|
+
else if (v !== undefined) budget[key] = v;
|
|
222
|
+
}
|
|
223
|
+
const saved = saveWorkspace({ ...findWorkspace(workspace.id), budget });
|
|
224
|
+
const spend = await workspaceSpend(saved, deps.now(), undefined, []);
|
|
225
|
+
return { budget: saved.budget, spend, status: budgetStatus(spend, saved.budget) };
|
|
226
|
+
},
|
|
227
|
+
{ timeoutMs: WORKSPACE_LOCK_WAIT_MS },
|
|
228
|
+
);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/** The shared contract is frozen when the first child starts, then copied into child dossiers. */
|
|
232
|
+
export async function workspaceContract(i: {
|
|
233
|
+
workspace: string;
|
|
234
|
+
content?: string;
|
|
235
|
+
}): Promise<{ path: string; content: string }> {
|
|
236
|
+
const workspace = findWorkspace(i.workspace);
|
|
237
|
+
const paths = workspacePaths(workspaceDirectory(workspace.id));
|
|
238
|
+
if (i.content === undefined)
|
|
239
|
+
return {
|
|
240
|
+
path: paths.contract,
|
|
241
|
+
content: existsSync(paths.contract) ? readFileSync(paths.contract, "utf8") : "",
|
|
242
|
+
};
|
|
243
|
+
return withFileLock(
|
|
244
|
+
paths.admission,
|
|
245
|
+
() => {
|
|
246
|
+
if (workspaceChildren(workspace).length)
|
|
247
|
+
throw new CatherdError(
|
|
248
|
+
"E_INPUT_INVALID",
|
|
249
|
+
"the workspace contract is frozen because a child has started",
|
|
250
|
+
{
|
|
251
|
+
fix: "start a new workspace execution to change the shared contract",
|
|
252
|
+
},
|
|
253
|
+
);
|
|
254
|
+
writeTextAtomic(paths.contract, i.content as string);
|
|
255
|
+
return { path: paths.contract, content: i.content as string };
|
|
256
|
+
},
|
|
257
|
+
{ timeoutMs: WORKSPACE_LOCK_WAIT_MS },
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
import { existsSync, readFileSync, realpathSync, statSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { type Budget, budgetStatus } from "../domain/budget.ts";
|
|
5
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
6
|
+
import { WORKSPACE_LIMIT, WorkspaceIdSchema, WorkspaceSchema, type Workspace } from "../domain/workspace.ts";
|
|
7
|
+
import { withFileLock } from "../infra/filelock.ts";
|
|
8
|
+
import { gitToplevel } from "../infra/git.ts";
|
|
9
|
+
import { indexPath } from "../infra/paths.ts";
|
|
10
|
+
import { readIndexFile } from "../infra/slopalytics.ts";
|
|
11
|
+
import { readVersioned, writeTextAtomic } from "../infra/store.ts";
|
|
12
|
+
import { writeRunIndex } from "./catalog-service.ts";
|
|
13
|
+
import { listDispatches } from "./dispatches.ts";
|
|
14
|
+
import { landedCommits, landedMilestones } from "./milestones.ts";
|
|
15
|
+
import { assertNotPaused, pausesOver } from "./pause.ts";
|
|
16
|
+
import { writePin } from "./run-pin.ts";
|
|
17
|
+
import type { Deps } from "./ports.ts";
|
|
18
|
+
import { createRun, readRecords, type Run } from "./run-store.ts";
|
|
19
|
+
import { claimRun, currentSession } from "./sessions.ts";
|
|
20
|
+
import { refreshState } from "./state.ts";
|
|
21
|
+
import { summarizeRun } from "./summary.ts";
|
|
22
|
+
import { assertWorkspaceBudget, dependencyBlockers, workspaceSpend } from "./workspace-admission.ts";
|
|
23
|
+
import {
|
|
24
|
+
createWorkspace,
|
|
25
|
+
findWorkspace,
|
|
26
|
+
unreadableWarning,
|
|
27
|
+
WORKSPACE_LOCK_WAIT_MS,
|
|
28
|
+
workspaceChildren,
|
|
29
|
+
workspaceDirectory,
|
|
30
|
+
workspaceListing,
|
|
31
|
+
workspacePaths,
|
|
32
|
+
} from "./workspace-store.ts";
|
|
33
|
+
|
|
34
|
+
const ManifestSchema = z.strictObject({
|
|
35
|
+
schema: z.literal(1),
|
|
36
|
+
repos: z
|
|
37
|
+
.record(WorkspaceIdSchema, z.string().min(1))
|
|
38
|
+
.refine((repos) => Object.keys(repos).length <= WORKSPACE_LIMIT),
|
|
39
|
+
});
|
|
40
|
+
const invalid = (message: string) => new CatherdError("E_INPUT_INVALID", message);
|
|
41
|
+
|
|
42
|
+
function registry(root: string, repos?: Record<string, string>): Record<string, string> {
|
|
43
|
+
if (repos !== undefined) {
|
|
44
|
+
const parsed = ManifestSchema.safeParse({ schema: 1, repos });
|
|
45
|
+
if (!parsed.success) throw invalid("workspace repositories require safe aliases and nonempty paths");
|
|
46
|
+
return parsed.data.repos;
|
|
47
|
+
}
|
|
48
|
+
return readVersioned(join(root, "catherd.workspace.json"), ManifestSchema, 1).repos;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function workspaceRoot(root: string): string {
|
|
52
|
+
try {
|
|
53
|
+
const real = realpathSync(root);
|
|
54
|
+
if (!statSync(real).isDirectory()) throw new Error("not a directory");
|
|
55
|
+
return real;
|
|
56
|
+
} catch {
|
|
57
|
+
throw new CatherdError("E_IO_PATH", `workspace root is not an existing directory: ${root}`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** No repository discovery scan: paths are an explicit registry, relative to the workspace root. */
|
|
62
|
+
export async function inspectWorkspace(
|
|
63
|
+
root: string,
|
|
64
|
+
repos?: Record<string, string>,
|
|
65
|
+
): Promise<{ root: string; repos: Record<string, string> }> {
|
|
66
|
+
root = workspaceRoot(root);
|
|
67
|
+
const declared = registry(root, repos);
|
|
68
|
+
if (!Object.keys(declared).length) throw invalid("workspace requires at least one repository");
|
|
69
|
+
const canonical: Record<string, string> = {};
|
|
70
|
+
for (const [alias, path] of Object.entries(declared)) {
|
|
71
|
+
const top = await gitToplevel(resolve(root, path));
|
|
72
|
+
if (!top)
|
|
73
|
+
throw new CatherdError(
|
|
74
|
+
"E_IO_PATH",
|
|
75
|
+
`workspace repository ${alias} is not inside a git repository: ${path}`,
|
|
76
|
+
);
|
|
77
|
+
const real = realpathSync(top);
|
|
78
|
+
if (Object.values(canonical).includes(real))
|
|
79
|
+
throw invalid(`workspace repository ${alias} duplicates a canonical git root`);
|
|
80
|
+
canonical[alias] = real;
|
|
81
|
+
}
|
|
82
|
+
return { root, repos: canonical };
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function startWorkspace(
|
|
86
|
+
deps: Deps,
|
|
87
|
+
input: {
|
|
88
|
+
root: string;
|
|
89
|
+
title: string;
|
|
90
|
+
aLines: string[];
|
|
91
|
+
repos?: Record<string, string>;
|
|
92
|
+
steps: Array<{
|
|
93
|
+
id: string;
|
|
94
|
+
repo: string;
|
|
95
|
+
title: string;
|
|
96
|
+
aLines: string[];
|
|
97
|
+
dependsOn?: string[];
|
|
98
|
+
milestone?: string;
|
|
99
|
+
release?: "land" | "merge";
|
|
100
|
+
base?: string;
|
|
101
|
+
}>;
|
|
102
|
+
budget?: Budget;
|
|
103
|
+
},
|
|
104
|
+
): Promise<{ workspace: Workspace; dir: string }> {
|
|
105
|
+
const root = workspaceRoot(input.root);
|
|
106
|
+
const declared = registry(root, input.repos);
|
|
107
|
+
// Only selected repositories are captured. An unused unavailable checkout cannot block this task.
|
|
108
|
+
const selected: Record<string, string> = {};
|
|
109
|
+
for (const step of input.steps) {
|
|
110
|
+
if (!Object.hasOwn(declared, step.repo)) throw invalid(`unknown workspace repository ${step.repo}`);
|
|
111
|
+
selected[step.repo] = declared[step.repo]!;
|
|
112
|
+
}
|
|
113
|
+
const snapshot = WorkspaceSchema.safeParse({
|
|
114
|
+
schema: 1,
|
|
115
|
+
id: "validation",
|
|
116
|
+
root,
|
|
117
|
+
title: input.title,
|
|
118
|
+
aLines: input.aLines,
|
|
119
|
+
createdAt: new Date(deps.now()).toISOString(),
|
|
120
|
+
repos: selected,
|
|
121
|
+
steps: input.steps,
|
|
122
|
+
// #43 finding 4: no cap unless the owner sets one; each child keeps its own profile budget
|
|
123
|
+
budget: input.budget ?? {},
|
|
124
|
+
});
|
|
125
|
+
if (!snapshot.success)
|
|
126
|
+
throw invalid(
|
|
127
|
+
`workspace requires unique step ids, ordered repository reuse, valid budget, and an acyclic dependency graph: ${snapshot.error.issues[0]?.message ?? "invalid"}`,
|
|
128
|
+
);
|
|
129
|
+
const canonical = await inspectWorkspace(root, selected);
|
|
130
|
+
const workspace = createWorkspace({ ...snapshot.data, repos: canonical.repos }, new Date(deps.now()));
|
|
131
|
+
return { workspace, dir: workspaceDirectory(workspace.id) };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether a child still has dispatches nobody collected; null when its records cannot be read. */
|
|
135
|
+
function ownPending(run: Run): boolean | null {
|
|
136
|
+
const { records, corrupt } = readRecords(run);
|
|
137
|
+
if (corrupt) return null;
|
|
138
|
+
const recorded = new Set(records.map((r) => r.dispatchId));
|
|
139
|
+
try {
|
|
140
|
+
return listDispatches(run, true).some((d) => !recorded.has(d.admit.dispatchId));
|
|
141
|
+
} catch {
|
|
142
|
+
return null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export async function startWorkspaceChild(
|
|
147
|
+
deps: Deps,
|
|
148
|
+
input: { workspace: string; step: string },
|
|
149
|
+
): Promise<{ run: string; dir: string; contract: string | null }> {
|
|
150
|
+
const workspace = findWorkspace(input.workspace);
|
|
151
|
+
const paths = workspacePaths(workspaceDirectory(workspace.id));
|
|
152
|
+
const step = workspace.steps.find((s) => s.id === input.step);
|
|
153
|
+
if (!step) throw invalid(`unknown workspace step ${input.step}`);
|
|
154
|
+
// Recovery must validate the checkout too; stored status remains readable without it. The git work runs
|
|
155
|
+
// outside the workspace lock (#43 finding 5).
|
|
156
|
+
const repo = workspace.repos[step.repo]!;
|
|
157
|
+
if ((await gitToplevel(repo)) !== repo)
|
|
158
|
+
throw new CatherdError(
|
|
159
|
+
"E_IO_PATH",
|
|
160
|
+
`workspace repository ${step.repo} is no longer the captured git root`,
|
|
161
|
+
);
|
|
162
|
+
const run = await withFileLock(
|
|
163
|
+
paths.admission,
|
|
164
|
+
() => childFor(deps, workspace, step, repo, paths.contract),
|
|
165
|
+
{ timeoutMs: WORKSPACE_LOCK_WAIT_MS },
|
|
166
|
+
);
|
|
167
|
+
const contract = existsSync(paths.contract) ? join(run.dir, "workspace-contract.md") : null;
|
|
168
|
+
await claimRun(deps, run);
|
|
169
|
+
await refreshState(run);
|
|
170
|
+
return { run: run.id, dir: run.dir, contract };
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
/** Under the workspace lock: the step's child, created once its dependencies release it. */
|
|
174
|
+
async function childFor(
|
|
175
|
+
deps: Deps,
|
|
176
|
+
workspace: Workspace,
|
|
177
|
+
step: Workspace["steps"][number],
|
|
178
|
+
repo: string,
|
|
179
|
+
contractFile: string,
|
|
180
|
+
): Promise<Run> {
|
|
181
|
+
const children = workspaceChildren(workspace);
|
|
182
|
+
let run = children.find((r) => r.meta.workspace?.step === step.id);
|
|
183
|
+
if (!run) {
|
|
184
|
+
assertNotPaused({ meta: { workspace: { id: workspace.id, step: step.id } } });
|
|
185
|
+
const blockers = await dependencyBlockers(workspace, step, children, deps.now(), { merge: true });
|
|
186
|
+
if (blockers.length)
|
|
187
|
+
throw invalid(`workspace step ${step.id} waits: ${blockers.map((b) => b.why).join("; ")}`);
|
|
188
|
+
await assertWorkspaceBudget(workspace, deps.now(), children);
|
|
189
|
+
run = createRun({
|
|
190
|
+
repo,
|
|
191
|
+
title: step.title,
|
|
192
|
+
aLines: step.aLines,
|
|
193
|
+
version: deps.version,
|
|
194
|
+
now: new Date(deps.now()),
|
|
195
|
+
startedBy: currentSession(deps),
|
|
196
|
+
workspace: { id: workspace.id, step: step.id },
|
|
197
|
+
});
|
|
198
|
+
writePin(deps, run);
|
|
199
|
+
// pinned like startRun does, so a pair the machine index drops later never breaks the child midway
|
|
200
|
+
writeRunIndex(run.dir, readIndexFile(indexPath()));
|
|
201
|
+
}
|
|
202
|
+
const contract = existsSync(contractFile) ? join(run.dir, "workspace-contract.md") : null;
|
|
203
|
+
if (contract && !existsSync(contract)) writeTextAtomic(contract, readFileSync(contractFile, "utf8"));
|
|
204
|
+
return run;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
export async function workspaceStatus(deps: Deps, id: string) {
|
|
208
|
+
const workspace = findWorkspace(id);
|
|
209
|
+
const { children, unreadable } = workspaceListing(workspace);
|
|
210
|
+
// #43 finding 2: a corrupt child is a warning here, never a failed status
|
|
211
|
+
const warnings = unreadable.map(unreadableWarning);
|
|
212
|
+
const steps = [];
|
|
213
|
+
for (const step of workspace.steps) {
|
|
214
|
+
const child = children.find((r) => r.meta.workspace?.step === step.id);
|
|
215
|
+
const summary = child ? summarizeRun(deps, child) : null;
|
|
216
|
+
const landed = child ? landedMilestones(child) : [];
|
|
217
|
+
const pending = child ? ownPending(child) : false;
|
|
218
|
+
if (child && pending === null)
|
|
219
|
+
warnings.push(`${step.id}: run ${child.id} has unreadable dispatch records`);
|
|
220
|
+
// #43 finding 7: a milestone that differs only by case never releases the dependents
|
|
221
|
+
const twin = landed.find((m) => m !== step.milestone && m.toLowerCase() === step.milestone.toLowerCase());
|
|
222
|
+
if (twin && !landed.includes(step.milestone))
|
|
223
|
+
warnings.push(`${step.id} completes on ${step.milestone}, but its run landed ${twin}`);
|
|
224
|
+
const blockers = await dependencyBlockers(workspace, step, children, deps.now(), { merge: true });
|
|
225
|
+
const state: "waiting" | "ready" | "active" | "landed" = child
|
|
226
|
+
? landed.includes(step.milestone) && pending === false
|
|
227
|
+
? "landed"
|
|
228
|
+
: "active"
|
|
229
|
+
: blockers.length
|
|
230
|
+
? "waiting"
|
|
231
|
+
: "ready";
|
|
232
|
+
steps.push({
|
|
233
|
+
id: step.id,
|
|
234
|
+
repo: step.repo,
|
|
235
|
+
state,
|
|
236
|
+
run: child?.id ?? null,
|
|
237
|
+
blockedBy: blockers.map((b) => b.step),
|
|
238
|
+
waitingFor: blockers.map((b) => b.why),
|
|
239
|
+
landedMilestones: landed,
|
|
240
|
+
landedCommits: child ? landedCommits(child) : [],
|
|
241
|
+
failures: summary?.totals.notOk ?? [],
|
|
242
|
+
questions: summary?.questions ?? [],
|
|
243
|
+
live: summary?.live ?? [],
|
|
244
|
+
childBudget: summary?.budget ?? null,
|
|
245
|
+
warnings: summary?.warnings ?? [],
|
|
246
|
+
});
|
|
247
|
+
}
|
|
248
|
+
const spend = await workspaceSpend(workspace, deps.now(), children, warnings);
|
|
249
|
+
return {
|
|
250
|
+
// spec 1.5 "Group pause": a pause over the workspace comes first
|
|
251
|
+
paused: pausesOver([{ meta: { workspace: { id, step: workspace.steps[0]!.id } } }]),
|
|
252
|
+
workspace,
|
|
253
|
+
dir: workspaceDirectory(id),
|
|
254
|
+
steps,
|
|
255
|
+
spend,
|
|
256
|
+
budget: budgetStatus(spend, workspace.budget),
|
|
257
|
+
warnings,
|
|
258
|
+
};
|
|
259
|
+
}
|