catherd-cli 1.5.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 +164 -208
- package/THIRD_PARTY_NOTICES.md +217 -35
- package/catalog/jev.json +27 -1
- package/catalog/models.json +56 -247
- 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 -421
- package/plugin/skills/catherd-setup/SKILL.md +20 -19
- package/src/adapters/all.ts +0 -4
- package/src/adapters/backend.ts +1 -2
- package/src/adapters/cursor/home.ts +1 -1
- package/src/adapters/cursor/index.ts +4 -4
- package/src/cli.ts +22 -27
- 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 +21 -6
- package/src/domain/catalog.ts +69 -324
- package/src/domain/cost.ts +15 -30
- package/src/domain/errors.ts +2 -2
- package/src/domain/fail-lines.ts +26 -0
- package/src/domain/failover.ts +37 -87
- package/src/domain/findings.ts +47 -0
- package/src/domain/gate-brief.ts +1 -0
- 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 +22 -3
- package/src/domain/milestone-memory.ts +53 -0
- package/src/domain/notice.ts +25 -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 +101 -160
- package/src/domain/profile.ts +116 -63
- package/src/domain/record.ts +53 -2
- package/src/domain/role-prompts.ts +131 -45
- package/src/domain/role-scope.ts +4 -34
- package/src/domain/role-tools.ts +2 -2
- package/src/domain/roles.ts +2 -0
- package/src/domain/route.ts +17 -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 +151 -146
- package/src/domain/slopalytics.ts +72 -0
- package/src/entry/answer-command.ts +39 -0
- package/src/entry/catalog-command.ts +25 -174
- package/src/entry/check-bin.ts +17 -0
- package/src/entry/cli-kit.ts +1 -1
- package/src/entry/daemon-bin.ts +22 -0
- package/src/entry/glyphs.ts +15 -2
- package/src/entry/init-command.ts +18 -68
- package/src/entry/knowledge-command.ts +45 -4
- package/src/entry/lock-command.ts +6 -7
- package/src/entry/mcp/role-server.ts +18 -0
- package/src/entry/mcp/server.ts +13 -40
- 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/profile-command.ts +10 -14
- package/src/entry/role-cli-command.ts +47 -3
- package/src/entry/runs-command.ts +3 -19
- package/src/entry/scorecard-command.ts +187 -0
- package/src/entry/supervise-bin.ts +1 -1
- package/src/infra/env.ts +0 -2
- package/src/infra/git.ts +64 -0
- package/src/infra/heavy-lock.ts +45 -7
- package/src/infra/launch.ts +63 -7
- 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 +2 -2
- package/src/infra/slopalytics.ts +30 -0
- package/src/infra/store.ts +6 -1
- package/src/services/admission.ts +24 -9
- package/src/services/backends.ts +4 -6
- package/src/services/capture.ts +1 -22
- package/src/services/catalog-service.ts +155 -262
- package/src/services/credentials.ts +4 -20
- package/src/services/daemon.ts +181 -0
- package/src/services/dispatch-service.ts +5 -1
- package/src/services/doctor-backends.ts +1 -1
- package/src/services/doctor-checks.ts +47 -0
- package/src/services/doctor.ts +4 -9
- package/src/services/failover-rungs.ts +109 -0
- package/src/services/finalize.ts +12 -0
- package/src/services/gate-env.ts +1 -2
- package/src/services/gate-service.ts +21 -6
- package/src/services/jev-service.ts +2 -4
- package/src/services/lane-service.ts +34 -3
- package/src/services/milestone-check.ts +433 -0
- package/src/services/milestone-tree.ts +170 -0
- package/src/services/milestones.ts +35 -8
- package/src/services/notifier.ts +69 -2
- package/src/services/notify-service.ts +26 -0
- package/src/services/panel-setup.ts +116 -0
- package/src/services/plan-input.ts +151 -0
- package/src/services/ports.ts +30 -4
- package/src/services/preflight.ts +82 -8
- package/src/services/profile-service.ts +30 -3
- package/src/services/profile-store.ts +8 -3
- package/src/services/protocol.ts +4 -0
- package/src/services/provenance.ts +31 -90
- package/src/services/questions.ts +9 -0
- package/src/services/reconcile.ts +11 -0
- package/src/services/routing-service.ts +26 -15
- 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-read.ts +221 -0
- package/src/services/run-report.ts +176 -0
- package/src/services/run-service.ts +55 -6
- package/src/services/run-store.ts +54 -7
- package/src/services/scorecard-metrics.ts +86 -0
- package/src/services/scorecard.ts +382 -0
- package/src/services/summary.ts +27 -3
- package/src/services/tree-path.ts +27 -0
- package/src/services/workspace-admission.ts +5 -13
- package/src/services/workspace-service.ts +5 -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 -44
- 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 -335
- package/src/adapters/antigravity/models.ts +0 -37
- package/src/adapters/grok/events.ts +0 -112
- package/src/adapters/grok/home.ts +0 -187
- package/src/adapters/grok/index.ts +0 -358
- package/src/adapters/grok/models.ts +0 -48
- package/src/domain/calibration.ts +0 -132
- package/src/domain/sources.ts +0 -144
- package/src/entry/mcp/dispatch-tools.ts +0 -52
- package/src/entry/mcp/lane-tools.ts +0 -131
- package/src/entry/mcp/protocol-tools.ts +0 -63
- package/src/entry/mcp/push-tools.ts +0 -16
- package/src/entry/mcp/run-tools.ts +0 -164
- package/src/entry/mcp/setup-tools.ts +0 -109
- package/src/entry/mcp/workspace-tools.ts +0 -142
- package/src/entry/tui/commands.ts +0 -563
- package/src/entry/tui/effects.ts +0 -385
- 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 -400
- 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 -619
- package/src/entry/tui/views/save-dialog.tsx +0 -344
- package/src/entry/tui/views/status.tsx +0 -213
- 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 -95
- package/src/infra/sources/artificial-analysis.ts +0 -119
- package/src/infra/sources/cache.ts +0 -126
- 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 -290
- package/src/services/source-derive.ts +0 -397
- package/src/services/source-sync.ts +0 -312
- package/src/services/standins.ts +0 -238
- package/src/services/treat-likes.ts +0 -113
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
import { join, resolve } from "node:path";
|
|
2
|
+
import { defineCommand } from "citty";
|
|
3
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
4
|
+
import {
|
|
5
|
+
passBar,
|
|
6
|
+
SCORECARD_TASKS,
|
|
7
|
+
type ScorecardFile,
|
|
8
|
+
type ScorecardTaskId,
|
|
9
|
+
type Verdict,
|
|
10
|
+
} from "../domain/scorecard.ts";
|
|
11
|
+
import {
|
|
12
|
+
defaultPreparedRoot,
|
|
13
|
+
loadTask,
|
|
14
|
+
type Prepared,
|
|
15
|
+
prepareTask,
|
|
16
|
+
readScorecard,
|
|
17
|
+
resultsFile,
|
|
18
|
+
saveTaskScore,
|
|
19
|
+
scoreTask,
|
|
20
|
+
scorecardRoot,
|
|
21
|
+
} from "../services/scorecard.ts";
|
|
22
|
+
import { JSON_ARG, mark, printJson, refuseInRole } from "./cli-kit.ts";
|
|
23
|
+
|
|
24
|
+
const BASELINE = "1.5.0";
|
|
25
|
+
|
|
26
|
+
/** POSIX single-quote escaping; a value of plain path characters stays bare. */
|
|
27
|
+
const quote = (v: string): string => (/^[\w@%+=:,./-]+$/.test(v) ? v : `'${v.replaceAll("'", `'\\''`)}'`);
|
|
28
|
+
|
|
29
|
+
/** The prepared task as the operator needs it: where to start, what to paste, and how to record. */
|
|
30
|
+
export function formatPrepared(p: Prepared, label = "<label>"): string[] {
|
|
31
|
+
const env =
|
|
32
|
+
p.limits && p.shim ? `CATHERD_SCORECARD_LIMITS=${quote(p.limits)} PATH=${quote(p.shim)}:"$PATH" ` : "";
|
|
33
|
+
return [
|
|
34
|
+
`${mark("ok")} ${p.task} prepared in ${p.dir} at ${p.startCommit.slice(0, 8)}`,
|
|
35
|
+
` start: cd ${quote(p.repo)} && ${env}claude`,
|
|
36
|
+
` prompt: ${p.prompt}`,
|
|
37
|
+
` record: catherd scorecard record ${quote(p.dir)} --label ${label}`,
|
|
38
|
+
];
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const yes = (b: boolean): string => (b ? "yes" : "no");
|
|
42
|
+
const num = (n: number): string => String(Math.round(n * 10) / 10);
|
|
43
|
+
const k = (n: number): string => `${Math.round(n / 1000)}k`;
|
|
44
|
+
|
|
45
|
+
function row(id: ScorecardTaskId, current: ScorecardFile, baseline: ScorecardFile | null): string {
|
|
46
|
+
const s = current.tasks[id];
|
|
47
|
+
if (!s) return `${id} -`;
|
|
48
|
+
const was = baseline?.tasks[id];
|
|
49
|
+
const input = s.quota.backends.reduce((n, b) => n + b.input, 0);
|
|
50
|
+
const output = s.quota.backends.reduce((n, b) => n + b.output, 0);
|
|
51
|
+
return [
|
|
52
|
+
id,
|
|
53
|
+
s.catherdVersion,
|
|
54
|
+
`finished ${yes(s.finished)}`,
|
|
55
|
+
`interventions ${s.interventions.total}`,
|
|
56
|
+
`rounds ${s.reviewRounds}`,
|
|
57
|
+
`wall ${num(s.wallMinutes)} min (${was ? num(was.wallMinutes) : "?"} min)`,
|
|
58
|
+
`first try ${yes(s.firstTry)}`,
|
|
59
|
+
`quota ${k(input)}/${k(output)}`,
|
|
60
|
+
].join(" ");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export function formatComparison(
|
|
64
|
+
current: ScorecardFile,
|
|
65
|
+
baseline: ScorecardFile | null,
|
|
66
|
+
v: Verdict,
|
|
67
|
+
): string[] {
|
|
68
|
+
return [
|
|
69
|
+
`scorecard ${current.label} against ${baseline ? baseline.label : "no baseline"}`,
|
|
70
|
+
...SCORECARD_TASKS.map((id) => row(id, current, baseline)),
|
|
71
|
+
...(v.pass ? ["PASS"] : v.failures.map((f) => `FAIL: ${f}`)),
|
|
72
|
+
];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** The contributor commands read fixtures from the checkout; an installed package ships none. */
|
|
76
|
+
function checkoutRoot(): string {
|
|
77
|
+
const root = scorecardRoot();
|
|
78
|
+
if (!root)
|
|
79
|
+
throw new CatherdError("E_INPUT_INVALID", "this catherd is not a source checkout", {
|
|
80
|
+
fix: "run it from a catherd checkout: bun src/cli.ts scorecard …",
|
|
81
|
+
});
|
|
82
|
+
return root;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const stamp = (d: Date): string => d.toISOString().replace(/[-:]/g, "").replace("T", "-").slice(0, 15);
|
|
86
|
+
|
|
87
|
+
const RESULTS_ARG = {
|
|
88
|
+
results: { type: "string", description: "results folder (default: scorecard/results)" },
|
|
89
|
+
} as const;
|
|
90
|
+
const resultsDir = (root: string, given: string | undefined): string =>
|
|
91
|
+
given ? resolve(given) : join(root, "results");
|
|
92
|
+
|
|
93
|
+
const prepare = defineCommand({
|
|
94
|
+
meta: { name: "prepare", description: `Prepare a task's starting point: ${SCORECARD_TASKS.join(", ")}` },
|
|
95
|
+
args: {
|
|
96
|
+
task: { type: "positional", required: true, description: SCORECARD_TASKS.join("|") },
|
|
97
|
+
source: {
|
|
98
|
+
type: "string",
|
|
99
|
+
description: "the repository to clone (a path or URL); the Sanitell task needs one",
|
|
100
|
+
},
|
|
101
|
+
dir: { type: "string", description: "where prepared tasks go (default: catherd's data dir)" },
|
|
102
|
+
setup: {
|
|
103
|
+
type: "boolean",
|
|
104
|
+
default: true,
|
|
105
|
+
description: "run the task's setup commands (--no-setup skips them)",
|
|
106
|
+
},
|
|
107
|
+
...JSON_ARG,
|
|
108
|
+
},
|
|
109
|
+
async run({ args }) {
|
|
110
|
+
refuseInRole(process.env, "catherd scorecard prepare");
|
|
111
|
+
const root = checkoutRoot();
|
|
112
|
+
const task = loadTask(args.task, root);
|
|
113
|
+
const now = new Date();
|
|
114
|
+
const into = join(args.dir ? resolve(args.dir) : defaultPreparedRoot(), `${task.id}-${stamp(now)}`);
|
|
115
|
+
const p = await prepareTask({ task, root, into, source: args.source, now, setup: args.setup });
|
|
116
|
+
if (args.json) return printJson(p);
|
|
117
|
+
for (const line of formatPrepared(p)) console.log(line);
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
const record = defineCommand({
|
|
122
|
+
meta: { name: "record", description: "Score a prepared task's run and store its row under a label" },
|
|
123
|
+
args: {
|
|
124
|
+
dir: { type: "positional", required: true, description: "the folder `prepare` printed" },
|
|
125
|
+
label: {
|
|
126
|
+
type: "string",
|
|
127
|
+
required: true,
|
|
128
|
+
description: "a version or plan name: 1.5.0, plan-29, 2.0.0-beta.1",
|
|
129
|
+
},
|
|
130
|
+
run: { type: "string", description: "the run to score, when the folder holds more than one" },
|
|
131
|
+
chat: { type: "string", description: "messages you typed into the session after the start prompt" },
|
|
132
|
+
...RESULTS_ARG,
|
|
133
|
+
...JSON_ARG,
|
|
134
|
+
},
|
|
135
|
+
async run({ args }) {
|
|
136
|
+
refuseInRole(process.env, "catherd scorecard record");
|
|
137
|
+
const chat = args.chat === undefined ? 0 : Number(args.chat);
|
|
138
|
+
if (!Number.isInteger(chat) || chat < 0)
|
|
139
|
+
throw new CatherdError("E_INPUT_INVALID", `--chat ${args.chat} is not a count`, {
|
|
140
|
+
fix: "--chat <the number of messages you typed into the session>",
|
|
141
|
+
});
|
|
142
|
+
const root = checkoutRoot();
|
|
143
|
+
const out = resultsFile(resultsDir(root, args.results), args.label);
|
|
144
|
+
const s = await scoreTask({ dir: resolve(args.dir), root, run: args.run, chat, now: Date.now() });
|
|
145
|
+
const saved = saveTaskScore(out, args.label, s);
|
|
146
|
+
if (args.json) return printJson({ file: out, score: s });
|
|
147
|
+
const baseline = readScorecard(resultsFile(resultsDir(root, args.results), BASELINE));
|
|
148
|
+
console.log(
|
|
149
|
+
formatComparison(saved, baseline, passBar(saved, baseline))[1 + SCORECARD_TASKS.indexOf(s.task)],
|
|
150
|
+
);
|
|
151
|
+
console.log(out);
|
|
152
|
+
},
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
const compare = defineCommand({
|
|
156
|
+
meta: {
|
|
157
|
+
name: "compare",
|
|
158
|
+
description: `Check a label against the baseline (${BASELINE}) by the spec's pass bar`,
|
|
159
|
+
},
|
|
160
|
+
args: {
|
|
161
|
+
label: { type: "positional", required: true, description: "the label to check" },
|
|
162
|
+
baseline: { type: "string", description: `the label to compare with (default: ${BASELINE})` },
|
|
163
|
+
...RESULTS_ARG,
|
|
164
|
+
...JSON_ARG,
|
|
165
|
+
},
|
|
166
|
+
run({ args }) {
|
|
167
|
+
const dir = resultsDir(checkoutRoot(), args.results);
|
|
168
|
+
const current = readScorecard(resultsFile(dir, args.label));
|
|
169
|
+
if (!current)
|
|
170
|
+
throw new CatherdError("E_INPUT_INVALID", `no scorecard ${args.label} yet`, {
|
|
171
|
+
fix: `catherd scorecard record <dir> --label ${args.label}`,
|
|
172
|
+
});
|
|
173
|
+
const baseline = readScorecard(resultsFile(dir, args.baseline ?? BASELINE));
|
|
174
|
+
const verdict = passBar(current, baseline);
|
|
175
|
+
process.exitCode = verdict.pass ? 0 : 1;
|
|
176
|
+
if (args.json) return printJson({ current, baseline, verdict });
|
|
177
|
+
for (const line of formatComparison(current, baseline, verdict)) console.log(line);
|
|
178
|
+
},
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
export const scorecardCommand = defineCommand({
|
|
182
|
+
meta: {
|
|
183
|
+
name: "scorecard",
|
|
184
|
+
description: "Contributors: measure a catherd version on four fixed tasks against the 1.5.0 baseline",
|
|
185
|
+
},
|
|
186
|
+
subCommands: { prepare, record, compare },
|
|
187
|
+
});
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
|
-
// The detached supervisor's entry: it imports only what supervising needs, never src/cli.ts
|
|
2
|
+
// The detached supervisor's entry: it imports only what supervising needs, never src/cli.ts.
|
|
3
3
|
import { errorMessage } from "../domain/errors.ts";
|
|
4
4
|
import { runSupervise } from "./supervise-command.ts";
|
|
5
5
|
|
package/src/infra/env.ts
CHANGED
|
@@ -9,8 +9,6 @@ const SECRET_ENV = new Set([
|
|
|
9
9
|
"CODEX_SESSION_ID",
|
|
10
10
|
"CATHERD_ORCHESTRATION_HOST",
|
|
11
11
|
"TYPESAFE_API_KEY",
|
|
12
|
-
// spec 1.2 §9: the user's Artificial Analysis key reads scores for catherd alone
|
|
13
|
-
"ARTIFICIAL_ANALYSIS_API_KEY",
|
|
14
12
|
"CLAUDE_CODE_MESSAGING_SOCKET",
|
|
15
13
|
"CLAUDE_CODE_MESSAGING_TOKEN",
|
|
16
14
|
"CLAUDE_CODE_SESSION_ID",
|
package/src/infra/git.ts
CHANGED
|
@@ -95,3 +95,67 @@ export async function statusSnapshot(repo: string, timeoutMs?: number): Promise<
|
|
|
95
95
|
for (const { xy, path } of parsePorcelainZ(r.out)) snap[path] = fingerprint(repo, xy, path);
|
|
96
96
|
return snap;
|
|
97
97
|
}
|
|
98
|
+
|
|
99
|
+
/** What a git command printed and how it ended, for the callers that show a failure (a commit hook's output). */
|
|
100
|
+
export interface GitOutput {
|
|
101
|
+
code: number | null;
|
|
102
|
+
out: string;
|
|
103
|
+
err: string;
|
|
104
|
+
timedOut: boolean;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `git -C repo <args>` with both streams, for a command that may run hooks (a commit): a long timeout, and no
|
|
109
|
+
* optional lock avoidance, since this one writes the index.
|
|
110
|
+
*/
|
|
111
|
+
export async function gitRun(repo: string, args: string[], timeoutMs = 300_000): Promise<GitOutput> {
|
|
112
|
+
let p: Bun.Subprocess<"ignore", "pipe", "pipe">;
|
|
113
|
+
try {
|
|
114
|
+
p = Bun.spawn(["git", "-C", repo, ...args], {
|
|
115
|
+
stdin: "ignore",
|
|
116
|
+
stdout: "pipe",
|
|
117
|
+
stderr: "pipe",
|
|
118
|
+
env: { ...scrubSecrets(process.env), LC_ALL: "C" },
|
|
119
|
+
});
|
|
120
|
+
} catch (e) {
|
|
121
|
+
return { code: null, out: "", err: (e as Error).message, timedOut: false };
|
|
122
|
+
}
|
|
123
|
+
let timedOut = false;
|
|
124
|
+
const timer = setTimeout(() => {
|
|
125
|
+
timedOut = true;
|
|
126
|
+
p.kill("SIGKILL");
|
|
127
|
+
}, timeoutMs);
|
|
128
|
+
const out = new Response(p.stdout).text().catch(() => "");
|
|
129
|
+
const err = new Response(p.stderr).text().catch(() => "");
|
|
130
|
+
const code = await p.exited;
|
|
131
|
+
clearTimeout(timer);
|
|
132
|
+
return {
|
|
133
|
+
code: timedOut ? null : code,
|
|
134
|
+
out: timedOut ? "" : await out,
|
|
135
|
+
err: timedOut ? "" : await err,
|
|
136
|
+
timedOut,
|
|
137
|
+
};
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/**
|
|
141
|
+
* `git fetch -q origin <base>` with a cap: catherd's one network git call (spec 2.0 decision 21). True when it
|
|
142
|
+
* exits 0; a failure never throws, the caller works from the last fetch and says so.
|
|
143
|
+
*/
|
|
144
|
+
export async function fetchBase(repo: string, base: string, timeoutMs = 60_000): Promise<boolean> {
|
|
145
|
+
const r = await gitRun(repo, ["fetch", "-q", "origin", base], timeoutMs);
|
|
146
|
+
return r.code === 0;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Whether `commit` is an ancestor of `ref`: true on exit 0, false on 1, null when git cannot tell. */
|
|
150
|
+
export async function isAncestor(repo: string, commit: string, ref: string): Promise<boolean | null> {
|
|
151
|
+
const r = await git(repo, ["merge-base", "--is-ancestor", commit, ref]);
|
|
152
|
+
if (r.kind === "ok") return true;
|
|
153
|
+
return r.kind === "failed" && r.exit === 1 ? false : null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** The checked-out branch, or null when HEAD is detached (or git cannot say). */
|
|
157
|
+
export async function currentBranch(repo: string): Promise<string | null> {
|
|
158
|
+
const r = await git(repo, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
159
|
+
const name = r.kind === "ok" ? r.out.trim() : "";
|
|
160
|
+
return name && name !== "HEAD" ? name : null;
|
|
161
|
+
}
|
package/src/infra/heavy-lock.ts
CHANGED
|
@@ -1,12 +1,40 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
|
-
import { existsSync, readFileSync } from "node:fs";
|
|
3
|
-
import { availableParallelism } from "node:os";
|
|
2
|
+
import { existsSync, readFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { availableParallelism, loadavg } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
|
+
import { roleScopeOfScratch } from "../domain/role-scope.ts";
|
|
5
6
|
import { tryLock, withFileLock } from "./filelock.ts";
|
|
6
7
|
import { locksDir } from "./paths.ts";
|
|
7
8
|
import { isAlive, selfIdentity } from "./proc.ts";
|
|
8
9
|
import { ensurePrivateDir, writeJsonAtomic } from "./store.ts";
|
|
9
10
|
|
|
11
|
+
/** Set in a locked command's env: a `catherd lock` inside it runs at once, holding nothing more. */
|
|
12
|
+
export const LOCK_HELD_ENV = "CATHERD_LOCK_HELD";
|
|
13
|
+
|
|
14
|
+
const envLoadWaitMs = Number(process.env.CATHERD_HEAVY_LOAD_WAIT_MS ?? 900_000);
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Spec 2.0 decision 18: heavy slots wait while the load is above twice the cores, up to 15 minutes.
|
|
18
|
+
* CATHERD_HEAVY_LOAD_WAIT_MS overrides the wait (0 = none); the test preload sets 0, since a busy CI runner
|
|
19
|
+
* would otherwise stall every lock in the suite, spawned `catherd lock` processes included.
|
|
20
|
+
*/
|
|
21
|
+
export const heavyLimits = {
|
|
22
|
+
loadWaitMs: envLoadWaitMs >= 0 ? envLoadWaitMs : 900_000,
|
|
23
|
+
loadPollMs: 15_000,
|
|
24
|
+
load: (): number => loadavg()[0] ?? 0,
|
|
25
|
+
cores: (): number => availableParallelism(),
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The emptied TMPDIR and GOTMPDIR of one slot: under the role's scratch when the caller's TMPDIR is one (a role
|
|
30
|
+
* is told by its TMPDIR, so the slot dir must stay under it), else under catherd's locks dir.
|
|
31
|
+
*/
|
|
32
|
+
export function slotTmpDir(slot: number, inherited?: string): string {
|
|
33
|
+
return roleScopeOfScratch(inherited)
|
|
34
|
+
? join(inherited as string, `heavy-${slot}`)
|
|
35
|
+
: join(locksDir(), "tmp", `slot-${slot}`);
|
|
36
|
+
}
|
|
37
|
+
|
|
10
38
|
/** A profile's `lock.heavy` as a slot count: a number, or half the cores. */
|
|
11
39
|
export function heavySlots(setting: number | "cpus/2" | undefined): number {
|
|
12
40
|
if (typeof setting === "number" && Number.isFinite(setting)) return Math.max(1, Math.floor(setting));
|
|
@@ -19,8 +47,8 @@ export function heavySlots(setting: number | "cpus/2" | undefined): number {
|
|
|
19
47
|
*/
|
|
20
48
|
export async function withHeavySlot<T>(
|
|
21
49
|
slots: number,
|
|
22
|
-
fn: (slot: number) => T | Promise<T>,
|
|
23
|
-
o: { pollMs?: number; onWait?: () => void } = {},
|
|
50
|
+
fn: (slot: number, tmp: string) => T | Promise<T>,
|
|
51
|
+
o: { pollMs?: number; onWait?: () => void; tmpBase?: string } = {},
|
|
24
52
|
): Promise<T> {
|
|
25
53
|
const r = await withHeavySlotWithin(slots, Number.POSITIVE_INFINITY, fn, o);
|
|
26
54
|
if (r.busy) throw new Error("unreachable: an unbounded wait never gives up");
|
|
@@ -34,19 +62,29 @@ export async function withHeavySlot<T>(
|
|
|
34
62
|
export async function withHeavySlotWithin<T>(
|
|
35
63
|
slots: number,
|
|
36
64
|
waitMs: number,
|
|
37
|
-
fn: (slot: number) => T | Promise<T>,
|
|
38
|
-
o: { pollMs?: number; onWait?: () => void } = {},
|
|
65
|
+
fn: (slot: number, tmp: string) => T | Promise<T>,
|
|
66
|
+
o: { pollMs?: number; onWait?: () => void; tmpBase?: string } = {},
|
|
39
67
|
): Promise<{ busy: false; value: T } | { busy: true }> {
|
|
40
68
|
const dir = locksDir();
|
|
41
69
|
ensurePrivateDir(dir);
|
|
42
70
|
const deadline = Date.now() + waitMs;
|
|
71
|
+
// an overloaded host runs the command late, not never: the wait is bounded, and the slot loop follows it
|
|
72
|
+
const loadDeadline = Date.now() + Math.min(heavyLimits.loadWaitMs, waitMs);
|
|
73
|
+
while (heavyLimits.load() > 2 * heavyLimits.cores() && Date.now() < loadDeadline) {
|
|
74
|
+
o.onWait?.();
|
|
75
|
+
await Bun.sleep(Math.min(heavyLimits.loadPollMs, Math.max(1, loadDeadline - Date.now())));
|
|
76
|
+
}
|
|
43
77
|
for (;;) {
|
|
44
78
|
for (let i = 0; i < slots; i++) {
|
|
45
79
|
const release = tryLock(join(dir, `slot-${i}`));
|
|
46
80
|
if (!release) continue;
|
|
81
|
+
const tmp = slotTmpDir(i, o.tmpBase);
|
|
47
82
|
try {
|
|
48
|
-
|
|
83
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
84
|
+
ensurePrivateDir(tmp);
|
|
85
|
+
return { busy: false, value: await fn(i, tmp) };
|
|
49
86
|
} finally {
|
|
87
|
+
rmSync(tmp, { recursive: true, force: true });
|
|
50
88
|
release();
|
|
51
89
|
}
|
|
52
90
|
}
|
package/src/infra/launch.ts
CHANGED
|
@@ -1,25 +1,81 @@
|
|
|
1
1
|
import { closeSync, openSync } from "node:fs";
|
|
2
2
|
import { dirname, join } from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
+
import { ROLE_ENV } from "../domain/role-scope.ts";
|
|
4
5
|
import { scrubSecrets } from "./env.ts";
|
|
5
6
|
import { log as logRow } from "./log.ts";
|
|
6
7
|
import { PRIVATE_FILE } from "./store.ts";
|
|
7
8
|
|
|
8
|
-
/** A thin executable that supervises one spec;
|
|
9
|
+
/** A thin executable that supervises one spec; it never loads src/cli.ts. */
|
|
9
10
|
export const SUPERVISE_ENTRY = fileURLToPath(new URL("../entry/supervise-bin.ts", import.meta.url));
|
|
10
11
|
|
|
11
|
-
/**
|
|
12
|
-
export
|
|
13
|
-
|
|
14
|
-
|
|
12
|
+
/** The thin executable that drives one run; like the supervisor's, it never loads src/cli.ts. */
|
|
13
|
+
export const DAEMON_ENTRY = fileURLToPath(new URL("../entry/daemon-bin.ts", import.meta.url));
|
|
14
|
+
|
|
15
|
+
/** The thin executable that runs one milestone check (plan 30b); like the others it never loads src/cli.ts. */
|
|
16
|
+
export const CHECK_ENTRY = fileURLToPath(new URL("../entry/check-bin.ts", import.meta.url));
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Starts `entry args` in its own session, its output appended to `logFile`, and returns at once: the process
|
|
20
|
+
* outlives this one.
|
|
21
|
+
*/
|
|
22
|
+
export function launchDetached(
|
|
23
|
+
entry: string,
|
|
24
|
+
args: string[],
|
|
25
|
+
logFile: string,
|
|
26
|
+
env: Record<string, string>,
|
|
27
|
+
): number {
|
|
28
|
+
const log = openSync(logFile, "a", PRIVATE_FILE);
|
|
29
|
+
const p = Bun.spawn([process.execPath, entry, ...args], {
|
|
15
30
|
stdin: "ignore",
|
|
16
31
|
stdout: log,
|
|
17
32
|
stderr: log,
|
|
18
33
|
detached: true,
|
|
19
|
-
env
|
|
34
|
+
env,
|
|
20
35
|
});
|
|
21
36
|
closeSync(log);
|
|
22
37
|
p.unref();
|
|
23
|
-
logRow("info", "launch", { spec: specPath, pid: p.pid });
|
|
24
38
|
return p.pid;
|
|
25
39
|
}
|
|
40
|
+
|
|
41
|
+
/** Starts `supervise-bin.ts <spec>` in its own session and returns at once; it outlives this process. */
|
|
42
|
+
export function launchSupervisor(specPath: string): number {
|
|
43
|
+
const pid = launchDetached(
|
|
44
|
+
SUPERVISE_ENTRY,
|
|
45
|
+
[specPath],
|
|
46
|
+
join(dirname(specPath), "supervisor.log"),
|
|
47
|
+
scrubSecrets(process.env),
|
|
48
|
+
);
|
|
49
|
+
logRow("info", "launch", { spec: specPath, pid });
|
|
50
|
+
return pid;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The daemon's env (spec 2.0, plan 30 ruling 4): the launching process's own, so Jev's key reaches its routing,
|
|
55
|
+
* without the session identity of whoever launched it, and never a role's scope: it is nobody's role.
|
|
56
|
+
*/
|
|
57
|
+
export function daemonEnv(base: Record<string, string | undefined> = process.env): Record<string, string> {
|
|
58
|
+
const env = scrubSecrets(base);
|
|
59
|
+
if (base.TYPESAFE_API_KEY !== undefined) env.TYPESAFE_API_KEY = base.TYPESAFE_API_KEY;
|
|
60
|
+
delete env[ROLE_ENV];
|
|
61
|
+
return env;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Starts `daemon-bin.ts <run id>` for a run; its log is `<run dir>/daemon.log`. */
|
|
65
|
+
export function launchDaemon(runId: string, runDir: string): number {
|
|
66
|
+
const pid = launchDetached(DAEMON_ENTRY, [runId], join(runDir, "daemon.log"), daemonEnv());
|
|
67
|
+
logRow("info", "launch", { daemon: runId, pid });
|
|
68
|
+
return pid;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Starts `check-bin.ts <flight file>` detached, so a tool timeout that kills its caller does not lose the check. */
|
|
72
|
+
export function launchCheckWorker(flight: string): number {
|
|
73
|
+
const pid = launchDetached(
|
|
74
|
+
CHECK_ENTRY,
|
|
75
|
+
[flight],
|
|
76
|
+
join(dirname(flight), "worker.log"),
|
|
77
|
+
scrubSecrets(process.env),
|
|
78
|
+
);
|
|
79
|
+
logRow("info", "launch", { check: flight, pid });
|
|
80
|
+
return pid;
|
|
81
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { freemem, totalmem } from "node:os";
|
|
3
|
+
|
|
4
|
+
const MB = 2 ** 20;
|
|
5
|
+
|
|
6
|
+
const sysctlLevel = (): string | null => {
|
|
7
|
+
try {
|
|
8
|
+
const r = Bun.spawnSync(["sysctl", "-n", "kern.memorystatus_level"]);
|
|
9
|
+
return r.exitCode === 0 ? r.stdout.toString() : null;
|
|
10
|
+
} catch {
|
|
11
|
+
return null;
|
|
12
|
+
}
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
const procMeminfo = (): string | null => {
|
|
16
|
+
try {
|
|
17
|
+
return readFileSync("/proc/meminfo", "utf8");
|
|
18
|
+
} catch {
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* The memory the machine could give a new process, in MB. macOS reports almost nothing through `os.freemem()`
|
|
25
|
+
* (it counts cache as used), so it is the kernel's free percentage of the total; Linux has `MemAvailable`;
|
|
26
|
+
* anything unreadable falls back to `os.freemem()`. Never throws.
|
|
27
|
+
*/
|
|
28
|
+
export function availableMemoryMb(
|
|
29
|
+
o: {
|
|
30
|
+
platform?: NodeJS.Platform;
|
|
31
|
+
sysctl?: () => string | null;
|
|
32
|
+
meminfo?: () => string | null;
|
|
33
|
+
} = {},
|
|
34
|
+
): number {
|
|
35
|
+
const platform = o.platform ?? process.platform;
|
|
36
|
+
if (platform === "darwin") {
|
|
37
|
+
const level = Number.parseInt((o.sysctl ?? sysctlLevel)() ?? "", 10);
|
|
38
|
+
if (Number.isInteger(level) && level >= 0 && level <= 100)
|
|
39
|
+
return Math.floor(((level / 100) * totalmem()) / MB);
|
|
40
|
+
} else if (platform === "linux") {
|
|
41
|
+
const kb = /^MemAvailable:\s+(\d+) kB$/m.exec((o.meminfo ?? procMeminfo)() ?? "")?.[1];
|
|
42
|
+
if (kb !== undefined) return Math.floor(Number(kb) / 1024);
|
|
43
|
+
}
|
|
44
|
+
return Math.floor(freemem() / MB);
|
|
45
|
+
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import type { NotifyMessage } from "../domain/notify.ts";
|
|
2
|
+
import { log } from "./log.ts";
|
|
3
|
+
|
|
4
|
+
/** A header value as ntfy reads it: plain ASCII as it is, anything else as an RFC 2047 encoded word. */
|
|
5
|
+
const headerValue = (s: string): string =>
|
|
6
|
+
/^[\x20-\x7e]*$/.test(s) ? s : `=?UTF-8?B?${Buffer.from(s).toString("base64")}?=`;
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Posts one message to an ntfy topic. True once the server accepted it; any error, a non-2xx answer or a
|
|
10
|
+
* server slower than `timeoutMs` is false, never a throw, and the log never carries the topic (the url is a
|
|
11
|
+
* secret of the user's).
|
|
12
|
+
*/
|
|
13
|
+
export async function postNotify(
|
|
14
|
+
url: string,
|
|
15
|
+
m: NotifyMessage,
|
|
16
|
+
o: { fetch?: typeof fetch; timeoutMs?: number } = {},
|
|
17
|
+
): Promise<boolean> {
|
|
18
|
+
const timeoutMs = o.timeoutMs ?? 10_000;
|
|
19
|
+
let timer: ReturnType<typeof setTimeout> | undefined;
|
|
20
|
+
try {
|
|
21
|
+
const sent = (o.fetch ?? fetch)(url, {
|
|
22
|
+
method: "POST",
|
|
23
|
+
body: m.message,
|
|
24
|
+
headers: { Title: headerValue(m.title), Tags: m.tags.join(",") },
|
|
25
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
26
|
+
});
|
|
27
|
+
// a fetch that ignores its signal still gives up
|
|
28
|
+
const gaveUp = new Promise<never>((_, reject) => {
|
|
29
|
+
timer = setTimeout(() => reject(new Error(`no answer in ${timeoutMs} ms`)), timeoutMs);
|
|
30
|
+
});
|
|
31
|
+
const res = await Promise.race([sent, gaveUp]);
|
|
32
|
+
if (!res.ok) log("warn", "notify", { ntfy: res.status });
|
|
33
|
+
return res.ok;
|
|
34
|
+
} catch (e) {
|
|
35
|
+
log("warn", "notify", { ntfy: e instanceof Error ? e.name : "error" });
|
|
36
|
+
return false;
|
|
37
|
+
} finally {
|
|
38
|
+
clearTimeout(timer);
|
|
39
|
+
}
|
|
40
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { mkdtempSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { tmpdir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* A page that sends the browser on to `url`, so the opener's argv (readable by every local user, and kept in
|
|
7
|
+
* /proc/<pid>/cmdline) carries a path instead of the token. The dir is 0700 and the file 0600; `remove` deletes both.
|
|
8
|
+
*/
|
|
9
|
+
export function writeRedirectPage(url: string): {
|
|
10
|
+
path: string;
|
|
11
|
+
remove(): void;
|
|
12
|
+
} {
|
|
13
|
+
const dir = mkdtempSync(join(tmpdir(), "catherd-panel-"));
|
|
14
|
+
const path = join(dir, "sign-in.html");
|
|
15
|
+
writeFileSync(
|
|
16
|
+
path,
|
|
17
|
+
`<!doctype html><meta charset="utf-8"><title>catherd panel</title><script>location.replace(${JSON.stringify(url)})</script>\n`,
|
|
18
|
+
{ mode: 0o600 },
|
|
19
|
+
);
|
|
20
|
+
return { path, remove: () => rmSync(dir, { recursive: true, force: true }) };
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** The command that opens `url` in the user's browser on `platform`. */
|
|
24
|
+
export function openerFor(platform: NodeJS.Platform, url: string): string[] {
|
|
25
|
+
if (platform === "darwin") return ["open", url];
|
|
26
|
+
if (platform === "win32") return ["cmd", "/c", "start", "", url];
|
|
27
|
+
return ["xdg-open", url];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Opens `url` in the default browser, detached; a machine with no opener simply opens nothing. */
|
|
31
|
+
export function openUrl(url: string): void {
|
|
32
|
+
try {
|
|
33
|
+
Bun.spawn(openerFor(process.platform, url), {
|
|
34
|
+
stdin: "ignore",
|
|
35
|
+
stdout: "ignore",
|
|
36
|
+
stderr: "ignore",
|
|
37
|
+
detached: true,
|
|
38
|
+
}).unref();
|
|
39
|
+
} catch {
|
|
40
|
+
// no opener on PATH: the printed link still works
|
|
41
|
+
}
|
|
42
|
+
}
|
package/src/infra/paths.ts
CHANGED
|
@@ -73,5 +73,5 @@ export const runsDir = (toplevel: string): string => join(repoDir(toplevel), "ru
|
|
|
73
73
|
export const logsDir = (): string => join(dataDir(), "logs");
|
|
74
74
|
export const discoveryDir = (): string => join(dataDir(), "discovery");
|
|
75
75
|
export const locksDir = (): string => join(dataDir(), "locks");
|
|
76
|
-
/**
|
|
77
|
-
export const
|
|
76
|
+
/** The Slopalytics index: the last good answer of the proxy, one intelligence per model and effort. */
|
|
77
|
+
export const indexPath = (): string => join(dataDir(), "slopalytics.json");
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { parseProxy, type IndexFile, IndexFileSchema, type SlopRecord } from "../domain/slopalytics.ts";
|
|
3
|
+
import { readVersioned, writeJsonAtomic } from "./store.ts";
|
|
4
|
+
|
|
5
|
+
export const SLOPALYTICS_URL = "https://slopalyticsproxy.vercel.app";
|
|
6
|
+
|
|
7
|
+
/** The proxy's answer, parsed. Throws on a transport error, a non-2xx, or an answer with no indexed pair. */
|
|
8
|
+
export async function fetchProxy(
|
|
9
|
+
o: { fetch?: typeof fetch; timeoutMs?: number } = {},
|
|
10
|
+
): Promise<{ fetchedAt: string | null; models: SlopRecord[] }> {
|
|
11
|
+
const res = await (o.fetch ?? fetch)(SLOPALYTICS_URL, {
|
|
12
|
+
signal: AbortSignal.timeout(o.timeoutMs ?? 15_000),
|
|
13
|
+
});
|
|
14
|
+
if (!res.ok) throw new Error(`http ${res.status}`);
|
|
15
|
+
const parsed = parseProxy(await res.json());
|
|
16
|
+
if (parsed.models.length === 0) throw new Error("no indexed pairs");
|
|
17
|
+
return parsed;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/** The cached index, or null when there is none or it cannot be read (the next refresh rewrites it). */
|
|
21
|
+
export function readIndexFile(file: string): IndexFile | null {
|
|
22
|
+
if (!existsSync(file)) return null;
|
|
23
|
+
try {
|
|
24
|
+
return readVersioned(file, IndexFileSchema, 1);
|
|
25
|
+
} catch {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export const writeIndexFile = (file: string, f: IndexFile): void => writeJsonAtomic(file, f, { mode: 0o600 });
|
package/src/infra/store.ts
CHANGED
|
@@ -150,9 +150,14 @@ function endsMidLine(file: string): boolean {
|
|
|
150
150
|
* tail left by an earlier crash is ended first, in the same write, so the new row stays whole.
|
|
151
151
|
*/
|
|
152
152
|
export function appendJsonl(file: string, value: unknown): void {
|
|
153
|
+
appendJsonlBatch(file, [value]);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** Several rows in one write, so a crash leaves all of them or at worst a torn last one. */
|
|
157
|
+
export function appendJsonlBatch(file: string, values: unknown[]): void {
|
|
153
158
|
ensurePrivateDir(dirname(file));
|
|
154
159
|
const prefix = endsMidLine(file) ? "\n" : "";
|
|
155
|
-
appendPrivate(file, `${
|
|
160
|
+
appendPrivate(file, prefix + values.map((v) => `${JSON.stringify(v)}\n`).join(""));
|
|
156
161
|
}
|
|
157
162
|
|
|
158
163
|
const madePrivate = new Set<string>();
|