@crewhaus/hangar-ui 0.5.0
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 +309 -0
- package/assets/hangar.css +1726 -0
- package/assets/index.html +21 -0
- package/assets/js/api.js +368 -0
- package/assets/js/app.js +734 -0
- package/assets/js/dom.js +228 -0
- package/assets/js/keys.js +159 -0
- package/assets/js/markdown.js +131 -0
- package/assets/js/notify.js +152 -0
- package/assets/js/omnibox.js +307 -0
- package/assets/js/router.js +280 -0
- package/assets/js/routes.js +793 -0
- package/assets/js/shapes.js +36 -0
- package/assets/js/supervision.js +1334 -0
- package/assets/js/util.js +426 -0
- package/assets/js/views/activity.js +140 -0
- package/assets/js/views/approvals.js +155 -0
- package/assets/js/views/channels.js +455 -0
- package/assets/js/views/control.js +253 -0
- package/assets/js/views/costs.js +103 -0
- package/assets/js/views/creds.js +610 -0
- package/assets/js/views/data.js +456 -0
- package/assets/js/views/deploy.js +106 -0
- package/assets/js/views/evals-lab.js +1029 -0
- package/assets/js/views/evals.js +342 -0
- package/assets/js/views/feedback.js +555 -0
- package/assets/js/views/health.js +176 -0
- package/assets/js/views/inbox.js +103 -0
- package/assets/js/views/inspect.js +751 -0
- package/assets/js/views/jobs.js +110 -0
- package/assets/js/views/library.js +605 -0
- package/assets/js/views/memory-fabric.js +2026 -0
- package/assets/js/views/memory.js +228 -0
- package/assets/js/views/onboarding.js +253 -0
- package/assets/js/views/overview.js +196 -0
- package/assets/js/views/panes.js +132 -0
- package/assets/js/views/proc.js +280 -0
- package/assets/js/views/review.js +164 -0
- package/assets/js/views/runs.js +452 -0
- package/assets/js/views/runtime.js +450 -0
- package/assets/js/views/schedulers.js +133 -0
- package/assets/js/views/security.js +925 -0
- package/assets/js/views/sessions.js +265 -0
- package/assets/js/views/settings.js +489 -0
- package/assets/js/views/spec-edit.js +1289 -0
- package/assets/js/views/spec.js +66 -0
- package/assets/js/views/thredz.js +1853 -0
- package/assets/js/views/token.js +49 -0
- package/dist/index.d.ts +15 -0
- package/dist/index.js +145 -0
- package/package.json +36 -0
|
@@ -0,0 +1,1334 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pure view logic for the M2 supervision surfaces — the process board, the
|
|
3
|
+
* run console, the four-lane timeline, the two inboxes, the activity digest,
|
|
4
|
+
* the job queue, and the deployments panel.
|
|
5
|
+
*
|
|
6
|
+
* Like `util.js` this module is deliberately DOM-free and side-effect-free,
|
|
7
|
+
* so the same file runs in the browser and under `bun test`: every screen's
|
|
8
|
+
* hard thinking (state → row, envelope → disabled-with-reason, SSE frame →
|
|
9
|
+
* feed item, refusal → modal, keystroke → next state) lives here and is
|
|
10
|
+
* asserted directly, while `views/*.js` stay thin DOM builders. Anything
|
|
11
|
+
* time-dependent takes an explicit `nowMs`.
|
|
12
|
+
*
|
|
13
|
+
* THE THREE RULES THIS MODULE ENCODES.
|
|
14
|
+
*
|
|
15
|
+
* 1. **`expected: true` is a fact, not a fault.** `no_control_port`,
|
|
16
|
+
* `lane_not_armed` and `draining` render the control DISABLED WITH ITS
|
|
17
|
+
* REASON — never an error toast. Only `tick_in_flight` retries.
|
|
18
|
+
* 2. **Absence is not an error.** A null payload, a missing field, a
|
|
19
|
+
* 404-shaped `null` all degrade to "nothing yet" models rather than
|
|
20
|
+
* throwing.
|
|
21
|
+
* 3. **Say which answer you got.** Bundle freshness distinguishes the
|
|
22
|
+
* hash-exact verdict from the mtime approximation; a lane with no
|
|
23
|
+
* control plane says "cadence only" instead of implying a gap.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { hrefHarness } from "./router.js";
|
|
27
|
+
import { fmtRelativeTime, parseTs, usdFromMicros } from "./util.js";
|
|
28
|
+
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// supervision state
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
/** The supervisor's state machine, as pill copy. Dots are ALWAYS paired with
|
|
34
|
+
* this text (color-blind-safe invariant). */
|
|
35
|
+
export const PROC_STATE_META = {
|
|
36
|
+
stopped: { dot: "off", label: "stopped", note: "no daemon running" },
|
|
37
|
+
preflight: { dot: "warn", label: "preflight", note: "checking before the spawn" },
|
|
38
|
+
starting: { dot: "warn", label: "starting", note: "spawned, not yet confirmed" },
|
|
39
|
+
running: { dot: "ok", label: "running", note: "" },
|
|
40
|
+
draining: { dot: "warn", label: "draining", note: "finishing in-flight work, then exiting" },
|
|
41
|
+
crashed: { dot: "bad", label: "crashed", note: "exited unexpectedly" },
|
|
42
|
+
parked: { dot: "warn", label: "parked", note: "waiting on an approval — not a failure" },
|
|
43
|
+
terminal: { dot: "bad", label: "terminal", note: "an exit class that must never auto-restart" },
|
|
44
|
+
"crash-looping": {
|
|
45
|
+
dot: "bad",
|
|
46
|
+
label: "crash-looping",
|
|
47
|
+
note: "the restart window is exhausted — start it manually",
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** States in which something is (or is becoming) alive. */
|
|
52
|
+
const LIVE_STATES = new Set(["preflight", "starting", "running", "draining"]);
|
|
53
|
+
|
|
54
|
+
/** One supervision state → its dot state + label + one-line note. */
|
|
55
|
+
export function procStatePill(state) {
|
|
56
|
+
const key = typeof state === "string" ? state : "";
|
|
57
|
+
const meta = PROC_STATE_META[key];
|
|
58
|
+
if (meta === undefined) {
|
|
59
|
+
return { state: key === "" ? "unknown" : key, dot: "unknown", label: "unknown", note: "" };
|
|
60
|
+
}
|
|
61
|
+
return { state: key, ...meta };
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** Coarse duration: "42s", "7m 3s", "5h 12m", "3d 4h". "—" when unknown. */
|
|
65
|
+
export function fmtDurationMs(ms) {
|
|
66
|
+
if (typeof ms !== "number" || !Number.isFinite(ms) || ms < 0) return "—";
|
|
67
|
+
const seconds = Math.floor(ms / 1000);
|
|
68
|
+
if (seconds < 60) return `${seconds}s`;
|
|
69
|
+
const minutes = Math.floor(seconds / 60);
|
|
70
|
+
if (minutes < 60) return `${minutes}m ${seconds % 60}s`;
|
|
71
|
+
const hours = Math.floor(minutes / 60);
|
|
72
|
+
if (hours < 24) return `${hours}h ${minutes % 60}m`;
|
|
73
|
+
return `${Math.floor(hours / 24)}d ${hours % 24}h`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Uptime since an ISO start time. "—" when the process is not running. */
|
|
77
|
+
export function uptimeLabel(startedAtIso, nowMs) {
|
|
78
|
+
const ts = parseTs(startedAtIso);
|
|
79
|
+
if (ts === null) return "—";
|
|
80
|
+
return fmtDurationMs(nowMs - ts);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* The pending-restart countdown. Null when no restart is scheduled — the
|
|
85
|
+
* common case; a number here is the supervisor's backoff timer, and an
|
|
86
|
+
* operator staring at a crashed row deserves to see it tick.
|
|
87
|
+
*/
|
|
88
|
+
export function restartCountdown(nextRestartAtMs, nowMs) {
|
|
89
|
+
if (typeof nextRestartAtMs !== "number" || !Number.isFinite(nextRestartAtMs)) return null;
|
|
90
|
+
const ms = nextRestartAtMs - nowMs;
|
|
91
|
+
return {
|
|
92
|
+
ms,
|
|
93
|
+
due: ms <= 0,
|
|
94
|
+
label: ms <= 0 ? "restarting now" : `restart in ${fmtDurationMs(ms)}`,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** `proc.control` → availability model. Absence is a fact with a reason. */
|
|
99
|
+
export function controlAvailability(control) {
|
|
100
|
+
const c = control && typeof control === "object" ? control : {};
|
|
101
|
+
const available = c.available === true;
|
|
102
|
+
const port = typeof c.port === "number" ? c.port : null;
|
|
103
|
+
return {
|
|
104
|
+
available,
|
|
105
|
+
port,
|
|
106
|
+
dot: available ? "ok" : "off",
|
|
107
|
+
label: available ? `control.v1 :${port}` : "no control plane",
|
|
108
|
+
reason: typeof c.reason === "string" ? c.reason : null,
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Freshness state → dot. `approximate-*` are honest guesses, not verdicts. */
|
|
113
|
+
const FRESHNESS_DOT = {
|
|
114
|
+
fresh: "ok",
|
|
115
|
+
stale: "warn",
|
|
116
|
+
"approximate-fresh": "unknown",
|
|
117
|
+
"approximate-stale": "warn",
|
|
118
|
+
unstamped: "unknown",
|
|
119
|
+
unknown: "unknown",
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
/**
|
|
123
|
+
* `proc.bundle` → the freshness badge. `exact` is the whole point: the F-5
|
|
124
|
+
* spec-hash verdict and the mtime approximation are different claims, and a
|
|
125
|
+
* badge that hid the difference would nag every operator who has not
|
|
126
|
+
* recompiled since upgrading.
|
|
127
|
+
*/
|
|
128
|
+
export function bundleBadge(bundle) {
|
|
129
|
+
const b = bundle && typeof bundle === "object" ? bundle : {};
|
|
130
|
+
const f = b.freshness && typeof b.freshness === "object" ? b.freshness : {};
|
|
131
|
+
const present = b.present === true;
|
|
132
|
+
const state = typeof f.state === "string" && f.state !== "" ? f.state : "unknown";
|
|
133
|
+
const exact = f.exact === true;
|
|
134
|
+
return {
|
|
135
|
+
present,
|
|
136
|
+
dir: typeof b.dir === "string" ? b.dir : null,
|
|
137
|
+
entry: typeof b.entry === "string" ? b.entry : null,
|
|
138
|
+
state,
|
|
139
|
+
exact,
|
|
140
|
+
dot: present ? (FRESHNESS_DOT[state] ?? "unknown") : "off",
|
|
141
|
+
label: present
|
|
142
|
+
? typeof f.label === "string" && f.label !== ""
|
|
143
|
+
? f.label
|
|
144
|
+
: state
|
|
145
|
+
: "no compiled bundle",
|
|
146
|
+
precision: !present
|
|
147
|
+
? "nothing has been compiled here yet"
|
|
148
|
+
: exact
|
|
149
|
+
? "exact — the bundle's spec hash was compared"
|
|
150
|
+
: "approximate — compared by file times, not by hash",
|
|
151
|
+
compiledWith: typeof f.compiledWith === "string" ? f.compiledWith : null,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Remedies a failed spawn plan can carry, as buttons rather than toasts. */
|
|
156
|
+
export const REMEDIES = {
|
|
157
|
+
compile: {
|
|
158
|
+
label: "Compile now",
|
|
159
|
+
jobKind: "compile",
|
|
160
|
+
hint: "there is no compiled bundle to launch",
|
|
161
|
+
},
|
|
162
|
+
"add-spec": {
|
|
163
|
+
label: "No crewhaus.yaml here",
|
|
164
|
+
jobKind: null,
|
|
165
|
+
hint: "add a spec to this directory, then compile it",
|
|
166
|
+
},
|
|
167
|
+
"install-cli": {
|
|
168
|
+
label: "crewhaus CLI not found",
|
|
169
|
+
jobKind: null,
|
|
170
|
+
hint: "install the CLI so the manager can spawn through it",
|
|
171
|
+
},
|
|
172
|
+
};
|
|
173
|
+
|
|
174
|
+
/** One `launch.error.remedy` → its button model, or null. */
|
|
175
|
+
export function remedyAction(remedy) {
|
|
176
|
+
return typeof remedy === "string" ? (REMEDIES[remedy] ?? null) : null;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/** `proc.launch` → the "what Start would actually run" model. */
|
|
180
|
+
export function launchModel(launch) {
|
|
181
|
+
const l = launch && typeof launch === "object" ? launch : {};
|
|
182
|
+
const err = l.error && typeof l.error === "object" ? l.error : null;
|
|
183
|
+
return {
|
|
184
|
+
mode: typeof l.mode === "string" ? l.mode : null,
|
|
185
|
+
canResume: l.canResume === true,
|
|
186
|
+
cliTwin: typeof l.cliTwin === "string" && l.cliTwin !== "" ? l.cliTwin : null,
|
|
187
|
+
detached: l.detached === true,
|
|
188
|
+
supervised: l.supervised === true,
|
|
189
|
+
envFiles: Array.isArray(l.envFiles) ? l.envFiles.map(String) : [],
|
|
190
|
+
overrides: Array.isArray(l.overrides) ? l.overrides : [],
|
|
191
|
+
error:
|
|
192
|
+
err === null
|
|
193
|
+
? null
|
|
194
|
+
: {
|
|
195
|
+
message:
|
|
196
|
+
typeof err.message === "string" ? err.message : "the spawn plan could not be built",
|
|
197
|
+
remedy: typeof err.remedy === "string" ? err.remedy : null,
|
|
198
|
+
action: remedyAction(err.remedy),
|
|
199
|
+
},
|
|
200
|
+
};
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Failure classes that carry an operator-actionable next step. */
|
|
204
|
+
const CLASS_REMEDIATION = {
|
|
205
|
+
billing: "the provider account is out of funding — top it up, then start again",
|
|
206
|
+
auth: "the provider rejected the credential — fix the key, then start again",
|
|
207
|
+
rate_limit: "the provider quota was exhausted after retries — wait, or lower concurrency",
|
|
208
|
+
crewhaus_budget: "this harness's own budget cap ended the run — raise or reset it",
|
|
209
|
+
timeout: "a configured wall-clock limit ended the run",
|
|
210
|
+
evaluation: "the in-loop evaluation gate halted the run",
|
|
211
|
+
approval_pending: "an approval is waiting — grant or deny it in the inbox",
|
|
212
|
+
mcp_boot: "an MCP server failed to boot — check its command and env",
|
|
213
|
+
context_overflow: "the context window overflowed — compaction or a smaller prompt",
|
|
214
|
+
spec: "the spec cannot compile — restarting would recompile the same spec",
|
|
215
|
+
config: "a required key is missing — a restart leaves it missing",
|
|
216
|
+
tool: "a tool crashed the run",
|
|
217
|
+
unknown: null,
|
|
218
|
+
};
|
|
219
|
+
|
|
220
|
+
/** Human names for the failure classes the fleet board groups by. */
|
|
221
|
+
export const CLASS_LABELS = {
|
|
222
|
+
billing: "provider funding",
|
|
223
|
+
auth: "provider auth",
|
|
224
|
+
rate_limit: "provider rate limit",
|
|
225
|
+
crewhaus_budget: "budget cap",
|
|
226
|
+
timeout: "timeout",
|
|
227
|
+
evaluation: "evaluation gate",
|
|
228
|
+
approval_pending: "waiting on approval",
|
|
229
|
+
mcp_boot: "MCP boot",
|
|
230
|
+
context_overflow: "context overflow",
|
|
231
|
+
spec: "spec",
|
|
232
|
+
config: "config",
|
|
233
|
+
tool: "tool",
|
|
234
|
+
unknown: "unclassified",
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/** Classes the restart policy refuses to auto-restart. */
|
|
238
|
+
const TERMINAL_CLASSES = new Set(["spec", "config", "auth", "billing", "crewhaus_budget"]);
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* The newest CLOSED run-ledger row as an exit classification.
|
|
242
|
+
*
|
|
243
|
+
* A manager that did not supervise the last run has no `lastExit` in its
|
|
244
|
+
* live snapshot — but the harness-local ledger still records how that run
|
|
245
|
+
* ended, and after a console restart that is the only honest answer
|
|
246
|
+
* available. Rows built this way carry `fromLedger` so the UI can say where
|
|
247
|
+
* the fact came from instead of implying this manager watched it happen.
|
|
248
|
+
*/
|
|
249
|
+
export function ledgerExit(recentRuns) {
|
|
250
|
+
const runs = Array.isArray(recentRuns) ? recentRuns : [];
|
|
251
|
+
const closed = runs.find(
|
|
252
|
+
(r) => r !== null && typeof r === "object" && typeof r.exitCode === "number",
|
|
253
|
+
);
|
|
254
|
+
if (closed === undefined) return null;
|
|
255
|
+
const code = closed.exitCode;
|
|
256
|
+
const failureClass = typeof closed.failureClass === "string" ? closed.failureClass : undefined;
|
|
257
|
+
return {
|
|
258
|
+
disposition:
|
|
259
|
+
code === 0
|
|
260
|
+
? "clean"
|
|
261
|
+
: failureClass !== undefined && TERMINAL_CLASSES.has(failureClass)
|
|
262
|
+
? "terminal"
|
|
263
|
+
: "crash",
|
|
264
|
+
...(failureClass !== undefined ? { failureClass } : {}),
|
|
265
|
+
title: code === 0 ? "exited cleanly" : `exited ${code}`,
|
|
266
|
+
exitCode: code,
|
|
267
|
+
restartable: false,
|
|
268
|
+
unexpectedClean: false,
|
|
269
|
+
fromLedger: true,
|
|
270
|
+
};
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** An `ExitClassification` → the banner model (dot + title + remediation). */
|
|
274
|
+
export function exitBanner(classification) {
|
|
275
|
+
const c = classification && typeof classification === "object" ? classification : null;
|
|
276
|
+
if (c === null) return null;
|
|
277
|
+
const disposition = typeof c.disposition === "string" ? c.disposition : "crash";
|
|
278
|
+
const failureClass = typeof c.failureClass === "string" ? c.failureClass : null;
|
|
279
|
+
return {
|
|
280
|
+
disposition,
|
|
281
|
+
dot:
|
|
282
|
+
disposition === "clean"
|
|
283
|
+
? c.unexpectedClean === true
|
|
284
|
+
? "warn"
|
|
285
|
+
: "off"
|
|
286
|
+
: disposition === "parked"
|
|
287
|
+
? "warn"
|
|
288
|
+
: "bad",
|
|
289
|
+
title: typeof c.title === "string" && c.title !== "" ? c.title : disposition,
|
|
290
|
+
failureClass,
|
|
291
|
+
classLabel: failureClass !== null ? (CLASS_LABELS[failureClass] ?? failureClass) : null,
|
|
292
|
+
exitCode: typeof c.exitCode === "number" ? c.exitCode : null,
|
|
293
|
+
signal: typeof c.signal === "string" ? c.signal : null,
|
|
294
|
+
restartable: c.restartable === true,
|
|
295
|
+
unexpectedClean: c.unexpectedClean === true,
|
|
296
|
+
// True when the fact came from the harness's own ledger rather than from
|
|
297
|
+
// this manager's supervision of the run.
|
|
298
|
+
fromLedger: c.fromLedger === true,
|
|
299
|
+
remediation: failureClass !== null ? (CLASS_REMEDIATION[failureClass] ?? null) : null,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
/**
|
|
304
|
+
* Which row actions are live, and WHY they are not. A disabled control that
|
|
305
|
+
* cannot say why is indistinguishable from a broken one.
|
|
306
|
+
*/
|
|
307
|
+
export function procActions(proc) {
|
|
308
|
+
const p = proc && typeof proc === "object" ? proc : {};
|
|
309
|
+
const state = typeof p.state === "string" ? p.state : "stopped";
|
|
310
|
+
const live = LIVE_STATES.has(state);
|
|
311
|
+
const draining = p.draining === true || state === "draining";
|
|
312
|
+
const launch = launchModel(p.launch);
|
|
313
|
+
const planError = launch.error;
|
|
314
|
+
const yes = { enabled: true, reason: null };
|
|
315
|
+
const no = (reason) => ({ enabled: false, reason });
|
|
316
|
+
const spawnBlocked =
|
|
317
|
+
planError !== null ? no(planError.message) : draining ? no("this daemon is draining") : yes;
|
|
318
|
+
return {
|
|
319
|
+
start: live ? no(`already ${state}`) : spawnBlocked,
|
|
320
|
+
stop: live ? yes : no(`nothing is running (${state})`),
|
|
321
|
+
restart: live && draining ? no("this daemon is draining — it is going away") : spawnBlocked,
|
|
322
|
+
drain:
|
|
323
|
+
state === "running"
|
|
324
|
+
? yes
|
|
325
|
+
: draining
|
|
326
|
+
? no("already draining")
|
|
327
|
+
: no(`nothing is running (${state})`),
|
|
328
|
+
};
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** What the supervisor says when it held no pid and a live runfile exists:
|
|
332
|
+
* a daemon is running that this manager never adopted, so nothing was
|
|
333
|
+
* signalled. It is NOT a stop, and rendering it as one is how a console
|
|
334
|
+
* tells an operator a live channel bot is down. */
|
|
335
|
+
const NOT_ADOPTED_SENTENCE =
|
|
336
|
+
"a daemon is running for this harness but this console does not hold it — nothing was signalled";
|
|
337
|
+
/** …and what to do about it, which the server's own sentence never carries. */
|
|
338
|
+
const NOT_ADOPTED_RETRY = "Try again: the next request adopts it off the runfile.";
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* `POST /proc/{stop,drain}` → what ACTUALLY happened.
|
|
342
|
+
*
|
|
343
|
+
* Three answers hide behind one button, and only one of them is a stop:
|
|
344
|
+
*
|
|
345
|
+
* - the daemon stopped (`stopped: true`) — silence is the right report;
|
|
346
|
+
* - it drained by SIGTERM because no control plane answered
|
|
347
|
+
* (`viaSignal: true`) — an honest degradation, said out loud;
|
|
348
|
+
* - nothing was signalled at all — a 409 `not-adopted`, or a body that
|
|
349
|
+
* says `stopped: false`. This one must never read as success.
|
|
350
|
+
*
|
|
351
|
+
* `res` is the `{ ok, status, body }` envelope `api.procStop`/`api.procDrain`
|
|
352
|
+
* return, because a refusal here is a PAYLOAD, not an exception.
|
|
353
|
+
*/
|
|
354
|
+
export function procWriteOutcome(res, label = "Stop") {
|
|
355
|
+
const r = res && typeof res === "object" ? res : {};
|
|
356
|
+
const body = r.body && typeof r.body === "object" ? r.body : {};
|
|
357
|
+
const status = typeof r.status === "number" ? r.status : 0;
|
|
358
|
+
const reason = typeof body.reason === "string" && body.reason !== "" ? body.reason : null;
|
|
359
|
+
const serverMessage =
|
|
360
|
+
typeof body.message === "string" && body.message !== "" ? body.message : null;
|
|
361
|
+
const notAdopted = reason === "not-adopted";
|
|
362
|
+
if (r.ok === true && body.stopped !== false) {
|
|
363
|
+
return body.viaSignal === true
|
|
364
|
+
? {
|
|
365
|
+
ok: true,
|
|
366
|
+
code: "via-signal",
|
|
367
|
+
notAdopted: false,
|
|
368
|
+
tone: "info",
|
|
369
|
+
message: "No control plane — drained with SIGTERM instead",
|
|
370
|
+
}
|
|
371
|
+
: { ok: true, code: "stopped", notAdopted: false, tone: "info", message: null };
|
|
372
|
+
}
|
|
373
|
+
if (notAdopted) {
|
|
374
|
+
return {
|
|
375
|
+
ok: false,
|
|
376
|
+
code: "not-adopted",
|
|
377
|
+
notAdopted: true,
|
|
378
|
+
tone: "error",
|
|
379
|
+
message: `${label} did nothing — ${(serverMessage ?? NOT_ADOPTED_SENTENCE).replace(
|
|
380
|
+
/\.$/,
|
|
381
|
+
"",
|
|
382
|
+
)}. ${NOT_ADOPTED_RETRY}`,
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
const code = reason ?? (status === 0 ? "error" : `http-${status}`);
|
|
386
|
+
return {
|
|
387
|
+
ok: false,
|
|
388
|
+
code,
|
|
389
|
+
notAdopted: false,
|
|
390
|
+
tone: "error",
|
|
391
|
+
message: `${label} did nothing — ${serverMessage ?? code}`,
|
|
392
|
+
};
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/** One `/api/h/:id/proc` payload → the board row / detail header model. */
|
|
396
|
+
export function procRow(proc, nowMs) {
|
|
397
|
+
const p = proc && typeof proc === "object" ? proc : {};
|
|
398
|
+
return {
|
|
399
|
+
id: typeof p.id === "string" ? p.id : "",
|
|
400
|
+
target: typeof p.target === "string" ? p.target : "",
|
|
401
|
+
runClass: typeof p.runClass === "string" ? p.runClass : "",
|
|
402
|
+
pill: procStatePill(p.state),
|
|
403
|
+
state: typeof p.state === "string" ? p.state : "unknown",
|
|
404
|
+
runId: typeof p.runId === "string" ? p.runId : null,
|
|
405
|
+
pid: typeof p.pid === "number" ? p.pid : null,
|
|
406
|
+
uptime: uptimeLabel(p.startedAt, nowMs),
|
|
407
|
+
adopted: p.adopted === true,
|
|
408
|
+
draining: p.draining === true,
|
|
409
|
+
restartsInWindow: typeof p.restartsInWindow === "number" ? p.restartsInWindow : 0,
|
|
410
|
+
restart: restartCountdown(p.nextRestartAtMs, nowMs),
|
|
411
|
+
// The live snapshot first; after a manager restart the ledger is the only
|
|
412
|
+
// record of how the last run ended, and saying nothing would read as
|
|
413
|
+
// "nothing has ever gone wrong here".
|
|
414
|
+
lastExit: exitBanner(p.lastExit ?? ledgerExit(p.recentRuns)),
|
|
415
|
+
forensics: p.forensics && typeof p.forensics === "object" ? p.forensics : null,
|
|
416
|
+
control: controlAvailability(p.control),
|
|
417
|
+
bundle: bundleBadge(p.bundle),
|
|
418
|
+
launch: launchModel(p.launch),
|
|
419
|
+
recentRuns: Array.isArray(p.recentRuns) ? p.recentRuns : [],
|
|
420
|
+
actions: procActions(p),
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* The fleet failure board: harnesses grouped by how they died, biggest group
|
|
426
|
+
* first — "3 harnesses exited 31 — provider funding" reads as one incident
|
|
427
|
+
* rather than three unrelated red rows.
|
|
428
|
+
*/
|
|
429
|
+
export function failureBoard(entries) {
|
|
430
|
+
const groups = new Map();
|
|
431
|
+
for (const raw of Array.isArray(entries) ? entries : []) {
|
|
432
|
+
const entry = raw && typeof raw === "object" ? raw : {};
|
|
433
|
+
const banner = exitBanner(entry.lastExit);
|
|
434
|
+
if (banner === null) continue;
|
|
435
|
+
// An operator stop is not news. A long-running shape that "finished" on
|
|
436
|
+
// its own IS — it has lost its listener — so it groups like a failure.
|
|
437
|
+
if (banner.disposition === "clean" && !banner.unexpectedClean) continue;
|
|
438
|
+
const key =
|
|
439
|
+
banner.failureClass ??
|
|
440
|
+
(banner.exitCode !== null ? `exit-${banner.exitCode}` : banner.disposition);
|
|
441
|
+
const group = groups.get(key) ?? {
|
|
442
|
+
key,
|
|
443
|
+
failureClass: banner.failureClass,
|
|
444
|
+
classLabel: banner.classLabel,
|
|
445
|
+
exitCode: banner.exitCode,
|
|
446
|
+
title: banner.title,
|
|
447
|
+
remediation: banner.remediation,
|
|
448
|
+
dot: banner.dot,
|
|
449
|
+
harnesses: [],
|
|
450
|
+
};
|
|
451
|
+
group.harnesses.push({
|
|
452
|
+
id: typeof entry.id === "string" ? entry.id : "",
|
|
453
|
+
name: typeof entry.name === "string" && entry.name !== "" ? entry.name : (entry.id ?? ""),
|
|
454
|
+
});
|
|
455
|
+
groups.set(key, group);
|
|
456
|
+
}
|
|
457
|
+
return [...groups.values()]
|
|
458
|
+
.map((g) => ({
|
|
459
|
+
...g,
|
|
460
|
+
count: g.harnesses.length,
|
|
461
|
+
// "3 harnesses exited 31 — provider funding": the count, the code an
|
|
462
|
+
// operator can grep for, and the plain-English class.
|
|
463
|
+
label: `${g.harnesses.length} harness${g.harnesses.length === 1 ? "" : "es"} ${
|
|
464
|
+
g.exitCode !== null ? `exited ${g.exitCode}` : g.title
|
|
465
|
+
}${g.classLabel !== null ? ` — ${g.classLabel}` : g.exitCode !== null ? ` — ${g.title}` : ""}`,
|
|
466
|
+
}))
|
|
467
|
+
.sort((a, b) => b.count - a.count || (a.key < b.key ? -1 : a.key > b.key ? 1 : 0));
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
// ---------------------------------------------------------------------------
|
|
471
|
+
// preflight refusal → modal
|
|
472
|
+
// ---------------------------------------------------------------------------
|
|
473
|
+
|
|
474
|
+
/**
|
|
475
|
+
* A `POST /proc/start` 409 body → the modal model.
|
|
476
|
+
*
|
|
477
|
+
* `canForce` is the load-bearing field: an operator often knows better than
|
|
478
|
+
* an offline check, EXCEPT about missing channel secrets, which the compiled
|
|
479
|
+
* daemon's own boot gate exits 2 on. So "Start anyway" is hidden — not
|
|
480
|
+
* merely disabled — whenever any refused item is unforceable.
|
|
481
|
+
*/
|
|
482
|
+
export function refusalModel(body) {
|
|
483
|
+
const b = body && typeof body === "object" ? body : {};
|
|
484
|
+
const reason = typeof b.reason === "string" ? b.reason : "unknown";
|
|
485
|
+
if (reason === "preflight-blocked") {
|
|
486
|
+
const items = (Array.isArray(b.refused) ? b.refused : []).map((raw) => {
|
|
487
|
+
const item = raw && typeof raw === "object" ? raw : {};
|
|
488
|
+
return {
|
|
489
|
+
id: typeof item.id === "string" ? item.id : "",
|
|
490
|
+
area: typeof item.area === "string" ? item.area : "",
|
|
491
|
+
level: typeof item.level === "string" ? item.level : "blocking",
|
|
492
|
+
message: typeof item.message === "string" ? item.message : "",
|
|
493
|
+
remediation: typeof item.remediation === "string" ? item.remediation : null,
|
|
494
|
+
acknowledgeable: item.acknowledgeable === true,
|
|
495
|
+
};
|
|
496
|
+
});
|
|
497
|
+
const unforceable = Array.isArray(b.unforceable) ? b.unforceable.map(String) : [];
|
|
498
|
+
const canForce = items.length > 0 && items.every((i) => i.acknowledgeable);
|
|
499
|
+
return {
|
|
500
|
+
kind: "preflight",
|
|
501
|
+
title: "Preflight refused this start",
|
|
502
|
+
message: `${items.length} blocking finding${items.length === 1 ? "" : "s"} — fix them, or wave the forceable ones through.`,
|
|
503
|
+
items,
|
|
504
|
+
unforceable,
|
|
505
|
+
canForce,
|
|
506
|
+
acknowledge: items.filter((i) => i.acknowledgeable).map((i) => i.id),
|
|
507
|
+
forceHint: canForce
|
|
508
|
+
? "Start anyway acknowledges every finding above. The daemon still runs its own boot gate."
|
|
509
|
+
: "Start anyway is unavailable: the compiled daemon's boot gate exits 2 on the findings marked unforceable.",
|
|
510
|
+
remedy: null,
|
|
511
|
+
action: null,
|
|
512
|
+
};
|
|
513
|
+
}
|
|
514
|
+
if (reason === "plan-failed") {
|
|
515
|
+
const remedy = typeof b.remedy === "string" ? b.remedy : null;
|
|
516
|
+
return {
|
|
517
|
+
kind: "plan-failed",
|
|
518
|
+
title: "There is nothing to launch",
|
|
519
|
+
message: typeof b.message === "string" ? b.message : "the spawn plan could not be built",
|
|
520
|
+
items: [],
|
|
521
|
+
unforceable: [],
|
|
522
|
+
canForce: false,
|
|
523
|
+
acknowledge: [],
|
|
524
|
+
forceHint: null,
|
|
525
|
+
remedy,
|
|
526
|
+
action: remedyAction(remedy),
|
|
527
|
+
};
|
|
528
|
+
}
|
|
529
|
+
if (reason === "already-running") {
|
|
530
|
+
return {
|
|
531
|
+
kind: "already-running",
|
|
532
|
+
title: "A daemon is already running",
|
|
533
|
+
message:
|
|
534
|
+
typeof b.message === "string"
|
|
535
|
+
? b.message
|
|
536
|
+
: "a daemon is already running for this harness (the runfile is the lock)",
|
|
537
|
+
items: [],
|
|
538
|
+
unforceable: [],
|
|
539
|
+
canForce: false,
|
|
540
|
+
acknowledge: [],
|
|
541
|
+
forceHint: null,
|
|
542
|
+
remedy: null,
|
|
543
|
+
action: null,
|
|
544
|
+
runfile: b.runfile ?? null,
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
return {
|
|
548
|
+
kind: "error",
|
|
549
|
+
title: "The start was refused",
|
|
550
|
+
message: typeof b.message === "string" ? b.message : reason,
|
|
551
|
+
items: [],
|
|
552
|
+
unforceable: [],
|
|
553
|
+
canForce: false,
|
|
554
|
+
acknowledge: [],
|
|
555
|
+
forceHint: null,
|
|
556
|
+
remedy: null,
|
|
557
|
+
action: null,
|
|
558
|
+
};
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// ---------------------------------------------------------------------------
|
|
562
|
+
// control.v1 envelopes
|
|
563
|
+
// ---------------------------------------------------------------------------
|
|
564
|
+
|
|
565
|
+
/**
|
|
566
|
+
* A control envelope → how to render the control that produced it.
|
|
567
|
+
*
|
|
568
|
+
* - `expected: true` ⇒ DISABLED WITH REASON (a fact about this bundle);
|
|
569
|
+
* - `tick_in_flight` ⇒ retryable, spinner, same button;
|
|
570
|
+
* - anything else ⇒ a real fault worth an error.
|
|
571
|
+
*/
|
|
572
|
+
export function controlOutcome(envelope) {
|
|
573
|
+
const e = envelope && typeof envelope === "object" ? envelope : {};
|
|
574
|
+
if (e.ok === true) {
|
|
575
|
+
return {
|
|
576
|
+
ok: true,
|
|
577
|
+
code: null,
|
|
578
|
+
reason: null,
|
|
579
|
+
expected: false,
|
|
580
|
+
retryable: false,
|
|
581
|
+
disabled: false,
|
|
582
|
+
tone: "ok",
|
|
583
|
+
dot: "ok",
|
|
584
|
+
body: e,
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
const code = typeof e.code === "string" && e.code !== "" ? e.code : "error";
|
|
588
|
+
const expected = e.expected === true;
|
|
589
|
+
const retryable = e.retryable === true;
|
|
590
|
+
return {
|
|
591
|
+
ok: false,
|
|
592
|
+
code,
|
|
593
|
+
reason: typeof e.reason === "string" && e.reason !== "" ? e.reason : code,
|
|
594
|
+
expected,
|
|
595
|
+
retryable,
|
|
596
|
+
// Expected refusals disable the control; a retryable one keeps it live.
|
|
597
|
+
disabled: expected,
|
|
598
|
+
tone: expected ? "expected" : retryable ? "retry" : "error",
|
|
599
|
+
dot: expected ? "off" : retryable ? "warn" : "bad",
|
|
600
|
+
upstreamStatus: typeof e.upstreamStatus === "number" ? e.upstreamStatus : null,
|
|
601
|
+
body: null,
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
// the four-lane timeline
|
|
607
|
+
// ---------------------------------------------------------------------------
|
|
608
|
+
|
|
609
|
+
/** Lanes control.v1 can actually poke; the other two are read-only rows. */
|
|
610
|
+
export const POKEABLE_LANES = ["heartbeat", "schedule"];
|
|
611
|
+
|
|
612
|
+
/**
|
|
613
|
+
* One scheduler row + the view it came from → the lane's render model.
|
|
614
|
+
*
|
|
615
|
+
* The asymmetry is the whole story: cadence is declared in the spec and so is
|
|
616
|
+
* knowable offline, while PHASE (last fired / next due) is knowable only
|
|
617
|
+
* inside the process that armed the timer. With no control plane the honest
|
|
618
|
+
* answer is "cadence only", never a blank that reads as a gap.
|
|
619
|
+
*/
|
|
620
|
+
export function laneModel(row, view, nowMs) {
|
|
621
|
+
const r = row && typeof row === "object" ? row : {};
|
|
622
|
+
const v = view && typeof view === "object" ? view : {};
|
|
623
|
+
const reachable = v.controlReachable === true;
|
|
624
|
+
const armed = r.armed === true;
|
|
625
|
+
const nextDueAt = typeof r.nextDueAt === "string" ? r.nextDueAt : null;
|
|
626
|
+
const lastFiredAt = typeof r.lastFiredAt === "string" ? r.lastFiredAt : null;
|
|
627
|
+
const lastOutcome = typeof r.lastOutcome === "string" ? r.lastOutcome : null;
|
|
628
|
+
const failed = lastOutcome === "error" || lastOutcome === "failed";
|
|
629
|
+
return {
|
|
630
|
+
lane: typeof r.lane === "string" ? r.lane : "",
|
|
631
|
+
armed,
|
|
632
|
+
cadence: typeof r.cadence === "string" ? r.cadence : null,
|
|
633
|
+
cadenceSource: typeof r.cadenceSource === "string" ? r.cadenceSource : "none",
|
|
634
|
+
lastFiredAt,
|
|
635
|
+
lastFiredLabel: lastFiredAt !== null ? fmtRelativeTime(lastFiredAt, nowMs) : "—",
|
|
636
|
+
lastOutcome,
|
|
637
|
+
nextDueAt,
|
|
638
|
+
nextDueLabel:
|
|
639
|
+
nextDueAt !== null
|
|
640
|
+
? fmtRelativeTime(nextDueAt, nowMs)
|
|
641
|
+
: reachable
|
|
642
|
+
? "no tick scheduled"
|
|
643
|
+
: "cadence only",
|
|
644
|
+
// Null exactly when the phase IS known; otherwise the honest reason.
|
|
645
|
+
phaseNote:
|
|
646
|
+
nextDueAt !== null
|
|
647
|
+
? null
|
|
648
|
+
: reachable
|
|
649
|
+
? "this daemon armed no next tick for the lane"
|
|
650
|
+
: typeof v.controlReason === "string" && v.controlReason !== ""
|
|
651
|
+
? v.controlReason
|
|
652
|
+
: "the daemon's control plane is not reachable",
|
|
653
|
+
pokeable: r.pokeable === true,
|
|
654
|
+
pokeReason: typeof r.pokeReason === "string" ? r.pokeReason : null,
|
|
655
|
+
detail: r.detail ?? null,
|
|
656
|
+
dot: !armed ? "off" : failed ? "bad" : reachable ? "ok" : "unknown",
|
|
657
|
+
label: !armed
|
|
658
|
+
? "not declared"
|
|
659
|
+
: failed
|
|
660
|
+
? `last tick ${lastOutcome}`
|
|
661
|
+
: reachable
|
|
662
|
+
? "armed"
|
|
663
|
+
: "declared — phase unknown",
|
|
664
|
+
};
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
/** Every lane of a `/schedulers` payload, in the server's display order. */
|
|
668
|
+
export function laneModels(view, nowMs) {
|
|
669
|
+
const v = view && typeof view === "object" ? view : {};
|
|
670
|
+
const lanes = Array.isArray(v.lanes) ? v.lanes : [];
|
|
671
|
+
return lanes.map((lane) => laneModel(lane, v, nowMs));
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
// ---------------------------------------------------------------------------
|
|
675
|
+
// the live run feed
|
|
676
|
+
// ---------------------------------------------------------------------------
|
|
677
|
+
|
|
678
|
+
/** Trace kinds whose card leads with a bad/warn dot. */
|
|
679
|
+
const EVENT_DOT = {
|
|
680
|
+
run_failed: "bad",
|
|
681
|
+
alert_raised: "bad",
|
|
682
|
+
error_recovered: "warn",
|
|
683
|
+
circuit_state_changed: "warn",
|
|
684
|
+
model_failover: "warn",
|
|
685
|
+
compaction_fired: "warn",
|
|
686
|
+
approval_requested: "warn",
|
|
687
|
+
};
|
|
688
|
+
|
|
689
|
+
function shortJson(value, max = 120) {
|
|
690
|
+
if (value === null || value === undefined) return "";
|
|
691
|
+
let text;
|
|
692
|
+
if (typeof value === "string") text = value;
|
|
693
|
+
else {
|
|
694
|
+
try {
|
|
695
|
+
text = JSON.stringify(value);
|
|
696
|
+
} catch {
|
|
697
|
+
text = String(value);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
return text.length <= max ? text : `${text.slice(0, max - 1)}…`;
|
|
701
|
+
}
|
|
702
|
+
|
|
703
|
+
/**
|
|
704
|
+
* One TraceEvent → its feed card. Known kinds get a written line; unknown
|
|
705
|
+
* kinds still render (tolerant-reader contract) with a clipped payload
|
|
706
|
+
* summary, so nothing is silently dropped from an operator's feed.
|
|
707
|
+
*/
|
|
708
|
+
export function traceCard(event) {
|
|
709
|
+
const e = event && typeof event === "object" ? event : {};
|
|
710
|
+
const kind = typeof e.kind === "string" && e.kind !== "" ? e.kind : "event";
|
|
711
|
+
const at = typeof e.timestamp === "string" ? e.timestamp : null;
|
|
712
|
+
const base = { type: "event", kind, at, dot: EVENT_DOT[kind] ?? null, payload: e };
|
|
713
|
+
switch (kind) {
|
|
714
|
+
case "turn_start":
|
|
715
|
+
return { ...base, title: `turn ${e.turn ?? e.turnNumber ?? "?"} started`, detail: "" };
|
|
716
|
+
case "turn_end":
|
|
717
|
+
return {
|
|
718
|
+
...base,
|
|
719
|
+
title: `turn ${e.turn ?? e.turnNumber ?? "?"} ended`,
|
|
720
|
+
detail: typeof e.durationMs === "number" ? fmtDurationMs(e.durationMs) : "",
|
|
721
|
+
};
|
|
722
|
+
case "model_request":
|
|
723
|
+
return { ...base, title: "model request", detail: String(e.model ?? e.modelId ?? "") };
|
|
724
|
+
case "model_response":
|
|
725
|
+
return { ...base, title: "model response", detail: String(e.model ?? e.modelId ?? "") };
|
|
726
|
+
case "tool_call_start":
|
|
727
|
+
return { ...base, title: `tool ${String(e.toolName ?? "?")}`, detail: "started" };
|
|
728
|
+
case "tool_call_end":
|
|
729
|
+
return {
|
|
730
|
+
...base,
|
|
731
|
+
dot: e.isError === true ? "bad" : "ok",
|
|
732
|
+
title: `tool ${String(e.toolName ?? "?")}`,
|
|
733
|
+
detail: `${e.isError === true ? "failed" : "ok"}${
|
|
734
|
+
typeof e.durationMs === "number" ? ` · ${fmtDurationMs(e.durationMs)}` : ""
|
|
735
|
+
}`,
|
|
736
|
+
};
|
|
737
|
+
case "cost_accrual":
|
|
738
|
+
return {
|
|
739
|
+
...base,
|
|
740
|
+
title: "cost",
|
|
741
|
+
detail: `${String(e.modelId ?? "")} · ${(usdFromMicros(e.costUsdMicros) ?? 0).toFixed(4)} USD`,
|
|
742
|
+
};
|
|
743
|
+
case "permission_decision":
|
|
744
|
+
return {
|
|
745
|
+
...base,
|
|
746
|
+
dot: e.decision === "deny" ? "bad" : e.decision === "ask" ? "warn" : "ok",
|
|
747
|
+
title: `permission ${String(e.decision ?? "?")}`,
|
|
748
|
+
detail: String(e.toolName ?? ""),
|
|
749
|
+
};
|
|
750
|
+
case "approval_requested":
|
|
751
|
+
return { ...base, title: "approval requested", detail: String(e.toolName ?? "") };
|
|
752
|
+
case "approval_resolved":
|
|
753
|
+
return {
|
|
754
|
+
...base,
|
|
755
|
+
title: `approval ${String(e.decision ?? "resolved")}`,
|
|
756
|
+
detail: String(e.toolName ?? ""),
|
|
757
|
+
};
|
|
758
|
+
case "run_failed":
|
|
759
|
+
return {
|
|
760
|
+
...base,
|
|
761
|
+
title: `run failed — ${String(e.class ?? "unknown")}`,
|
|
762
|
+
detail: String(e.message ?? ""),
|
|
763
|
+
remediation: typeof e.remediation === "string" ? e.remediation : null,
|
|
764
|
+
};
|
|
765
|
+
case "model_route":
|
|
766
|
+
case "model_tier_route":
|
|
767
|
+
return { ...base, title: "model route", detail: String(e.modelId ?? e.band ?? "") };
|
|
768
|
+
case "janitor_action":
|
|
769
|
+
return { ...base, title: "janitor", detail: String(e.action ?? "") };
|
|
770
|
+
default:
|
|
771
|
+
return { ...base, title: kind, detail: shortJson(e.payload ?? e.detail ?? null) };
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** Non-empty prose lines of a captured-output chunk, as one feed item. */
|
|
776
|
+
function proseItem(prose) {
|
|
777
|
+
const lines = String(prose ?? "")
|
|
778
|
+
.split("\n")
|
|
779
|
+
.filter((line) => line.trim() !== "");
|
|
780
|
+
return lines.length === 0 ? null : { type: "prose", lines };
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
/**
|
|
784
|
+
* One SSE frame → the feed items it contributes.
|
|
785
|
+
*
|
|
786
|
+
* The grammar is fixed: `replay` (a full RunDetail) opens every stream and
|
|
787
|
+
* `done` always closes it, so a closed run replays and terminates without the
|
|
788
|
+
* client needing a live-vs-dead branch.
|
|
789
|
+
*/
|
|
790
|
+
export function sseFrameToFeed(eventName, data) {
|
|
791
|
+
const d = data && typeof data === "object" ? data : {};
|
|
792
|
+
switch (eventName) {
|
|
793
|
+
case "replay": {
|
|
794
|
+
const events = Array.isArray(d.events) ? d.events : [];
|
|
795
|
+
const items = [
|
|
796
|
+
{
|
|
797
|
+
type: "marker",
|
|
798
|
+
title: `replayed ${events.length} recorded event${events.length === 1 ? "" : "s"}`,
|
|
799
|
+
detail: d.eventsTruncated === true ? "older events were capped" : "",
|
|
800
|
+
},
|
|
801
|
+
...events.map(traceCard),
|
|
802
|
+
];
|
|
803
|
+
const tail = Array.isArray(d.proseTail) ? d.proseTail.join("\n") : "";
|
|
804
|
+
const prose = proseItem(tail);
|
|
805
|
+
if (prose !== null) items.push(prose);
|
|
806
|
+
// A run that already ended carries its outcome on the ledger row — the
|
|
807
|
+
// `exit` frame only ever arrives LIVE, so a replayed run would
|
|
808
|
+
// otherwise show a feed that just stops.
|
|
809
|
+
const ended = exitBanner(
|
|
810
|
+
ledgerExit(d.entry === null || d.entry === undefined ? [] : [d.entry]),
|
|
811
|
+
);
|
|
812
|
+
if (ended !== null) {
|
|
813
|
+
items.push({ type: "exit", banner: ended, title: ended.title, dot: ended.dot });
|
|
814
|
+
}
|
|
815
|
+
return items;
|
|
816
|
+
}
|
|
817
|
+
case "output": {
|
|
818
|
+
const items = [];
|
|
819
|
+
const prose = proseItem(d.prose);
|
|
820
|
+
if (prose !== null) items.push(prose);
|
|
821
|
+
for (const event of Array.isArray(d.events) ? d.events : []) items.push(traceCard(event));
|
|
822
|
+
return items;
|
|
823
|
+
}
|
|
824
|
+
case "state": {
|
|
825
|
+
const snapshot = d.snapshot && typeof d.snapshot === "object" ? d.snapshot : {};
|
|
826
|
+
const pill = procStatePill(snapshot.state);
|
|
827
|
+
return [{ type: "state", title: `state → ${pill.label}`, dot: pill.dot, pill }];
|
|
828
|
+
}
|
|
829
|
+
case "exit": {
|
|
830
|
+
const banner = exitBanner(d.classification);
|
|
831
|
+
return [
|
|
832
|
+
{ type: "exit", banner, title: banner?.title ?? "exited", dot: banner?.dot ?? "bad" },
|
|
833
|
+
];
|
|
834
|
+
}
|
|
835
|
+
case "done":
|
|
836
|
+
return [
|
|
837
|
+
{
|
|
838
|
+
type: "done",
|
|
839
|
+
title: "stream closed",
|
|
840
|
+
detail: typeof d.reason === "string" ? d.reason : "",
|
|
841
|
+
},
|
|
842
|
+
];
|
|
843
|
+
default:
|
|
844
|
+
return [];
|
|
845
|
+
}
|
|
846
|
+
}
|
|
847
|
+
|
|
848
|
+
/** One `event:`/`data:` block → `{ event, data }`, or null when it carries
|
|
849
|
+
* no data line (a comment/keep-alive). */
|
|
850
|
+
function parseSseFrame(raw) {
|
|
851
|
+
let event = "message";
|
|
852
|
+
const dataLines = [];
|
|
853
|
+
for (const line of raw.split("\n")) {
|
|
854
|
+
const clean = line.endsWith("\r") ? line.slice(0, -1) : line;
|
|
855
|
+
if (clean.startsWith("event:")) event = clean.slice(6).trim();
|
|
856
|
+
else if (clean.startsWith("data:")) dataLines.push(clean.slice(5).replace(/^ /, ""));
|
|
857
|
+
}
|
|
858
|
+
if (dataLines.length === 0) return null;
|
|
859
|
+
let data = null;
|
|
860
|
+
try {
|
|
861
|
+
data = JSON.parse(dataLines.join("\n"));
|
|
862
|
+
} catch {
|
|
863
|
+
data = null; // a torn frame is skipped, never fatal to the stream
|
|
864
|
+
}
|
|
865
|
+
return { event, data };
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
/**
|
|
869
|
+
* Incremental SSE parser. `push(chunk)` returns the frames completed by that
|
|
870
|
+
* chunk and keeps the remainder buffered — network chunks split frames
|
|
871
|
+
* anywhere, including mid-JSON.
|
|
872
|
+
*
|
|
873
|
+
* Hand-rolled rather than `EventSource` for one reason: the console
|
|
874
|
+
* authenticates with a bearer header, and `EventSource` cannot send one (its
|
|
875
|
+
* only credential channel is cookies, which this app deliberately has none
|
|
876
|
+
* of — no cookies, no CSRF surface).
|
|
877
|
+
*/
|
|
878
|
+
export function createSseParser() {
|
|
879
|
+
let buffer = "";
|
|
880
|
+
return {
|
|
881
|
+
push(chunk) {
|
|
882
|
+
buffer += String(chunk ?? "");
|
|
883
|
+
const frames = [];
|
|
884
|
+
let boundary = buffer.indexOf("\n\n");
|
|
885
|
+
while (boundary !== -1) {
|
|
886
|
+
const frame = parseSseFrame(buffer.slice(0, boundary));
|
|
887
|
+
buffer = buffer.slice(boundary + 2);
|
|
888
|
+
if (frame !== null) frames.push(frame);
|
|
889
|
+
boundary = buffer.indexOf("\n\n");
|
|
890
|
+
}
|
|
891
|
+
return frames;
|
|
892
|
+
},
|
|
893
|
+
/** Whatever never terminated — a dropped connection mid-frame. */
|
|
894
|
+
rest: () => buffer,
|
|
895
|
+
};
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/** Fold feed items into the stats/cost HUD. Pure; recomputed on every frame. */
|
|
899
|
+
export function runHud(items) {
|
|
900
|
+
const hud = {
|
|
901
|
+
events: 0,
|
|
902
|
+
turns: 0,
|
|
903
|
+
tools: 0,
|
|
904
|
+
toolErrors: 0,
|
|
905
|
+
inputTokens: 0,
|
|
906
|
+
outputTokens: 0,
|
|
907
|
+
costUsd: 0,
|
|
908
|
+
failed: false,
|
|
909
|
+
};
|
|
910
|
+
for (const item of Array.isArray(items) ? items : []) {
|
|
911
|
+
if (!item || item.type !== "event") continue;
|
|
912
|
+
hud.events += 1;
|
|
913
|
+
const p = item.payload && typeof item.payload === "object" ? item.payload : {};
|
|
914
|
+
if (item.kind === "turn_start") hud.turns += 1;
|
|
915
|
+
if (item.kind === "tool_call_end") {
|
|
916
|
+
hud.tools += 1;
|
|
917
|
+
if (p.isError === true) hud.toolErrors += 1;
|
|
918
|
+
}
|
|
919
|
+
if (item.kind === "cost_accrual") {
|
|
920
|
+
hud.costUsd += usdFromMicros(p.costUsdMicros) ?? 0;
|
|
921
|
+
if (typeof p.inputTokens === "number") hud.inputTokens += p.inputTokens;
|
|
922
|
+
if (typeof p.outputTokens === "number") hud.outputTokens += p.outputTokens;
|
|
923
|
+
}
|
|
924
|
+
if (item.kind === "run_failed") hud.failed = true;
|
|
925
|
+
}
|
|
926
|
+
return hud;
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
/** One run-ledger row → the runs-table model. */
|
|
930
|
+
export function runLedgerRow(entry, nowMs) {
|
|
931
|
+
const e = entry && typeof entry === "object" ? entry : {};
|
|
932
|
+
const started = parseTs(e.startedAt);
|
|
933
|
+
const ended = parseTs(e.endedAt);
|
|
934
|
+
const open = ended === null;
|
|
935
|
+
const exitCode = typeof e.exitCode === "number" ? e.exitCode : null;
|
|
936
|
+
return {
|
|
937
|
+
runId: typeof e.runId === "string" ? e.runId : "",
|
|
938
|
+
kind: typeof e.kind === "string" ? e.kind : "run",
|
|
939
|
+
startedAt: typeof e.startedAt === "string" ? e.startedAt : null,
|
|
940
|
+
startedLabel: fmtRelativeTime(e.startedAt ?? null, nowMs),
|
|
941
|
+
duration: started === null ? "—" : fmtDurationMs((ended ?? nowMs) - started),
|
|
942
|
+
open,
|
|
943
|
+
exitCode,
|
|
944
|
+
failureClass: typeof e.failureClass === "string" ? e.failureClass : null,
|
|
945
|
+
forced: e.forced === true,
|
|
946
|
+
sessionId: typeof e.sessionId === "string" ? e.sessionId : null,
|
|
947
|
+
logFile: typeof e.logFile === "string" ? e.logFile : null,
|
|
948
|
+
dot: open ? "unknown" : exitCode === 0 ? "ok" : "bad",
|
|
949
|
+
exitLabel: open
|
|
950
|
+
? "still running"
|
|
951
|
+
: exitCode === null
|
|
952
|
+
? "ended"
|
|
953
|
+
: `exit ${exitCode}${typeof e.failureClass === "string" ? ` — ${CLASS_LABELS[e.failureClass] ?? e.failureClass}` : ""}`,
|
|
954
|
+
};
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// ---------------------------------------------------------------------------
|
|
958
|
+
// the two inboxes: keyboard triage
|
|
959
|
+
// ---------------------------------------------------------------------------
|
|
960
|
+
|
|
961
|
+
/** Approvals-inbox action keys. */
|
|
962
|
+
export const APPROVAL_KEYS = { g: "grant", d: "deny" };
|
|
963
|
+
/** Review-queue action keys — the verdicts the server accepts. */
|
|
964
|
+
export const REVIEW_KEYS = { u: "up", d: "down", p: "pass", f: "fail" };
|
|
965
|
+
|
|
966
|
+
/** The bindings help sheet, rendered by `?`. */
|
|
967
|
+
export function keyHelp(actionKeys) {
|
|
968
|
+
const keys = actionKeys && typeof actionKeys === "object" ? actionKeys : {};
|
|
969
|
+
return [
|
|
970
|
+
{ key: "j / ↓", does: "next row" },
|
|
971
|
+
{ key: "k / ↑", does: "previous row" },
|
|
972
|
+
...Object.entries(keys).map(([key, action]) => ({ key, does: action })),
|
|
973
|
+
{ key: ".", does: "row menu" },
|
|
974
|
+
{ key: "?", does: "this list" },
|
|
975
|
+
{ key: "esc", does: "close menus" },
|
|
976
|
+
];
|
|
977
|
+
}
|
|
978
|
+
|
|
979
|
+
/** Navigation keys the triage reducer owns (everything else falls through
|
|
980
|
+
* to the browser — an inbox must not swallow ⌘F). */
|
|
981
|
+
const TRIAGE_NAV_KEYS = new Set(["j", "k", "ArrowDown", "ArrowUp", ".", "?", "Escape"]);
|
|
982
|
+
|
|
983
|
+
/** True when the reducer would act on this key — the view's "should I
|
|
984
|
+
* preventDefault?" test, kept here so it cannot drift from the reducer. */
|
|
985
|
+
export function isTriageKey(key, actionKeys = APPROVAL_KEYS) {
|
|
986
|
+
if (TRIAGE_NAV_KEYS.has(key)) return true;
|
|
987
|
+
const map = actionKeys && typeof actionKeys === "object" ? actionKeys : {};
|
|
988
|
+
return Object.prototype.hasOwnProperty.call(map, key);
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
/**
|
|
992
|
+
* The triage keyboard reducer. Pure: `(state, key) → { state, effect }`,
|
|
993
|
+
* where `effect` is the action a row asked for (the view performs it). Both
|
|
994
|
+
* inboxes share it; only the action-key map differs.
|
|
995
|
+
*/
|
|
996
|
+
export function triageKey(state, key, actionKeys = APPROVAL_KEYS) {
|
|
997
|
+
const s = {
|
|
998
|
+
index: 0,
|
|
999
|
+
count: 0,
|
|
1000
|
+
menuOpen: false,
|
|
1001
|
+
helpOpen: false,
|
|
1002
|
+
...(state && typeof state === "object" ? state : {}),
|
|
1003
|
+
};
|
|
1004
|
+
const clamp = (i) => (s.count <= 0 ? 0 : Math.min(Math.max(i, 0), s.count - 1));
|
|
1005
|
+
const next = (patch) => ({ state: { ...s, ...patch }, effect: null });
|
|
1006
|
+
switch (key) {
|
|
1007
|
+
case "j":
|
|
1008
|
+
case "ArrowDown":
|
|
1009
|
+
return next({ index: clamp(s.index + 1), menuOpen: false });
|
|
1010
|
+
case "k":
|
|
1011
|
+
case "ArrowUp":
|
|
1012
|
+
return next({ index: clamp(s.index - 1), menuOpen: false });
|
|
1013
|
+
case ".":
|
|
1014
|
+
return next({ menuOpen: !s.menuOpen });
|
|
1015
|
+
case "?":
|
|
1016
|
+
return next({ helpOpen: !s.helpOpen });
|
|
1017
|
+
case "Escape":
|
|
1018
|
+
return next({ menuOpen: false, helpOpen: false });
|
|
1019
|
+
default: {
|
|
1020
|
+
const map = actionKeys && typeof actionKeys === "object" ? actionKeys : {};
|
|
1021
|
+
const action = map[key];
|
|
1022
|
+
if (action === undefined || s.count <= 0) return { state: s, effect: null };
|
|
1023
|
+
return { state: { ...s, menuOpen: false }, effect: { action, index: clamp(s.index) } };
|
|
1024
|
+
}
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
|
|
1028
|
+
/** One approvals row → its inbox model. */
|
|
1029
|
+
export function approvalRow(row, nowMs) {
|
|
1030
|
+
const r = row && typeof row === "object" ? row : {};
|
|
1031
|
+
const status = typeof r.status === "string" ? r.status : "pending";
|
|
1032
|
+
const parked = r.parkedRun && typeof r.parkedRun === "object" ? r.parkedRun : null;
|
|
1033
|
+
return {
|
|
1034
|
+
id: typeof r.id === "string" ? r.id : "",
|
|
1035
|
+
harnessId: typeof r.harnessId === "string" ? r.harnessId : "",
|
|
1036
|
+
specName: typeof r.specName === "string" ? r.specName : "",
|
|
1037
|
+
toolName: typeof r.toolName === "string" ? r.toolName : "tool",
|
|
1038
|
+
// Verbatim (the server already masked it) — an approver cannot judge a
|
|
1039
|
+
// call they cannot see.
|
|
1040
|
+
input: r.input ?? null,
|
|
1041
|
+
inputHash: typeof r.inputHash === "string" ? r.inputHash : "",
|
|
1042
|
+
surface: typeof r.surface === "string" ? r.surface : "",
|
|
1043
|
+
createdAt: typeof r.createdAt === "string" ? r.createdAt : null,
|
|
1044
|
+
age: fmtRelativeTime(r.createdAt ?? null, nowMs),
|
|
1045
|
+
status,
|
|
1046
|
+
pending: status === "pending",
|
|
1047
|
+
decidedBy: typeof r.decidedBy === "string" ? r.decidedBy : null,
|
|
1048
|
+
dot: status === "pending" ? "warn" : status === "denied" ? "bad" : "ok",
|
|
1049
|
+
parkedRun: parked,
|
|
1050
|
+
// A parked CLI/daemon run resumes once the approval is granted; the row
|
|
1051
|
+
// says so instead of leaving the operator wondering what happens next.
|
|
1052
|
+
resumeNote:
|
|
1053
|
+
parked !== null && typeof parked.runId === "string" && parked.runId !== ""
|
|
1054
|
+
? "granting resumes the parked run (it re-executes pre-resolved)"
|
|
1055
|
+
: null,
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** One review-queue row → its inbox model. */
|
|
1060
|
+
export function reviewRow(row, nowMs) {
|
|
1061
|
+
const r = row && typeof row === "object" ? row : {};
|
|
1062
|
+
const sourceRef = r.sourceRef && typeof r.sourceRef === "object" ? r.sourceRef : {};
|
|
1063
|
+
const status = typeof r.status === "string" ? r.status : "open";
|
|
1064
|
+
const adjudicable = r.adjudicable === true;
|
|
1065
|
+
return {
|
|
1066
|
+
id: typeof r.id === "string" ? r.id : "",
|
|
1067
|
+
harnessId: typeof r.harnessId === "string" ? r.harnessId : "",
|
|
1068
|
+
specName: typeof r.specName === "string" ? r.specName : "",
|
|
1069
|
+
kind: typeof r.kind === "string" ? r.kind : "item",
|
|
1070
|
+
status,
|
|
1071
|
+
open: status === "open",
|
|
1072
|
+
ts: typeof r.ts === "string" ? r.ts : null,
|
|
1073
|
+
age: fmtRelativeTime(r.ts ?? null, nowMs),
|
|
1074
|
+
context: typeof r.context === "string" ? r.context : "",
|
|
1075
|
+
sourceRef,
|
|
1076
|
+
sessionId: typeof sourceRef.sessionId === "string" ? sourceRef.sessionId : null,
|
|
1077
|
+
turn: typeof sourceRef.turn === "number" ? sourceRef.turn : null,
|
|
1078
|
+
adjudicable,
|
|
1079
|
+
// Thumbs settle the underlying disagreement; pass/fail records a verdict
|
|
1080
|
+
// on the item. Offering thumbs where they cannot land would 409.
|
|
1081
|
+
verdicts: adjudicable ? ["up", "down", "pass", "fail"] : ["pass", "fail"],
|
|
1082
|
+
verdictNote: adjudicable
|
|
1083
|
+
? "up/down adjudicate the rated turn itself"
|
|
1084
|
+
: "this item points at no session turn — pass/fail records the verdict on the item",
|
|
1085
|
+
dot: status === "open" ? "warn" : "ok",
|
|
1086
|
+
};
|
|
1087
|
+
}
|
|
1088
|
+
|
|
1089
|
+
// ---------------------------------------------------------------------------
|
|
1090
|
+
// the activity digest
|
|
1091
|
+
// ---------------------------------------------------------------------------
|
|
1092
|
+
|
|
1093
|
+
/** Digest kinds in display order, with their group headings. */
|
|
1094
|
+
export const ACTIVITY_KINDS = [
|
|
1095
|
+
{ kind: "run", label: "Runs" },
|
|
1096
|
+
{ kind: "session", label: "Sessions" },
|
|
1097
|
+
{ kind: "eval", label: "Evals" },
|
|
1098
|
+
{ kind: "approval", label: "Approvals" },
|
|
1099
|
+
{ kind: "spec", label: "Spec changes" },
|
|
1100
|
+
{ kind: "dream", label: "Dream" },
|
|
1101
|
+
{ kind: "wiki", label: "Wiki" },
|
|
1102
|
+
{ kind: "incident", label: "Incidents" },
|
|
1103
|
+
{ kind: "deploy", label: "Deploys" },
|
|
1104
|
+
];
|
|
1105
|
+
|
|
1106
|
+
/** Deep link for one digest item — every row lands on the screen that owns it. */
|
|
1107
|
+
export function activityHref(item) {
|
|
1108
|
+
const i = item && typeof item === "object" ? item : {};
|
|
1109
|
+
const id = typeof i.harnessId === "string" ? i.harnessId : "";
|
|
1110
|
+
const ref = typeof i.ref === "string" && i.ref !== "" ? i.ref : null;
|
|
1111
|
+
if (id === "") return null;
|
|
1112
|
+
switch (i.kind) {
|
|
1113
|
+
case "session":
|
|
1114
|
+
return ref !== null ? hrefHarness(id, "sessions", ref) : hrefHarness(id, "sessions");
|
|
1115
|
+
case "run":
|
|
1116
|
+
return ref !== null ? hrefHarness(id, "runs", ref) : hrefHarness(id, "runs");
|
|
1117
|
+
case "eval":
|
|
1118
|
+
return ref !== null ? hrefHarness(id, "evals", ref) : hrefHarness(id, "evals");
|
|
1119
|
+
case "spec":
|
|
1120
|
+
return hrefHarness(id, "spec");
|
|
1121
|
+
case "dream":
|
|
1122
|
+
case "wiki":
|
|
1123
|
+
return i.kind === "wiki" && ref !== null
|
|
1124
|
+
? hrefHarness(id, "memory", "wiki", ref)
|
|
1125
|
+
: hrefHarness(id, "memory");
|
|
1126
|
+
case "deploy":
|
|
1127
|
+
return hrefHarness(id, "deploy");
|
|
1128
|
+
case "approval":
|
|
1129
|
+
return "#/approvals";
|
|
1130
|
+
default:
|
|
1131
|
+
return hrefHarness(id);
|
|
1132
|
+
}
|
|
1133
|
+
}
|
|
1134
|
+
|
|
1135
|
+
/**
|
|
1136
|
+
* Group a digest by kind, in display order. `enabled` (a Set of kinds, or
|
|
1137
|
+
* null for "all") filters which groups render; every group keeps its count so
|
|
1138
|
+
* a filtered-out kind still shows how much it is hiding.
|
|
1139
|
+
*/
|
|
1140
|
+
export function activityGroups(items, enabled = null) {
|
|
1141
|
+
const list = Array.isArray(items) ? items : [];
|
|
1142
|
+
return ACTIVITY_KINDS.map((meta) => {
|
|
1143
|
+
const matching = list.filter((i) => i && i.kind === meta.kind);
|
|
1144
|
+
return {
|
|
1145
|
+
kind: meta.kind,
|
|
1146
|
+
label: meta.label,
|
|
1147
|
+
count: matching.length,
|
|
1148
|
+
shown: enabled === null || enabled.has(meta.kind),
|
|
1149
|
+
items: matching,
|
|
1150
|
+
};
|
|
1151
|
+
}).filter((g) => g.count > 0);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
// ---------------------------------------------------------------------------
|
|
1155
|
+
// jobs + deployments
|
|
1156
|
+
// ---------------------------------------------------------------------------
|
|
1157
|
+
|
|
1158
|
+
const JOB_DOT = {
|
|
1159
|
+
running: "ok",
|
|
1160
|
+
pending: "unknown",
|
|
1161
|
+
interrupted: "warn",
|
|
1162
|
+
failed: "bad",
|
|
1163
|
+
cancelled: "off",
|
|
1164
|
+
done: "off",
|
|
1165
|
+
};
|
|
1166
|
+
|
|
1167
|
+
/**
|
|
1168
|
+
* `/api/jobs` → the queue panel model.
|
|
1169
|
+
*
|
|
1170
|
+
* `recent` is READ, not decoration. A job that was running when the previous
|
|
1171
|
+
* manager died is closed as `interrupted` at the next boot and never
|
|
1172
|
+
* silently re-run — and closing it is exactly what takes it OUT of the
|
|
1173
|
+
* queue: `restore()` appends the terminal record to the ledger rather than
|
|
1174
|
+
* returning the job to `pending`/`running`. So the only channel an
|
|
1175
|
+
* interrupted job reaches the console through is `recent`, and folding just
|
|
1176
|
+
* `pending` + `running` left the Interrupted group permanently empty while
|
|
1177
|
+
* an abandoned mutating `compile` went unreported.
|
|
1178
|
+
*/
|
|
1179
|
+
export function jobQueueModel(payload) {
|
|
1180
|
+
const p = payload && typeof payload === "object" ? payload : {};
|
|
1181
|
+
const jobs = (v) => (Array.isArray(v) ? v : []).filter((j) => j && typeof j === "object");
|
|
1182
|
+
const pending = jobs(p.pending);
|
|
1183
|
+
const running = jobs(p.running);
|
|
1184
|
+
const recent = jobs(p.recent);
|
|
1185
|
+
// The same job can legitimately appear in two lists across a restore, so
|
|
1186
|
+
// the fold is de-duplicated by id rather than concatenated.
|
|
1187
|
+
const seen = new Set();
|
|
1188
|
+
const interrupted = [];
|
|
1189
|
+
for (const job of [...running, ...pending, ...recent]) {
|
|
1190
|
+
if (job.state !== "interrupted") continue;
|
|
1191
|
+
const key = typeof job.jobId === "string" && job.jobId !== "" ? job.jobId : null;
|
|
1192
|
+
if (key !== null) {
|
|
1193
|
+
if (seen.has(key)) continue;
|
|
1194
|
+
seen.add(key);
|
|
1195
|
+
}
|
|
1196
|
+
interrupted.push(job);
|
|
1197
|
+
}
|
|
1198
|
+
const runningLive = running.filter((j) => j.state !== "interrupted");
|
|
1199
|
+
const pendingLive = pending.filter((j) => j.state !== "interrupted");
|
|
1200
|
+
return {
|
|
1201
|
+
running: runningLive,
|
|
1202
|
+
pending: pendingLive,
|
|
1203
|
+
interrupted,
|
|
1204
|
+
total: runningLive.length + pendingLive.length + interrupted.length,
|
|
1205
|
+
};
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
/** One job record → its row model. */
|
|
1209
|
+
export function jobRow(job, nowMs) {
|
|
1210
|
+
const j = job && typeof job === "object" ? job : {};
|
|
1211
|
+
const state = typeof j.state === "string" ? j.state : "pending";
|
|
1212
|
+
return {
|
|
1213
|
+
jobId: typeof j.jobId === "string" ? j.jobId : "",
|
|
1214
|
+
harnessId: typeof j.harnessId === "string" ? j.harnessId : null,
|
|
1215
|
+
harnessDir: typeof j.harnessDir === "string" ? j.harnessDir : "",
|
|
1216
|
+
kind: typeof j.kind === "string" ? j.kind : "job",
|
|
1217
|
+
argv: Array.isArray(j.argv) ? j.argv.map(String) : [],
|
|
1218
|
+
state,
|
|
1219
|
+
dot: JOB_DOT[state] ?? "unknown",
|
|
1220
|
+
mutating: j.mutating === true,
|
|
1221
|
+
enqueued: fmtRelativeTime(j.enqueuedAt ?? null, nowMs),
|
|
1222
|
+
error: typeof j.error === "string" ? j.error : null,
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
/** PaaS providers the deploy adapters ship. */
|
|
1227
|
+
const PAAS_PROVIDERS = new Set(["fly", "fly.io", "flyio", "render", "railway", "heroku"]);
|
|
1228
|
+
|
|
1229
|
+
/** One deployment record → its row model, with an inferred deploy class. */
|
|
1230
|
+
export function deploymentRow(record, nowMs) {
|
|
1231
|
+
const r = record && typeof record === "object" ? record : {};
|
|
1232
|
+
const provider = String(r.provider ?? r.target ?? r.platform ?? "").toLowerCase();
|
|
1233
|
+
const klass = /cloudflare|cf-?worker|workers?/.test(provider)
|
|
1234
|
+
? "cf-worker"
|
|
1235
|
+
: PAAS_PROVIDERS.has(provider)
|
|
1236
|
+
? "PaaS"
|
|
1237
|
+
: provider === ""
|
|
1238
|
+
? "unknown"
|
|
1239
|
+
: "local";
|
|
1240
|
+
const health =
|
|
1241
|
+
typeof r.health === "string" ? r.health : typeof r.status === "string" ? r.status : null;
|
|
1242
|
+
const healthy = health === null ? null : /^(ok|healthy|up|live|running|success)$/i.test(health);
|
|
1243
|
+
return {
|
|
1244
|
+
env:
|
|
1245
|
+
typeof r.env === "string" ? r.env : typeof r.environment === "string" ? r.environment : "—",
|
|
1246
|
+
version: typeof r.version === "string" ? r.version : null,
|
|
1247
|
+
provider: provider === "" ? null : provider,
|
|
1248
|
+
klass,
|
|
1249
|
+
url: typeof r.url === "string" ? r.url : null,
|
|
1250
|
+
at: typeof r.at === "string" ? r.at : typeof r.deployedAt === "string" ? r.deployedAt : null,
|
|
1251
|
+
when: fmtRelativeTime(r.at ?? r.deployedAt ?? null, nowMs),
|
|
1252
|
+
health,
|
|
1253
|
+
dot: healthy === null ? "unknown" : healthy ? "ok" : "bad",
|
|
1254
|
+
healthLabel: health ?? "health not recorded",
|
|
1255
|
+
};
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
// ---------------------------------------------------------------------------
|
|
1259
|
+
// CLI twins
|
|
1260
|
+
// ---------------------------------------------------------------------------
|
|
1261
|
+
|
|
1262
|
+
function quoteArg(value) {
|
|
1263
|
+
const s = String(value);
|
|
1264
|
+
return s.includes(" ") ? JSON.stringify(s) : s;
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
/** The `crewhaus` argv each job kind runs — mirrors the server's `jobArgv`. */
|
|
1268
|
+
export const JOB_COMMANDS = {
|
|
1269
|
+
doctor: "crewhaus doctor",
|
|
1270
|
+
compile: "crewhaus compile crewhaus.yaml -o dist",
|
|
1271
|
+
eval: "crewhaus eval crewhaus.yaml",
|
|
1272
|
+
"dream-run": "crewhaus dream run crewhaus.yaml",
|
|
1273
|
+
};
|
|
1274
|
+
|
|
1275
|
+
/**
|
|
1276
|
+
* The exact command an action's CLI twin runs. Every button in the console
|
|
1277
|
+
* shows one: it is the single best trust affordance in the design, because
|
|
1278
|
+
* an operator can verify what a click will do before clicking it — and the
|
|
1279
|
+
* two heads (CLI and console) drive the same state tree.
|
|
1280
|
+
*
|
|
1281
|
+
* Returns null where no CLI verb exists yet; the caller renders the honest
|
|
1282
|
+
* note from {@link NO_TWIN_NOTES} instead of inventing a command.
|
|
1283
|
+
*/
|
|
1284
|
+
export function cliTwinFor(action, args = {}) {
|
|
1285
|
+
const a = args && typeof args === "object" ? args : {};
|
|
1286
|
+
const dir = typeof a.dir === "string" && a.dir !== "" ? a.dir : null;
|
|
1287
|
+
const at = (command) => (dir !== null ? `cd ${quoteArg(dir)} && ${command}` : command);
|
|
1288
|
+
switch (action) {
|
|
1289
|
+
case "start": {
|
|
1290
|
+
const ack = Array.isArray(a.acknowledge) ? a.acknowledge.filter((x) => x !== "") : [];
|
|
1291
|
+
return at(
|
|
1292
|
+
`crewhaus daemon start${a.force === true ? " --force" : ""}${
|
|
1293
|
+
ack.length > 0 ? ` --ack ${ack.join(",")}` : ""
|
|
1294
|
+
}`,
|
|
1295
|
+
);
|
|
1296
|
+
}
|
|
1297
|
+
case "stop":
|
|
1298
|
+
return at("crewhaus daemon stop");
|
|
1299
|
+
case "restart":
|
|
1300
|
+
return at("crewhaus daemon restart");
|
|
1301
|
+
case "drain":
|
|
1302
|
+
return at("crewhaus daemon drain");
|
|
1303
|
+
case "wake":
|
|
1304
|
+
return at(`crewhaus daemon wake --lane ${quoteArg(a.lane ?? "heartbeat")}`);
|
|
1305
|
+
case "logs":
|
|
1306
|
+
return at(
|
|
1307
|
+
`crewhaus daemon logs${a.runId !== undefined ? ` --run ${quoteArg(a.runId)}` : ""} --follow`,
|
|
1308
|
+
);
|
|
1309
|
+
case "grant":
|
|
1310
|
+
return at(`crewhaus approvals grant ${quoteArg(a.id ?? "<id>")}`);
|
|
1311
|
+
case "deny":
|
|
1312
|
+
return at(`crewhaus approvals deny ${quoteArg(a.id ?? "<id>")}`);
|
|
1313
|
+
case "review":
|
|
1314
|
+
return at(`crewhaus review resolve ${quoteArg(a.id ?? "<id>")}`);
|
|
1315
|
+
case "adjudicate":
|
|
1316
|
+
return at(
|
|
1317
|
+
`crewhaus rate --session ${quoteArg(a.sessionId ?? "<sess>")} --turn ${a.turn ?? 0} --thumbs ${quoteArg(a.verdict ?? "up")} --adjudicate`,
|
|
1318
|
+
);
|
|
1319
|
+
case "baseline":
|
|
1320
|
+
return at(`crewhaus eval-report baseline set ${quoteArg(a.runId ?? "<run>")}`);
|
|
1321
|
+
case "job":
|
|
1322
|
+
return typeof a.kind === "string" && JOB_COMMANDS[a.kind] !== undefined
|
|
1323
|
+
? at(JOB_COMMANDS[a.kind])
|
|
1324
|
+
: null;
|
|
1325
|
+
default:
|
|
1326
|
+
return null;
|
|
1327
|
+
}
|
|
1328
|
+
}
|
|
1329
|
+
|
|
1330
|
+
/** Actions with no CLI verb yet — said out loud rather than faked. */
|
|
1331
|
+
export const NO_TWIN_NOTES = {
|
|
1332
|
+
"pin-session":
|
|
1333
|
+
"no CLI verb yet — this writes the `pins` list in .crewhaus/retention.json, which both retention enforcers read",
|
|
1334
|
+
};
|