@clemrig/flow-ui 0.5.0 → 0.6.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 +3 -1
- package/dist/server.d.ts +5 -0
- package/dist/server.js +29 -13
- package/dist/server.js.map +1 -1
- package/package.json +15 -5
- package/page/app.css +1 -0
- package/page/app.js +13 -0
- package/page/index.html +13 -0
- package/dist/static/dashboard.js +0 -207
- package/dist/static/detail.js +0 -489
- package/dist/static/follow.js +0 -103
- package/dist/static/index.html +0 -20
- package/dist/static/style.css +0 -460
- package/dist/static/timeline.js +0 -443
package/page/index.html
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8">
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
6
|
+
<title>runs</title>
|
|
7
|
+
<script type="module" crossorigin src="./app.js"></script>
|
|
8
|
+
<link rel="stylesheet" crossorigin href="./app.css">
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<div id="root"></div>
|
|
12
|
+
</body>
|
|
13
|
+
</html>
|
package/dist/static/dashboard.js
DELETED
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* The dashboard: the run list every 2 seconds, and the selected run as
|
|
4
|
-
* `follow.js` follows it. Text only, never HTML.
|
|
5
|
-
*/
|
|
6
|
-
import { detailSections } from "./detail.js";
|
|
7
|
-
import { follower } from "./follow.js";
|
|
8
|
-
import { cost, duration } from "./timeline.js";
|
|
9
|
-
|
|
10
|
-
/** @typedef {import("@clemrig/flow").RunEvent} RunEvent */
|
|
11
|
-
/** @typedef {import("./detail.js").Summary} Summary */
|
|
12
|
-
/** @typedef {import("./detail.js").Detail} Detail */
|
|
13
|
-
|
|
14
|
-
const LIST_EVERY_MS = 2000;
|
|
15
|
-
const DETAIL_EVERY_MS = 1000;
|
|
16
|
-
|
|
17
|
-
/**
|
|
18
|
-
* @param {string} id
|
|
19
|
-
* @returns {HTMLElement}
|
|
20
|
-
*/
|
|
21
|
-
function byId(id) {
|
|
22
|
-
const found = document.getElementById(id);
|
|
23
|
-
if (!found) throw new Error(`Missing element #${id}`);
|
|
24
|
-
return found;
|
|
25
|
-
}
|
|
26
|
-
|
|
27
|
-
/**
|
|
28
|
-
* @param {string} tag
|
|
29
|
-
* @param {string} [text]
|
|
30
|
-
* @param {string} [className]
|
|
31
|
-
* @returns {HTMLElement}
|
|
32
|
-
*/
|
|
33
|
-
function el(tag, text, className) {
|
|
34
|
-
const node = document.createElement(tag);
|
|
35
|
-
if (text !== undefined) node.textContent = text;
|
|
36
|
-
if (className) node.className = className;
|
|
37
|
-
return node;
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
/** @param {string} iso */
|
|
41
|
-
function time(iso) {
|
|
42
|
-
const date = new Date(iso);
|
|
43
|
-
return Number.isNaN(date.getTime())
|
|
44
|
-
? iso
|
|
45
|
-
: date.toLocaleString(undefined, {
|
|
46
|
-
dateStyle: "short",
|
|
47
|
-
timeStyle: "medium",
|
|
48
|
-
});
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
/**
|
|
52
|
-
* @param {string} path
|
|
53
|
-
* @returns {Promise<any>}
|
|
54
|
-
*/
|
|
55
|
-
async function load(path) {
|
|
56
|
-
const response = await fetch(path, { cache: "no-store" });
|
|
57
|
-
if (!response.ok)
|
|
58
|
-
throw Object.assign(
|
|
59
|
-
new Error((await response.text()) || `HTTP ${response.status}`),
|
|
60
|
-
{ status: response.status },
|
|
61
|
-
);
|
|
62
|
-
return response.json();
|
|
63
|
-
}
|
|
64
|
-
|
|
65
|
-
/** The run the address names, or none when it cannot be read. */
|
|
66
|
-
function fromHash() {
|
|
67
|
-
try {
|
|
68
|
-
return decodeURIComponent(location.hash.slice(1));
|
|
69
|
-
} catch {
|
|
70
|
-
return "";
|
|
71
|
-
}
|
|
72
|
-
}
|
|
73
|
-
|
|
74
|
-
let selected = fromHash();
|
|
75
|
-
|
|
76
|
-
/** @param {Summary[]} runs */
|
|
77
|
-
function showRuns(runs) {
|
|
78
|
-
byId("none").hidden = runs.length > 0;
|
|
79
|
-
byId("runs").replaceChildren(
|
|
80
|
-
...runs.map((run) => {
|
|
81
|
-
const row = el("tr");
|
|
82
|
-
row.title = run.id;
|
|
83
|
-
if (run.id === selected) row.className = "selected";
|
|
84
|
-
if (run.error !== undefined) {
|
|
85
|
-
const cause = el("td", run.error);
|
|
86
|
-
cause.setAttribute("colspan", "4");
|
|
87
|
-
row.append(el("td", run.id), el("td", "unreadable", "failed"), cause);
|
|
88
|
-
} else
|
|
89
|
-
row.append(
|
|
90
|
-
el("td", run.workflow),
|
|
91
|
-
el("td", run.status, run.status),
|
|
92
|
-
el("td", run.stage),
|
|
93
|
-
el("td", duration(run.durationMs ?? null)),
|
|
94
|
-
el("td", cost(run.costUsd ?? null, run.partial ?? false)),
|
|
95
|
-
el("td", time(run.updatedAt ?? "")),
|
|
96
|
-
);
|
|
97
|
-
row.addEventListener("click", () => select(run.id));
|
|
98
|
-
return row;
|
|
99
|
-
}),
|
|
100
|
-
);
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
/** The detail on screen, to render it again when the operator changes the view. */
|
|
104
|
-
let shown = /** @type {[Detail, RunEvent[]] | null} */ (null);
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Renders the selected run's detail, keeping where the operator scrolled and
|
|
108
|
-
* what has the focus, as the page renders it again each time the run changes.
|
|
109
|
-
* @param {Detail} detail
|
|
110
|
-
* @param {RunEvent[]} events Every event received for the run.
|
|
111
|
-
*/
|
|
112
|
-
function showDetail(detail, events) {
|
|
113
|
-
const container = byId("detail");
|
|
114
|
-
const same = shown?.[0].run.id === detail.run.id;
|
|
115
|
-
shown = [detail, events];
|
|
116
|
-
/** @type {Map<string, number>} */
|
|
117
|
-
const scrolled = new Map();
|
|
118
|
-
if (same)
|
|
119
|
-
for (const node of container.querySelectorAll("[data-scroll]"))
|
|
120
|
-
if (node instanceof HTMLElement && node.dataset.scroll)
|
|
121
|
-
scrolled.set(node.dataset.scroll, node.scrollTop);
|
|
122
|
-
const active = document.activeElement;
|
|
123
|
-
const focused =
|
|
124
|
-
same && active instanceof HTMLElement && container.contains(active)
|
|
125
|
-
? active.dataset.key
|
|
126
|
-
: undefined;
|
|
127
|
-
container.replaceChildren(
|
|
128
|
-
...detailSections(detail, events, () => shown && showDetail(...shown)),
|
|
129
|
-
);
|
|
130
|
-
for (const node of container.querySelectorAll("[data-scroll]"))
|
|
131
|
-
if (node instanceof HTMLElement && node.dataset.scroll)
|
|
132
|
-
node.scrollTop = scrolled.get(node.dataset.scroll) ?? 0;
|
|
133
|
-
if (focused) {
|
|
134
|
-
const again = container.querySelector(
|
|
135
|
-
`[data-key="${CSS.escape(focused)}"]`,
|
|
136
|
-
);
|
|
137
|
-
if (again instanceof HTMLElement) again.focus({ preventScroll: true });
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
/** @param {string} message */
|
|
142
|
-
function updated(message) {
|
|
143
|
-
byId("updated").textContent = message;
|
|
144
|
-
}
|
|
145
|
-
|
|
146
|
-
async function refreshRuns() {
|
|
147
|
-
try {
|
|
148
|
-
/** @type {{ title: string; label: string; runs: Summary[] }} */
|
|
149
|
-
const { title, label, runs } = await load("api/runs");
|
|
150
|
-
byId("title").textContent = title;
|
|
151
|
-
document.title = title;
|
|
152
|
-
byId("label").textContent = label;
|
|
153
|
-
showRuns(runs);
|
|
154
|
-
// A resumed run goes on under the same ID, running or already ended again.
|
|
155
|
-
const listed = runs.find((run) => run.id === selected);
|
|
156
|
-
if (listed?.updatedAt) follow.listed(selected, listed.updatedAt);
|
|
157
|
-
updated(`Updated ${new Date().toLocaleTimeString()}`);
|
|
158
|
-
} catch (error) {
|
|
159
|
-
unavailable(error);
|
|
160
|
-
}
|
|
161
|
-
setTimeout(refreshRuns, LIST_EVERY_MS);
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
/** @param {unknown} error */
|
|
165
|
-
const unavailable = (error) =>
|
|
166
|
-
updated(
|
|
167
|
-
`Unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
168
|
-
);
|
|
169
|
-
|
|
170
|
-
/** @param {string} text */
|
|
171
|
-
const placeholder = (text) =>
|
|
172
|
-
byId("detail").replaceChildren(el("p", text, "empty"));
|
|
173
|
-
|
|
174
|
-
const follow = follower({
|
|
175
|
-
load,
|
|
176
|
-
schedule: (work) => setTimeout(work, DETAIL_EVERY_MS),
|
|
177
|
-
cancel: (timer) => clearTimeout(timer),
|
|
178
|
-
show: (detail, events) => showDetail(detail, events),
|
|
179
|
-
fail: (error) => {
|
|
180
|
-
const { status } = /** @type {{ status?: number }} */ (error);
|
|
181
|
-
// The follower stops on a run that does not exist and retries the rest.
|
|
182
|
-
if (status === 404) placeholder("This run does not exist.");
|
|
183
|
-
else if (status)
|
|
184
|
-
placeholder(
|
|
185
|
-
`This run cannot be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
186
|
-
);
|
|
187
|
-
else unavailable(error);
|
|
188
|
-
},
|
|
189
|
-
});
|
|
190
|
-
|
|
191
|
-
/** @param {string} id */
|
|
192
|
-
function select(id) {
|
|
193
|
-
if (id === selected) return;
|
|
194
|
-
selected = id;
|
|
195
|
-
if (fromHash() !== id) location.hash = encodeURIComponent(id);
|
|
196
|
-
placeholder(id ? "Loading…" : "Select a run.");
|
|
197
|
-
for (const row of byId("runs").children)
|
|
198
|
-
row.className =
|
|
199
|
-
/** @type {HTMLElement} */ (row).title === id ? "selected" : "";
|
|
200
|
-
follow.select(id);
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// A link or the history can change the selected run without reloading the page.
|
|
204
|
-
window.addEventListener("hashchange", () => select(fromHash()));
|
|
205
|
-
refreshRuns();
|
|
206
|
-
if (selected) placeholder("Loading…");
|
|
207
|
-
follow.select(selected);
|
package/dist/static/detail.js
DELETED
|
@@ -1,489 +0,0 @@
|
|
|
1
|
-
// @ts-check
|
|
2
|
-
/**
|
|
3
|
-
* A run's detail: one card with its facts and launches, each launch's stages as
|
|
4
|
-
* a strip sized by duration; then the list of its stages beside the selected
|
|
5
|
-
* one. Text only, never HTML.
|
|
6
|
-
*/
|
|
7
|
-
import {
|
|
8
|
-
brief,
|
|
9
|
-
cost,
|
|
10
|
-
dollars,
|
|
11
|
-
duration,
|
|
12
|
-
launches,
|
|
13
|
-
outputText,
|
|
14
|
-
settingRows,
|
|
15
|
-
shownStage,
|
|
16
|
-
spent,
|
|
17
|
-
stateOf,
|
|
18
|
-
summary,
|
|
19
|
-
timeline,
|
|
20
|
-
tokenLine,
|
|
21
|
-
toolUse,
|
|
22
|
-
} from "./timeline.js";
|
|
23
|
-
|
|
24
|
-
/** @typedef {import("@clemrig/flow").RunEvent} RunEvent */
|
|
25
|
-
/** @typedef {import("./timeline.js").Block} Block */
|
|
26
|
-
/** @typedef {import("./timeline.js").Launch} Launch */
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* @typedef {object} Summary
|
|
30
|
-
* @property {string} id
|
|
31
|
-
* @property {string} [workflow]
|
|
32
|
-
* @property {string} [status]
|
|
33
|
-
* @property {string} [stage]
|
|
34
|
-
* @property {number | null} [durationMs]
|
|
35
|
-
* @property {number | null} [costUsd]
|
|
36
|
-
* @property {boolean} [partial]
|
|
37
|
-
* @property {string} [updatedAt]
|
|
38
|
-
* @property {string} [error] Why the run cannot be read.
|
|
39
|
-
*/
|
|
40
|
-
|
|
41
|
-
/**
|
|
42
|
-
* @typedef {object} Detail
|
|
43
|
-
* @property {Required<Omit<Summary, "error">>} run
|
|
44
|
-
* @property {Record<string, string>} runtimes
|
|
45
|
-
* @property {string | null} error
|
|
46
|
-
* @property {RunEvent[]} events
|
|
47
|
-
* @property {number} next
|
|
48
|
-
* @property {import("@clemrig/flow").Correction[]} corrections
|
|
49
|
-
* @property {string[]} result
|
|
50
|
-
*/
|
|
51
|
-
|
|
52
|
-
/** Tool calls a stage shows until the operator asks for all of them. */
|
|
53
|
-
const LATEST_TOOLS = 5;
|
|
54
|
-
|
|
55
|
-
const icons = /** @type {const} */ ({
|
|
56
|
-
passed: "✓",
|
|
57
|
-
correction: "↻",
|
|
58
|
-
failed: "✕",
|
|
59
|
-
running: "●",
|
|
60
|
-
completed: "✓",
|
|
61
|
-
});
|
|
62
|
-
|
|
63
|
-
/**
|
|
64
|
-
* What the page keeps from one rendering of a run to the next, as it follows it.
|
|
65
|
-
* @type {{ run: string; selected: number | null; chosen: boolean; allTools: Set<number>; outputs: Set<number> }}
|
|
66
|
-
*/
|
|
67
|
-
const view = {
|
|
68
|
-
run: "",
|
|
69
|
-
selected: null,
|
|
70
|
-
chosen: false,
|
|
71
|
-
allTools: new Set(),
|
|
72
|
-
outputs: new Set(),
|
|
73
|
-
};
|
|
74
|
-
|
|
75
|
-
/**
|
|
76
|
-
* @param {string} tag
|
|
77
|
-
* @param {string | (string | Node)[]} [content]
|
|
78
|
-
* @param {string} [className]
|
|
79
|
-
* @returns {HTMLElement}
|
|
80
|
-
*/
|
|
81
|
-
function el(tag, content, className) {
|
|
82
|
-
const node = document.createElement(tag);
|
|
83
|
-
if (typeof content === "string") node.textContent = content;
|
|
84
|
-
else if (content) node.append(...content);
|
|
85
|
-
if (className) node.className = className;
|
|
86
|
-
return node;
|
|
87
|
-
}
|
|
88
|
-
|
|
89
|
-
/**
|
|
90
|
-
* A state as data, so that it never meets the page's status classes.
|
|
91
|
-
* @param {HTMLElement} node
|
|
92
|
-
* @param {string} state
|
|
93
|
-
* @returns {HTMLElement}
|
|
94
|
-
*/
|
|
95
|
-
function stated(node, state) {
|
|
96
|
-
node.dataset.state = state;
|
|
97
|
-
return node;
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
const timeFormat = /** @type {const} */ ({
|
|
101
|
-
hour: "2-digit",
|
|
102
|
-
minute: "2-digit",
|
|
103
|
-
second: "2-digit",
|
|
104
|
-
hourCycle: "h23",
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
/** @param {string} iso A clock time, without its date. */
|
|
108
|
-
const hour = (iso) => new Date(iso).toLocaleTimeString(undefined, timeFormat);
|
|
109
|
-
|
|
110
|
-
/** @param {string} iso A day, unless it is today. */
|
|
111
|
-
function day(iso) {
|
|
112
|
-
const date = new Date(iso);
|
|
113
|
-
return new Date().toDateString() === date.toDateString()
|
|
114
|
-
? "today"
|
|
115
|
-
: date.toLocaleDateString(undefined, { day: "numeric", month: "short" });
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
/** @param {string | null} iso A clock time, with its day unless it is today. */
|
|
119
|
-
function clock(iso) {
|
|
120
|
-
if (!iso) return "—";
|
|
121
|
-
const today = day(iso) === "today";
|
|
122
|
-
return today ? hour(iso) : `${day(iso)} ${hour(iso)}`;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
/**
|
|
126
|
-
* The run's card: its status and facts, then each launch with its strip of
|
|
127
|
-
* stages sized by duration, why a failed one stopped, and the pause before the
|
|
128
|
-
* next.
|
|
129
|
-
* @param {Detail} detail
|
|
130
|
-
* @param {Launch[]} found
|
|
131
|
-
* @param {(index: number) => void} select
|
|
132
|
-
* @param {number} now
|
|
133
|
-
*/
|
|
134
|
-
function overview(detail, found, select, now) {
|
|
135
|
-
const { run } = detail;
|
|
136
|
-
const section = el("section", undefined, "overview");
|
|
137
|
-
const title = el("div", undefined, "overview-title");
|
|
138
|
-
title.append(
|
|
139
|
-
el("h3", `${run.workflow} · ${run.id}`),
|
|
140
|
-
stated(el("span", run.status, "pill"), run.status),
|
|
141
|
-
);
|
|
142
|
-
const facts = el("dl", undefined, "facts");
|
|
143
|
-
const fact = (/** @type {string} */ name, /** @type {string} */ value) =>
|
|
144
|
-
facts.append(el("div", [el("dt", name), el("dd", value)]));
|
|
145
|
-
fact("Started", clock(found[0]?.startedAt ?? null));
|
|
146
|
-
if (run.status !== "running")
|
|
147
|
-
fact("Ended", clock(found.at(-1)?.endedAt ?? null));
|
|
148
|
-
fact("Active time", duration(run.durationMs));
|
|
149
|
-
fact("Cost", dollars(run.costUsd, run.partial));
|
|
150
|
-
section.append(title, facts);
|
|
151
|
-
// A failure no stage recorded, such as a journal that could not be written.
|
|
152
|
-
if (detail.error && !found.at(-1)?.cause)
|
|
153
|
-
section.append(el("pre", detail.error, "callout bad"));
|
|
154
|
-
found.forEach((launch, position) => {
|
|
155
|
-
const end = launch.endedAt ? Date.parse(launch.endedAt) : now;
|
|
156
|
-
const length = end - Date.parse(launch.startedAt);
|
|
157
|
-
const head = el("div", undefined, "launch-head");
|
|
158
|
-
head.append(
|
|
159
|
-
el("span", [
|
|
160
|
-
el("strong", `#${launch.number}`),
|
|
161
|
-
` ${position ? "resumed " : ""}${clock(launch.startedAt)} → ${launch.endedAt ? clock(launch.endedAt) : "now"}`,
|
|
162
|
-
el("span", ` · ${brief(length)}`, "muted"),
|
|
163
|
-
]),
|
|
164
|
-
stated(
|
|
165
|
-
el("span", `${icons[launch.status]} ${launch.status}`, "state"),
|
|
166
|
-
launch.status,
|
|
167
|
-
),
|
|
168
|
-
);
|
|
169
|
-
const track = el("div", undefined, "track");
|
|
170
|
-
for (const { block, index } of launch.stages) {
|
|
171
|
-
const segment = stated(
|
|
172
|
-
el("button", undefined, "segment"),
|
|
173
|
-
stateOf(block),
|
|
174
|
-
);
|
|
175
|
-
segment.dataset.kind = block.kind;
|
|
176
|
-
segment.dataset.key = `segment-${index}`;
|
|
177
|
-
segment.title = `${block.stage} · ${block.kind} · ${clock(block.startedAt)} · ${brief(spent(block, now))}`;
|
|
178
|
-
if (view.selected === index) segment.setAttribute("aria-current", "true");
|
|
179
|
-
// A width relative to the launch, set through CSSOM: the page allows no inline style.
|
|
180
|
-
segment.style.flexGrow = String(Math.max(spent(block, now), 1));
|
|
181
|
-
if (spent(block, now) > length / 12) segment.textContent = block.stage;
|
|
182
|
-
segment.addEventListener("click", () => select(index));
|
|
183
|
-
track.append(segment);
|
|
184
|
-
}
|
|
185
|
-
const item = el("div", [head, track], "launch");
|
|
186
|
-
if (launch.status === "failed" && launch.cause)
|
|
187
|
-
item.append(el("p", launch.cause, "launch-cause"));
|
|
188
|
-
section.append(item);
|
|
189
|
-
const next = found[position + 1];
|
|
190
|
-
if (next && launch.endedAt)
|
|
191
|
-
section.append(
|
|
192
|
-
el(
|
|
193
|
-
"p",
|
|
194
|
-
`paused ${brief(Date.parse(next.startedAt) - Date.parse(launch.endedAt))}`,
|
|
195
|
-
"pause",
|
|
196
|
-
),
|
|
197
|
-
);
|
|
198
|
-
});
|
|
199
|
-
return section;
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
/**
|
|
203
|
-
* The run's stages, grouped by launch and day: state, name, kind and duration.
|
|
204
|
-
* @param {Launch[]} found
|
|
205
|
-
* @param {(index: number) => void} select
|
|
206
|
-
* @param {number} now
|
|
207
|
-
*/
|
|
208
|
-
function stageList(found, select, now) {
|
|
209
|
-
const list = el("div", undefined, "stage-list");
|
|
210
|
-
list.dataset.scroll = "stages";
|
|
211
|
-
const count = found.reduce((sum, launch) => sum + launch.stages.length, 0);
|
|
212
|
-
list.append(el("h2", `Stages (${count})`));
|
|
213
|
-
for (const launch of found) {
|
|
214
|
-
list.append(
|
|
215
|
-
el(
|
|
216
|
-
"p",
|
|
217
|
-
`${found.length > 1 ? `Launch #${launch.number} · ` : ""}${day(launch.startedAt)}`,
|
|
218
|
-
"group",
|
|
219
|
-
),
|
|
220
|
-
);
|
|
221
|
-
const rows = el("ol", undefined, "stages");
|
|
222
|
-
for (const { block, index } of launch.stages) {
|
|
223
|
-
const state = stateOf(block);
|
|
224
|
-
const row = stated(el("button", undefined, "stage"), state);
|
|
225
|
-
row.dataset.kind = block.kind;
|
|
226
|
-
row.dataset.key = `stage-${index}`;
|
|
227
|
-
row.title = summary(block);
|
|
228
|
-
if (view.selected === index) row.setAttribute("aria-current", "true");
|
|
229
|
-
row.append(
|
|
230
|
-
el("span", icons[state], "icon"),
|
|
231
|
-
el("span", block.stage, "name"),
|
|
232
|
-
el("span", block.kind, "kind"),
|
|
233
|
-
el("span", brief(spent(block, now)), "took"),
|
|
234
|
-
);
|
|
235
|
-
row.addEventListener("click", () => select(index));
|
|
236
|
-
rows.append(el("li", [row]));
|
|
237
|
-
}
|
|
238
|
-
list.append(rows);
|
|
239
|
-
}
|
|
240
|
-
return list;
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
/**
|
|
244
|
-
* What a stage execution did: a sheet of its facts, why it failed or was sent
|
|
245
|
-
* back, the tools it used, then its journal and its tool calls.
|
|
246
|
-
* @param {Block} block
|
|
247
|
-
* @param {number} index
|
|
248
|
-
* @param {() => void} rerender
|
|
249
|
-
* @param {number} now
|
|
250
|
-
*/
|
|
251
|
-
function stagePanel(block, index, rerender, now) {
|
|
252
|
-
const state = stateOf(block);
|
|
253
|
-
const panel = el("div", undefined, "stage-panel");
|
|
254
|
-
panel.append(
|
|
255
|
-
el(
|
|
256
|
-
"div",
|
|
257
|
-
[
|
|
258
|
-
stated(el("span", state, "pill"), state),
|
|
259
|
-
el("h3", block.stage),
|
|
260
|
-
el("span", block.kind, "kind tag"),
|
|
261
|
-
],
|
|
262
|
-
"panel-title",
|
|
263
|
-
),
|
|
264
|
-
);
|
|
265
|
-
const sheet = el("dl", undefined, "sheet");
|
|
266
|
-
const fact = (
|
|
267
|
-
/** @type {string} */ name,
|
|
268
|
-
/** @type {string} */ value,
|
|
269
|
-
/** @type {string} */ className = "",
|
|
270
|
-
) => sheet.append(el("dt", name), el("dd", value, className));
|
|
271
|
-
fact("Started", clock(block.startedAt));
|
|
272
|
-
fact(
|
|
273
|
-
block.durationMs === null ? "Running for" : "Duration",
|
|
274
|
-
brief(spent(block, now)),
|
|
275
|
-
);
|
|
276
|
-
if (block.decision) {
|
|
277
|
-
const { verdict, destination } = block.decision;
|
|
278
|
-
const retry = block.retry
|
|
279
|
-
? ` (${block.retry.key} ${block.retry.count}/${block.retry.max})`
|
|
280
|
-
: "";
|
|
281
|
-
// A correction past its maximum is not taken: the run stopped there.
|
|
282
|
-
const refused = verdict === "correction" && pastMaximum(block);
|
|
283
|
-
fact(
|
|
284
|
-
"Outcome",
|
|
285
|
-
refused
|
|
286
|
-
? `✕ not sent back to ${destination}${retry}`
|
|
287
|
-
: verdict === "correction"
|
|
288
|
-
? `↻ back to ${destination}${retry}`
|
|
289
|
-
: `accepted → ${destination ?? "end"}`,
|
|
290
|
-
refused ? "bad" : verdict === "correction" ? "warn" : "",
|
|
291
|
-
);
|
|
292
|
-
} else fact("Outcome", block.status, block.status === "failed" ? "bad" : "");
|
|
293
|
-
const { call } = block;
|
|
294
|
-
if (call?.model) {
|
|
295
|
-
fact("Agent", `${call.runtime} · ${call.model}`);
|
|
296
|
-
if (call.session) fact("Session", call.session);
|
|
297
|
-
if (call.settings)
|
|
298
|
-
for (const { label, value } of settingRows(call.settings))
|
|
299
|
-
fact(label, value);
|
|
300
|
-
}
|
|
301
|
-
if (call?.usage) {
|
|
302
|
-
fact(
|
|
303
|
-
call.scope === "session" ? "Tokens (session)" : "Tokens",
|
|
304
|
-
tokenLine(call.usage, false),
|
|
305
|
-
);
|
|
306
|
-
fact("Cost", cost(call.usage.costUsd ?? null, false));
|
|
307
|
-
} else if (block.tokens)
|
|
308
|
-
fact("Tokens so far", tokenLine(block.tokens, block.provisional));
|
|
309
|
-
panel.append(sheet);
|
|
310
|
-
if (block.detail) panel.append(el("pre", block.detail, "callout bad"));
|
|
311
|
-
else if (block.decision?.verdict === "correction")
|
|
312
|
-
panel.append(el("pre", block.decision.reason, "callout warn"));
|
|
313
|
-
for (const warning of block.warnings)
|
|
314
|
-
panel.append(el("p", warning, "callout warn"));
|
|
315
|
-
const output = outputSection(block, index);
|
|
316
|
-
if (output) panel.append(output);
|
|
317
|
-
if (block.tools.length) {
|
|
318
|
-
const chips = el("div", undefined, "chips");
|
|
319
|
-
for (const { name, count, durationMs, failed } of toolUse(block.tools))
|
|
320
|
-
chips.append(
|
|
321
|
-
el(
|
|
322
|
-
"span",
|
|
323
|
-
`${name} ×${count} · ${brief(durationMs)}${failed ? ` · ${failed} failed` : ""}`,
|
|
324
|
-
failed ? "chip warn" : "chip",
|
|
325
|
-
),
|
|
326
|
-
);
|
|
327
|
-
panel.append(el("h4", `Tools used (${block.tools.length})`), chips);
|
|
328
|
-
}
|
|
329
|
-
if (block.journal.length) {
|
|
330
|
-
const journal = el("ol", undefined, "log");
|
|
331
|
-
journal.dataset.scroll = `journal-${index}`;
|
|
332
|
-
for (const entry of block.journal)
|
|
333
|
-
journal.append(
|
|
334
|
-
el("li", [el("span", hour(entry.at), "at"), el("span", entry.message)]),
|
|
335
|
-
);
|
|
336
|
-
panel.append(el("h4", "Journal"), journal);
|
|
337
|
-
}
|
|
338
|
-
if (block.tools.length) {
|
|
339
|
-
const all = view.allTools.has(index);
|
|
340
|
-
const shown = all ? block.tools : block.tools.slice(-LATEST_TOOLS);
|
|
341
|
-
const head = el(
|
|
342
|
-
"div",
|
|
343
|
-
[
|
|
344
|
-
el(
|
|
345
|
-
"h4",
|
|
346
|
-
all
|
|
347
|
-
? `Tool calls (${block.tools.length})`
|
|
348
|
-
: `Tool calls · latest ${shown.length} of ${block.tools.length}`,
|
|
349
|
-
),
|
|
350
|
-
],
|
|
351
|
-
"log-head",
|
|
352
|
-
);
|
|
353
|
-
if (block.tools.length > LATEST_TOOLS) {
|
|
354
|
-
const toggle = el("button", all ? "Latest only" : "Show all", "link");
|
|
355
|
-
toggle.dataset.key = `tools-${index}`;
|
|
356
|
-
toggle.addEventListener("click", () => {
|
|
357
|
-
if (all) view.allTools.delete(index);
|
|
358
|
-
else view.allTools.add(index);
|
|
359
|
-
rerender();
|
|
360
|
-
});
|
|
361
|
-
head.append(toggle);
|
|
362
|
-
}
|
|
363
|
-
const calls = el("ol", undefined, "log");
|
|
364
|
-
calls.dataset.scroll = `tools-${index}`;
|
|
365
|
-
for (const tool of shown)
|
|
366
|
-
calls.append(
|
|
367
|
-
el(
|
|
368
|
-
"li",
|
|
369
|
-
[
|
|
370
|
-
el(
|
|
371
|
-
"span",
|
|
372
|
-
tool.status === "running"
|
|
373
|
-
? "…"
|
|
374
|
-
: `${((tool.durationMs ?? 0) / 1000).toFixed(1)} s`,
|
|
375
|
-
"at",
|
|
376
|
-
),
|
|
377
|
-
el(
|
|
378
|
-
"span",
|
|
379
|
-
`${tool.name}${tool.status === "failed" ? " — failed" : ""}`,
|
|
380
|
-
),
|
|
381
|
-
],
|
|
382
|
-
tool.status === "failed" ? "bad" : "",
|
|
383
|
-
),
|
|
384
|
-
);
|
|
385
|
-
panel.append(head, calls);
|
|
386
|
-
}
|
|
387
|
-
return panel;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
/**
|
|
391
|
-
* Whether the stage's correction went past its maximum.
|
|
392
|
-
* @param {Block} block
|
|
393
|
-
*/
|
|
394
|
-
function pastMaximum(block) {
|
|
395
|
-
return block.retry !== undefined && block.retry.count > block.retry.max;
|
|
396
|
-
}
|
|
397
|
-
|
|
398
|
-
/**
|
|
399
|
-
* What the stage produced, folded until the operator opens it: its agent's
|
|
400
|
-
* answer, and the correction it sent back or could not send.
|
|
401
|
-
* @param {Block} block
|
|
402
|
-
* @param {number} index
|
|
403
|
-
* @returns {HTMLElement | undefined}
|
|
404
|
-
*/
|
|
405
|
-
function outputSection(block, index) {
|
|
406
|
-
/** @type {[string, unknown][]} */
|
|
407
|
-
const parts = [];
|
|
408
|
-
if (Object.hasOwn(block, "output")) parts.push(["Answer", block.output]);
|
|
409
|
-
if (block.feedback !== undefined)
|
|
410
|
-
parts.push([
|
|
411
|
-
pastMaximum(block)
|
|
412
|
-
? "Correction not sent, past its maximum"
|
|
413
|
-
: "Correction sent",
|
|
414
|
-
block.feedback,
|
|
415
|
-
]);
|
|
416
|
-
if (parts.length === 0) return undefined;
|
|
417
|
-
const head = el("summary", "Output");
|
|
418
|
-
head.dataset.key = `output-${index}`;
|
|
419
|
-
const section = el(
|
|
420
|
-
"details",
|
|
421
|
-
[
|
|
422
|
-
head,
|
|
423
|
-
...parts.flatMap(([title, value], part) => {
|
|
424
|
-
const text = el("pre", outputText(value));
|
|
425
|
-
text.dataset.scroll = `output-${index}-${part}`;
|
|
426
|
-
return [el("h4", title), text];
|
|
427
|
-
}),
|
|
428
|
-
],
|
|
429
|
-
"output",
|
|
430
|
-
);
|
|
431
|
-
// Opened by the operator, it stays open, and focused, as the page follows the run.
|
|
432
|
-
/** @type {HTMLDetailsElement} */ (section).open = view.outputs.has(index);
|
|
433
|
-
section.addEventListener("toggle", () => {
|
|
434
|
-
if (/** @type {HTMLDetailsElement} */ (section).open)
|
|
435
|
-
view.outputs.add(index);
|
|
436
|
-
else view.outputs.delete(index);
|
|
437
|
-
});
|
|
438
|
-
return section;
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
/**
|
|
442
|
-
* The detail's sections. The selected stage follows the running or failed one
|
|
443
|
-
* until the operator chooses one, and stays chosen as the run goes on.
|
|
444
|
-
* @param {Detail} detail
|
|
445
|
-
* @param {RunEvent[]} events Every event received for the run.
|
|
446
|
-
* @param {() => void} rerender Renders the detail again, as the operator changes the view.
|
|
447
|
-
* @returns {HTMLElement[]}
|
|
448
|
-
*/
|
|
449
|
-
export function detailSections(detail, events, rerender) {
|
|
450
|
-
const now = Date.now();
|
|
451
|
-
const blocks = timeline(events);
|
|
452
|
-
const found = launches(blocks);
|
|
453
|
-
if (view.run !== detail.run.id) {
|
|
454
|
-
view.run = detail.run.id;
|
|
455
|
-
view.chosen = false;
|
|
456
|
-
view.allTools.clear();
|
|
457
|
-
view.outputs.clear();
|
|
458
|
-
}
|
|
459
|
-
if (!view.chosen || view.selected === null || !blocks[view.selected])
|
|
460
|
-
view.selected = shownStage(blocks);
|
|
461
|
-
const select = (/** @type {number} */ index) => {
|
|
462
|
-
view.selected = index;
|
|
463
|
-
view.chosen = true;
|
|
464
|
-
rerender();
|
|
465
|
-
};
|
|
466
|
-
const sections = [overview(detail, found, select, now)];
|
|
467
|
-
const stages = el("section", undefined, "stages-section");
|
|
468
|
-
const chosen = view.selected === null ? undefined : blocks[view.selected];
|
|
469
|
-
stages.append(
|
|
470
|
-
stageList(found, select, now),
|
|
471
|
-
chosen && view.selected !== null
|
|
472
|
-
? stagePanel(chosen, view.selected, rerender, now)
|
|
473
|
-
: el("p", "No stage yet.", "empty"),
|
|
474
|
-
);
|
|
475
|
-
sections.push(stages);
|
|
476
|
-
if (detail.corrections.length) {
|
|
477
|
-
const corrections = el("section");
|
|
478
|
-
corrections.append(el("h2", "Corrections"));
|
|
479
|
-
for (const { key, count, max } of detail.corrections)
|
|
480
|
-
corrections.append(el("p", `${key} ${count}/${max ?? "?"}`, "mono"));
|
|
481
|
-
sections.push(corrections);
|
|
482
|
-
}
|
|
483
|
-
const result = el("section");
|
|
484
|
-
result.append(el("h2", "Result"), el("pre", detail.result.join("\n")));
|
|
485
|
-
for (const runtime of Object.values(detail.runtimes))
|
|
486
|
-
result.append(el("p", `Runtime: ${runtime}`, "runtime"));
|
|
487
|
-
sections.push(result);
|
|
488
|
-
return sections;
|
|
489
|
-
}
|