@clemrig/flow-ui 0.2.0 → 0.4.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 CHANGED
@@ -11,7 +11,7 @@ npm install -g @clemrig/flow-ui
11
11
  flow-ui [RUNS_DIR] [--port PORT]
12
12
  ```
13
13
 
14
- `flow-ui` serves the runs kept in `RUNS_DIR` (default: `.flow/runs`, where `@clemrig/flow` keeps them; a `RUNS_DIR` given must be an existing directory) on `127.0.0.1`, on `PORT` or a port the system chooses, prints its URL and stops on Ctrl-C. The page lists the runs with their workflow, status, current stage, duration, estimated cost and last update, and for a run its runtimes, a timeline with one block per stage (the agent call's model and settings, its tools and tokens, journal, decision), its correction counters and its result. It follows a running run as it goes. A result shows the run's validated data as JSON.
14
+ `flow-ui` serves the runs kept in `RUNS_DIR` (default: `.flow/runs`, where `@clemrig/flow` keeps them; a `RUNS_DIR` given must be an existing directory) on `127.0.0.1`, on `PORT` or a port the system chooses, prints its URL and stops on Ctrl-C. The page lists the runs with their workflow, status, current stage, duration, estimated cost and last update, and for a run a card with its start, end, active time and cost, then each launch (the first one, then each resumption) with its status, the cause of a failure and a strip of its stages sized by duration; the list of its stages beside the selected one's panel (start, duration, outcome, the agent call's model, settings, tools and tokens, the tools used, then the journal and the tool calls); its correction counters, its result and its runtimes. It follows a running run as it goes, and follows the selected run again when it is resumed. A result shows the run's validated data as JSON.
15
15
 
16
16
  To watch a run on another machine, forward the same port through SSH: `ssh -L 4321:127.0.0.1:4321 machine`, run `flow-ui --port 4321` there, and open `http://localhost:4321/`.
17
17
 
package/dist/server.js CHANGED
@@ -33,6 +33,7 @@ const pageFiles = {
33
33
  "/dashboard.js": ["dashboard.js", "text/javascript; charset=utf-8"],
34
34
  "/timeline.js": ["timeline.js", "text/javascript; charset=utf-8"],
35
35
  "/follow.js": ["follow.js", "text/javascript; charset=utf-8"],
36
+ "/detail.js": ["detail.js", "text/javascript; charset=utf-8"],
36
37
  };
37
38
  async function loadPage() {
38
39
  const page = new Map();
@@ -1 +1 @@
1
- {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
1
+ {"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
@@ -3,35 +3,13 @@
3
3
  * The dashboard: the run list every 2 seconds, and the selected run as
4
4
  * `follow.js` follows it. Text only, never HTML.
5
5
  */
6
+ import { detailSections } from "./detail.js";
6
7
  import { follower } from "./follow.js";
7
- import { cost, duration, settingsLines, timeline } from "./timeline.js";
8
+ import { cost, duration } from "./timeline.js";
8
9
 
9
10
  /** @typedef {import("@clemrig/flow").RunEvent} RunEvent */
10
- /** @typedef {import("./timeline.js").Block} Block */
11
-
12
- /**
13
- * @typedef {object} Summary
14
- * @property {string} id
15
- * @property {string} [workflow]
16
- * @property {string} [status]
17
- * @property {string} [stage]
18
- * @property {number | null} [durationMs]
19
- * @property {number | null} [costUsd]
20
- * @property {boolean} [partial]
21
- * @property {string} [updatedAt]
22
- * @property {string} [error] Why the run cannot be read.
23
- */
24
-
25
- /**
26
- * @typedef {object} Detail
27
- * @property {Required<Omit<Summary, "error">>} run
28
- * @property {Record<string, string>} runtimes
29
- * @property {string | null} error
30
- * @property {RunEvent[]} events
31
- * @property {number} next
32
- * @property {import("@clemrig/flow").Correction[]} corrections
33
- * @property {string[]} result
34
- */
11
+ /** @typedef {import("./detail.js").Summary} Summary */
12
+ /** @typedef {import("./detail.js").Detail} Detail */
35
13
 
36
14
  const LIST_EVERY_MS = 2000;
37
15
  const DETAIL_EVERY_MS = 1000;
@@ -70,11 +48,6 @@ function time(iso) {
70
48
  });
71
49
  }
72
50
 
73
- /** @param {import("@clemrig/flow").TokenCounts} tokens */
74
- function tokenLine(tokens) {
75
- return `${tokens.input} input · ${tokens.output} output · ${tokens.cacheRead} cache read · ${tokens.cacheWrite} cache write tokens`;
76
- }
77
-
78
51
  /**
79
52
  * @param {string} path
80
53
  * @returns {Promise<any>}
@@ -127,82 +100,41 @@ function showRuns(runs) {
127
100
  );
128
101
  }
129
102
 
130
- /** @param {Block} block */
131
- function showBlock(block) {
132
- const item = el("li", undefined, "block");
133
- const head = el("p");
134
- head.append(
135
- `${block.kind} ${block.stage} · `,
136
- el("span", block.status, block.status),
137
- block.durationMs === null ? "" : ` · ${duration(block.durationMs)}`,
138
- );
139
- item.append(head);
140
- const line = (/** @type {string} */ text, className = "line") =>
141
- item.append(el("p", text, className));
142
- const { call } = block;
143
- if (call?.model)
144
- line(`${call.runtime} ${call.model} · session ${call.session}`);
145
- if (call?.settings)
146
- for (const setting of settingsLines(call.settings)) line(setting);
147
- for (const tool of block.tools)
148
- line(
149
- tool.status === "running"
150
- ? `${tool.name} …`
151
- : `${tool.name}${tool.status === "failed" ? " failed" : ""} ${((tool.durationMs ?? 0) / 1000).toFixed(1)} s`,
152
- );
153
- if (call?.usage)
154
- line(
155
- `${call.scope === "session" ? "Session total" : "Call"}: ${tokenLine(call.usage)} · ${cost(call.usage.costUsd ?? null, false)}`,
156
- );
157
- else if (block.tokens)
158
- line(
159
- `So far: ${tokenLine(block.tokens)}${block.provisional ? " (provisional)" : ""}`,
160
- );
161
- for (const warning of block.warnings) line(warning, "line warning");
162
- for (const entry of block.journal) line(`${time(entry.at)} ${entry.message}`);
163
- if (block.decision) {
164
- const { verdict, reason, destination } = block.decision;
165
- const retry = block.retry
166
- ? ` [${block.retry.key} ${block.retry.count}/${block.retry.max}]`
167
- : "";
168
- line(`${verdict} — ${reason} → ${destination ?? "end"}${retry}`);
169
- }
170
- if (block.detail) line(block.detail, "line failed");
171
- return item;
172
- }
103
+ /** The detail on screen, to render it again when the operator changes the view. */
104
+ let shown = /** @type {[Detail, RunEvent[]] | null} */ (null);
173
105
 
174
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.
175
109
  * @param {Detail} detail
176
110
  * @param {RunEvent[]} events Every event received for the run.
177
111
  */
178
112
  function showDetail(detail, events) {
179
- const { run } = detail;
180
- const summary = el("section");
181
- const line = el("p", undefined, "empty");
182
- line.append(
183
- el("span", run.status, run.status),
184
- ` · ${run.stage} · ${duration(run.durationMs)} · ${cost(run.costUsd, run.partial)}`,
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)),
185
129
  );
