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,433 @@
|
|
|
1
|
+
import { existsSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { z } from "zod";
|
|
4
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { checkFailLines, newFails } from "../domain/fail-lines.ts";
|
|
6
|
+
import { git } from "../infra/git.ts";
|
|
7
|
+
import { heavySlots, LOCK_HELD_ENV, withHeavySlot, withRoleLock } from "../infra/heavy-lock.ts";
|
|
8
|
+
import { launchCheckWorker } from "../infra/launch.ts";
|
|
9
|
+
import { repoDir } from "../infra/paths.ts";
|
|
10
|
+
import { isAlive, processStartTime } from "../infra/proc.ts";
|
|
11
|
+
import {
|
|
12
|
+
appendJsonl,
|
|
13
|
+
ensureJsonlHeader,
|
|
14
|
+
ensurePrivateDir,
|
|
15
|
+
readJsonl,
|
|
16
|
+
writeJsonAtomic,
|
|
17
|
+
} from "../infra/store.ts";
|
|
18
|
+
import { checkEnvFor } from "./preflight.ts";
|
|
19
|
+
import { activeName, readProfileDoc } from "./profile-store.ts";
|
|
20
|
+
import { findRun, type Run } from "./run-store.ts";
|
|
21
|
+
import { treeOf } from "./tree-path.ts";
|
|
22
|
+
|
|
23
|
+
/** One run of a milestone's full check on one commit (spec 2.0 decision 18), or of the base's own (a baseline). */
|
|
24
|
+
export const CheckRecordSchema = z.looseObject({
|
|
25
|
+
at: z.string(),
|
|
26
|
+
run: z.string(),
|
|
27
|
+
milestone: z.string(),
|
|
28
|
+
command: z.string(),
|
|
29
|
+
/** the full sha the check ran on */
|
|
30
|
+
head: z.string(),
|
|
31
|
+
exit: z.number(),
|
|
32
|
+
durationMs: z.number(),
|
|
33
|
+
fails: z.array(z.string()),
|
|
34
|
+
log: z.string(),
|
|
35
|
+
baseline: z.literal(true).optional(),
|
|
36
|
+
/** the first run's exit, when a failure with new lines was rerun once */
|
|
37
|
+
rerunOf: z.number().optional(),
|
|
38
|
+
/** "clean" | "found" | "skipped: <why>"; non-baseline records only */
|
|
39
|
+
secrets: z.string().optional(),
|
|
40
|
+
});
|
|
41
|
+
export type CheckRecord = z.infer<typeof CheckRecordSchema>;
|
|
42
|
+
|
|
43
|
+
/** `<data>/repos/<repo key>/checks.jsonl`, beside gates.jsonl. */
|
|
44
|
+
export const checksFile = (toplevel: string): string => join(repoDir(toplevel), "checks.jsonl");
|
|
45
|
+
export const checkLogDir = (toplevel: string): string => join(repoDir(toplevel), "checks");
|
|
46
|
+
|
|
47
|
+
export function readChecks(toplevel: string): CheckRecord[] {
|
|
48
|
+
return readJsonl<unknown>(checksFile(toplevel)).rows.flatMap((row) => {
|
|
49
|
+
const r = CheckRecordSchema.safeParse(row);
|
|
50
|
+
return r.success ? [r.data] : [];
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function appendCheckRecord(toplevel: string, rec: CheckRecord): void {
|
|
55
|
+
const file = checksFile(toplevel);
|
|
56
|
+
ensureJsonlHeader(file, "checks");
|
|
57
|
+
appendJsonl(file, rec);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The commit the baseline and the secret scan start from: the one place the base is chosen. A run in the repo scans
|
|
62
|
+
* from where it started; a base run's milestone from where its branch left origin's base (plan 35), null when git
|
|
63
|
+
* cannot say.
|
|
64
|
+
*/
|
|
65
|
+
export async function checkBase(run: Run, m?: string): Promise<string | null> {
|
|
66
|
+
if (!run.meta.base || m === undefined) return run.meta.startHead ?? null;
|
|
67
|
+
const r = await git(treeOf(run, m), ["merge-base", `origin/${run.meta.base}`, "HEAD"]);
|
|
68
|
+
return r.kind === "ok" ? r.out.trim() || null : null;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** The baseline record of `command` on `base`; another commit's is none. Without a base, the newest. */
|
|
72
|
+
export function baselineFor(toplevel: string, command: string, base: string | null): CheckRecord | null {
|
|
73
|
+
const all = readChecks(toplevel).filter((r) => r.baseline && r.command === command);
|
|
74
|
+
return (base ? all.filter((r) => r.head === base) : all).at(-1) ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** The scanner a test replaces. */
|
|
78
|
+
export const checkTools = { gitleaks: (): string | null => Bun.which("gitleaks") };
|
|
79
|
+
|
|
80
|
+
/** `gitleaks` over `base..HEAD` in `cwd`: "found" blocks, a missing or failing scanner is an audit note. */
|
|
81
|
+
export async function secretScan(cwd: string, base: string | null): Promise<string> {
|
|
82
|
+
if (!base) return "skipped: no base commit to scan from";
|
|
83
|
+
const bin = checkTools.gitleaks();
|
|
84
|
+
if (!bin) return "skipped: gitleaks is not installed";
|
|
85
|
+
const p = Bun.spawn(
|
|
86
|
+
[bin, "git", "--no-banner", "--redact", "--exit-code", "1", `--log-opts=${base}..HEAD`],
|
|
87
|
+
{ cwd, stdin: "ignore", stdout: "ignore", stderr: "ignore" },
|
|
88
|
+
);
|
|
89
|
+
const code = await p.exited;
|
|
90
|
+
return code === 0 ? "clean" : code === 1 ? "found" : `skipped: gitleaks exited ${code}`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export interface CheckJob {
|
|
94
|
+
run: string;
|
|
95
|
+
repo: string;
|
|
96
|
+
cwd: string;
|
|
97
|
+
milestone: string;
|
|
98
|
+
command: string;
|
|
99
|
+
head: string;
|
|
100
|
+
base: string | null;
|
|
101
|
+
baseline: boolean;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const invalid = (message: string, fix: string) => new CatherdError("E_INPUT_INVALID", message, { fix });
|
|
105
|
+
|
|
106
|
+
/** One logged run of the command: its merged output goes to the log, its exit code is kept. */
|
|
107
|
+
async function runLogged(job: CheckJob, log: string, tmp: string | null): Promise<number> {
|
|
108
|
+
const env = await checkEnvFor(job.repo);
|
|
109
|
+
const p = Bun.spawn(["sh", "-c", 'exec > "$1" 2>&1; eval "$2"', "sh", log, job.command], {
|
|
110
|
+
cwd: job.cwd,
|
|
111
|
+
env: tmp ? { ...env, TMPDIR: tmp, GOTMPDIR: tmp } : env,
|
|
112
|
+
stdin: "ignore",
|
|
113
|
+
stdout: "ignore",
|
|
114
|
+
stderr: "ignore",
|
|
115
|
+
detached: true,
|
|
116
|
+
});
|
|
117
|
+
return (await p.exited) ?? 1;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* A record names the commit it ran on: the checkout must still be at `job.head`, with no tracked edits (a
|
|
122
|
+
* baseline's worktree is the base's own), or what ran is not what the record would claim.
|
|
123
|
+
*/
|
|
124
|
+
async function assertCheckout(job: CheckJob, when: string): Promise<void> {
|
|
125
|
+
const moved = (why: string) =>
|
|
126
|
+
invalid(
|
|
127
|
+
`the checkout moved ${when} the check (${why}); nothing is recorded for ${job.head.slice(0, 12)}`,
|
|
128
|
+
"commit the milestone, then call gate_run again",
|
|
129
|
+
);
|
|
130
|
+
const head = await git(job.cwd, ["rev-parse", "HEAD"]);
|
|
131
|
+
if (head.kind !== "ok") throw moved("HEAD is unreadable");
|
|
132
|
+
if (head.out.trim() !== job.head) throw moved(`HEAD is ${head.out.trim().slice(0, 12)}`);
|
|
133
|
+
if (job.baseline) return;
|
|
134
|
+
const dirty = await git(job.cwd, ["status", "--porcelain", "--untracked-files=no"]);
|
|
135
|
+
if (dirty.kind !== "ok" || dirty.out.trim()) throw moved("tracked files changed");
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Runs a milestone's check under the verifier lock and a heavy slot, records its exit code and failing lines
|
|
140
|
+
* against `head`, reruns a failure with lines the base lacks once, and scans the run's commits for secrets.
|
|
141
|
+
*/
|
|
142
|
+
export async function execMilestoneCheck(job: CheckJob): Promise<CheckRecord> {
|
|
143
|
+
const dir = checkLogDir(job.repo);
|
|
144
|
+
ensurePrivateDir(dir);
|
|
145
|
+
const one = async () => {
|
|
146
|
+
const log = join(
|
|
147
|
+
dir,
|
|
148
|
+
`${job.milestone}-${job.head.slice(0, 12)}${job.baseline ? "-base" : ""}-${Date.now()}.log`,
|
|
149
|
+
);
|
|
150
|
+
const start = Date.now();
|
|
151
|
+
// the locks may have been waited for while the checkout moved on, and the command may move it
|
|
152
|
+
const guarded = async (tmp: string | null): Promise<number> => {
|
|
153
|
+
await assertCheckout(job, "before");
|
|
154
|
+
const code = await runLogged(job, log, tmp);
|
|
155
|
+
await assertCheckout(job, "during");
|
|
156
|
+
return code;
|
|
157
|
+
};
|
|
158
|
+
let exit: number;
|
|
159
|
+
if (process.env[LOCK_HELD_ENV] === "1") exit = await guarded(null);
|
|
160
|
+
else {
|
|
161
|
+
let setting: number | "cpus/2" = "cpus/2";
|
|
162
|
+
try {
|
|
163
|
+
setting = readProfileDoc(activeName(job.repo)).lock?.heavy ?? "cpus/2";
|
|
164
|
+
} catch {
|
|
165
|
+
// a profile that cannot be read leaves the default
|
|
166
|
+
}
|
|
167
|
+
exit = await withRoleLock("verifier", `run:${job.run}`, () =>
|
|
168
|
+
withHeavySlot(heavySlots(setting), (_slot, tmp) => guarded(tmp), {
|
|
169
|
+
tmpBase: process.env.TMPDIR,
|
|
170
|
+
}),
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
const out = readFileSync(log, "utf8");
|
|
174
|
+
return { exit, durationMs: Date.now() - start, fails: checkFailLines(out), log };
|
|
175
|
+
};
|
|
176
|
+
let r = await one();
|
|
177
|
+
let rerunOf: number | undefined;
|
|
178
|
+
if (r.exit !== 0 && !job.baseline) {
|
|
179
|
+
// a failure with lines the base lacks gets one rerun: load flakes are common on a shared host
|
|
180
|
+
const base = baselineFor(job.repo, job.command, job.base);
|
|
181
|
+
if (!base || newFails(r.fails, base.fails).length > 0) {
|
|
182
|
+
rerunOf = r.exit;
|
|
183
|
+
r = await one();
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
const rec: CheckRecord = {
|
|
187
|
+
at: new Date().toISOString(),
|
|
188
|
+
run: job.run,
|
|
189
|
+
milestone: job.milestone,
|
|
190
|
+
command: job.command,
|
|
191
|
+
head: job.head,
|
|
192
|
+
...r,
|
|
193
|
+
...(job.baseline ? { baseline: true as const } : { secrets: await secretScan(job.cwd, job.base) }),
|
|
194
|
+
...(rerunOf !== undefined ? { rerunOf } : {}),
|
|
195
|
+
};
|
|
196
|
+
appendCheckRecord(job.repo, rec);
|
|
197
|
+
return rec;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The machine half of the land gate for milestone `m` on `head`: every command the run checked on it exits 0, or
|
|
202
|
+
* fails only in lines the base fails in too. `audit` lists what passed only because of the base or a rerun.
|
|
203
|
+
*/
|
|
204
|
+
export async function machineGate(
|
|
205
|
+
run: Run,
|
|
206
|
+
m: string,
|
|
207
|
+
head: string,
|
|
208
|
+
): Promise<{ missing: string[]; audit: string[] }> {
|
|
209
|
+
const missing: string[] = [];
|
|
210
|
+
const audit: string[] = [];
|
|
211
|
+
const latest = new Map<string, CheckRecord>();
|
|
212
|
+
for (const r of readChecks(run.meta.repo))
|
|
213
|
+
if (r.run === run.id && r.milestone === m && r.head === head && !r.baseline) latest.set(r.command, r);
|
|
214
|
+
if (latest.size === 0) {
|
|
215
|
+
missing.push(
|
|
216
|
+
`no machine check of ${m} on ${head.slice(0, 12)}: the verifier runs its full check with gate_run(run, "${m}", <command>) on the committed HEAD`,
|
|
217
|
+
);
|
|
218
|
+
return { missing, audit };
|
|
219
|
+
}
|
|
220
|
+
const start = await checkBase(run, m);
|
|
221
|
+
for (const rec of latest.values()) {
|
|
222
|
+
if (rec.secrets === "found")
|
|
223
|
+
missing.push(
|
|
224
|
+
`gitleaks found secrets in ${(start ?? "").slice(0, 12)}..${head.slice(0, 12)}; see ${rec.log}`,
|
|
225
|
+
);
|
|
226
|
+
if (rec.secrets?.startsWith("skipped")) audit.push(`secret scan ${rec.secrets}`);
|
|
227
|
+
if (rec.rerunOf !== undefined)
|
|
228
|
+
audit.push(`\`${rec.command}\` exited ${rec.rerunOf} once and was rerun (exit ${rec.exit})`);
|
|
229
|
+
if (rec.exit === 0) continue;
|
|
230
|
+
const base = baselineFor(run.meta.repo, rec.command, start);
|
|
231
|
+
const fresh = newFails(rec.fails, base?.fails ?? null);
|
|
232
|
+
if (!base || base.exit === 0)
|
|
233
|
+
missing.push(
|
|
234
|
+
`\`${rec.command}\` exits ${rec.exit} on ${head.slice(0, 12)}${fresh.length ? `: ${fresh.slice(0, 5).join(" | ")}` : ""}`,
|
|
235
|
+
);
|
|
236
|
+
else if (fresh.length)
|
|
237
|
+
missing.push(`failing lines the base does not have: ${fresh.slice(0, 8).join(" | ")}`);
|
|
238
|
+
// a failure catherd cannot read is never matched against the base: it could be anything
|
|
239
|
+
else if (rec.fails.length === 0)
|
|
240
|
+
missing.push(
|
|
241
|
+
`\`${rec.command}\` exits ${rec.exit} with no failing line catherd can compare; see ${rec.log}`,
|
|
242
|
+
);
|
|
243
|
+
else
|
|
244
|
+
audit.push(
|
|
245
|
+
`\`${rec.command}\` exits ${rec.exit} here and ${base.exit} on the base ${base.head.slice(0, 12)}; every failing line is pre-existing (${rec.fails.length})`,
|
|
246
|
+
);
|
|
247
|
+
}
|
|
248
|
+
return { missing, audit };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
/** What a caller waits for, and how often it looks; a worker that has not written its pid yet gets `graceMs`. */
|
|
252
|
+
export const checkLimits = { waitMs: 600_000, pollMs: 1_000, graceMs: 30_000 };
|
|
253
|
+
|
|
254
|
+
export type GateRunResult =
|
|
255
|
+
| { running: true; log: string | null; hint: string }
|
|
256
|
+
| {
|
|
257
|
+
running: false;
|
|
258
|
+
cached: boolean;
|
|
259
|
+
record: CheckRecord;
|
|
260
|
+
missing: string[];
|
|
261
|
+
audit: string[];
|
|
262
|
+
hints?: string[];
|
|
263
|
+
};
|
|
264
|
+
|
|
265
|
+
interface Flight {
|
|
266
|
+
schema: 1;
|
|
267
|
+
pid: number;
|
|
268
|
+
startTime: string | null;
|
|
269
|
+
at: string;
|
|
270
|
+
job: CheckJob;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const flightOf = (job: CheckJob): string =>
|
|
274
|
+
join(
|
|
275
|
+
checkLogDir(job.repo),
|
|
276
|
+
`inflight-${new Bun.CryptoHasher("sha1")
|
|
277
|
+
.update(JSON.stringify([job.run, job.milestone, job.command, job.head, job.baseline]))
|
|
278
|
+
.digest("hex")}.json`,
|
|
279
|
+
);
|
|
280
|
+
|
|
281
|
+
function readFlight(file: string): Flight | null {
|
|
282
|
+
try {
|
|
283
|
+
const f = JSON.parse(readFileSync(file, "utf8")) as Flight;
|
|
284
|
+
return typeof f?.pid === "number" && typeof f.at === "string" ? f : null;
|
|
285
|
+
} catch {
|
|
286
|
+
return null;
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
/** A flight is live while its worker is, or while a worker that has not written its pid yet is within its grace. */
|
|
291
|
+
const flightLive = (f: Flight): boolean =>
|
|
292
|
+
f.pid > 0 ? isAlive(f.pid, f.startTime) : Date.now() - Date.parse(f.at) < checkLimits.graceMs;
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* The verifier's full check of a milestone on the committed HEAD (spec 2.0 decision 18): the same run,
|
|
296
|
+
* milestone, command and HEAD answer the recorded result; else a detached worker runs it. Past `waitMs` the
|
|
297
|
+
* answer is `running`, and the next call waits for that same run.
|
|
298
|
+
*/
|
|
299
|
+
export async function runMilestoneCheck(
|
|
300
|
+
i: { run: string; milestone: string; command: string; at?: string; force?: boolean },
|
|
301
|
+
o: { onWait?: () => void } = {},
|
|
302
|
+
): Promise<GateRunResult> {
|
|
303
|
+
const run = findRun(i.run);
|
|
304
|
+
const repo = run.meta.repo;
|
|
305
|
+
const cwd = i.at ?? treeOf(run, i.milestone);
|
|
306
|
+
const baseline = i.at !== undefined;
|
|
307
|
+
const headOut = await git(cwd, ["rev-parse", "HEAD"]);
|
|
308
|
+
if (headOut.kind !== "ok" || !headOut.out.trim())
|
|
309
|
+
throw invalid(`cannot read HEAD in ${cwd}`, "run it in a git checkout with a commit");
|
|
310
|
+
const head = headOut.out.trim();
|
|
311
|
+
const base = await checkBase(run, i.milestone);
|
|
312
|
+
if (baseline) {
|
|
313
|
+
if (!base || head !== base)
|
|
314
|
+
throw invalid(
|
|
315
|
+
`${cwd} is at ${head.slice(0, 12)}, not at the run's base ${base ? base.slice(0, 12) : "(none)"}`,
|
|
316
|
+
`git worktree add --detach <dir> ${base ?? "<base>"}, then pass it as at`,
|
|
317
|
+
);
|
|
318
|
+
} else {
|
|
319
|
+
const dirty = await git(cwd, ["status", "--porcelain", "--untracked-files=no"]);
|
|
320
|
+
if (dirty.kind !== "ok") throw invalid(`git status failed in ${cwd}`, "check that git works there");
|
|
321
|
+
if (dirty.out.trim())
|
|
322
|
+
throw invalid(
|
|
323
|
+
"tracked files have uncommitted changes, and a check record names a commit",
|
|
324
|
+
"commit the milestone first",
|
|
325
|
+
);
|
|
326
|
+
}
|
|
327
|
+
const mine = (r: CheckRecord): boolean =>
|
|
328
|
+
r.run === i.run &&
|
|
329
|
+
r.milestone === i.milestone &&
|
|
330
|
+
r.command === i.command &&
|
|
331
|
+
r.head === head &&
|
|
332
|
+
Boolean(r.baseline) === baseline;
|
|
333
|
+
const result = async (record: CheckRecord, cached: boolean): Promise<GateRunResult> => {
|
|
334
|
+
const gate = baseline ? { missing: [], audit: [] } : await machineGate(run, i.milestone, head);
|
|
335
|
+
const hints =
|
|
336
|
+
record.exit !== 0 && !baseline && !baselineFor(repo, i.command, base) && base
|
|
337
|
+
? [
|
|
338
|
+
`the base's own failures are not recorded: git worktree add --detach <dir> ${base.slice(0, 12)}, run gate_run with at: <dir> there, then git worktree remove <dir>`,
|
|
339
|
+
]
|
|
340
|
+
: [];
|
|
341
|
+
return { running: false, cached, record, ...gate, ...(hints.length ? { hints } : {}) };
|
|
342
|
+
};
|
|
343
|
+
if (!i.force) {
|
|
344
|
+
const prior = readChecks(repo).filter(mine).at(-1);
|
|
345
|
+
if (prior) return result(prior, true);
|
|
346
|
+
}
|
|
347
|
+
const job: CheckJob = {
|
|
348
|
+
run: i.run,
|
|
349
|
+
repo,
|
|
350
|
+
cwd,
|
|
351
|
+
milestone: i.milestone,
|
|
352
|
+
command: i.command,
|
|
353
|
+
head,
|
|
354
|
+
base,
|
|
355
|
+
baseline,
|
|
356
|
+
};
|
|
357
|
+
const file = flightOf(job);
|
|
358
|
+
ensurePrivateDir(checkLogDir(repo));
|
|
359
|
+
let started: string | null = null;
|
|
360
|
+
for (let tries = 0; tries < 3 && started === null; tries++) {
|
|
361
|
+
const at = new Date().toISOString();
|
|
362
|
+
try {
|
|
363
|
+
writeFileSync(file, JSON.stringify({ schema: 1, pid: -1, startTime: null, at, job } satisfies Flight), {
|
|
364
|
+
flag: "wx",
|
|
365
|
+
mode: 0o600,
|
|
366
|
+
});
|
|
367
|
+
} catch (e) {
|
|
368
|
+
if ((e as NodeJS.ErrnoException).code !== "EEXIST") throw e;
|
|
369
|
+
const other = readFlight(file);
|
|
370
|
+
if (other && flightLive(other)) {
|
|
371
|
+
started = other.at;
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
unlinkSync(file);
|
|
376
|
+
} catch {
|
|
377
|
+
// another caller removed it first
|
|
378
|
+
}
|
|
379
|
+
continue;
|
|
380
|
+
}
|
|
381
|
+
const pid = launchCheckWorker(file);
|
|
382
|
+
// the worker may be done and its flight removed already: never write the file back
|
|
383
|
+
if (existsSync(file))
|
|
384
|
+
writeJsonAtomic(file, { schema: 1, pid, startTime: processStartTime(pid), at, job } satisfies Flight);
|
|
385
|
+
started = at;
|
|
386
|
+
}
|
|
387
|
+
if (started === null)
|
|
388
|
+
throw new CatherdError("E_IO_UNEXPECTED", "the check could not be started", { fix: `remove ${file}` });
|
|
389
|
+
const deadline = Date.now() + checkLimits.waitMs;
|
|
390
|
+
for (;;) {
|
|
391
|
+
const f = existsSync(file) ? readFlight(file) : null;
|
|
392
|
+
if (!f) break;
|
|
393
|
+
if (!flightLive(f)) {
|
|
394
|
+
try {
|
|
395
|
+
unlinkSync(file);
|
|
396
|
+
} catch {
|
|
397
|
+
// already gone
|
|
398
|
+
}
|
|
399
|
+
break;
|
|
400
|
+
}
|
|
401
|
+
if (Date.now() >= deadline)
|
|
402
|
+
return {
|
|
403
|
+
running: true,
|
|
404
|
+
log: checkLogDir(repo),
|
|
405
|
+
hint: "the check is still running; call gate_run again with the same arguments to wait for it",
|
|
406
|
+
};
|
|
407
|
+
o.onWait?.();
|
|
408
|
+
await Bun.sleep(Math.min(checkLimits.pollMs, Math.max(1, deadline - Date.now())));
|
|
409
|
+
}
|
|
410
|
+
const record = readChecks(repo)
|
|
411
|
+
.filter((r) => mine(r) && r.at >= started)
|
|
412
|
+
.at(-1);
|
|
413
|
+
if (!record)
|
|
414
|
+
throw new CatherdError("E_IO_UNEXPECTED", "the check worker stopped without a record", {
|
|
415
|
+
fix: `see worker.log in ${checkLogDir(repo)}`,
|
|
416
|
+
});
|
|
417
|
+
return result(record, false);
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** The detached worker: runs the job its flight file holds, records it, and removes the flight. */
|
|
421
|
+
export async function checkWorker(flight: string): Promise<void> {
|
|
422
|
+
const f = readFlight(flight);
|
|
423
|
+
if (!f) throw new Error(`no readable flight file ${flight}`);
|
|
424
|
+
try {
|
|
425
|
+
await execMilestoneCheck(f.job);
|
|
426
|
+
} finally {
|
|
427
|
+
try {
|
|
428
|
+
unlinkSync(flight);
|
|
429
|
+
} catch {
|
|
430
|
+
// the caller removed a stale one
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { milestoneBranch, planSetup } from "../domain/branch.ts";
|
|
4
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { availableMemoryMb } from "../infra/memory.ts";
|
|
6
|
+
import { currentBranch, fetchBase, git, gitRun } from "../infra/git.ts";
|
|
7
|
+
import { heavySlots, withHeavySlot } from "../infra/heavy-lock.ts";
|
|
8
|
+
import { ensurePrivateDir } from "../infra/store.ts";
|
|
9
|
+
import type { Deps } from "./ports.ts";
|
|
10
|
+
import { planDocs } from "./plan-input.ts";
|
|
11
|
+
import { checkEnvFor, runCheck } from "./preflight.ts";
|
|
12
|
+
import { runProfile } from "./run-pin.ts";
|
|
13
|
+
import { type Run, runPaths } from "./run-store.ts";
|
|
14
|
+
import { readyFile, treeDir, treeOf } from "./tree-path.ts";
|
|
15
|
+
|
|
16
|
+
/** How long a plan's `Setup:` command may run (tests shorten it: it is the seam for a slow install). */
|
|
17
|
+
export const treeLimits = { setupMs: 15 * 60_000 };
|
|
18
|
+
|
|
19
|
+
/** The machine's available memory in MB; tests replace it. */
|
|
20
|
+
export const memoryReader = { read: (): number => availableMemoryMb() };
|
|
21
|
+
|
|
22
|
+
const PUSH_MS = 120_000;
|
|
23
|
+
const TAIL = 1_500;
|
|
24
|
+
|
|
25
|
+
/** `git -C <tree> push -u origin <branch>`: the one push catherd makes, on the owner's word. Tests replace it. */
|
|
26
|
+
export const branchPusher = {
|
|
27
|
+
push: async (tree: string, branch: string): Promise<void> => {
|
|
28
|
+
const r = await gitRun(tree, ["push", "-u", "origin", branch], PUSH_MS);
|
|
29
|
+
if (r.code !== 0)
|
|
30
|
+
throw new CatherdError(
|
|
31
|
+
"E_TREE",
|
|
32
|
+
`git push of ${branch} failed: ${r.timedOut ? `timed out after ${PUSH_MS / 1000} s` : tail(`${r.out}${r.err}`)}`,
|
|
33
|
+
{ fix: "fix the remote or push it yourself, then answer" },
|
|
34
|
+
);
|
|
35
|
+
},
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const tail = (s: string): string => s.trim().slice(-TAIL);
|
|
39
|
+
|
|
40
|
+
export { treeDir, treeOf };
|
|
41
|
+
|
|
42
|
+
export interface TreeClaim {
|
|
43
|
+
tree: string;
|
|
44
|
+
branch: string;
|
|
45
|
+
base: string;
|
|
46
|
+
hints: string[];
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const tree_ = (message: string, fix: string) => new CatherdError("E_TREE", message, { fix });
|
|
50
|
+
|
|
51
|
+
const seconds = (ms: number): string =>
|
|
52
|
+
ms >= 60_000 ? `${Math.round(ms / 60_000)} min` : `${Math.ceil(ms / 1000)} s`;
|
|
53
|
+
|
|
54
|
+
/** The first `Setup:` line of plan.md, else of the plan's input copies. */
|
|
55
|
+
function setupOf(run: Run): string | null {
|
|
56
|
+
let own = "";
|
|
57
|
+
try {
|
|
58
|
+
own = readFileSync(runPaths(run.dir).plan, "utf8");
|
|
59
|
+
} catch {
|
|
60
|
+
// no plan.md: the input copies may have the line
|
|
61
|
+
}
|
|
62
|
+
for (const text of [own, ...planDocs(run).map((d) => d.text)]) {
|
|
63
|
+
const cmd = planSetup(text);
|
|
64
|
+
if (cmd) return cmd;
|
|
65
|
+
}
|
|
66
|
+
return null;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Submodules, then the plan's Setup: command, in a heavy slot with the gate's check env. */
|
|
70
|
+
async function setUp(deps: Deps, run: Run, tree: string): Promise<void> {
|
|
71
|
+
const setup = setupOf(run);
|
|
72
|
+
const submodules = existsSync(join(tree, ".gitmodules"));
|
|
73
|
+
if (!setup && !submodules) return;
|
|
74
|
+
const env = await checkEnvFor(run.meta.repo);
|
|
75
|
+
await withHeavySlot(heavySlots(runProfile(deps, run).heavy), async (_slot, tmp) => {
|
|
76
|
+
if (submodules) {
|
|
77
|
+
const r = await gitRun(tree, ["submodule", "update", "--init", "--recursive"], treeLimits.setupMs);
|
|
78
|
+
if (r.code !== 0)
|
|
79
|
+
throw tree_(
|
|
80
|
+
`git submodule update failed in ${tree}: ${r.timedOut ? "timed out" : tail(`${r.out}${r.err}`)}`,
|
|
81
|
+
"make the submodule host reachable, then answer",
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
if (!setup) return;
|
|
85
|
+
const r = await runCheck(tree, setup, treeLimits.setupMs, { ...env, TMPDIR: tmp, GOTMPDIR: tmp });
|
|
86
|
+
if (r.timedOut || r.code !== 0)
|
|
87
|
+
throw tree_(
|
|
88
|
+
`Setup: ${setup} ${r.timedOut ? `timed out after ${seconds(treeLimits.setupMs)}` : `exited ${r.code}`} in ${tree}: ${tail(r.tail.join("\n"))}`,
|
|
89
|
+
"correct the Setup: line in plan.md or the machine, then answer",
|
|
90
|
+
);
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Makes (or finds again) milestone `m`'s worktree: a branch from `origin/<base>` after a fetch, set up once. A claim a
|
|
96
|
+
* crash interrupted reuses the folder and runs Setup: again, since `.ready` was never written. `known` says a
|
|
97
|
+
* `tree.ready` happened for `m` before, so a branch of its name is this run's own.
|
|
98
|
+
*/
|
|
99
|
+
export async function claimTree(deps: Deps, run: Run, m: string, o: { known: boolean }): Promise<TreeClaim> {
|
|
100
|
+
const base = run.meta.base;
|
|
101
|
+
if (!base)
|
|
102
|
+
throw new CatherdError("E_INPUT_INVALID", `run ${run.id} has no base branch`, {
|
|
103
|
+
fix: "start the run with run_start base",
|
|
104
|
+
});
|
|
105
|
+
const repo = run.meta.repo;
|
|
106
|
+
const branch = milestoneBranch(run.meta.title, m);
|
|
107
|
+
const tree = treeDir(run, m);
|
|
108
|
+
const hints: string[] = [];
|
|
109
|
+
if (!(await fetchBase(repo, base)))
|
|
110
|
+
hints.push(`fetch of origin/${base} failed: using the copy from the last fetch`);
|
|
111
|
+
await git(repo, ["worktree", "prune"]);
|
|
112
|
+
if (existsSync(tree)) {
|
|
113
|
+
const have = await currentBranch(tree);
|
|
114
|
+
if (have !== branch)
|
|
115
|
+
throw tree_(
|
|
116
|
+
`${tree} has ${have ?? "no branch"} checked out, not ${branch}`,
|
|
117
|
+
"move the folder away, then answer",
|
|
118
|
+
);
|
|
119
|
+
// a `git worktree add` cut off mid-checkout leaves the right HEAD and a part of the files: not complete
|
|
120
|
+
if (!existsSync(readyFile(run, m))) {
|
|
121
|
+
const r = await gitRun(tree, ["reset", "--hard", "-q", "HEAD"]);
|
|
122
|
+
if (r.code !== 0)
|
|
123
|
+
throw tree_(
|
|
124
|
+
`git reset of ${tree} failed: ${tail(`${r.out}${r.err}`)}`,
|
|
125
|
+
"fix the folder, then answer",
|
|
126
|
+
);
|
|
127
|
+
}
|
|
128
|
+
} else {
|
|
129
|
+
ensurePrivateDir(join(run.dir, "trees"));
|
|
130
|
+
const exists = await git(repo, ["rev-parse", "--verify", "--quiet", `refs/heads/${branch}`]);
|
|
131
|
+
if (exists.kind === "ok" && !o.known)
|
|
132
|
+
throw tree_(
|
|
133
|
+
`branch ${branch} exists and is not this run's`,
|
|
134
|
+
`rename it (git branch -m ${branch} ${branch}-old) or delete it, then answer`,
|
|
135
|
+
);
|
|
136
|
+
const args =
|
|
137
|
+
exists.kind === "ok"
|
|
138
|
+
? ["worktree", "add", tree, branch]
|
|
139
|
+
: ["worktree", "add", "--no-track", "-b", branch, tree, `origin/${base}`];
|
|
140
|
+
const r = await gitRun(repo, args);
|
|
141
|
+
if (r.code !== 0)
|
|
142
|
+
throw tree_(
|
|
143
|
+
`git worktree add failed for ${branch}: ${tail(`${r.out}${r.err}`)}`,
|
|
144
|
+
"fix the repository, then answer",
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (!existsSync(readyFile(run, m))) {
|
|
148
|
+
await setUp(deps, run, tree);
|
|
149
|
+
mkdirSync(join(run.dir, "trees"), { recursive: true });
|
|
150
|
+
writeFileSync(readyFile(run, m), "");
|
|
151
|
+
}
|
|
152
|
+
return { tree, branch, base, hints };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Removes a merged milestone's worktree with `git worktree remove` (never `--force`), keeping its branch. A tree with
|
|
157
|
+
* uncommitted or untracked files stays, with a hint that says so.
|
|
158
|
+
*/
|
|
159
|
+
export async function removeTree(run: Run, m: string): Promise<{ removed: boolean; hint: string | null }> {
|
|
160
|
+
const tree = treeDir(run, m);
|
|
161
|
+
if (!existsSync(tree)) return { removed: false, hint: null };
|
|
162
|
+
const r = await gitRun(run.meta.repo, ["worktree", "remove", tree]);
|
|
163
|
+
if (r.code !== 0)
|
|
164
|
+
return {
|
|
165
|
+
removed: false,
|
|
166
|
+
hint: `kept ${tree}: ${tail(`${r.err}${r.out}`)}; git worktree remove it once that is settled`,
|
|
167
|
+
};
|
|
168
|
+
rmSync(readyFile(run, m), { force: true });
|
|
169
|
+
return { removed: true, hint: null };
|
|
170
|
+
}
|