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,410 @@
|
|
|
1
|
+
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
|
|
2
|
+
import { existsSync, statSync } from "node:fs";
|
|
3
|
+
import { join, resolve, sep } from "node:path";
|
|
4
|
+
import type { ServerWebSocket } from "bun";
|
|
5
|
+
import { z } from "zod";
|
|
6
|
+
import { CatherdError, errorMessage, isCatherdError } from "../../domain/errors.ts";
|
|
7
|
+
import { describeEvent } from "../../domain/run-events.ts";
|
|
8
|
+
import { ROLES, type Role } from "../../domain/roles.ts";
|
|
9
|
+
import { openUrl, writeRedirectPage } from "../../infra/open-url.ts";
|
|
10
|
+
import { VERSION } from "../../infra/version.ts";
|
|
11
|
+
import { setRoleRungs, setupView } from "../../services/panel-setup.ts";
|
|
12
|
+
import type { Deps } from "../../services/ports.ts";
|
|
13
|
+
import { answerUnit, cancelRun } from "../../services/run-client.ts";
|
|
14
|
+
import { listRunViews, parkedQuestions, runTimeline, runView, tailEvents } from "../../services/run-read.ts";
|
|
15
|
+
import { readEvents } from "../../services/run-events-store.ts";
|
|
16
|
+
import { findRun } from "../../services/run-store.ts";
|
|
17
|
+
import {
|
|
18
|
+
AnswerBodySchema,
|
|
19
|
+
ErrorBodySchema,
|
|
20
|
+
ListRunsQuerySchema,
|
|
21
|
+
LoginBodySchema,
|
|
22
|
+
OPERATIONS,
|
|
23
|
+
type Operation,
|
|
24
|
+
type OperationId,
|
|
25
|
+
QuestionsQuerySchema,
|
|
26
|
+
RungsBodySchema,
|
|
27
|
+
} from "./openapi.ts";
|
|
28
|
+
|
|
29
|
+
export const PANEL_COOKIE = "catherd_panel";
|
|
30
|
+
|
|
31
|
+
export interface PanelOptions {
|
|
32
|
+
deps: Deps;
|
|
33
|
+
repo: string | null;
|
|
34
|
+
token: string;
|
|
35
|
+
port: number;
|
|
36
|
+
distDir: string;
|
|
37
|
+
open: boolean;
|
|
38
|
+
/** What opens the sign-in page: a test replaces it. */
|
|
39
|
+
opener?: (target: string) => void;
|
|
40
|
+
pollMs?: number;
|
|
41
|
+
}
|
|
42
|
+
export interface Panel {
|
|
43
|
+
url: string;
|
|
44
|
+
port: number;
|
|
45
|
+
stop(): Promise<void>;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** 32 random bytes, base64url: the token a `catherd panel` run accepts on its login page. */
|
|
49
|
+
export const newToken = (): string => randomBytes(32).toString("base64url");
|
|
50
|
+
|
|
51
|
+
const SECURITY_HEADERS = {
|
|
52
|
+
"Content-Security-Policy":
|
|
53
|
+
"default-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none'",
|
|
54
|
+
"X-Frame-Options": "DENY",
|
|
55
|
+
"Referrer-Policy": "no-referrer",
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
const STATUS: Record<string, number> = {
|
|
59
|
+
E_INPUT_INVALID: 400,
|
|
60
|
+
unauthorized: 401,
|
|
61
|
+
forbidden: 403,
|
|
62
|
+
E_RUN_NOT_FOUND: 404,
|
|
63
|
+
not_found: 404,
|
|
64
|
+
E_RUN_NOT_LIVE: 409,
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
const errorResponse = (code: string, message: string, fix?: string): Response =>
|
|
68
|
+
Response.json(
|
|
69
|
+
ErrorBodySchema.parse({
|
|
70
|
+
error: {
|
|
71
|
+
message,
|
|
72
|
+
type: code,
|
|
73
|
+
code,
|
|
74
|
+
...(fix === undefined ? {} : { fix }),
|
|
75
|
+
},
|
|
76
|
+
}),
|
|
77
|
+
{ status: STATUS[code] ?? 500 },
|
|
78
|
+
);
|
|
79
|
+
|
|
80
|
+
const digest = (s: string): Buffer => createHash("sha256").update(s).digest();
|
|
81
|
+
const sameToken = (a: string, b: string): boolean => timingSafeEqual(digest(a), digest(b));
|
|
82
|
+
|
|
83
|
+
const cookieOf = (req: Request): string | undefined =>
|
|
84
|
+
req.headers
|
|
85
|
+
.get("cookie")
|
|
86
|
+
?.split(";")
|
|
87
|
+
.map((c) => c.trim().split("="))
|
|
88
|
+
.find(([k]) => k === PANEL_COOKIE)?.[1];
|
|
89
|
+
|
|
90
|
+
/** The first problem of a request, as the 400's message: the field's path, then what is wrong with it. */
|
|
91
|
+
const issueText = (e: z.ZodError): string => {
|
|
92
|
+
const i = e.issues[0];
|
|
93
|
+
return i ? `${i.path.length ? `${i.path.join(".")}: ` : ""}${i.message}` : "invalid request";
|
|
94
|
+
};
|
|
95
|
+
|
|
96
|
+
interface Call {
|
|
97
|
+
deps: Deps;
|
|
98
|
+
repo: string | null;
|
|
99
|
+
params: Record<string, string>;
|
|
100
|
+
query: unknown;
|
|
101
|
+
body: unknown;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const page = <T>(all: T[], q: { limit: number; offset: number }) => ({
|
|
105
|
+
items: all.slice(q.offset, q.offset + q.limit),
|
|
106
|
+
total: all.length,
|
|
107
|
+
});
|
|
108
|
+
const has = (hay: string[], needle: string | undefined): boolean =>
|
|
109
|
+
needle === undefined || hay.some((h) => h.toLowerCase().includes(needle.toLowerCase()));
|
|
110
|
+
|
|
111
|
+
const HANDLERS: Record<OperationId, (c: Call) => unknown> = {
|
|
112
|
+
login: () => null,
|
|
113
|
+
logout: () => null,
|
|
114
|
+
getSession: () => ({ authenticated: true }),
|
|
115
|
+
getVersion: () => ({ catherd: VERSION }),
|
|
116
|
+
getSetup: (c) => setupView(c.deps, c.repo),
|
|
117
|
+
setRoleRungs: (c) => {
|
|
118
|
+
const role = c.params.role as Role;
|
|
119
|
+
if (!ROLES.includes(role))
|
|
120
|
+
throw new CatherdError("E_INPUT_INVALID", `no role named "${role}"`, {
|
|
121
|
+
fix: `one of ${ROLES.join(", ")}`,
|
|
122
|
+
});
|
|
123
|
+
return setRoleRungs(c.deps, c.repo, role, (c.body as z.infer<typeof RungsBodySchema>).rungs);
|
|
124
|
+
},
|
|
125
|
+
// shortcut: reads every run's view per list call, upgrade when a home holds thousands of runs
|
|
126
|
+
listRuns: (c) => {
|
|
127
|
+
const q = c.query as z.infer<typeof ListRunsQuerySchema>;
|
|
128
|
+
const all = listRunViews(c.deps, { limit: 100_000 }).filter(
|
|
129
|
+
(r) => (q.phase === undefined || r.phase === q.phase) && has([r.title, r.id, r.repo], q.q),
|
|
130
|
+
);
|
|
131
|
+
return page(all, q);
|
|
132
|
+
},
|
|
133
|
+
getRun: (c) => runView(c.deps, c.params.id as string),
|
|
134
|
+
// shortcut: reads every event per page, upgrade past ~50k events
|
|
135
|
+
getRunTimeline: (c) => {
|
|
136
|
+
const id = c.params.id as string;
|
|
137
|
+
if (runView(c.deps, id).engine === "1.5") return { items: [], total: 0 };
|
|
138
|
+
return page(
|
|
139
|
+
runTimeline(id, 1, Number.MAX_SAFE_INTEGER).toReversed(),
|
|
140
|
+
c.query as { limit: number; offset: number },
|
|
141
|
+
);
|
|
142
|
+
},
|
|
143
|
+
cancelRun: (c) => cancelRun(c.deps, { run: c.params.id as string }),
|
|
144
|
+
answerUnit: (c) =>
|
|
145
|
+
answerUnit(c.deps, {
|
|
146
|
+
run: c.params.id as string,
|
|
147
|
+
...(c.body as z.infer<typeof AnswerBodySchema>),
|
|
148
|
+
}),
|
|
149
|
+
listQuestions: (c) => {
|
|
150
|
+
const q = c.query as z.infer<typeof QuestionsQuerySchema>;
|
|
151
|
+
return page(
|
|
152
|
+
parkedQuestions({ run: q.run }).filter((p) => has([p.question, p.unit, p.runTitle], q.q)),
|
|
153
|
+
q,
|
|
154
|
+
);
|
|
155
|
+
},
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const compile = (op: Operation): RegExp => new RegExp(`^${op.path.replace(/\{(\w+)\}/g, "(?<$1>[^/]+)")}$`);
|
|
159
|
+
const ROUTES = OPERATIONS.map((op) => ({ op, re: compile(op) }));
|
|
160
|
+
const LIVE = /^\/api\/runs\/([^/]+)\/live$/;
|
|
161
|
+
|
|
162
|
+
/** The Host this panel answers to, or null. A browser omits the default port, so port 80 also takes the bare name. */
|
|
163
|
+
export const panelHost = (header: string | null, port: number): string | null => {
|
|
164
|
+
const names = ["127.0.0.1", "localhost"];
|
|
165
|
+
const ok = names.flatMap((n) => (port === 80 ? [n, `${n}:80`] : [`${n}:${port}`]));
|
|
166
|
+
return header !== null && ok.includes(header) ? header : null;
|
|
167
|
+
};
|
|
168
|
+
/** A request without an Origin passes; one with it must come from the page this Host serves. */
|
|
169
|
+
export const panelOriginOk = (origin: string | null, host: string): boolean =>
|
|
170
|
+
origin === null || origin === `http://${host}`;
|
|
171
|
+
|
|
172
|
+
interface SocketData {
|
|
173
|
+
run: string;
|
|
174
|
+
from: number;
|
|
175
|
+
session: string;
|
|
176
|
+
stop?: () => void;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** One Bun process on 127.0.0.1: the built files, the JSON API and a socket per run tailing its events. */
|
|
180
|
+
export function servePanel(o: PanelOptions): Panel {
|
|
181
|
+
const sessions = new Set<string>();
|
|
182
|
+
const sockets = new Set<ServerWebSocket<SocketData>>();
|
|
183
|
+
/** Ends a session and the run sockets it opened: a socket outlives its cookie otherwise. */
|
|
184
|
+
const revoke = (session: string): void => {
|
|
185
|
+
sessions.delete(session);
|
|
186
|
+
for (const ws of sockets) if (ws.data.session === session) ws.close(4401, "signed out");
|
|
187
|
+
};
|
|
188
|
+
const dist = resolve(o.distDir);
|
|
189
|
+
let origin = "";
|
|
190
|
+
let signInPage: ReturnType<typeof writeRedirectPage> | undefined;
|
|
191
|
+
const dropSignInPage = (): void => {
|
|
192
|
+
signInPage?.remove();
|
|
193
|
+
signInPage = undefined;
|
|
194
|
+
};
|
|
195
|
+
|
|
196
|
+
const hostOk = (req: Request): string | null => panelHost(req.headers.get("host"), server.port as number);
|
|
197
|
+
const originOk = (req: Request, host: string): boolean => panelOriginOk(req.headers.get("origin"), host);
|
|
198
|
+
const signedIn = (req: Request): boolean => {
|
|
199
|
+
const c = cookieOf(req);
|
|
200
|
+
return c !== undefined && sessions.has(c);
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
const staticFile = (req: Request, rawPath: string): Response => {
|
|
204
|
+
let path: string;
|
|
205
|
+
try {
|
|
206
|
+
path = decodeURIComponent(rawPath);
|
|
207
|
+
} catch {
|
|
208
|
+
return errorResponse("not_found", "not found");
|
|
209
|
+
}
|
|
210
|
+
const file = resolve(dist, `.${path}`);
|
|
211
|
+
if (file !== dist && !file.startsWith(dist + sep)) return errorResponse("not_found", "not found");
|
|
212
|
+
const served = existsSync(file) && statSync(file).isFile() ? file : join(dist, "index.html");
|
|
213
|
+
const accepts = req.headers.get("accept-encoding") ?? "";
|
|
214
|
+
for (const [enc, ext] of [
|
|
215
|
+
["br", ".br"],
|
|
216
|
+
["gzip", ".gz"],
|
|
217
|
+
] as const)
|
|
218
|
+
if (new RegExp(`\\b${enc}\\b`).test(accepts) && existsSync(served + ext))
|
|
219
|
+
return new Response(Bun.file(served + ext), {
|
|
220
|
+
headers: {
|
|
221
|
+
...cache(served),
|
|
222
|
+
"Content-Type": Bun.file(served).type,
|
|
223
|
+
"Content-Encoding": enc,
|
|
224
|
+
Vary: "Accept-Encoding",
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
return new Response(Bun.file(served), {
|
|
228
|
+
headers: { ...cache(served), Vary: "Accept-Encoding" },
|
|
229
|
+
});
|
|
230
|
+
};
|
|
231
|
+
const cache = (file: string): Record<string, string> => ({
|
|
232
|
+
"Cache-Control": file.startsWith(join(dist, "assets") + sep)
|
|
233
|
+
? "public, max-age=31536000, immutable"
|
|
234
|
+
: "no-cache",
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
const api = async (req: Request, url: URL, host: string): Promise<Response> => {
|
|
238
|
+
const hit = ROUTES.map((r) => ({ ...r, m: r.re.exec(url.pathname) })).find(
|
|
239
|
+
(r) => r.m && r.op.method === req.method,
|
|
240
|
+
);
|
|
241
|
+
if (!hit?.m) return errorResponse("not_found", "not found");
|
|
242
|
+
const { op } = hit;
|
|
243
|
+
if (!op.public && !signedIn(req)) return errorResponse("unauthorized", "sign in first");
|
|
244
|
+
if (op.method !== "GET" && !originOk(req, host))
|
|
245
|
+
return errorResponse("forbidden", "cross-origin request refused");
|
|
246
|
+
let params: Record<string, string>;
|
|
247
|
+
try {
|
|
248
|
+
params = Object.fromEntries(
|
|
249
|
+
Object.entries(hit.m.groups ?? {}).map(([k, v]) => [k, decodeURIComponent(v)]),
|
|
250
|
+
);
|
|
251
|
+
} catch {
|
|
252
|
+
return errorResponse("E_INPUT_INVALID", "the request path has a malformed escape");
|
|
253
|
+
}
|
|
254
|
+
let query: unknown;
|
|
255
|
+
let body: unknown;
|
|
256
|
+
if (op.query) {
|
|
257
|
+
const r = op.query.safeParse(Object.fromEntries(url.searchParams));
|
|
258
|
+
if (!r.success) return errorResponse("E_INPUT_INVALID", issueText(r.error));
|
|
259
|
+
query = r.data;
|
|
260
|
+
}
|
|
261
|
+
if (op.body) {
|
|
262
|
+
let raw: unknown;
|
|
263
|
+
try {
|
|
264
|
+
raw = await req.json();
|
|
265
|
+
} catch {
|
|
266
|
+
return errorResponse("E_INPUT_INVALID", "the request body is not valid JSON");
|
|
267
|
+
}
|
|
268
|
+
const r = op.body.safeParse(raw);
|
|
269
|
+
if (!r.success) return errorResponse("E_INPUT_INVALID", issueText(r.error));
|
|
270
|
+
body = r.data;
|
|
271
|
+
}
|
|
272
|
+
try {
|
|
273
|
+
if (op.id === "login") {
|
|
274
|
+
if (!sameToken((body as z.infer<typeof LoginBodySchema>).token, o.token))
|
|
275
|
+
return errorResponse("unauthorized", "wrong token");
|
|
276
|
+
const session = randomBytes(32).toString("base64url");
|
|
277
|
+
sessions.add(session);
|
|
278
|
+
dropSignInPage();
|
|
279
|
+
return new Response(null, {
|
|
280
|
+
status: 204,
|
|
281
|
+
headers: {
|
|
282
|
+
"Set-Cookie": `${PANEL_COOKIE}=${session}; HttpOnly; SameSite=Strict; Path=/`,
|
|
283
|
+
},
|
|
284
|
+
});
|
|
285
|
+
}
|
|
286
|
+
if (op.id === "logout") {
|
|
287
|
+
const c = cookieOf(req);
|
|
288
|
+
if (c) revoke(c);
|
|
289
|
+
return new Response(null, {
|
|
290
|
+
status: 204,
|
|
291
|
+
headers: {
|
|
292
|
+
"Set-Cookie": `${PANEL_COOKIE}=; Max-Age=0; HttpOnly; SameSite=Strict; Path=/`,
|
|
293
|
+
},
|
|
294
|
+
});
|
|
295
|
+
}
|
|
296
|
+
const out = await HANDLERS[op.id]({
|
|
297
|
+
deps: o.deps,
|
|
298
|
+
repo: o.repo,
|
|
299
|
+
params,
|
|
300
|
+
query,
|
|
301
|
+
body,
|
|
302
|
+
});
|
|
303
|
+
return op.response === null
|
|
304
|
+
? new Response(null, { status: 204 })
|
|
305
|
+
: Response.json(op.response.parse(out));
|
|
306
|
+
} catch (e) {
|
|
307
|
+
if (isCatherdError(e)) return errorResponse(e.code, e.message, e.fix);
|
|
308
|
+
return errorResponse("internal", errorMessage(e));
|
|
309
|
+
}
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
const fetch = async (req: Request, srv: Bun.Server<SocketData>): Promise<Response | undefined> => {
|
|
313
|
+
const host = hostOk(req);
|
|
314
|
+
if (host === null) return withHeaders(errorResponse("forbidden", "this panel answers on 127.0.0.1 only"));
|
|
315
|
+
// the raw target, not URL's normalised one: a `%2e%2e` segment must reach the path check
|
|
316
|
+
const target = req.url.replace(/^[a-z]+:\/\/[^/]+/i, "");
|
|
317
|
+
const [rawPath = "/"] = target.split("?");
|
|
318
|
+
const url = new URL(req.url);
|
|
319
|
+
const live = LIVE.exec(url.pathname);
|
|
320
|
+
if (live && req.headers.get("upgrade")?.toLowerCase() === "websocket") {
|
|
321
|
+
const session = cookieOf(req);
|
|
322
|
+
if (session === undefined || !sessions.has(session))
|
|
323
|
+
return withHeaders(errorResponse("unauthorized", "sign in first"));
|
|
324
|
+
if (!originOk(req, host)) return withHeaders(errorResponse("forbidden", "cross-origin socket refused"));
|
|
325
|
+
let id: string;
|
|
326
|
+
try {
|
|
327
|
+
id = decodeURIComponent(live[1] as string);
|
|
328
|
+
} catch {
|
|
329
|
+
return withHeaders(errorResponse("E_INPUT_INVALID", "the request path has a malformed escape"));
|
|
330
|
+
}
|
|
331
|
+
try {
|
|
332
|
+
findRun(id);
|
|
333
|
+
} catch (e) {
|
|
334
|
+
return withHeaders(
|
|
335
|
+
isCatherdError(e)
|
|
336
|
+
? errorResponse(e.code, e.message, e.fix)
|
|
337
|
+
: errorResponse("internal", errorMessage(e)),
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
// no `from`: only what is appended from now on (the page loads the history over HTTP)
|
|
341
|
+
const asked = url.searchParams.get("from");
|
|
342
|
+
const from =
|
|
343
|
+
asked === null
|
|
344
|
+
? (readEvents(findRun(id)).at(-1)?.seq ?? 0) + 1
|
|
345
|
+
: Math.max(1, Number.parseInt(asked, 10) || 1);
|
|
346
|
+
if (srv.upgrade(req, { data: { run: id, from, session } })) return undefined;
|
|
347
|
+
return withHeaders(errorResponse("E_INPUT_INVALID", "not a websocket upgrade"));
|
|
348
|
+
}
|
|
349
|
+
if (url.pathname.startsWith("/api/")) return withHeaders(await api(req, url, host));
|
|
350
|
+
return withHeaders(staticFile(req, rawPath));
|
|
351
|
+
};
|
|
352
|
+
|
|
353
|
+
const withHeaders = (r: Response): Response => {
|
|
354
|
+
for (const [k, v] of Object.entries(SECURITY_HEADERS)) r.headers.set(k, v);
|
|
355
|
+
return r;
|
|
356
|
+
};
|
|
357
|
+
|
|
358
|
+
let server: Bun.Server<SocketData>;
|
|
359
|
+
try {
|
|
360
|
+
server = Bun.serve<SocketData>({
|
|
361
|
+
hostname: "127.0.0.1",
|
|
362
|
+
port: o.port,
|
|
363
|
+
fetch,
|
|
364
|
+
websocket: {
|
|
365
|
+
open(ws: ServerWebSocket<SocketData>) {
|
|
366
|
+
sockets.add(ws);
|
|
367
|
+
ws.data.stop = tailEvents(
|
|
368
|
+
findRun(ws.data.run),
|
|
369
|
+
ws.data.from,
|
|
370
|
+
(e) =>
|
|
371
|
+
ws.send(
|
|
372
|
+
JSON.stringify({
|
|
373
|
+
seq: e.seq,
|
|
374
|
+
at: e.at,
|
|
375
|
+
type: e.type,
|
|
376
|
+
text: describeEvent(e),
|
|
377
|
+
}),
|
|
378
|
+
),
|
|
379
|
+
{ pollMs: o.pollMs },
|
|
380
|
+
);
|
|
381
|
+
},
|
|
382
|
+
close(ws: ServerWebSocket<SocketData>) {
|
|
383
|
+
sockets.delete(ws);
|
|
384
|
+
ws.data.stop?.();
|
|
385
|
+
},
|
|
386
|
+
// a `ping` frame keeps the tab's socket warm and means nothing
|
|
387
|
+
message() {},
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
} catch (e) {
|
|
391
|
+
if (/in use|EADDRINUSE/i.test(errorMessage(e)))
|
|
392
|
+
throw new CatherdError("E_INPUT_INVALID", `port ${o.port} is in use`, {
|
|
393
|
+
fix: "catherd panel --port 0 (any free port)",
|
|
394
|
+
});
|
|
395
|
+
throw e;
|
|
396
|
+
}
|
|
397
|
+
origin = `http://127.0.0.1:${server.port}`;
|
|
398
|
+
if (o.open) {
|
|
399
|
+
signInPage = writeRedirectPage(`${origin}/login#token=${o.token}`);
|
|
400
|
+
(o.opener ?? openUrl)(signInPage.path);
|
|
401
|
+
}
|
|
402
|
+
return {
|
|
403
|
+
url: origin,
|
|
404
|
+
port: server.port as number,
|
|
405
|
+
stop: async () => {
|
|
406
|
+
dropSignInPage();
|
|
407
|
+
await server.stop(true);
|
|
408
|
+
},
|
|
409
|
+
};
|
|
410
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { defineCommand } from "citty";
|
|
4
|
+
import { CatherdError } from "../domain/errors.ts";
|
|
5
|
+
import { gitToplevel } from "../infra/git.ts";
|
|
6
|
+
import { defaultDeps } from "./deps.ts";
|
|
7
|
+
import { refuseInRole } from "./cli-kit.ts";
|
|
8
|
+
import { HOST_ARG, terminalHost } from "./host-arg.ts";
|
|
9
|
+
import { type Panel, newToken, servePanel } from "./panel/server.ts";
|
|
10
|
+
|
|
11
|
+
/** The seams a test replaces: the server, and what keeps `catherd panel` alive until Ctrl-C. */
|
|
12
|
+
export const panelServer = {
|
|
13
|
+
serve: servePanel as (o: Parameters<typeof servePanel>[0]) => Panel,
|
|
14
|
+
untilStopped: (): Promise<void> =>
|
|
15
|
+
new Promise((done) => {
|
|
16
|
+
for (const sig of ["SIGINT", "SIGTERM"] as const) process.once(sig, () => done());
|
|
17
|
+
}),
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** The built panel: `CATHERD_PANEL_DIST`, else the `web/dist` the package ships. */
|
|
21
|
+
export const panelDist = (): string =>
|
|
22
|
+
process.env.CATHERD_PANEL_DIST ?? join(import.meta.dir, "../../web/dist");
|
|
23
|
+
|
|
24
|
+
/** `catherd panel`'s run, and a bare `catherd`'s: serves the panel until Ctrl-C. */
|
|
25
|
+
export async function runPanel(o: { port: number; open: boolean; host?: string }): Promise<void> {
|
|
26
|
+
refuseInRole(process.env, "catherd panel");
|
|
27
|
+
const distDir = panelDist();
|
|
28
|
+
if (!existsSync(join(distDir, "index.html")))
|
|
29
|
+
throw new CatherdError("E_CONFIG_INVALID", "the panel is not built", { fix: "bun run panel:build" });
|
|
30
|
+
const token = newToken();
|
|
31
|
+
const panel = panelServer.serve({
|
|
32
|
+
deps: defaultDeps(terminalHost(o.host)),
|
|
33
|
+
repo: await gitToplevel(process.cwd()),
|
|
34
|
+
token,
|
|
35
|
+
port: o.port,
|
|
36
|
+
distDir,
|
|
37
|
+
open: o.open,
|
|
38
|
+
});
|
|
39
|
+
console.log(`catherd panel: ${panel.url}/login#token=${token}`);
|
|
40
|
+
console.log("Ctrl-C stops it.");
|
|
41
|
+
await panelServer.untilStopped();
|
|
42
|
+
await panel.stop();
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** `catherd panel [--port <n>] [--no-open]`: Setup, Runs and Questions in your browser, on 127.0.0.1 only. */
|
|
46
|
+
export const panelCommand = defineCommand({
|
|
47
|
+
meta: {
|
|
48
|
+
name: "panel",
|
|
49
|
+
description: "Serve the panel on 127.0.0.1 and open it in your browser (a bare catherd does the same)",
|
|
50
|
+
},
|
|
51
|
+
args: {
|
|
52
|
+
port: { type: "string", default: "4317", description: "the port (0 picks a free one)" },
|
|
53
|
+
open: {
|
|
54
|
+
type: "boolean",
|
|
55
|
+
default: true,
|
|
56
|
+
description: "open the browser (--no-open prints the link only)",
|
|
57
|
+
},
|
|
58
|
+
...HOST_ARG,
|
|
59
|
+
},
|
|
60
|
+
async run({ args }) {
|
|
61
|
+
const port = Number(args.port);
|
|
62
|
+
if (!/^\d+$/.test(args.port) || port > 65535)
|
|
63
|
+
throw new CatherdError("E_INPUT_INVALID", `--port takes a number from 0 to 65535, not "${args.port}"`, {
|
|
64
|
+
fix: "catherd panel --port 4317",
|
|
65
|
+
});
|
|
66
|
+
await runPanel({ port, open: args.open, host: args.host });
|
|
67
|
+
},
|
|
68
|
+
});
|
|
@@ -3,11 +3,11 @@ import { HOST_ARG, terminalHost } from "./host-arg.ts";
|
|
|
3
3
|
import { assertProfileName } from "../domain/profile.ts";
|
|
4
4
|
import { defineCommand } from "citty";
|
|
5
5
|
import type { Catalog } from "../domain/catalog.ts";
|
|
6
|
-
import {
|
|
6
|
+
import { pairOf } from "../domain/catalog.ts";
|
|
7
7
|
import { CatherdError } from "../domain/errors.ts";
|
|
8
8
|
import type { OrchestrationHost } from "../domain/host.ts";
|
|
9
9
|
import { ProfileDocSchema, type Change, patchAt, type Profile } from "../domain/profile.ts";
|
|
10
|
-
import {
|
|
10
|
+
import type { Issue } from "../domain/profile-rules.ts";
|
|
11
11
|
import { ROLES, type Role } from "../domain/roles.ts";
|
|
12
12
|
import { gitToplevel } from "../infra/git.ts";
|
|
13
13
|
import { loadCatalog } from "../services/catalog-service.ts";
|
|
@@ -41,21 +41,17 @@ const json = JSON_ARG;
|
|
|
41
41
|
export interface StandIn {
|
|
42
42
|
from: string;
|
|
43
43
|
to: string;
|
|
44
|
-
|
|
45
|
-
via: string | null;
|
|
46
|
-
/** what the treat-like lends, as `profile show` says it */
|
|
44
|
+
/** `no Slopalytics index` when the stand-in is not indexed, so routing skips it */
|
|
47
45
|
note: string | null;
|
|
48
46
|
}
|
|
49
47
|
|
|
50
|
-
/** Spec §7.2: each stand-in
|
|
48
|
+
/** Spec §7.2: each stand-in, noting one that has no Slopalytics index. */
|
|
51
49
|
export function standIns(p: Profile, c: Catalog): StandIn[] {
|
|
52
|
-
return Object.entries(p.failover).map(([from, to]) => {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
}
|
|
58
|
-
});
|
|
50
|
+
return Object.entries(p.failover).map(([from, to]) => ({
|
|
51
|
+
from,
|
|
52
|
+
to,
|
|
53
|
+
note: pairOf(c, to) === null ? "no Slopalytics index" : null,
|
|
54
|
+
}));
|
|
59
55
|
}
|
|
60
56
|
|
|
61
57
|
const value = (v: unknown) => (v === null ? "none" : typeof v === "string" ? v : JSON.stringify(v));
|
|
@@ -198,7 +194,7 @@ const show = defineCommand({
|
|
|
198
194
|
const o = {
|
|
199
195
|
active: name === here,
|
|
200
196
|
enforcement: roleEnforcement(p),
|
|
201
|
-
standIns: standIns(p, loadCatalog(
|
|
197
|
+
standIns: standIns(p, loadCatalog()),
|
|
202
198
|
};
|
|
203
199
|
if (args.json) return printJson({ profile: p, ...o });
|
|
204
200
|
for (const l of formatProfile(p, { ...o, backends: runnableBackends() })) console.log(l);
|
|
@@ -3,6 +3,8 @@ import { CatherdError } from "../domain/errors.ts";
|
|
|
3
3
|
import { ID_PATTERN } from "../domain/ids.ts";
|
|
4
4
|
import { roleScopeFromEnv } from "../domain/role-scope.ts";
|
|
5
5
|
import { gateCheckOrList, gatePass } from "../services/gate-service.ts";
|
|
6
|
+
import { DISPATCH_ID_ENV, activityReporter } from "../infra/lock-activity.ts";
|
|
7
|
+
import { runMilestoneCheck } from "../services/milestone-check.ts";
|
|
6
8
|
import { assertRoleMay } from "../services/role-access.ts";
|
|
7
9
|
import { readRunFile, writeRunFile } from "../services/run-service.ts";
|
|
8
10
|
import { printJson } from "./cli-kit.ts";
|
|
@@ -10,7 +12,7 @@ import { defaultDeps } from "./deps.ts";
|
|
|
10
12
|
|
|
11
13
|
/**
|
|
12
14
|
* Spec 1.5 plan 21: the role server's operations as commands any role can run from its shell, on a backend
|
|
13
|
-
* whose harness has no role server (opencode, Cursor
|
|
15
|
+
* whose harness has no role server (opencode, Cursor) or when no MCP tool is listed. In a role
|
|
14
16
|
* (CATHERD_ROLE, or its scratch TMPDIR) they are bound to its own run and its role's operations.
|
|
15
17
|
*/
|
|
16
18
|
const scope = () => roleScopeFromEnv(process.env);
|
|
@@ -109,7 +111,49 @@ const pass = defineCommand({
|
|
|
109
111
|
},
|
|
110
112
|
});
|
|
111
113
|
|
|
114
|
+
const runCheck = defineCommand({
|
|
115
|
+
meta: {
|
|
116
|
+
name: "run",
|
|
117
|
+
description: "Run the milestone's full check on the committed HEAD, detached (gate_run)",
|
|
118
|
+
},
|
|
119
|
+
args: {
|
|
120
|
+
run: GATE.run,
|
|
121
|
+
milestone: { type: "string", required: true, description: "the milestone you verify, like M1" },
|
|
122
|
+
command: GATE.command,
|
|
123
|
+
at: { type: "string", description: "a checkout of the run's base, to record the base's own failures" },
|
|
124
|
+
force: { type: "boolean", description: "run it again although a record of this HEAD exists" },
|
|
125
|
+
},
|
|
126
|
+
async run({ args }) {
|
|
127
|
+
assertRoleMay(scope(), args.run, "gate_run");
|
|
128
|
+
if (!ID_PATTERN.test(args.milestone))
|
|
129
|
+
throw new CatherdError("E_INPUT_INVALID", `bad milestone "${args.milestone}"`, {
|
|
130
|
+
fix: "pass --milestone M1",
|
|
131
|
+
});
|
|
132
|
+
// a shell tool call may be cut off; the check goes on, and this ticks the dispatch's activity while it waits
|
|
133
|
+
const reporter = activityReporter(process.env[DISPATCH_ID_ENV]);
|
|
134
|
+
try {
|
|
135
|
+
const r = await runMilestoneCheck(
|
|
136
|
+
{
|
|
137
|
+
run: args.run,
|
|
138
|
+
milestone: args.milestone,
|
|
139
|
+
command: args.command,
|
|
140
|
+
...(args.at ? { at: args.at } : {}),
|
|
141
|
+
...(args.force ? { force: true } : {}),
|
|
142
|
+
},
|
|
143
|
+
reporter ? { onWait: reporter.tick } : {},
|
|
144
|
+
);
|
|
145
|
+
printJson(r);
|
|
146
|
+
if (!r.running && r.missing.length > 0) process.exitCode = 1;
|
|
147
|
+
} finally {
|
|
148
|
+
reporter?.done();
|
|
149
|
+
}
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
112
153
|
export const gateCommand = defineCommand({
|
|
113
|
-
meta: {
|
|
114
|
-
|
|
154
|
+
meta: {
|
|
155
|
+
name: "gate",
|
|
156
|
+
description: "A verifier's gate evidence: check an item, record a pass, run the check",
|
|
157
|
+
},
|
|
158
|
+
subCommands: { check, pass, run: runCheck },
|
|
115
159
|
});
|
|
@@ -149,36 +149,20 @@ export function redrawMs(interval: string | undefined): number {
|
|
|
149
149
|
return Math.max(1, secs) * 1000;
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
-
/** Spec §8 `catherd watch [--once]`: the
|
|
152
|
+
/** Spec §8 `catherd watch [--once]`: the live runs redrawn as text. */
|
|
153
153
|
export const watchCommand = defineCommand({
|
|
154
154
|
meta: {
|
|
155
155
|
name: "watch",
|
|
156
|
-
description:
|
|
157
|
-
"The live runs: the dashboard's Runs tab on a terminal, else redrawn as text (--once: print once)",
|
|
156
|
+
description: "The live runs, redrawn as text (--once: print once); catherd panel shows them in a browser",
|
|
158
157
|
},
|
|
159
158
|
args: {
|
|
160
159
|
once: { type: "boolean", description: "print one snapshot and exit" },
|
|
161
|
-
interval: { type: "string", description: "seconds between text redraws
|
|
162
|
-
plain: {
|
|
163
|
-
type: "boolean",
|
|
164
|
-
description: "the dashboard in ASCII without colour (NO_COLOR also drops colour)",
|
|
165
|
-
},
|
|
166
|
-
"reduced-motion": { type: "boolean", description: "the dashboard without animation" },
|
|
160
|
+
interval: { type: "string", description: "seconds between text redraws (default 2)" },
|
|
167
161
|
...json,
|
|
168
162
|
},
|
|
169
163
|
async run({ args }) {
|
|
170
164
|
const every = redrawMs(args.interval);
|
|
171
165
|
if (args.once || args.json) return printStatus(undefined, args.json === true);
|
|
172
|
-
// on a terminal, watch is the dashboard's Runs tab (plan 6 Ruling 10); piped output keeps the plain view
|
|
173
|
-
if (process.stdin.isTTY && process.stdout.isTTY) {
|
|
174
|
-
const { openTui } = await import("./tui/run.tsx");
|
|
175
|
-
const rawArgs = [
|
|
176
|
-
...(args.plain ? ["--plain"] : []),
|
|
177
|
-
...(args["reduced-motion"] ? ["--reduced-motion"] : []),
|
|
178
|
-
];
|
|
179
|
-
process.exitCode = await openTui({ rawArgs, tab: "runs", tty: true });
|
|
180
|
-
return;
|
|
181
|
-
}
|
|
182
166
|
for (;;) {
|
|
183
167
|
if (process.stdout.isTTY) process.stdout.write("\x1b[2J\x1b[H");
|
|
184
168
|
await printStatus(undefined, false, true);
|