186
- summary.append(el("h3", `${run.workflow} · ${run.id}`), line);
187
- for (const runtime of Object.values(detail.runtimes))
188
- summary.append(el("p", runtime, "line"));
189
- if (detail.error) summary.append(el("pre", detail.error, "failed"));
190
- const stages = el("section");
191
- const blocks = el("ol");
192
- blocks.append(...timeline(events).map(showBlock));
193
- stages.append(el("h2", "Timeline"), blocks);
194
- const sections = [summary, stages];
195
- if (detail.corrections.length) {
196
- const corrections = el("section");
197
- corrections.append(el("h2", "Corrections"));
198
- for (const { key, count, max } of detail.corrections)
199
- corrections.append(el("p", `${key} ${count}/${max ?? "?"}`, "line"));
200
- sections.push(corrections);
201
- }
202
- const result = el("section");
203
- result.append(el("h2", "Result"), el("pre", detail.result.join("\n")));
204
- sections.push(result);
205
- byId("detail").replaceChildren(...sections);
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
+ container
135
+ .querySelector(`[data-key="${CSS.escape(focused)}"]`)
136
+ ?.closest("button")
137
+ ?.focus({ preventScroll: true });
206
138
  }
207
139
 
208
140
  /** @param {string} message */
@@ -212,11 +144,15 @@ function updated(message) {
212
144
 
213
145
  async function refreshRuns() {
214
146
  try {
147
+ /** @type {{ title: string; label: string; runs: Summary[] }} */
215
148
  const { title, label, runs } = await load("api/runs");
216
149
  byId("title").textContent = title;
217
150
  document.title = title;
218
151
  byId("label").textContent = label;
219
152
  showRuns(runs);
153
+ // A resumed run goes on under the same ID, running or already ended again.
154
+ const listed = runs.find((run) => run.id === selected);
155
+ if (listed?.updatedAt) follow.listed(selected, listed.updatedAt);
220
156
  updated(`Updated ${new Date().toLocaleTimeString()}`);
221
157
  } catch (error) {
222
158
  unavailable(error);
@@ -0,0 +1,424 @@
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
+ settingRows,
14
+ shownStage,
15
+ spent,
16
+ stateOf,
17
+ summary,
18
+ timeline,
19
+ tokenLine,
20
+ toolUse,
21
+ } from "./timeline.js";
22
+
23
+ /** @typedef {import("@clemrig/flow").RunEvent} RunEvent */
24
+ /** @typedef {import("./timeline.js").Block} Block */
25
+ /** @typedef {import("./timeline.js").Launch} Launch */
26
+
27
+ /**
28
+ * @typedef {object} Summary
29
+ * @property {string} id
30
+ * @property {string} [workflow]
31
+ * @property {string} [status]
32
+ * @property {string} [stage]
33
+ * @property {number | null} [durationMs]
34
+ * @property {number | null} [costUsd]
35
+ * @property {boolean} [partial]
36
+ * @property {string} [updatedAt]
37
+ * @property {string} [error] Why the run cannot be read.
38
+ */
39
+
40
+ /**
41
+ * @typedef {object} Detail
42
+ * @property {Required<Omit<Summary, "error">>} run
43
+ * @property {Record<string, string>} runtimes
44
+ * @property {string | null} error
45
+ * @property {RunEvent[]} events
46
+ * @property {number} next
47
+ * @property {import("@clemrig/flow").Correction[]} corrections
48
+ * @property {string[]} result
49
+ */
50
+
51
+ /** Tool calls a stage shows until the operator asks for all of them. */
52
+ const LATEST_TOOLS = 5;
53
+
54
+ const icons = /** @type {const} */ ({
55
+ passed: "✓",
56
+ correction: "↻",
57
+ failed: "✕",
58
+ running: "●",
59
+ completed: "✓",
60
+ });
61
+
62
+ /**
63
+ * What the page keeps from one rendering of a run to the next, as it follows it.
64
+ * @type {{ run: string; selected: number | null; chosen: boolean; allTools: Set<number> }}
65
+ */
66
+ const view = { run: "", selected: null, chosen: false, allTools: new Set() };
67
+
68
+ /**
69
+ * @param {string} tag
70
+ * @param {string | (string | Node)[]} [content]
71
+ * @param {string} [className]
72
+ * @returns {HTMLElement}
73
+ */
74
+ function el(tag, content, className) {
75
+ const node = document.createElement(tag);
76
+ if (typeof content === "string") node.textContent = content;
77
+ else if (content) node.append(...content);
78
+ if (className) node.className = className;
79
+ return node;
80
+ }
81
+
82
+ /**
83
+ * A state as data, so that it never meets the page's status classes.
84
+ * @param {HTMLElement} node
85
+ * @param {string} state
86
+ * @returns {HTMLElement}
87
+ */
88
+ function stated(node, state) {
89
+ node.dataset.state = state;
90
+ return node;
91
+ }
92
+
93
+ const timeFormat = /** @type {const} */ ({
94
+ hour: "2-digit",
95
+ minute: "2-digit",
96
+ second: "2-digit",
97
+ hourCycle: "h23",
98
+ });
99
+
100
+ /** @param {string} iso A clock time, without its date. */
101
+ const hour = (iso) => new Date(iso).toLocaleTimeString(undefined, timeFormat);
102
+
103
+ /** @param {string} iso A day, unless it is today. */
104
+ function day(iso) {
105
+ const date = new Date(iso);
106
+ return new Date().toDateString() === date.toDateString()
107
+ ? "today"
108
+ : date.toLocaleDateString(undefined, { day: "numeric", month: "short" });
109
+ }
110
+
111
+ /** @param {string | null} iso A clock time, with its day unless it is today. */
112
+ function clock(iso) {
113
+ if (!iso) return "—";
114
+ const today = day(iso) === "today";
115
+ return today ? hour(iso) : `${day(iso)} ${hour(iso)}`;
116
+ }
117
+
118
+ /**
119
+ * The run's card: its status and facts, then each launch with its strip of
120
+ * stages sized by duration, why a failed one stopped, and the pause before the
121
+ * next.
122
+ * @param {Detail} detail
123
+ * @param {Launch[]} found
124
+ * @param {(index: number) => void} select
125
+ * @param {number} now
126
+ */
127
+ function overview(detail, found, select, now) {
128
+ const { run } = detail;
129
+ const section = el("section", undefined, "overview");
130
+ const title = el("div", undefined, "overview-title");
131
+ title.append(
132
+ el("h3", `${run.workflow} · ${run.id}`),
133
+ stated(el("span", run.status, "pill"), run.status),
134
+ );
135
+ const facts = el("dl", undefined, "facts");
136
+ const fact = (/** @type {string} */ name, /** @type {string} */ value) =>
137
+ facts.append(el("div", [el("dt", name), el("dd", value)]));
138
+ fact("Started", clock(found[0]?.startedAt ?? null));
139
+ if (run.status !== "running")
140
+ fact("Ended", clock(found.at(-1)?.endedAt ?? null));
141
+ fact("Active time", duration(run.durationMs));
142
+ fact("Cost", dollars(run.costUsd, run.partial));
143
+ section.append(title, facts);
144
+ // A failure no stage recorded, such as a journal that could not be written.
145
+ if (detail.error && !found.at(-1)?.cause)
146
+ section.append(el("pre", detail.error, "callout bad"));
147
+ found.forEach((launch, position) => {
148
+ const end = launch.endedAt ? Date.parse(launch.endedAt) : now;
149
+ const length = end - Date.parse(launch.startedAt);
150
+ const head = el("div", undefined, "launch-head");
151
+ head.append(
152
+ el("span", [
153
+ el("strong", `#${launch.number}`),
154
+ ` ${position ? "resumed " : ""}${clock(launch.startedAt)} → ${launch.endedAt ? clock(launch.endedAt) : "now"}`,
155
+ el("span", ` · ${brief(length)}`, "muted"),
156
+ ]),
157
+ stated(
158
+ el("span", `${icons[launch.status]} ${launch.status}`, "state"),
159
+ launch.status,
160
+ ),
161
+ );
162
+ const track = el("div", undefined, "track");
163
+ for (const { block, index } of launch.stages) {
164
+ const segment = stated(
165
+ el("button", undefined, "segment"),
166
+ stateOf(block),
167
+ );
168
+ segment.dataset.kind = block.kind;
169
+ segment.dataset.key = `segment-${index}`;
170
+ segment.title = `${block.stage} · ${block.kind} · ${clock(block.startedAt)} · ${brief(spent(block, now))}`;
171
+ if (view.selected === index) segment.setAttribute("aria-current", "true");
172
+ // A width relative to the launch, set through CSSOM: the page allows no inline style.
173
+ segment.style.flexGrow = String(Math.max(spent(block, now), 1));
174
+ if (spent(block, now) > length / 12) segment.textContent = block.stage;
175
+ segment.addEventListener("click", () => select(index));
176
+ track.append(segment);
177
+ }
178
+ const item = el("div", [head, track], "launch");
179
+ if (launch.status === "failed" && launch.cause)
180
+ item.append(el("p", launch.cause, "launch-cause"));
181
+ section.append(item);
182
+ const next = found[position + 1];
183
+ if (next && launch.endedAt)
184
+ section.append(
185
+ el(
186
+ "p",
187
+ `paused ${brief(Date.parse(next.startedAt) - Date.parse(launch.endedAt))}`,
188
+ "pause",
189
+ ),
190
+ );
191
+ });
192
+ return section;
193
+ }
194
+
195
+ /**
196
+ * The run's stages, grouped by launch and day: state, name, kind and duration.
197
+ * @param {Launch[]} found
198
+ * @param {(index: number) => void} select
199
+ * @param {number} now
200
+ */
201
+ function stageList(found, select, now) {
202
+ const list = el("div", undefined, "stage-list");
203
+ list.dataset.scroll = "stages";
204
+ const count = found.reduce((sum, launch) => sum + launch.stages.length, 0);
205
+ list.append(el("h2", `Stages (${count})`));
206
+ for (const launch of found) {
207
+ list.append(
208
+ el(
209
+ "p",
210
+ `${found.length > 1 ? `Launch #${launch.number} · ` : ""}${day(launch.startedAt)}`,
211
+ "group",
212
+ ),
213
+ );
214
+ const rows = el("ol", undefined, "stages");
215
+ for (const { block, index } of launch.stages) {
216
+ const state = stateOf(block);
217
+ const row = stated(el("button", undefined, "stage"), state);
218
+ row.dataset.kind = block.kind;
219
+ row.dataset.key = `stage-${index}`;
220
+ row.title = summary(block);
221
+ if (view.selected === index) row.setAttribute("aria-current", "true");
222
+ row.append(
223
+ el("span", icons[state], "icon"),
224
+ el("span", block.stage, "name"),
225
+ el("span", block.kind, "kind"),
226
+ el("span", brief(spent(block, now)), "took"),
227
+ );
228
+ row.addEventListener("click", () => select(index));
229
+ rows.append(el("li", [row]));
230
+ }
231
+ list.append(rows);
232
+ }
233
+ return list;
234
+ }
235
+
236
+ /**
237
+ * What a stage execution did: a sheet of its facts, why it failed or was sent
238
+ * back, the tools it used, then its journal and its tool calls.
239
+ * @param {Block} block
240
+ * @param {number} index
241
+ * @param {() => void} rerender
242
+ * @param {number} now
243
+ */
244
+ function stagePanel(block, index, rerender, now) {
245
+ const state = stateOf(block);
246
+ const panel = el("div", undefined, "stage-panel");
247
+ panel.append(
248
+ el(
249
+ "div",
250
+ [
251
+ stated(el("span", state, "pill"), state),
252
+ el("h3", block.stage),
253
+ el("span", block.kind, "kind tag"),
254
+ ],
255
+ "panel-title",
256
+ ),
257
+ );
258
+ const sheet = el("dl", undefined, "sheet");
259
+ const fact = (
260
+ /** @type {string} */ name,
261
+ /** @type {string} */ value,
262
+ /** @type {string} */ className = "",
263
+ ) => sheet.append(el("dt", name), el("dd", value, className));
264
+ fact("Started", clock(block.startedAt));
265
+ fact(
266
+ block.durationMs === null ? "Running for" : "Duration",
267
+ brief(spent(block, now)),
268
+ );
269
+ if (block.decision) {
270
+ const { verdict, destination } = block.decision;
271
+ const retry = block.retry
272
+ ? ` (${block.retry.key} ${block.retry.count}/${block.retry.max})`
273
+ : "";
274
+ fact(
275
+ "Outcome",
276
+ verdict === "correction"
277
+ ? `↻ back to ${destination}${retry}`
278
+ : `accepted → ${destination ?? "end"}`,
279
+ verdict === "correction" ? "warn" : "",
280
+ );
281
+ } else fact("Outcome", block.status, block.status === "failed" ? "bad" : "");
282
+ const { call } = block;
283
+ if (call?.model) {
284
+ fact("Agent", `${call.runtime} · ${call.model}`);
285
+ if (call.session) fact("Session", call.session);
286
+ if (call.settings)
287
+ for (const { label, value } of settingRows(call.settings))
288
+ fact(label, value);
289
+ }
290
+ if (call?.usage) {
291
+ fact(
292
+ call.scope === "session" ? "Tokens (session)" : "Tokens",
293
+ tokenLine(call.usage, false),
294
+ );
295
+ fact("Cost", cost(call.usage.costUsd ?? null, false));
296
+ } else if (block.tokens)
297
+ fact("Tokens so far", tokenLine(block.tokens, block.provisional));
298
+ panel.append(sheet);
299
+ if (block.detail) panel.append(el("pre", block.detail, "callout bad"));
300
+ else if (block.decision?.verdict === "correction")
301
+ panel.append(el("pre", block.decision.reason, "callout warn"));
302
+ for (const warning of block.warnings)
303
+ panel.append(el("p", warning, "callout warn"));
304
+ if (block.tools.length) {
305
+ const chips = el("div", undefined, "chips");
306
+ for (const { name, count, durationMs, failed } of toolUse(block.tools))
307
+ chips.append(
308
+ el(
309
+ "span",
310
+ `${name} ×${count} · ${brief(durationMs)}${failed ? ` · ${failed} failed` : ""}`,
311
+ failed ? "chip warn" : "chip",
312
+ ),
313
+ );
314
+ panel.append(el("h4", `Tools used (${block.tools.length})`), chips);
315
+ }
316
+ if (block.journal.length) {
317
+ const journal = el("ol", undefined, "log");
318
+ journal.dataset.scroll = `journal-${index}`;
319
+ for (const entry of block.journal)
320
+ journal.append(
321
+ el("li", [el("span", hour(entry.at), "at"), el("span", entry.message)]),
322
+ );
323
+ panel.append(el("h4", "Journal"), journal);
324
+ }
325
+ if (block.tools.length) {
326
+ const all = view.allTools.has(index);
327
+ const shown = all ? block.tools : block.tools.slice(-LATEST_TOOLS);
328
+ const head = el(
329
+ "div",
330
+ [
331
+ el(
332
+ "h4",
333
+ all
334
+ ? `Tool calls (${block.tools.length})`
335
+ : `Tool calls · latest ${shown.length} of ${block.tools.length}`,
336
+ ),
337
+ ],
338
+ "log-head",
339
+ );
340
+ if (block.tools.length > LATEST_TOOLS) {
341
+ const toggle = el("button", all ? "Latest only" : "Show all", "link");
342
+ toggle.dataset.key = `tools-${index}`;
343
+ toggle.addEventListener("click", () => {
344
+ if (all) view.allTools.delete(index);
345
+ else view.allTools.add(index);
346
+ rerender();
347
+ });
348
+ head.append(toggle);
349
+ }
350
+ const calls = el("ol", undefined, "log");
351
+ calls.dataset.scroll = `tools-${index}`;
352
+ for (const tool of shown)
353
+ calls.append(
354
+ el(
355
+ "li",
356
+ [
357
+ el(
358
+ "span",
359
+ tool.status === "running"
360
+ ? "…"
361
+ : `${((tool.durationMs ?? 0) / 1000).toFixed(1)} s`,
362
+ "at",
363
+ ),
364
+ el(
365
+ "span",
366
+ `${tool.name}${tool.status === "failed" ? " — failed" : ""}`,
367
+ ),
368
+ ],
369
+ tool.status === "failed" ? "bad" : "",
370
+ ),
371
+ );
372
+ panel.append(head, calls);
373
+ }
374
+ return panel;
375
+ }
376
+
377
+ /**
378
+ * The detail's sections. The selected stage follows the running or failed one
379
+ * until the operator chooses one, and stays chosen as the run goes on.
380
+ * @param {Detail} detail
381
+ * @param {RunEvent[]} events Every event received for the run.
382
+ * @param {() => void} rerender Renders the detail again, as the operator changes the view.
383
+ * @returns {HTMLElement[]}
384
+ */
385
+ export function detailSections(detail, events, rerender) {
386
+ const now = Date.now();
387
+ const blocks = timeline(events);
388
+ const found = launches(blocks);
389
+ if (view.run !== detail.run.id) {
390
+ view.run = detail.run.id;
391
+ view.chosen = false;
392
+ view.allTools.clear();
393
+ }
394
+ if (!view.chosen || view.selected === null || !blocks[view.selected])
395
+ view.selected = shownStage(blocks);
396
+ const select = (/** @type {number} */ index) => {
397
+ view.selected = index;
398
+ view.chosen = true;
399
+ rerender();
400
+ };
401
+ const sections = [overview(detail, found, select, now)];
402
+ const stages = el("section", undefined, "stages-section");
403
+ const chosen = view.selected === null ? undefined : blocks[view.selected];
404
+ stages.append(
405
+ stageList(found, select, now),
406
+ chosen && view.selected !== null
407
+ ? stagePanel(chosen, view.selected, rerender, now)
408
+ : el("p", "No stage yet.", "empty"),
409
+ );
410
+ sections.push(stages);
411
+ if (detail.corrections.length) {
412
+ const corrections = el("section");
413
+ corrections.append(el("h2", "Corrections"));
414
+ for (const { key, count, max } of detail.corrections)
415
+ corrections.append(el("p", `${key} ${count}/${max ?? "?"}`, "mono"));
416
+ sections.push(corrections);
417
+ }
418
+ const result = el("section");
419
+ result.append(el("h2", "Result"), el("pre", detail.result.join("\n")));
420
+ for (const runtime of Object.values(detail.runtimes))
421
+ result.append(el("p", `Runtime: ${runtime}`, "runtime"));
422
+ sections.push(result);
423
+ return sections;
424
+ }
@@ -2,7 +2,8 @@
2
2
  /**
3
3
  * Follows the selected run: asks only for the events not received yet, one
4
4
  * request after the answer to the previous one, drops an answer for a selection
5
- * that has changed since, and stops once the run has ended or does not exist.
5
+ * that has changed since, and stops once the run has ended or does not exist,
6
+ * until the run list shows it updated since, as a resumed run is.
6
7
  * Requests and timers are given, so that this logic is tested without a browser.
7
8
  */
8
9
 
@@ -10,7 +11,7 @@
10
11
 
11
12
  /**
12
13
  * @typedef {object} Answer
13
- * @property {{ status: string }} run
14
+ * @property {{ status: string; updatedAt?: string }} run
14
15
  * @property {RunEvent[]} events Events from the index the request asked for.
15
16
  * @property {number} next The index to ask for next.
16
17
  */
@@ -26,7 +27,15 @@
26
27
  */
27
28
  export function follower({ load, schedule, cancel, show, fail }) {
28
29
  /** One selection: a new object each time, so that an old answer never matches. */
29
- let current = { id: "", events: /** @type {RunEvent[]} */ ([]), next: 0 };
30
+ let current = {
31
+ id: "",
32
+ events: /** @type {RunEvent[]} */ ([]),
33
+ next: 0,
34
+ /** Whether following stopped, on the run's end or absence. */
35
+ stopped: false,
36
+ /** The run's last update, as the last answer showed it. */
37
+ updatedAt: /** @type {string | undefined} */ (undefined),
38
+ };
30
39
  /** @type {Timer | undefined} */
31
40
  let timer;
32
41
 
@@ -45,14 +54,17 @@ export function follower({ load, schedule, cancel, show, fail }) {
45
54
  // A run that does not exist will not appear later.
46
55
  const status = /** @type {{ status?: number }} */ (error).status;
47
56
  if (status !== 404) timer = schedule(() => refresh(target));
57
+ else target.stopped = true;
48
58
  return;
49
59
  }
50
60
  if (target !== current) return;
51
61
  target.events.push(...answer.events);
52
62
  target.next = answer.next;
63
+ target.updatedAt = answer.run.updatedAt;
53
64
  show(answer, target.events);
54
65
  if (answer.run.status === "running")
55
66
  timer = schedule(() => refresh(target));
67
+ else target.stopped = true;
56
68
  }
57
69
 
58
70
  return {
@@ -60,7 +72,31 @@ export function follower({ load, schedule, cancel, show, fail }) {
60
72
  select(id) {
61
73
  if (id === current.id) return;
62
74
  cancel(timer);
63
- current = { id, events: [], next: 0 };
75
+ current = {
76
+ id,
77
+ events: [],
78
+ next: 0,
79
+ stopped: false,
80
+ updatedAt: undefined,
81
+ };
82
+ refresh(current);
83
+ },
84
+ /**
85
+ * The run list shows `id` last updated at `updatedAt`: following starts
86
+ * again, from the events already received, if it had stopped on an earlier
87
+ * state of the selected run, as when it was resumed since, even if the
88
+ * resumed run already ended again.
89
+ * @param {string} id
90
+ * @param {string} updatedAt
91
+ */
92
+ listed(id, updatedAt) {
93
+ if (
94
+ id !== current.id ||
95
+ !current.stopped ||
96
+ updatedAt === current.updatedAt
97
+ )
98
+ return;
99
+ current.stopped = false;
64
100
  refresh(current);
65
101
  },
66
102
  };
@@ -121,46 +121,325 @@ h2 {
121
121
  text-transform: uppercase;
122
122
  }
123
123
  h3 {
124
- margin: 0 0 4px;
124
+ margin: 0;
125
125
  font-size: 14px;
126
126
  overflow-wrap: anywhere;
127
127
  }
128
+ h4 {
129
+ margin: 14px 0 6px;
130
+ color: var(--muted);
131
+ font-size: 11px;
132
+ font-weight: 600;
133
+ letter-spacing: 0.04em;
134
+ text-transform: uppercase;
135
+ }
128
136
  ol {
129
137
  margin: 0;
130
138
  padding: 0;
131
139
  list-style: none;
132
140
  }
133
- .block {
134
- padding: 6px 0;
135
- border-bottom: 1px dotted var(--line);
141
+ pre,
142
+ .mono,
143
+ .log {
144
+ font:
145
+ 12px / 1.45 ui-monospace,
146
+ SFMono-Regular,
147
+ Menlo,
148
+ monospace;
149
+ overflow-wrap: anywhere;
136
150
  }
137
- .block:last-child {
138
- border-bottom: 0;
151
+ pre {
152
+ margin: 0;
153
+ white-space: pre-wrap;
139
154
  }
140
- .block > p {
155
+ .empty {
141
156
  margin: 0;
157
+ color: var(--muted);
142
158
  }
143
- .line,
144
- pre {
159
+ .muted {
160
+ color: var(--muted);
161
+ }
162
+
163
+ /* States, carried as data so that they never meet the list's status classes. */
164
+ [data-state="passed"],
165
+ [data-state="completed"] {
166
+ --state: var(--ok);
167
+ }
168
+ [data-state="correction"] {
169
+ --state: var(--run);
170
+ }
171
+ [data-state="failed"] {
172
+ --state: var(--bad);
173
+ }
174
+ [data-state="running"] {
175
+ --state: var(--accent);
176
+ }
177
+ .pill {
178
+ padding: 1px 8px;
179
+ border-radius: 999px;
180
+ background: color-mix(in srgb, var(--state) 16%, transparent);
181
+ color: var(--state);
182
+ font-size: 12px;
183
+ font-weight: 600;
184
+ }
185
+
186
+ /* The run's card: facts on one line, then each launch with its strip. */
187
+ .overview-title {
188
+ display: flex;
189
+ align-items: center;
190
+ justify-content: space-between;
191
+ gap: 10px;
192
+ }
193
+ .facts {
194
+ display: flex;
195
+ flex-wrap: wrap;
196
+ gap: 6px 28px;
197
+ margin: 10px 0 14px;
198
+ }
199
+ .facts dt {
200
+ color: var(--muted);
201
+ font-size: 12px;
202
+ }
203
+ .facts dd {
204
+ margin: 0;
205
+ font-size: 15px;
206
+ font-weight: 600;
207
+ font-variant-numeric: tabular-nums;
208
+ }
209
+ .launch {
210
+ padding-top: 12px;
211
+ border-top: 1px solid var(--line);
212
+ }
213
+ .launch-head {
214
+ display: flex;
215
+ align-items: baseline;
216
+ justify-content: space-between;
217
+ gap: 12px;
218
+ margin-bottom: 6px;
219
+ font-size: 13px;
220
+ font-variant-numeric: tabular-nums;
221
+ }
222
+ .launch-head .state {
223
+ color: var(--state);
224
+ font-size: 12px;
225
+ font-weight: 600;
226
+ white-space: nowrap;
227
+ }
228
+ .launch-cause {
229
+ margin: 6px 0 0;
230
+ color: var(--bad);
231
+ font-size: 12px;
232
+ }
233
+ .pause {
234
+ margin: 10px 0;
235
+ color: var(--muted);
236
+ font-size: 12px;
237
+ }
238
+ .track {
239
+ display: flex;
240
+ gap: 2px;
241
+ height: 22px;
242
+ }
243
+ .segment {
244
+ min-width: 4px;
245
+ flex-basis: 0;
246
+ padding: 0 6px;
247
+ overflow: hidden;
248
+ border: 0;
249
+ border-radius: 3px;
250
+ background: var(--state);
251
+ color: var(--panel);
252
+ font:
253
+ 600 11px / 22px ui-sans-serif,
254
+ system-ui,
255
+ sans-serif;
256
+ text-overflow: ellipsis;
257
+ white-space: nowrap;
258
+ cursor: pointer;
259
+ }
260
+ .segment[data-state="passed"]:not([data-kind="AGENT"]) {
261
+ background: color-mix(in srgb, var(--ok) 65%, var(--panel));
262
+ }
263
+ .segment[aria-current="true"] {
264
+ outline: 2px solid var(--fg);
265
+ outline-offset: 1px;
266
+ }
267
+
268
+ /* The stages: the list beside the selected stage's panel. */
269
+ .stages-section {
270
+ display: grid;
271
+ grid-template-columns: minmax(0, 1fr);
272
+ gap: 16px;
273
+ }
274
+ @media (min-width: 1300px) {
275
+ .stages-section {
276
+ grid-template-columns: minmax(0, 1fr) minmax(0, 1.1fr);
277
+ align-items: start;
278
+ }
279
+ .stage-list {
280
+ max-height: 75vh;
281
+ overflow: auto;
282
+ }
283
+ .stage-panel {
284
+ position: sticky;
285
+ top: 12px;
286
+ }
287
+ }
288
+ .group {
289
+ margin: 10px 0 4px;
290
+ color: var(--muted);
291
+ font-size: 12px;
292
+ font-weight: 600;
293
+ }
294
+ .stages > li {
295
+ border-bottom: 1px solid var(--line);
296
+ }
297
+ .stages > li:last-child {
298
+ border-bottom: 0;
299
+ }
300
+ .stage {
301
+ display: grid;
302
+ grid-template-columns: 18px minmax(0, 1fr) 56px 84px;
303
+ gap: 8px;
304
+ align-items: center;
305
+ width: 100%;
306
+ padding: 4px;
307
+ border: 0;
308
+ background: none;
309
+ color: inherit;
310
+ font: inherit;
311
+ text-align: left;
312
+ cursor: pointer;
313
+ }
314
+ .stage:hover,
315
+ .stage[aria-current="true"] {
316
+ background: color-mix(in srgb, var(--accent) 10%, transparent);
317
+ }
318
+ .stage .icon {
319
+ color: var(--state);
320
+ font-weight: 700;
321
+ text-align: center;
322
+ }
323
+ .stage .name {
324
+ overflow: hidden;
325
+ font-weight: 600;
326
+ text-overflow: ellipsis;
327
+ white-space: nowrap;
328
+ }
329
+ .stage[data-kind="GATE"][data-state="passed"] {
330
+ font-size: 13px;
331
+ }
332
+ .stage[data-kind="GATE"][data-state="passed"] .name,
333
+ .stage[data-kind="GATE"][data-state="passed"] .icon {
334
+ color: var(--muted);
335
+ font-weight: 400;
336
+ }
337
+ .kind {
338
+ color: var(--muted);
339
+ font-size: 11px;
340
+ letter-spacing: 0.04em;
341
+ }
342
+ .kind.tag {
343
+ padding: 1px 6px;
344
+ border: 1px solid var(--line);
345
+ border-radius: 4px;
346
+ }
347
+ .took {
145
348
  font:
146
349
  12px / 1.45 ui-monospace,
147
350
  SFMono-Regular,
148
351
  Menlo,
149
352
  monospace;
150
- overflow-wrap: anywhere;
353
+ text-align: right;
354
+ white-space: nowrap;
355
+ }
356
+ .panel-title {
357
+ display: flex;
358
+ align-items: center;
359
+ gap: 8px;
360
+ }
361
+ .sheet {
362
+ display: grid;
363
+ grid-template-columns: max-content minmax(0, 1fr);
364
+ gap: 5px 14px;
365
+ margin: 12px 0 0;
366
+ font-size: 13px;
151
367
  }
152
- .line {
153
- margin: 2px 0 0 16px;
368
+ .sheet dt {
154
369
  color: var(--muted);
155
370
  }
156
- .warning {
371
+ .sheet dd {
372
+ margin: 0;
373
+ overflow-wrap: anywhere;
374
+ font-variant-numeric: tabular-nums;
375
+ }
376
+ .warn {
157
377
  color: var(--run);
158
378
  }
159
- pre {
160
- margin: 0;
379
+ .bad {
380
+ color: var(--bad);
381
+ }
382
+ .callout {
383
+ margin: 12px 0 0;
384
+ padding: 8px 10px;
385
+ border-left: 3px solid currentColor;
386
+ border-radius: 4px;
387
+ background: color-mix(in srgb, currentColor 8%, transparent);
388
+ font-size: 12px;
161
389
  white-space: pre-wrap;
162
390
  }
163
- .empty {
164
- margin: 0;
391
+ .chips {
392
+ display: flex;
393
+ flex-wrap: wrap;
394
+ gap: 4px;
395
+ }
396
+ .chip {
397
+ padding: 1px 7px;
398
+ border: 1px solid var(--line);
399
+ border-radius: 999px;
400
+ font-size: 12px;
401
+ font-variant-numeric: tabular-nums;
402
+ }
403
+ .chip.warn {
404
+ border-color: color-mix(in srgb, var(--run) 50%, transparent);
405
+ }
406
+ .log {
407
+ max-height: 260px;
408
+ padding: 6px 8px;
409
+ overflow: auto;
410
+ border: 1px solid var(--line);
411
+ border-radius: 6px;
412
+ }
413
+ .log li {
414
+ display: grid;
415
+ grid-template-columns: 70px minmax(0, 1fr);
416
+ gap: 8px;
417
+ padding: 1px 0;
418
+ }
419
+ .log .at {
165
420
  color: var(--muted);
166
421
  }
422
+ .log-head {
423
+ display: flex;
424
+ align-items: baseline;
425
+ justify-content: space-between;
426
+ }
427
+ button.link {
428
+ padding: 0;
429
+ border: 0;
430
+ background: none;
431
+ color: var(--accent);
432
+ font: inherit;
433
+ font-size: 12px;
434
+ cursor: pointer;
435
+ }
436
+ .runtime {
437
+ margin: 10px 0 0;
438
+ color: var(--muted);
439
+ font-size: 12px;
440
+ overflow-wrap: anywhere;
441
+ }
442
+ :is(.stage, .segment, button.link):focus-visible {
443
+ outline: 2px solid var(--accent);
444
+ outline-offset: 1px;
445
+ }
@@ -1,8 +1,8 @@
1
1
  // @ts-check
2
2
  /**
3
3
  * The dashboard's pure logic: a run's events grouped into one block per stage
4
- * execution, and the formats of durations, costs and agent settings. Served as
5
- * is to the browser.
4
+ * execution, those blocks grouped into launches, and the formats of durations,
5
+ * costs, tokens and agent settings. Served as is to the browser.
6
6
  */
7
7
 
8
8
  /** @typedef {import("@clemrig/flow").RunEvent} RunEvent */
@@ -171,6 +171,47 @@ export function duration(ms) {
171
171
  return `${hours ? `${hours} h ` : ""}${minutes} min ${seconds % 60} s`;
172
172
  }
173
173
 
174
+ /**
175
+ * A duration in its two largest units: `1 h 07 min`, `7 min 02 s`, `12 s`,
176
+ * `< 1 s`.
177
+ * @param {number | null} ms
178
+ * @returns {string}
179
+ */
180
+ export function brief(ms) {
181
+ if (ms === null) return "—";
182
+ const seconds = Math.floor(ms / 1000);
183
+ if (seconds < 1) return "< 1 s";
184
+ const hours = Math.floor(seconds / 3600);
185
+ const minutes = Math.floor((seconds % 3600) / 60);
186
+ const pad = (/** @type {number} */ n) => String(n).padStart(2, "0");
187
+ if (hours) return `${hours} h ${pad(minutes)} min`;
188
+ if (minutes) return `${minutes} min ${pad(seconds % 60)} s`;
189
+ return `${seconds} s`;
190
+ }
191
+
192
+ /**
193
+ * A token count: exact below ten thousand, then in thousands or millions.
194
+ * @param {number} count
195
+ * @returns {string}
196
+ */
197
+ export function tokenCount(count) {
198
+ if (count >= 1e6) return `${(count / 1e6).toFixed(1)} M`;
199
+ if (count >= 1e4) return `${Math.round(count / 1e3)} k`;
200
+ return count.toLocaleString("en-US");
201
+ }
202
+
203
+ /**
204
+ * A call's tokens: read without the cache, written, read from and written to
205
+ * the cache; provisional figures, which the call's end replaces, say so.
206
+ * @param {TokenCounts} tokens
207
+ * @param {boolean} provisional
208
+ * @returns {string}
209
+ */
210
+ export function tokenLine(tokens, provisional) {
211
+ const { input, output, cacheRead, cacheWrite } = tokens;
212
+ return `${tokenCount(input)} in · ${tokenCount(output)} out · ${tokenCount(cacheRead)} cache read · ${tokenCount(cacheWrite)} cache write${provisional ? " (provisional)" : ""}`;
213
+ }
214
+
174
215
  /** Setting names that are not plain words. */
175
216
  const settingLabels = /** @type {Record<string, string>} */ ({
176
217
  mcpServers: "MCP servers",
@@ -189,13 +230,13 @@ function settingLabel(key) {
189
230
  }
190
231
 
191
232
  /**
192
- * An agent call's settings as its runtime recorded them, whatever the runtime:
193
- * its thinking, its other single values and its timeout, then each list, its
194
- * tools even when empty, the others only with items.
233
+ * An agent call's settings as its runtime recorded them, whatever the runtime,
234
+ * as labelled rows: its thinking, other single values and timeout in one, then
235
+ * one per list, its tools even when empty, the others only with items.
195
236
  * @param {AgentSettings} settings
196
- * @returns {string[]}
237
+ * @returns {{ label: string; value: string }[]}
197
238
  */
198
- export function settingsLines(settings) {
239
+ export function settingRows(settings) {
199
240
  const entries = Object.entries(settings);
200
241
  const values = entries.flatMap(([key, value]) =>
201
242
  key === "thinking" ||
@@ -207,17 +248,23 @@ export function settingsLines(settings) {
207
248
  );
208
249
  const lists = entries.flatMap(([key, value]) => {
209
250
  if (!Array.isArray(value) || (!value.length && key !== "tools")) return [];
210
- const label = settingLabel(key);
251
+ const label = key === "tools" ? "allowed tools" : settingLabel(key);
211
252
  return [
212
- `${label[0]?.toUpperCase()}${label.slice(1)}: ${value.join(", ") || "none"}`,
253
+ {
254
+ label: `${label[0]?.toUpperCase()}${label.slice(1)}`,
255
+ value: value.join(", ") || "none",
256
+ },
213
257
  ];
214
258
  });
215
259
  return [
216
- [
217
- `thinking ${settings.thinking}`,
218
- ...values,
219
- `timeout ${duration(settings.timeoutMs)}`,
220
- ].join(" · "),
260
+ {
261
+ label: "Settings",
262
+ value: [
263
+ `thinking ${settings.thinking}`,
264
+ ...values,
265
+ `timeout ${brief(settings.timeoutMs)}`,
266
+ ].join(" · "),
267
+ },
221
268
  ...lists,
222
269
  ];
223
270
  }
@@ -232,3 +279,151 @@ export function cost(usd, partial) {
232
279
  const value = usd === null ? "—" : `${usd.toFixed(4)} USD`;
233
280
  return partial ? `${value} (partial)` : value;
234
281
  }
282
+
283
+ /**
284
+ * An estimated cost as the Kanban shows it: in dollars, `≥` while some call
285
+ * announced none, a dash while none announced any.
286
+ * @param {number | null} usd
287
+ * @param {boolean} partial
288
+ * @returns {string}
289
+ */
290
+ export function dollars(usd, partial) {
291
+ if (usd === null) return "—";
292
+ return `${partial ? "≥ " : ""}$${usd.toFixed(2)}`;
293
+ }
294
+
295
+ /** @typedef {"passed" | "correction" | "failed" | "running"} State */
296
+
297
+ /**
298
+ * How a stage execution went: running, failed, sent back for a correction, or
299
+ * passed (the run's last stage included).
300
+ * @param {Block} block
301
+ * @returns {State}
302
+ */
303
+ export function stateOf(block) {
304
+ if (block.status === "running" || block.status === "failed")
305
+ return block.status;
306
+ return block.decision?.verdict === "correction" ? "correction" : "passed";
307
+ }
308
+
309
+ /**
310
+ * A stage execution's time so far: its duration once ended.
311
+ * @param {Block} block
312
+ * @param {number} now
313
+ * @returns {number}
314
+ */
315
+ export function spent(block, now) {
316
+ return block.durationMs ?? Math.max(0, now - Date.parse(block.startedAt));
317
+ }
318
+
319
+ /**
320
+ * @typedef {object} Launch
321
+ * @property {number} number From 1: the run's first execution, then each resumption.
322
+ * @property {string} startedAt
323
+ * @property {string | null} endedAt Null while it runs.
324
+ * @property {"running" | "failed" | "completed"} status
325
+ * @property {string} [cause] Why a failed launch stopped.
326
+ * @property {{ block: Block; index: number }[]} stages Its stage executions, with their index in the run.
327
+ */
328
+
329
+ /**
330
+ * The stage execution to show until the operator chooses one: the last, which
331
+ * is the one running or the one where the run stopped, never an earlier
332
+ * launch's failure once the run was resumed.
333
+ * @param {Block[]} blocks
334
+ * @returns {number | null}
335
+ */
336
+ export function shownStage(blocks) {
337
+ return blocks.length ? blocks.length - 1 : null;
338
+ }
339
+
340
+ /**
341
+ * The run's launches: its first execution, then one per resumption. A failed
342
+ * stage ends a launch, since it ends the run, and only a failed run is
343
+ * resumed: the next stage starts the following launch. A failure reported
344
+ * once the run completed, when its completed state could not be saved,
345
+ * ends the same launch.
346
+ * @param {Block[]} blocks
347
+ * @returns {Launch[]}
348
+ */
349
+ export function launches(blocks) {
350
+ /** @type {Launch[]} */
351
+ const found = [];
352
+ blocks.forEach((block, index) => {
353
+ let current = found.at(-1);
354
+ if (!current || current.status === "failed") {
355
+ current = {
356
+ number: found.length + 1,
357
+ startedAt: block.startedAt,
358
+ endedAt: null,
359
+ status: "running",
360
+ stages: [],
361
+ };
362
+ found.push(current);
363
+ }
364
+ current.stages.push({ block, index });
365
+ if (block.status === "failed" || block.status === "completed") {
366
+ current.status = block.status;
367
+ current.endedAt =
368
+ block.durationMs === null
369
+ ? block.startedAt
370
+ : new Date(
371
+ Date.parse(block.startedAt) + block.durationMs,
372
+ ).toISOString();
373
+ if (block.detail) current.cause = block.detail;
374
+ }
375
+ });
376
+ return found;
377
+ }
378
+
379
+ /**
380
+ * What a stage execution did, in one line: its failure, its agent's model,
381
+ * tools and cost, its decision, or its last journal message.
382
+ * @param {Block} block
383
+ * @returns {string}
384
+ */
385
+ export function summary(block) {
386
+ if (block.status === "failed" && block.detail) return block.detail;
387
+ if (block.call?.model) {
388
+ const tools = `${block.tools.length} tool${block.tools.length === 1 ? "" : "s"}`;
389
+ const usd = block.call.usage?.costUsd;
390
+ return [
391
+ block.call.model,
392
+ tools,
393
+ ...(usd === undefined ? [] : [dollars(usd, false)]),
394
+ ].join(" · ");
395
+ }
396
+ if (block.decision) {
397
+ const { verdict, reason, destination } = block.decision;
398
+ return verdict === "correction"
399
+ ? `back to ${destination}: ${reason.split("\n")[0]}`
400
+ : `accepted → ${destination ?? "end"}`;
401
+ }
402
+ return block.journal.at(-1)?.message ?? "";
403
+ }
404
+
405
+ /**
406
+ * The tools a stage execution used, by name, the most used first: how many
407
+ * calls, how long in all and how many failed.
408
+ * @param {Tool[]} tools
409
+ * @returns {{ name: string; count: number; durationMs: number; failed: number }[]}
410
+ */
411
+ export function toolUse(tools) {
412
+ /** @type {Map<string, { name: string; count: number; durationMs: number; failed: number }>} */
413
+ const byName = new Map();
414
+ for (const tool of tools) {
415
+ const entry = byName.get(tool.name) ?? {
416
+ name: tool.name,
417
+ count: 0,
418
+ durationMs: 0,
419
+ failed: 0,
420
+ };
421
+ entry.count++;
422
+ entry.durationMs += tool.durationMs ?? 0;
423
+ if (tool.status === "failed") entry.failed++;
424
+ byName.set(tool.name, entry);
425
+ }
426
+ return [...byName.values()].sort(
427
+ (a, b) => b.count - a.count || a.name.localeCompare(b.name),
428
+ );
429
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clemrig/flow-ui",
3
- "version": "0.2.0",
3
+ "version": "0.4.0",
4
4
  "description": "A local read-only dashboard of the runs of @clemrig/flow workflows",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -34,6 +34,6 @@
34
34
  "prepack": "npm run build"
35
35
  },
36
36
  "dependencies": {
37
- "@clemrig/flow": "0.4.0"
37
+ "@clemrig/flow": "0.5.1"
38
38
  }
39
39
  }