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,382 @@
|
|
|
1
|
+
import {
|
|
2
|
+
copyFileSync,
|
|
3
|
+
existsSync,
|
|
4
|
+
mkdirSync,
|
|
5
|
+
readdirSync,
|
|
6
|
+
readFileSync,
|
|
7
|
+
realpathSync,
|
|
8
|
+
rmSync,
|
|
9
|
+
writeFileSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { dirname, join, resolve } from "node:path";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
14
|
+
import { latestOutcomes } from "../domain/route.ts";
|
|
15
|
+
import {
|
|
16
|
+
assertLabel,
|
|
17
|
+
SCORECARD_TASKS,
|
|
18
|
+
assertScorecardRows,
|
|
19
|
+
type ScorecardFile,
|
|
20
|
+
ScorecardFileSchema,
|
|
21
|
+
type ScorecardTask,
|
|
22
|
+
ScorecardTaskSchema,
|
|
23
|
+
type ScorecardTaskId,
|
|
24
|
+
type TaskScore,
|
|
25
|
+
} from "../domain/scorecard.ts";
|
|
26
|
+
import { assetPath } from "../infra/assets.ts";
|
|
27
|
+
import { git, gitHead } from "../infra/git.ts";
|
|
28
|
+
import { dataDir } from "../infra/paths.ts";
|
|
29
|
+
import { ensurePrivateDir, readVersioned, writeJsonAtomic } from "../infra/store.ts";
|
|
30
|
+
import { landedMilestones, reviewRounds } from "./milestones.ts";
|
|
31
|
+
import { checkEnvFor, runCheck } from "./preflight.ts";
|
|
32
|
+
import { runMilestones } from "./protocol.ts";
|
|
33
|
+
import { openQuestions } from "./questions.ts";
|
|
34
|
+
import {
|
|
35
|
+
findRun,
|
|
36
|
+
listRuns,
|
|
37
|
+
readAgentRuns,
|
|
38
|
+
readOutcomes,
|
|
39
|
+
readRecords,
|
|
40
|
+
type Run,
|
|
41
|
+
supersededBy,
|
|
42
|
+
} from "./run-store.ts";
|
|
43
|
+
import { failoverRecovered, interventionsOf, pausedMs, runEnd } from "./scorecard-metrics.ts";
|
|
44
|
+
import { quotaByBackend } from "./summary.ts";
|
|
45
|
+
|
|
46
|
+
// Plan 28: prepare a scorecard task's reproducible starting point, score the run that ended there, store the row.
|
|
47
|
+
|
|
48
|
+
/** The checkout's `scorecard/` folder, or null for an installed package (it ships no fixtures). */
|
|
49
|
+
export const scorecardRoot = (): string | null => {
|
|
50
|
+
const root = assetPath("scorecard");
|
|
51
|
+
return existsSync(root) ? root : null;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
export const defaultPreparedRoot = (): string => join(dataDir(), "scorecard");
|
|
55
|
+
|
|
56
|
+
const minutes = (ms: number): number => Math.round(ms / 6000) / 10;
|
|
57
|
+
|
|
58
|
+
export function loadTask(id: string, root: string): ScorecardTask {
|
|
59
|
+
if (!(SCORECARD_TASKS as readonly string[]).includes(id))
|
|
60
|
+
throw new CatherdError("E_INPUT_INVALID", `no scorecard task ${id}`, {
|
|
61
|
+
fix: `catherd scorecard prepare ${SCORECARD_TASKS.join("|")}`,
|
|
62
|
+
});
|
|
63
|
+
const file = join(root, "tasks", id, "task.json");
|
|
64
|
+
const task = readVersioned(file, ScorecardTaskSchema, 1);
|
|
65
|
+
if (task.id !== id)
|
|
66
|
+
throw new CatherdError("E_CONFIG_INVALID", `${file} names task ${task.id}, not ${id}`, {
|
|
67
|
+
fix: `set "id" to ${id} in ${file}`,
|
|
68
|
+
});
|
|
69
|
+
return task;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const PreparedSchema = z.looseObject({
|
|
73
|
+
schema: z.literal(1),
|
|
74
|
+
task: z.enum(SCORECARD_TASKS),
|
|
75
|
+
dir: z.string(),
|
|
76
|
+
repo: z.string(),
|
|
77
|
+
plan: z.string(),
|
|
78
|
+
startCommit: z.string(),
|
|
79
|
+
limits: z.string().nullable(),
|
|
80
|
+
shim: z.string().nullable(),
|
|
81
|
+
preparedAt: z.string(),
|
|
82
|
+
prompt: z.string(),
|
|
83
|
+
});
|
|
84
|
+
export type Prepared = z.infer<typeof PreparedSchema> & { task: ScorecardTaskId };
|
|
85
|
+
|
|
86
|
+
/** A URL (`scheme://`) or an scp-like `user@host:path`: anything else is a local path. */
|
|
87
|
+
const REMOTE = /^(?:[a-z][a-z0-9+.-]*:\/\/|[^/\s]+@[^/\s:]+:)/i;
|
|
88
|
+
|
|
89
|
+
const GIT_LONG = 30 * 60_000;
|
|
90
|
+
|
|
91
|
+
/** A git command that must succeed: its failure is `fail`'s error. */
|
|
92
|
+
async function must(
|
|
93
|
+
repo: string,
|
|
94
|
+
args: string[],
|
|
95
|
+
fail: () => CatherdError,
|
|
96
|
+
timeoutMs?: number,
|
|
97
|
+
): Promise<string> {
|
|
98
|
+
const r = await git(repo, args, timeoutMs);
|
|
99
|
+
if (r.kind !== "ok") throw fail();
|
|
100
|
+
return r.out;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
export async function prepareTask(i: {
|
|
104
|
+
task: ScorecardTask;
|
|
105
|
+
root: string;
|
|
106
|
+
into: string;
|
|
107
|
+
source?: string;
|
|
108
|
+
now: Date;
|
|
109
|
+
setup: boolean;
|
|
110
|
+
}): Promise<Prepared> {
|
|
111
|
+
const { task } = i;
|
|
112
|
+
const given = i.source ?? task.source;
|
|
113
|
+
if (!given)
|
|
114
|
+
throw new CatherdError("E_INPUT_INVALID", `${task.id} names no source`, {
|
|
115
|
+
fix: `catherd scorecard prepare ${task.id} --source <path or URL>`,
|
|
116
|
+
});
|
|
117
|
+
// the clone runs inside the new folder, so a local path must be absolute before it gets there
|
|
118
|
+
const src = REMOTE.test(given) ? given : resolve(given);
|
|
119
|
+
ensurePrivateDir(dirname(i.into));
|
|
120
|
+
try {
|
|
121
|
+
mkdirSync(i.into);
|
|
122
|
+
} catch (e) {
|
|
123
|
+
if ((e as NodeJS.ErrnoException).code === "EEXIST")
|
|
124
|
+
throw new CatherdError("E_INPUT_INVALID", `${i.into} already exists`, {
|
|
125
|
+
fix: "prepare again: the folder name carries the time",
|
|
126
|
+
});
|
|
127
|
+
throw e;
|
|
128
|
+
}
|
|
129
|
+
// the run's own repo toplevel is the real path; findRun and listRuns key runs by it
|
|
130
|
+
const into = realpathSync(i.into);
|
|
131
|
+
const repo = join(into, "repo");
|
|
132
|
+
const io = (what: string) => () =>
|
|
133
|
+
new CatherdError("E_IO_UNEXPECTED", `${what} failed in ${into}`, {
|
|
134
|
+
fix: `check that git works in ${into}`,
|
|
135
|
+
});
|
|
136
|
+
await must(
|
|
137
|
+
into,
|
|
138
|
+
["clone", "--quiet", "--no-checkout", src, "repo"],
|
|
139
|
+
() =>
|
|
140
|
+
new CatherdError("E_INPUT_INVALID", `cannot clone ${src}`, {
|
|
141
|
+
fix: `catherd scorecard prepare ${task.id} --source <path or URL>`,
|
|
142
|
+
}),
|
|
143
|
+
GIT_LONG,
|
|
144
|
+
);
|
|
145
|
+
await must(
|
|
146
|
+
repo,
|
|
147
|
+
["checkout", "--quiet", "-b", `scorecard/${task.id}`, task.commit],
|
|
148
|
+
() =>
|
|
149
|
+
new CatherdError("E_INPUT_INVALID", `${src} has no commit ${task.commit}`, {
|
|
150
|
+
fix: `catherd scorecard prepare ${task.id} --source <a clone that has it>`,
|
|
151
|
+
}),
|
|
152
|
+
);
|
|
153
|
+
await must(repo, ["remote", "remove", "origin"], io("git remote remove"));
|
|
154
|
+
|
|
155
|
+
if (task.inject) {
|
|
156
|
+
for (const e of task.inject.edits) {
|
|
157
|
+
const file = join(repo, e.path);
|
|
158
|
+
const text = existsSync(file) ? readFileSync(file, "utf8") : "";
|
|
159
|
+
const parts = text.split(e.find);
|
|
160
|
+
if (parts.length !== 2)
|
|
161
|
+
throw new CatherdError(
|
|
162
|
+
"E_CONFIG_INVALID",
|
|
163
|
+
`${e.path}: the inject text is there ${parts.length - 1} times, not once`,
|
|
164
|
+
{
|
|
165
|
+
fix: `update scorecard/tasks/${task.id}/task.json`,
|
|
166
|
+
},
|
|
167
|
+
);
|
|
168
|
+
writeFileSync(file, parts.join(e.replace));
|
|
169
|
+
}
|
|
170
|
+
await must(repo, ["add", "-A"], io("git add"));
|
|
171
|
+
await must(
|
|
172
|
+
repo,
|
|
173
|
+
[
|
|
174
|
+
"-c",
|
|
175
|
+
"user.name=catherd-scorecard",
|
|
176
|
+
"-c",
|
|
177
|
+
"user.email=scorecard@catherd.invalid",
|
|
178
|
+
"commit",
|
|
179
|
+
"--quiet",
|
|
180
|
+
"-m",
|
|
181
|
+
task.inject.message,
|
|
182
|
+
],
|
|
183
|
+
io("git commit"),
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
let plan: string;
|
|
188
|
+
if ("file" in task.plan) {
|
|
189
|
+
plan = join(into, "plan.md");
|
|
190
|
+
copyFileSync(join(i.root, "tasks", task.id, task.plan.file), plan);
|
|
191
|
+
} else {
|
|
192
|
+
plan = join(repo, task.plan.repoPath);
|
|
193
|
+
if (!existsSync(plan))
|
|
194
|
+
throw new CatherdError("E_CONFIG_INVALID", `${task.plan.repoPath} is not in ${src} at ${task.commit}`, {
|
|
195
|
+
fix: `update scorecard/tasks/${task.id}/task.json`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let limits: string | null = null;
|
|
200
|
+
if (task.limits > 0) {
|
|
201
|
+
limits = join(into, "limits");
|
|
202
|
+
mkdirSync(join(limits, "taken"), { recursive: true });
|
|
203
|
+
mkdirSync(join(limits, "tokens"));
|
|
204
|
+
for (let n = 1; n <= task.limits; n++)
|
|
205
|
+
writeFileSync(join(limits, "tokens", String(n)), "", { flag: "w" });
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (i.setup) {
|
|
209
|
+
const env = await checkEnvFor(repo);
|
|
210
|
+
for (const cmd of task.setup) {
|
|
211
|
+
const r = await runCheck(repo, cmd, GIT_LONG, env);
|
|
212
|
+
if (r.code !== 0 || r.timedOut)
|
|
213
|
+
throw new CatherdError(
|
|
214
|
+
"E_IO_UNEXPECTED",
|
|
215
|
+
`setup \`${cmd}\` failed in ${repo}: ${r.tail.at(-1) ?? "no output"}`,
|
|
216
|
+
{
|
|
217
|
+
fix: `fix it and run it in ${repo}, or prepare again`,
|
|
218
|
+
},
|
|
219
|
+
);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
const startCommit = (await must(repo, ["rev-parse", "HEAD"], io("git rev-parse"))).trim();
|
|
224
|
+
const prepared: Prepared = {
|
|
225
|
+
schema: 1,
|
|
226
|
+
task: task.id,
|
|
227
|
+
dir: into,
|
|
228
|
+
repo,
|
|
229
|
+
plan,
|
|
230
|
+
startCommit,
|
|
231
|
+
limits,
|
|
232
|
+
shim: limits ? join(i.root, "shim") : null,
|
|
233
|
+
preparedAt: i.now.toISOString(),
|
|
234
|
+
prompt: task.prompt.replaceAll("{plan}", () => plan).replaceAll("{acceptance}", () => task.acceptance),
|
|
235
|
+
};
|
|
236
|
+
writeJsonAtomic(join(into, "prepared.json"), prepared);
|
|
237
|
+
return prepared;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
export function readPrepared(dir: string): Prepared {
|
|
241
|
+
const file = join(dir, "prepared.json");
|
|
242
|
+
if (!existsSync(file))
|
|
243
|
+
throw new CatherdError("E_INPUT_INVALID", `${dir} is not a prepared scorecard task`, {
|
|
244
|
+
fix: `catherd scorecard prepare ${SCORECARD_TASKS.join("|")}`,
|
|
245
|
+
});
|
|
246
|
+
return readVersioned(file, PreparedSchema, 1) as Prepared;
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
/** The one run to score: the named one, else the only open run created in the prepared repo since it was prepared. */
|
|
250
|
+
function pickRun(p: Prepared, id: string | undefined): Run {
|
|
251
|
+
if (id) {
|
|
252
|
+
const run = findRun(id);
|
|
253
|
+
if (run.meta.repo !== p.repo)
|
|
254
|
+
throw new CatherdError("E_INPUT_INVALID", `run ${id} belongs to ${run.meta.repo}, not ${p.repo}`, {
|
|
255
|
+
fix: "name a run of the prepared repo, or leave --run out",
|
|
256
|
+
});
|
|
257
|
+
return run;
|
|
258
|
+
}
|
|
259
|
+
const since = Date.parse(p.preparedAt);
|
|
260
|
+
const runs = listRuns([p.repo]).runs.filter(
|
|
261
|
+
(r) => Date.parse(r.meta.createdAt) >= since && !supersededBy(r),
|
|
262
|
+
);
|
|
263
|
+
if (runs.length === 0)
|
|
264
|
+
throw new CatherdError("E_INPUT_INVALID", `no run in ${p.repo} yet`, {
|
|
265
|
+
fix: "run the task's start line, paste its prompt, and record once it has landed or been given up",
|
|
266
|
+
});
|
|
267
|
+
if (runs.length > 1)
|
|
268
|
+
throw new CatherdError("E_INPUT_INVALID", `${p.repo} has runs ${runs.map((r) => r.id).join(", ")}`, {
|
|
269
|
+
fix: `catherd scorecard record ${p.dir} --label <l> --run <id>`,
|
|
270
|
+
});
|
|
271
|
+
return runs[0] as Run;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
export async function scoreTask(i: {
|
|
275
|
+
dir: string;
|
|
276
|
+
root: string;
|
|
277
|
+
run?: string;
|
|
278
|
+
chat?: number;
|
|
279
|
+
now: number;
|
|
280
|
+
}): Promise<TaskScore> {
|
|
281
|
+
const p = readPrepared(i.dir);
|
|
282
|
+
const task = loadTask(p.task, i.root);
|
|
283
|
+
const run = pickRun(p, i.run);
|
|
284
|
+
const milestones = runMilestones(run);
|
|
285
|
+
const landed = landedMilestones(run);
|
|
286
|
+
const finished =
|
|
287
|
+
milestones.length > 0 &&
|
|
288
|
+
milestones.every((m) => landed.includes(m)) &&
|
|
289
|
+
openQuestions(run).length === 0 &&
|
|
290
|
+
!supersededBy(run);
|
|
291
|
+
const end = runEnd(run);
|
|
292
|
+
const start = Date.parse(run.meta.createdAt);
|
|
293
|
+
|
|
294
|
+
const copies = task.acceptFiles.map((f) => ({
|
|
295
|
+
from: join(i.root, "tasks", task.id, f.from),
|
|
296
|
+
to: join(p.repo, f.to),
|
|
297
|
+
}));
|
|
298
|
+
for (const c of copies)
|
|
299
|
+
if (existsSync(c.to))
|
|
300
|
+
throw new CatherdError(
|
|
301
|
+
"E_INPUT_INVALID",
|
|
302
|
+
`${c.to.slice(p.repo.length + 1)} already exists in the run's tree`,
|
|
303
|
+
{
|
|
304
|
+
fix: "the run wrote a file the hidden acceptance test needs; rename the file in the run, or score it by hand",
|
|
305
|
+
},
|
|
306
|
+
);
|
|
307
|
+
const done: string[] = [];
|
|
308
|
+
let check: Awaited<ReturnType<typeof runCheck>>;
|
|
309
|
+
const head = await gitHead(p.repo);
|
|
310
|
+
try {
|
|
311
|
+
for (const c of copies) {
|
|
312
|
+
mkdirSync(dirname(c.to), { recursive: true });
|
|
313
|
+
copyFileSync(c.from, c.to);
|
|
314
|
+
done.push(c.to);
|
|
315
|
+
}
|
|
316
|
+
check = await runCheck(
|
|
317
|
+
p.repo,
|
|
318
|
+
task.acceptance,
|
|
319
|
+
task.acceptanceTimeoutMin * 60_000,
|
|
320
|
+
await checkEnvFor(p.repo),
|
|
321
|
+
);
|
|
322
|
+
} finally {
|
|
323
|
+
for (const f of done) rmSync(f, { force: true });
|
|
324
|
+
}
|
|
325
|
+
const acceptance = {
|
|
326
|
+
passed: check.code === 0 && !check.timedOut,
|
|
327
|
+
code: check.code,
|
|
328
|
+
timedOut: check.timedOut,
|
|
329
|
+
tail: check.tail,
|
|
330
|
+
};
|
|
331
|
+
|
|
332
|
+
const outcomes = latestOutcomes(readOutcomes(run));
|
|
333
|
+
const takenDir = p.limits ? join(p.limits, "taken") : null;
|
|
334
|
+
return {
|
|
335
|
+
task: task.id,
|
|
336
|
+
run: run.id,
|
|
337
|
+
catherdVersion: run.meta.catherdVersion,
|
|
338
|
+
startCommit: p.startCommit,
|
|
339
|
+
head,
|
|
340
|
+
milestones,
|
|
341
|
+
landed,
|
|
342
|
+
finished,
|
|
343
|
+
interventions: interventionsOf(run, end, i.chat ?? 0),
|
|
344
|
+
reviewRounds: milestones.reduce((n, m) => n + reviewRounds(run, m), 0),
|
|
345
|
+
lanes: {
|
|
346
|
+
total: outcomes.length,
|
|
347
|
+
landed: outcomes.filter((o) => o.landed).length,
|
|
348
|
+
climbs: outcomes.reduce((n, o) => n + o.climbs.length, 0),
|
|
349
|
+
},
|
|
350
|
+
wallMinutes: minutes(end - start),
|
|
351
|
+
pausedMinutes: minutes(pausedMs(run, end)),
|
|
352
|
+
quota: {
|
|
353
|
+
backends: quotaByBackend(readRecords(run).records),
|
|
354
|
+
claudeAgentTokens: readAgentRuns(run).reduce((n, a) => n + a.totalTokens, 0),
|
|
355
|
+
},
|
|
356
|
+
acceptance,
|
|
357
|
+
firstTry: acceptance.passed && finished && new Set(landed).size === landed.length,
|
|
358
|
+
injected:
|
|
359
|
+
task.limits > 0
|
|
360
|
+
? {
|
|
361
|
+
limits: takenDir && existsSync(takenDir) ? readdirSync(takenDir).length : 0,
|
|
362
|
+
standInOk: failoverRecovered(run),
|
|
363
|
+
}
|
|
364
|
+
: null,
|
|
365
|
+
recordedAt: new Date(i.now).toISOString(),
|
|
366
|
+
};
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
export const resultsFile = (dir: string, label: string): string => join(dir, `${assertLabel(label)}.json`);
|
|
370
|
+
|
|
371
|
+
export function readScorecard(file: string): ScorecardFile | null {
|
|
372
|
+
return existsSync(file) ? assertScorecardRows(readVersioned(file, ScorecardFileSchema, 1), file) : null;
|
|
373
|
+
}
|
|
374
|
+
|
|
375
|
+
/** Sets the task's row in the label's file (a re-record replaces it) and returns the file's contents. */
|
|
376
|
+
export function saveTaskScore(file: string, label: string, s: TaskScore): ScorecardFile {
|
|
377
|
+
const f = readScorecard(file) ?? { schema: 1 as const, label, tasks: {} };
|
|
378
|
+
f.tasks[s.task] = s;
|
|
379
|
+
ensurePrivateDir(dirname(file));
|
|
380
|
+
writeJsonAtomic(file, f);
|
|
381
|
+
return f;
|
|
382
|
+
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import { existsSync, lstatSync, readdirSync, rmSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { tryLock } from "../infra/filelock.ts";
|
|
4
|
+
import { liveDispatches } from "./dispatches.ts";
|
|
5
|
+
import { findRun, listRuns, type Run, runPaths } from "./run-store.ts";
|
|
6
|
+
|
|
7
|
+
export interface ScratchCleaned {
|
|
8
|
+
/** the runs whose scratch folder was removed, with what it held */
|
|
9
|
+
removed: { run: string; bytes: number }[];
|
|
10
|
+
/** the runs left alone, and why: a role still writes there */
|
|
11
|
+
kept: { run: string; why: string }[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** The bytes under `dir`, links not followed; 0 for what cannot be read. */
|
|
15
|
+
function sizeOf(dir: string): number {
|
|
16
|
+
let total = 0;
|
|
17
|
+
try {
|
|
18
|
+
for (const e of readdirSync(dir, { withFileTypes: true })) {
|
|
19
|
+
const p = join(dir, e.name);
|
|
20
|
+
total += e.isDirectory() ? sizeOf(p) : (lstatSync(p, { throwIfNoEntry: false })?.size ?? 0);
|
|
21
|
+
}
|
|
22
|
+
} catch {
|
|
23
|
+
// gone or unreadable: counts as nothing
|
|
24
|
+
}
|
|
25
|
+
return total;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Spec 1.5 plan 21: `catherd runs clean [run]` removes the roles' scratch folders (`<run>/scratch/`, each role's
|
|
30
|
+
* TMPDIR) of a run with no live role, or of every such run. The run's own files, records and replies stay. A run
|
|
31
|
+
* whose admission is in progress is kept: that admission may have just created a role's scratch.
|
|
32
|
+
*/
|
|
33
|
+
export function cleanScratch(o: { run?: string; now?: number } = {}): ScratchCleaned {
|
|
34
|
+
const runs: Run[] = o.run ? [findRun(o.run)] : listRuns().runs;
|
|
35
|
+
const out: ScratchCleaned = { removed: [], kept: [] };
|
|
36
|
+
for (const run of runs) {
|
|
37
|
+
const dir = runPaths(run.dir).scratch;
|
|
38
|
+
if (!existsSync(dir)) continue;
|
|
39
|
+
// admission creates a role's scratch and admits its dispatch under this lock, so while cleanup holds it no
|
|
40
|
+
// scratch is half-admitted, and one an admission holds now belongs to a dispatch about to go live
|
|
41
|
+
const release = tryLock(runPaths(run.dir).admission);
|
|
42
|
+
if (!release) {
|
|
43
|
+
out.kept.push({ run: run.id, why: "a dispatch is being admitted" });
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
try {
|
|
47
|
+
const live = liveDispatches(run, o.now ?? Date.now());
|
|
48
|
+
if (live.length) {
|
|
49
|
+
out.kept.push({ run: run.id, why: `${live.map((d) => d.admit.name).join(", ")} still running` });
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
const bytes = sizeOf(dir);
|
|
53
|
+
rmSync(dir, { recursive: true, force: true });
|
|
54
|
+
out.removed.push({ run: run.id, bytes });
|
|
55
|
+
} finally {
|
|
56
|
+
release();
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
package/src/services/sessions.ts
CHANGED
|
@@ -69,9 +69,11 @@ export function readSessionRows(run: Run): SessionRow[] {
|
|
|
69
69
|
|
|
70
70
|
/**
|
|
71
71
|
* Spec §3.3: `run_start`, `dispatch` and `peek` make the calling session the run's owner when it is not, and
|
|
72
|
-
* append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Without host session identity it changes nothing. Returns whether the owner changed.
|
|
72
|
+
* append it to `R/sessions.jsonl`: that is how a run moves when it is continued from another session. Without host session identity it changes nothing, and neither does a role's process (`deps.role`, spec 1.5 plan 21). Returns whether the owner changed.
|
|
73
73
|
*/
|
|
74
74
|
export async function claimRun(deps: Deps, run: Run): Promise<boolean> {
|
|
75
|
+
// spec 1.5 plan 21: a role's process never becomes the run's owner, whatever host identity it has
|
|
76
|
+
if (deps.role) return false;
|
|
75
77
|
const me = currentSession(deps);
|
|
76
78
|
if (!me) return false;
|
|
77
79
|
const p = runPaths(run.dir);
|
package/src/services/state.ts
CHANGED
|
@@ -19,6 +19,8 @@ const NotesSchema = z.looseObject({
|
|
|
19
19
|
lastLandedAt: z.string().nullable(),
|
|
20
20
|
/** spec 1.1 §8: the milestones waiting on the owner */
|
|
21
21
|
parked: z.array(z.string()).optional(),
|
|
22
|
+
/** spec 1.5: what changed since the run's pin, as dispatch last saw it */
|
|
23
|
+
pinChanges: z.array(z.string()).optional(),
|
|
22
24
|
});
|
|
23
25
|
export type Notes = z.infer<typeof NotesSchema>;
|
|
24
26
|
export type NotesPatch = Partial<Omit<Notes, "schema">>;
|
|
@@ -68,6 +70,7 @@ export function updateState(run: Run, change: NotesPatch | ((n: Notes) => NotesP
|
|
|
68
70
|
brief: relative(run.dir, dispatchPaths(d.dir).brief),
|
|
69
71
|
})),
|
|
70
72
|
lastCheck: next.lastCheck,
|
|
73
|
+
pinChanges: next.pinChanges ?? [],
|
|
71
74
|
next: next.next,
|
|
72
75
|
protocol: protocolNext(run, next.parked ?? []),
|
|
73
76
|
});
|
|
@@ -76,6 +79,24 @@ export function updateState(run: Run, change: NotesPatch | ((n: Notes) => NotesP
|
|
|
76
79
|
});
|
|
77
80
|
}
|
|
78
81
|
|
|
82
|
+
/**
|
|
83
|
+
* Writes state.json's notes only, with no git, under the state lock; for a writer that must not wait on git
|
|
84
|
+
* while it holds another lock (`land` in a workspace, #43 finding 5). It waits out a refresh's git (15 s).
|
|
85
|
+
*/
|
|
86
|
+
export function patchNotes(run: Run, change: (n: Notes) => NotesPatch): Promise<Notes> {
|
|
87
|
+
const stateJson = runPaths(run.dir).stateJson;
|
|
88
|
+
return withFileLock(
|
|
89
|
+
stateJson,
|
|
90
|
+
() => {
|
|
91
|
+
const notes = readNotes(run);
|
|
92
|
+
const next = parkedNext({ ...notes, ...change(notes) });
|
|
93
|
+
writeJsonAtomic(stateJson, next);
|
|
94
|
+
return next;
|
|
95
|
+
},
|
|
96
|
+
{ timeoutMs: 30_000 },
|
|
97
|
+
);
|
|
98
|
+
}
|
|
99
|
+
|
|
79
100
|
/**
|
|
80
101
|
* Ruling (b): a failed state.md refresh (git broken) never fails the call. The notes still go to state.json
|
|
81
102
|
* under the state lock (state.md stays as it was), and the message comes back as a hint.
|
package/src/services/summary.ts
CHANGED
|
@@ -3,16 +3,22 @@ import type { QueueCapability } from "../infra/codex-queue.ts";
|
|
|
3
3
|
import { inspectionHost, inspectDeliveries, type DeliveryInspection } from "./run-debug.ts";
|
|
4
4
|
import { resolve } from "node:path";
|
|
5
5
|
import { type BudgetStatus, budgetStatus } from "../domain/budget.ts";
|
|
6
|
-
import { isCatherdError } from "../domain/errors.ts";
|
|
7
|
-
import type { Tokens } from "../domain/record.ts";
|
|
6
|
+
import { errorMessage, isCatherdError } from "../domain/errors.ts";
|
|
7
|
+
import type { RunRecord, Tokens } from "../domain/record.ts";
|
|
8
|
+
import type { QuotaRow } from "../domain/scorecard.ts";
|
|
8
9
|
import { median } from "../domain/util.ts";
|
|
9
10
|
import { nonBlankLines, readJsonl } from "../infra/store.ts";
|
|
10
11
|
import { spendOf } from "./budget.ts";
|
|
11
|
-
import {
|
|
12
|
+
import { ensureDaemon } from "./daemon.ts";
|
|
13
|
+
import { verifierStepView, type VerifierStepView } from "./verifier-step.ts";
|
|
14
|
+
import { type Pause, pausesOver } from "./pause.ts";
|
|
15
|
+
import { pinChanges } from "./run-pin.ts";
|
|
12
16
|
import { type OpenQuestion, openQuestions } from "./questions.ts";
|
|
13
17
|
import { type DispatchState, listDispatches, liveDispatches } from "./dispatches.ts";
|
|
18
|
+
import { readDelivery, ROLE_THREAD_REFUSAL } from "../infra/delivery.ts";
|
|
14
19
|
import { type RunSession, sessionFacts } from "./session-view.ts";
|
|
15
20
|
import type { Deps } from "./ports.ts";
|
|
21
|
+
import { orchestratorWait, type OrchestratorWait } from "./orchestrator-wait.ts";
|
|
16
22
|
import {
|
|
17
23
|
findRun,
|
|
18
24
|
listRuns,
|
|
@@ -21,11 +27,18 @@ import {
|
|
|
21
27
|
readRoutes,
|
|
22
28
|
type Run,
|
|
23
29
|
runPaths,
|
|
30
|
+
supersededBy,
|
|
24
31
|
} from "./run-store.ts";
|
|
25
32
|
|
|
26
33
|
export interface RunSummary {
|
|
34
|
+
/** No live roles and at least one durable completion the orchestrator has not collected. */
|
|
35
|
+
waiting?: OrchestratorWait | null;
|
|
27
36
|
delivery: (DeliveryInspection & { name: string; dispatchId: string })[];
|
|
28
37
|
id: string;
|
|
38
|
+
/** spec 1.5 "runs supersede": the run that took this one over; status() without a run hides it */
|
|
39
|
+
supersededBy?: string | null;
|
|
40
|
+
/** spec 1.5: what changed since the run's pin; dispatch keeps the pinned values */
|
|
41
|
+
pinChanges?: string[];
|
|
29
42
|
/** spec 1.1 §8: the owner questions not answered yet, listed first */
|
|
30
43
|
questions: OpenQuestion[];
|
|
31
44
|
title: string;
|
|
@@ -45,14 +58,21 @@ export interface RunSummary {
|
|
|
45
58
|
harness: { backend: string; native: number; isolated: number }[];
|
|
46
59
|
budget: BudgetStatus | null;
|
|
47
60
|
milestones: string[];
|
|
48
|
-
/** spec 1.1 §7: the verifier's latest gate_check, so the user sees where it is */
|
|
49
|
-
verifier:
|
|
61
|
+
/** spec 1.1 §7: the verifier's latest gate_check, so the user sees where it is; plan 22: its age, and what closed it */
|
|
62
|
+
verifier: VerifierStepView | null;
|
|
50
63
|
warnings: string[];
|
|
51
64
|
}
|
|
52
65
|
|
|
53
66
|
/** One run at a glance. Spec §3.4: reads are pure; nothing here finalizes a dispatch or writes a file. */
|
|
54
67
|
export function summarizeRun(deps: Deps, run: Run): RunSummary {
|
|
55
68
|
const now = deps.now();
|
|
69
|
+
const warnings: string[] = [];
|
|
70
|
+
// an unfinished 2.0 run whose daemon died gets it back from any reader: status, watch, the panel
|
|
71
|
+
try {
|
|
72
|
+
ensureDaemon(run);
|
|
73
|
+
} catch (e) {
|
|
74
|
+
warnings.push(`daemon: ${errorMessage(e)}`);
|
|
75
|
+
}
|
|
56
76
|
const { records, corrupt } = readRecords(run);
|
|
57
77
|
const live = liveDispatches(run, now);
|
|
58
78
|
const recorded = new Set(records.map((r) => r.dispatchId));
|
|
@@ -60,7 +80,10 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
|
|
|
60
80
|
const jev = readJsonl<{ source?: string }>(runPaths(run.dir).jev).rows;
|
|
61
81
|
const sum = (f: (t: Tokens) => number) => records.reduce((n, r) => n + f(r.tokens), 0);
|
|
62
82
|
let budget: BudgetStatus | null = null;
|
|
63
|
-
|
|
83
|
+
if (corrupt) warnings.push(`runs.jsonl: skipped ${corrupt} unreadable row(s)`);
|
|
84
|
+
// spec 1.5 "Pinned per run": a change since the pin, which dispatch does not follow
|
|
85
|
+
const pinned = pinChanges(deps, run);
|
|
86
|
+
for (const c of pinned) warnings.push(`pinned: ${c}; dispatch keeps the pinned value (run_pin re-pins)`);
|
|
64
87
|
try {
|
|
65
88
|
budget = budgetStatus(
|
|
66
89
|
spendOf(run, records, live, now),
|
|
@@ -69,7 +92,18 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
|
|
|
69
92
|
} catch (e) {
|
|
70
93
|
warnings.push(`budget: ${isCatherdError(e) ? e.message : String(e)}`);
|
|
71
94
|
}
|
|
95
|
+
// spec 1.5 plan 21: a notice refused because its target was a role's thread, while that is its latest attempt
|
|
96
|
+
for (const d of listDispatches(run)) {
|
|
97
|
+
try {
|
|
98
|
+
const last = readDelivery(d.dir).at(-1);
|
|
99
|
+
if (last?.status === "failed" && last.reason?.startsWith(ROLE_THREAD_REFUSAL))
|
|
100
|
+
warnings.push(`${d.admit.name}: its notice was not sent: ${last.reason}`);
|
|
101
|
+
} catch {
|
|
102
|
+
// unreadable delivery evidence is the delivery row's to show
|
|
103
|
+
}
|
|
104
|
+
}
|
|
72
105
|
return {
|
|
106
|
+
waiting: orchestratorWait(run, now, records, live.length > 0),
|
|
73
107
|
delivery: listDispatches(run).flatMap((d) =>
|
|
74
108
|
inspectDeliveries(run, d, recorded.has(d.admit.dispatchId)).map((inspection) => ({
|
|
75
109
|
name: d.admit.name,
|
|
@@ -78,6 +112,8 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
|
|
|
78
112
|
})),
|
|
79
113
|
),
|
|
80
114
|
id: run.id,
|
|
115
|
+
supersededBy: supersededBy(run)?.by ?? null,
|
|
116
|
+
pinChanges: pinned,
|
|
81
117
|
questions: openQuestions(run),
|
|
82
118
|
title: run.meta.title,
|
|
83
119
|
repo: run.meta.repo,
|
|
@@ -116,40 +152,53 @@ export function summarizeRun(deps: Deps, run: Run): RunSummary {
|
|
|
116
152
|
}),
|
|
117
153
|
budget,
|
|
118
154
|
milestones: nonBlankLines(runPaths(run.dir).ledger).slice(1),
|
|
119
|
-
verifier:
|
|
155
|
+
verifier: verifierStepView(run, now),
|
|
120
156
|
warnings,
|
|
121
157
|
};
|
|
122
158
|
}
|
|
123
159
|
|
|
124
|
-
/**
|
|
160
|
+
/**
|
|
161
|
+
* `status(run?)`: that run; without one, the live runs, else the runs waiting for their orchestrator (a current
|
|
162
|
+
* owner, a recent unread record: plan 22), else the newest run.
|
|
163
|
+
*/
|
|
125
164
|
export function status(
|
|
126
165
|
deps: Deps,
|
|
127
166
|
runId?: string,
|
|
128
167
|
queue: QueueCapability | null = null,
|
|
129
168
|
): {
|
|
169
|
+
/** spec 1.5 "Group pause": the machine's pause and the listed runs' workspace pauses, shown first */
|
|
170
|
+
paused: Pause[];
|
|
130
171
|
version: string;
|
|
131
172
|
runs: RunSummary[];
|
|
132
173
|
warnings: string[];
|
|
133
174
|
host: HostContext;
|
|
134
175
|
queue: QueueCapability | null;
|
|
135
176
|
} {
|
|
136
|
-
if (runId)
|
|
177
|
+
if (runId) {
|
|
178
|
+
const run = findRun(runId);
|
|
137
179
|
return {
|
|
180
|
+
paused: pausesOver([run]),
|
|
138
181
|
host: inspectionHost(deps.host),
|
|
139
182
|
queue,
|
|
140
183
|
version: deps.version,
|
|
141
|
-
runs: [summarizeRun(deps,
|
|
184
|
+
runs: [summarizeRun(deps, run)],
|
|
142
185
|
warnings: [],
|
|
143
186
|
};
|
|
144
|
-
|
|
187
|
+
}
|
|
188
|
+
const { runs: listed, corrupt } = listRuns();
|
|
145
189
|
const warnings = corrupt.map((c) => `skipped run ${c.id}: ${c.reason}`);
|
|
190
|
+
// a superseded run is closed: only status(run) shows it
|
|
191
|
+
const runs = listed.filter((r) => !supersededBy(r));
|
|
146
192
|
const all = runs.map((r) => summarizeRun(deps, r));
|
|
147
193
|
const live = all.filter((s) => s.live.length > 0);
|
|
194
|
+
const waiting = all.filter((s) => s.waiting);
|
|
195
|
+
const shown = live.length ? live : waiting.length ? waiting : all.slice(0, 1);
|
|
148
196
|
return {
|
|
197
|
+
paused: pausesOver(runs.filter((r) => shown.some((s) => s.id === r.id))),
|
|
149
198
|
host: inspectionHost(deps.host),
|
|
150
199
|
queue,
|
|
151
200
|
version: deps.version,
|
|
152
|
-
runs:
|
|
201
|
+
runs: shown,
|
|
153
202
|
warnings,
|
|
154
203
|
};
|
|
155
204
|
}
|
|
@@ -210,6 +259,21 @@ function harnessCosts(runs: Run[]): HarnessCost[] {
|
|
|
210
259
|
});
|
|
211
260
|
}
|
|
212
261
|
|
|
262
|
+
/** Plan 28: tokens and usage limits per backend over `records`, sorted by backend. */
|
|
263
|
+
export function quotaByBackend(records: RunRecord[]): QuotaRow[] {
|
|
264
|
+
const by = new Map<string, QuotaRow>();
|
|
265
|
+
for (const r of records) {
|
|
266
|
+
const q = by.get(r.backend) ?? { backend: r.backend, runs: 0, input: 0, cached: 0, output: 0, limits: 0 };
|
|
267
|
+
q.runs++;
|
|
268
|
+
q.input += r.tokens.input;
|
|
269
|
+
q.cached += r.tokens.cached;
|
|
270
|
+
q.output += r.tokens.output;
|
|
271
|
+
if (r.status === "limit") q.limits++;
|
|
272
|
+
by.set(r.backend, q);
|
|
273
|
+
}
|
|
274
|
+
return [...by.values()].sort((a, b) => a.backend.localeCompare(b.backend));
|
|
275
|
+
}
|
|
276
|
+
|
|
213
277
|
/** Per role and rung over past runs: runs, ok, refusals, climbs from it, seconds and tokens. Reads only. */
|
|
214
278
|
export function runsSummary(f: { run?: string; repo?: string; role?: string; sinceDays?: number }): {
|
|
215
279
|
rungs: RungStats[];
|