omp-conductor 0.18.2 → 0.19.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 +105 -40
- package/REFERENCE.md +865 -30
- package/package.json +1 -1
- package/schema/config.schema.json +26 -0
- package/src/admission.ts +212 -26
- package/src/ask.ts +288 -1
- package/src/briefs/orchestrator.md +6 -5
- package/src/cli.ts +5 -1
- package/src/command-help.ts +9 -1
- package/src/command-manifest.ts +36 -3
- package/src/commands/arm.ts +5 -1
- package/src/commands/context.ts +2 -0
- package/src/commands/message.ts +26 -2
- package/src/commands/reconcile-units.ts +104 -0
- package/src/commands/release-composition.ts +232 -0
- package/src/commands/resume.ts +2 -27
- package/src/commands/setup.ts +101 -16
- package/src/commands/stats.ts +11 -30
- package/src/commands/tail.ts +31 -1
- package/src/commands/upgrade.ts +20 -3
- package/src/commands/verb.ts +2 -1
- package/src/config-schema.ts +19 -0
- package/src/config.ts +80 -0
- package/src/credential-class.ts +366 -0
- package/src/daemon.ts +1218 -288
- package/src/dashboard/app.js +504 -2
- package/src/dashboard/controls.ts +336 -0
- package/src/dashboard/index.html +30 -0
- package/src/dashboard/server.ts +271 -30
- package/src/dashboard/style.css +116 -0
- package/src/dashboard/transcript.ts +173 -0
- package/src/doctor.ts +377 -20
- package/src/failure-class.ts +59 -0
- package/src/fleet.ts +497 -15
- package/src/host.ts +6 -130
- package/src/omp.ts +29 -0
- package/src/orchestrator-tick.ts +343 -88
- package/src/pause.ts +233 -0
- package/src/settlement.ts +159 -2
- package/src/setup-answers.ts +97 -0
- package/src/setup-host.ts +321 -1155
- package/src/setup-install.ts +204 -27
- package/src/setup-wizard.ts +111 -50
- package/src/setup.ts +33 -0
- package/src/spend-telemetry.ts +117 -0
- package/src/stats.ts +35 -0
- package/src/status-render.ts +348 -19
- package/src/store.ts +1229 -55
- package/src/telegram-freshness.ts +269 -0
- package/src/to-spec.ts +27 -0
- package/src/types.ts +697 -4
- package/src/unblock.ts +22 -0
- package/src/unit-reconcile.ts +303 -0
- package/src/upgrade-verify.ts +8 -1
- package/src/upgrade.ts +299 -12
- package/src/verbs/actions.ts +124 -10
- package/src/verbs/protocol.ts +70 -2
- package/src/verbs/server.ts +447 -8
- package/src/wake.ts +48 -0
- package/src/worker.ts +403 -3
|
@@ -0,0 +1,336 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The dashboard's mutating surface (epic #292, slice 3/5 — #295).
|
|
3
|
+
*
|
|
4
|
+
* Slices 1/5 and 2/5 made the fleet legible from a browser; this one makes it
|
|
5
|
+
* drivable, so an operator can pause dispatch, extend a run, unblock an issue,
|
|
6
|
+
* control a live worker and answer a decision without a shell on the host.
|
|
7
|
+
*
|
|
8
|
+
* ## One rule, and everything here follows from it
|
|
9
|
+
*
|
|
10
|
+
* **No parallel implementations.** Every action routes to the exact code its CLI
|
|
11
|
+
* twin runs, and where that code already lives behind an HTTP endpoint the
|
|
12
|
+
* dashboard *calls that endpoint* rather than reaching past it to the store.
|
|
13
|
+
*
|
|
14
|
+
* That second half is the part worth stating, because the naive reading goes the
|
|
15
|
+
* other way. `extend` ultimately calls `store.setTurnOverride`, so it is tempting
|
|
16
|
+
* to call that from here. But `turnLimitResponse` is not a thin wrapper around it:
|
|
17
|
+
* it owns a whole validation ladder — the `workerMaxTurnsCeiling` bound, the
|
|
18
|
+
* live-controller check against the run's stored state, the "already has a higher
|
|
19
|
+
* ceiling" refusal, the live-registry extend that takes effect *this* turn versus
|
|
20
|
+
* the next-attempt override that does not. Reimplementing that here would be
|
|
21
|
+
* exactly the fork the slice forbids, and the copy nobody updated would be the
|
|
22
|
+
* one the browser used. So `extend` is proxied, like worker control.
|
|
23
|
+
*
|
|
24
|
+
* What is called directly is what has no HTTP owner: the pause sentinel and the
|
|
25
|
+
* tick marker are host-local files, and `unblock` and decision resolution are
|
|
26
|
+
* store operations the CLI performs in-process too.
|
|
27
|
+
*
|
|
28
|
+
* ## Attribution
|
|
29
|
+
*
|
|
30
|
+
* Every mutation records itself the way its CLI twin does, with source
|
|
31
|
+
* `"dashboard"` where the sink takes a source. That is what makes
|
|
32
|
+
* `omp-conductor status` able to say *who* paused the fleet, rather than only
|
|
33
|
+
* that it is paused.
|
|
34
|
+
*
|
|
35
|
+
* ## What is deliberately absent
|
|
36
|
+
*
|
|
37
|
+
* `arm`. It requires an inbound Telegram proof that a human answered a
|
|
38
|
+
* challenge, and a browser button cannot supply one. A dashboard `arm` would
|
|
39
|
+
* either weaken that proof or lie about having it, so arming stays CLI/Telegram
|
|
40
|
+
* exactly as the epic says.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
import { disarmTicks, hold, releaseHold } from "../fleet.ts";
|
|
44
|
+
import { healthCheck, livingDaemon } from "../lifecycle.ts";
|
|
45
|
+
import { unblockIssue } from "../unblock.ts";
|
|
46
|
+
import { makeTracker } from "../tracker/github.ts";
|
|
47
|
+
import { dbPath, openStore } from "../store.ts";
|
|
48
|
+
import { loadConfig, resolveCaps } from "../config.ts";
|
|
49
|
+
import type { ProjectConfig } from "../types.ts";
|
|
50
|
+
|
|
51
|
+
/** The source string every dashboard mutation attributes itself with. */
|
|
52
|
+
/**
|
|
53
|
+
* The source token every dashboard mutation attributes itself with.
|
|
54
|
+
*
|
|
55
|
+
* A single word by contract: the pause fence matches on `source=` and whitespace
|
|
56
|
+
* there produces a pause it cannot prove (#552, `pauseSourceToken`). The
|
|
57
|
+
* operator's sentence rides in `reason=` instead.
|
|
58
|
+
*/
|
|
59
|
+
export const DASHBOARD_SOURCE = "dashboard";
|
|
60
|
+
|
|
61
|
+
/** A mutation's outcome, already shaped as the HTTP answer it becomes. */
|
|
62
|
+
export interface ControlOutcome {
|
|
63
|
+
status: number;
|
|
64
|
+
body: unknown;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Injected so tests drive every branch without a daemon, a tracker or a clock. */
|
|
68
|
+
export interface ControlDeps {
|
|
69
|
+
/** The project this action names, already resolved by the router. */
|
|
70
|
+
project: ProjectConfig;
|
|
71
|
+
hold?: typeof hold;
|
|
72
|
+
releaseHold?: typeof releaseHold;
|
|
73
|
+
disarmTicks?: typeof disarmTicks;
|
|
74
|
+
/**
|
|
75
|
+
* Forwards one request to the owning daemon's own HTTP surface. Returns
|
|
76
|
+
* `undefined` when no living daemon serves this project — a 502 the caller
|
|
77
|
+
* words, never a silent success.
|
|
78
|
+
*/
|
|
79
|
+
proxy?: (
|
|
80
|
+
project: ProjectConfig,
|
|
81
|
+
path: string,
|
|
82
|
+
body: unknown,
|
|
83
|
+
) => Promise<{ status: number; body: unknown } | undefined>;
|
|
84
|
+
/** `unblockIssue` with the CLI's own tracker/store/caps wiring. */
|
|
85
|
+
unblock?: (
|
|
86
|
+
project: ProjectConfig,
|
|
87
|
+
issue: number,
|
|
88
|
+
opts: { force?: boolean },
|
|
89
|
+
) => Promise<{ status: number; body: unknown }>;
|
|
90
|
+
/** Resolves one decision row; `false` when it changed nothing. */
|
|
91
|
+
answerDecision?: (id: string, answer: string) => boolean;
|
|
92
|
+
now?: () => number;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/** The reason string a pause/hold takes, validated once for every caller. */
|
|
96
|
+
function readReason(body: unknown): string | undefined {
|
|
97
|
+
if (body === null || typeof body !== "object") return undefined;
|
|
98
|
+
const reason = Reflect.get(body, "reason");
|
|
99
|
+
return typeof reason === "string" && reason.trim() !== "" ? reason.trim() : undefined;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Stop new claims, keeping the heartbeat.
|
|
104
|
+
*
|
|
105
|
+
* `keepTicks` is the whole reason this is `hold(..., { keepTicks: true })` and
|
|
106
|
+
* not a bare `hold`: disarming is the expensive half, because re-arming needs a
|
|
107
|
+
* Telegram challenge an operator has to answer in the chat. A browser button
|
|
108
|
+
* that cost a manual round trip to undo would not be used, so the dashboard's
|
|
109
|
+
* pause is the cheap, reversible one — `resume` alone restores the fleet.
|
|
110
|
+
*/
|
|
111
|
+
export function dashboardPause(body: unknown, d: ControlDeps): ControlOutcome {
|
|
112
|
+
const reason = readReason(body);
|
|
113
|
+
if (reason === undefined) {
|
|
114
|
+
return { status: 400, body: { error: "reason must be a non-empty string" } };
|
|
115
|
+
}
|
|
116
|
+
const held = (d.hold ?? hold)(d.project.name, DASHBOARD_SOURCE, { keepTicks: true, reason });
|
|
117
|
+
return { status: 200, body: { paused: true, wasPaused: held.wasPaused, source: DASHBOARD_SOURCE } };
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Clear the pause. Idempotent, exactly as `resume` is on the CLI. */
|
|
121
|
+
export function dashboardResume(d: ControlDeps): ControlOutcome {
|
|
122
|
+
(d.releaseHold ?? releaseHold)(d.project.name);
|
|
123
|
+
return { status: 200, body: { paused: false } };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* The heavier hold: stop claims *and* disarm the heartbeat.
|
|
128
|
+
*
|
|
129
|
+
* Distinct from `pause` on purpose — this is the one that costs a Telegram
|
|
130
|
+
* challenge to undo — so the two are separate endpoints and the UI can warn
|
|
131
|
+
* about this one and not the other.
|
|
132
|
+
*/
|
|
133
|
+
export function dashboardHold(body: unknown, d: ControlDeps): ControlOutcome {
|
|
134
|
+
const reason = readReason(body);
|
|
135
|
+
if (reason === undefined) {
|
|
136
|
+
return { status: 400, body: { error: "reason must be a non-empty string" } };
|
|
137
|
+
}
|
|
138
|
+
const held = (d.hold ?? hold)(d.project.name, DASHBOARD_SOURCE, { reason });
|
|
139
|
+
return {
|
|
140
|
+
status: 200,
|
|
141
|
+
body: {
|
|
142
|
+
paused: true,
|
|
143
|
+
wasPaused: held.wasPaused,
|
|
144
|
+
disarmed: held.disarmed?.wasArmed ?? false,
|
|
145
|
+
source: DASHBOARD_SOURCE,
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
/** Disarm ticks without touching dispatch. */
|
|
151
|
+
export function dashboardDisarm(d: ControlDeps): ControlOutcome {
|
|
152
|
+
const result = (d.disarmTicks ?? disarmTicks)(d.project.name);
|
|
153
|
+
return { status: 200, body: { armed: false, wasArmed: result.wasArmed, path: result.path } };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Raise one run's turn budget, through the daemon that owns it.
|
|
158
|
+
*
|
|
159
|
+
* Proxied rather than written directly: see this module's header. The daemon's
|
|
160
|
+
* `PUT /runs/:issue/turn-limit` owns every bound and refusal, and its answers —
|
|
161
|
+
* 409 for a settled run, 422 over the ceiling — pass through unchanged, so the
|
|
162
|
+
* browser and `omp-conductor extend` cannot come to disagree about what is
|
|
163
|
+
* allowed.
|
|
164
|
+
*/
|
|
165
|
+
export async function dashboardExtend(
|
|
166
|
+
issue: number,
|
|
167
|
+
body: unknown,
|
|
168
|
+
d: ControlDeps,
|
|
169
|
+
): Promise<ControlOutcome> {
|
|
170
|
+
const maxTurns = body === null || typeof body !== "object" ? undefined : Reflect.get(body, "maxTurns");
|
|
171
|
+
if (!Number.isSafeInteger(maxTurns) || (maxTurns as number) < 1) {
|
|
172
|
+
return { status: 400, body: { error: "maxTurns must be a positive integer" } };
|
|
173
|
+
}
|
|
174
|
+
return proxied(d, `/runs/${issue}/turn-limit`, {
|
|
175
|
+
project: d.project.name,
|
|
176
|
+
maxTurns: maxTurns as number,
|
|
177
|
+
});
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Pause, resume or stop one live worker, through the daemon that owns it.
|
|
182
|
+
*
|
|
183
|
+
* The daemon holds the session controller; nothing else can reach it. A dead
|
|
184
|
+
* daemon is a 502 naming it, never a 200 that pretends a worker was paused.
|
|
185
|
+
*/
|
|
186
|
+
export async function dashboardWorkerControl(
|
|
187
|
+
issue: number,
|
|
188
|
+
action: "pause" | "resume" | "stop",
|
|
189
|
+
body: unknown,
|
|
190
|
+
d: ControlDeps,
|
|
191
|
+
): Promise<ControlOutcome> {
|
|
192
|
+
const payload: Record<string, unknown> = { project: d.project.name };
|
|
193
|
+
if (action === "stop") {
|
|
194
|
+
const reason = readReason(body);
|
|
195
|
+
if (reason === undefined) {
|
|
196
|
+
// The CLI requires a reason to stop a run, because a terminal settlement
|
|
197
|
+
// with no recorded why is unauditable. A button must not be cheaper.
|
|
198
|
+
return { status: 400, body: { error: "reason must be a non-empty string" } };
|
|
199
|
+
}
|
|
200
|
+
payload["reason"] = `${DASHBOARD_SOURCE}: ${reason}`;
|
|
201
|
+
}
|
|
202
|
+
return proxied(d, `/runs/${issue}/${action}`, payload);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** One proxied call, with the daemon-down answer worded once. */
|
|
206
|
+
async function proxied(d: ControlDeps, path: string, body: unknown): Promise<ControlOutcome> {
|
|
207
|
+
const forward = d.proxy ?? defaultProxy;
|
|
208
|
+
const answer = await forward(d.project, path, body);
|
|
209
|
+
if (answer === undefined) {
|
|
210
|
+
return {
|
|
211
|
+
status: 502,
|
|
212
|
+
body: {
|
|
213
|
+
error:
|
|
214
|
+
`no living daemon serves project "${d.project.name}", so ${path} could not be applied. ` +
|
|
215
|
+
"Start it with `omp-conductor start` and try again.",
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
return { status: answer.status, body: answer.body };
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Take an issue out of a blocked/failed state, exactly as `omp-conductor
|
|
224
|
+
* unblock` does — same tracker, same store, same caps, same refusals.
|
|
225
|
+
*
|
|
226
|
+
* `force` is accepted and passed through, but the UI is required to confirm it
|
|
227
|
+
* by name: it records that a human inspected a worktree whose uncommitted work
|
|
228
|
+
* is the only copy, and a button that did that silently would destroy work
|
|
229
|
+
* nobody chose to lose.
|
|
230
|
+
*/
|
|
231
|
+
export async function dashboardUnblock(
|
|
232
|
+
issue: number,
|
|
233
|
+
body: unknown,
|
|
234
|
+
d: ControlDeps,
|
|
235
|
+
): Promise<ControlOutcome> {
|
|
236
|
+
const force = body !== null && typeof body === "object" && Reflect.get(body, "force") === true;
|
|
237
|
+
const run = d.unblock ?? defaultUnblock;
|
|
238
|
+
return run(d.project, issue, { force });
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/**
|
|
242
|
+
* Answer one open decision row.
|
|
243
|
+
*
|
|
244
|
+
* `resolveDecision` returns false when it changed no rows, which is exactly the
|
|
245
|
+
* already-answered case — so a second answer is a 409 rather than a silent
|
|
246
|
+
* overwrite of the first. That matters more than it looks: the row is the record
|
|
247
|
+
* of what a human decided, and a later write would rewrite history.
|
|
248
|
+
*/
|
|
249
|
+
export function dashboardAnswerDecision(id: string, body: unknown, d: ControlDeps): ControlOutcome {
|
|
250
|
+
const answer = body === null || typeof body !== "object" ? undefined : Reflect.get(body, "answer");
|
|
251
|
+
if (typeof answer !== "string" || answer.trim() === "") {
|
|
252
|
+
return { status: 400, body: { error: "answer must be a non-empty string" } };
|
|
253
|
+
}
|
|
254
|
+
const resolve = d.answerDecision ?? defaultAnswerDecision;
|
|
255
|
+
return resolve(id, answer.trim())
|
|
256
|
+
? { status: 200, body: { id, state: "answered", resolution: answer.trim() } }
|
|
257
|
+
: {
|
|
258
|
+
status: 409,
|
|
259
|
+
body: { error: `decision ${id} is not open — it was already answered or withdrawn` },
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ------------------------------------------------------- production wiring --
|
|
264
|
+
|
|
265
|
+
/** Forward to the living daemon's own HTTP surface, or report there is none. */
|
|
266
|
+
async function defaultProxy(
|
|
267
|
+
project: ProjectConfig,
|
|
268
|
+
path: string,
|
|
269
|
+
body: unknown,
|
|
270
|
+
): Promise<{ status: number; body: unknown } | undefined> {
|
|
271
|
+
const record = livingDaemon();
|
|
272
|
+
// The same membership rule every other dashboard read uses (#379): a record
|
|
273
|
+
// pinned to another project does not serve this one, and forwarding to it
|
|
274
|
+
// would apply an operator's action to the wrong fleet.
|
|
275
|
+
if (record === undefined) return undefined;
|
|
276
|
+
if (record.project !== undefined && record.project !== project.name) return undefined;
|
|
277
|
+
// Cheap liveness first, so an unreachable daemon is a 502 rather than a
|
|
278
|
+
// mutation attempt that hangs the browser.
|
|
279
|
+
if (!(await healthCheck(record.port)).ok) return undefined;
|
|
280
|
+
try {
|
|
281
|
+
const res = await fetch(`http://127.0.0.1:${record.port}${path}`, {
|
|
282
|
+
method: "PUT",
|
|
283
|
+
headers: { "content-type": "application/json" },
|
|
284
|
+
body: JSON.stringify(body),
|
|
285
|
+
signal: AbortSignal.timeout(10_000),
|
|
286
|
+
});
|
|
287
|
+
const text = await res.text();
|
|
288
|
+
let parsed: unknown;
|
|
289
|
+
try {
|
|
290
|
+
parsed = JSON.parse(text) as unknown;
|
|
291
|
+
} catch {
|
|
292
|
+
parsed = { error: text.trim() };
|
|
293
|
+
}
|
|
294
|
+
return { status: res.status, body: parsed };
|
|
295
|
+
} catch {
|
|
296
|
+
return undefined;
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** `unblockIssue` with the CLI's own wiring, translated to an HTTP answer. */
|
|
301
|
+
async function defaultUnblock(
|
|
302
|
+
project: ProjectConfig,
|
|
303
|
+
issue: number,
|
|
304
|
+
opts: { force?: boolean },
|
|
305
|
+
): Promise<{ status: number; body: unknown }> {
|
|
306
|
+
const store = openStore(dbPath());
|
|
307
|
+
try {
|
|
308
|
+
const cfg = loadConfig();
|
|
309
|
+
const outcome = await unblockIssue(
|
|
310
|
+
project,
|
|
311
|
+
makeTracker(project),
|
|
312
|
+
store,
|
|
313
|
+
issue,
|
|
314
|
+
opts,
|
|
315
|
+
resolveCaps(project, cfg.defaults),
|
|
316
|
+
);
|
|
317
|
+
// A refusal is the store's own verdict — an uncommitted worktree that is the
|
|
318
|
+
// only copy of a worker's output — and it is a 409, not a 500: nothing went
|
|
319
|
+
// wrong, the answer is no.
|
|
320
|
+
return outcome.refused === undefined
|
|
321
|
+
? { status: 200, body: outcome }
|
|
322
|
+
: { status: 409, body: outcome };
|
|
323
|
+
} finally {
|
|
324
|
+
store.close();
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
/** Resolve one decision through the same store call `decision resolve` uses. */
|
|
329
|
+
function defaultAnswerDecision(id: string, answer: string): boolean {
|
|
330
|
+
const store = openStore(dbPath());
|
|
331
|
+
try {
|
|
332
|
+
return store.resolveDecision(id, "answered", answer, Date.now());
|
|
333
|
+
} finally {
|
|
334
|
+
store.close();
|
|
335
|
+
}
|
|
336
|
+
}
|
package/src/dashboard/index.html
CHANGED
|
@@ -35,18 +35,48 @@
|
|
|
35
35
|
<h2 id="project-title"></h2>
|
|
36
36
|
<p id="project-daemon" class="state"></p>
|
|
37
37
|
<div id="project-summary" class="summary"></div>
|
|
38
|
+
<!-- The control surface (#295). `arm` is deliberately absent: it needs an
|
|
39
|
+
inbound Telegram proof a browser cannot supply, so it stays CLI. -->
|
|
40
|
+
<div class="controls" id="controls">
|
|
41
|
+
<button data-control="pause" type="button">Pause dispatch</button>
|
|
42
|
+
<button data-control="resume" type="button">Resume</button>
|
|
43
|
+
<button data-control="hold" type="button" class="destructive">Hold (disarms ticks)</button>
|
|
44
|
+
<button data-control="disarm" type="button" class="destructive">Disarm ticks</button>
|
|
45
|
+
<p id="control-result" class="state" hidden></p>
|
|
46
|
+
</div>
|
|
38
47
|
<nav class="tabs" id="tabs">
|
|
39
48
|
<button data-tab="board" type="button">Board</button>
|
|
40
49
|
<button data-tab="ledger" type="button">Ledger</button>
|
|
41
50
|
<button data-tab="reports" type="button">Reports</button>
|
|
51
|
+
<button data-tab="stats" type="button">Analytics</button>
|
|
42
52
|
</nav>
|
|
43
53
|
<section id="panel-board"></section>
|
|
44
54
|
<section id="panel-ledger" hidden></section>
|
|
45
55
|
<section id="panel-reports" hidden></section>
|
|
56
|
+
<section id="panel-stats" hidden>
|
|
57
|
+
<!-- Analytics (#297). The window selector mirrors `stats --since`, and the
|
|
58
|
+
numbers are the same aggregation `stats --json` prints. -->
|
|
59
|
+
<div class="controls" id="stats-window">
|
|
60
|
+
<button data-since="7d" type="button">7 days</button>
|
|
61
|
+
<button data-since="30d" type="button">30 days</button>
|
|
62
|
+
<button data-since="custom" type="button">Custom…</button>
|
|
63
|
+
<span id="stats-window-label" class="state"></span>
|
|
64
|
+
</div>
|
|
65
|
+
<div id="stats-body"></div>
|
|
66
|
+
</section>
|
|
46
67
|
|
|
47
68
|
<div id="run-detail" hidden>
|
|
48
69
|
<p class="back"><a href="#" id="back-to-board">← board</a></p>
|
|
49
70
|
<h3 id="run-title"></h3>
|
|
71
|
+
<div class="controls" id="run-controls"></div>
|
|
72
|
+
<!-- Live transcript (#296): the same lines `omp-conductor tail` prints,
|
|
73
|
+
streamed as SSE. Autoscroll is on until the operator scrolls up. -->
|
|
74
|
+
<div class="transcript-head">
|
|
75
|
+
<button id="transcript-toggle" type="button">Watch transcript</button>
|
|
76
|
+
<label><input type="checkbox" id="transcript-autoscroll" checked /> autoscroll</label>
|
|
77
|
+
<span id="transcript-state" class="state"></span>
|
|
78
|
+
</div>
|
|
79
|
+
<pre id="transcript" class="transcript" hidden></pre>
|
|
50
80
|
<table class="runs">
|
|
51
81
|
<thead>
|
|
52
82
|
<tr><th>Attempt</th><th>State</th><th>Spend</th><th>Failure class</th><th>PR</th><th>Started</th></tr>
|