@clemrig/flow-ui 0.0.0-stage → 0.1.1
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/LICENSE +21 -0
- package/README.md +36 -2
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +73 -0
- package/dist/cli.js.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/main.d.ts +2 -0
- package/dist/main.js +4 -0
- package/dist/main.js.map +1 -0
- package/dist/server.d.ts +18 -0
- package/dist/server.js +107 -0
- package/dist/server.js.map +1 -0
- package/dist/static/dashboard.js +268 -0
- package/dist/static/follow.js +67 -0
- package/dist/static/index.html +20 -0
- package/dist/static/style.css +166 -0
- package/dist/static/timeline.js +180 -0
- package/package.json +37 -4
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Clément CHESNEAU
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,37 @@
|
|
|
1
|
-
#
|
|
1
|
+
# @clemrig/flow-ui
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
A local, read-only dashboard of the runs of [`@clemrig/flow`](https://www.npmjs.com/package/@clemrig/flow) workflows.
|
|
4
|
+
|
|
5
|
+
## Use
|
|
6
|
+
|
|
7
|
+
Requires macOS or Linux and Node.js 24 or later.
|
|
8
|
+
|
|
9
|
+
```sh
|
|
10
|
+
npm install -g @clemrig/flow-ui
|
|
11
|
+
flow-ui [RUNS_DIR] [--port PORT]
|
|
12
|
+
```
|
|
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 (agent 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.
|
|
15
|
+
|
|
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
|
+
|
|
18
|
+
While it runs, any local process that can reach `127.0.0.1` can read the runs it serves.
|
|
19
|
+
|
|
20
|
+
## From code
|
|
21
|
+
|
|
22
|
+
```ts
|
|
23
|
+
import { startDashboard } from "@clemrig/flow-ui";
|
|
24
|
+
|
|
25
|
+
const dashboard = await startDashboard({
|
|
26
|
+
runsDir: ".flow/runs",
|
|
27
|
+
title: "flow",
|
|
28
|
+
label: "my-project",
|
|
29
|
+
presentResult: (state) => [JSON.stringify(state.data)], // optional
|
|
30
|
+
});
|
|
31
|
+
console.log(dashboard.url);
|
|
32
|
+
// later: await dashboard.close();
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
## License
|
|
36
|
+
|
|
37
|
+
MIT
|
package/dist/cli.d.ts
ADDED
package/dist/cli.js
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { stat } from "node:fs/promises";
|
|
2
|
+
import { isAbsolute, relative, resolve, sep } from "node:path";
|
|
3
|
+
import { defaultRunsDir } from "@clemrig/flow";
|
|
4
|
+
import { startDashboard } from "./server.js";
|
|
5
|
+
const usage = `Usage: flow-ui [RUNS_DIR] [--port PORT]
|
|
6
|
+
|
|
7
|
+
Serves a read-only dashboard of the runs kept in RUNS_DIR (default: .flow/runs)
|
|
8
|
+
on 127.0.0.1, on PORT or one the system chooses, until Ctrl-C.
|
|
9
|
+
`;
|
|
10
|
+
/** `--port`: a fixed port, so that an SSH tunnel can forward the same one. */
|
|
11
|
+
function parsePort(value) {
|
|
12
|
+
const port = Number(value);
|
|
13
|
+
if (value === undefined || !/^\d+$/.test(value) || port < 1 || port > 65535)
|
|
14
|
+
throw new Error(`Invalid --port ${value ?? ""}: expected a port from 1 to 65535`);
|
|
15
|
+
return port;
|
|
16
|
+
}
|
|
17
|
+
/** `flow-ui`: serves the dashboard of a runs directory until Ctrl-C. */
|
|
18
|
+
export async function main(argv, cwd) {
|
|
19
|
+
try {
|
|
20
|
+
if (argv.includes("--help") || argv.includes("-h")) {
|
|
21
|
+
process.stdout.write(usage);
|
|
22
|
+
return 0;
|
|
23
|
+
}
|
|
24
|
+
const positional = [];
|
|
25
|
+
let port;
|
|
26
|
+
for (let i = 0; i < argv.length; i++) {
|
|
27
|
+
const arg = argv[i] ?? "";
|
|
28
|
+
if (arg === "--port") {
|
|
29
|
+
if (port !== undefined)
|
|
30
|
+
throw new Error("Duplicate option --port");
|
|
31
|
+
port = parsePort(argv[++i]);
|
|
32
|
+
}
|
|
33
|
+
else if (arg.startsWith("-"))
|
|
34
|
+
throw new Error(`Unknown option ${arg}; see flow-ui --help`);
|
|
35
|
+
else
|
|
36
|
+
positional.push(arg);
|
|
37
|
+
}
|
|
38
|
+
if (positional.length > 1)
|
|
39
|
+
throw new Error("flow-ui takes one runs directory; see flow-ui --help");
|
|
40
|
+
const runsDir = positional[0]
|
|
41
|
+
? resolve(cwd, positional[0])
|
|
42
|
+
: defaultRunsDir(cwd);
|
|
43
|
+
// The default may not exist before the first run; a directory given must.
|
|
44
|
+
const found = await stat(runsDir).catch((error) => {
|
|
45
|
+
if (error.code === "ENOENT")
|
|
46
|
+
return undefined;
|
|
47
|
+
throw error;
|
|
48
|
+
});
|
|
49
|
+
if (found ? !found.isDirectory() : positional[0] !== undefined)
|
|
50
|
+
throw new Error(`No runs directory at ${runsDir}`);
|
|
51
|
+
const inside = relative(cwd, runsDir);
|
|
52
|
+
const outside = inside === ".." || inside.startsWith(`..${sep}`) || isAbsolute(inside);
|
|
53
|
+
const label = outside ? runsDir : inside || ".";
|
|
54
|
+
const dashboard = await startDashboard({ runsDir, title: "flow", label }, port);
|
|
55
|
+
process.stdout.write(`flow-ui: ${dashboard.url} (runs in ${label}; read-only; Ctrl-C to stop)\n`);
|
|
56
|
+
await new Promise((resolve) => {
|
|
57
|
+
const stop = () => {
|
|
58
|
+
process.off("SIGINT", stop);
|
|
59
|
+
process.off("SIGTERM", stop);
|
|
60
|
+
resolve();
|
|
61
|
+
};
|
|
62
|
+
process.on("SIGINT", stop);
|
|
63
|
+
process.on("SIGTERM", stop);
|
|
64
|
+
});
|
|
65
|
+
await dashboard.close();
|
|
66
|
+
return 0;
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
process.stderr.write(`flow-ui: ${error instanceof Error ? error.message : String(error)}\n`);
|
|
70
|
+
return 1;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
//# sourceMappingURL=cli.js.map
|
package/dist/cli.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cli.js","sourceRoot":"","sources":["../src/cli.ts"],"names":[],"mappings":"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"}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA,2FAA2F;AAE3F,OAAO,EAAyB,cAAc,EAAE,MAAM,aAAa,CAAC"}
|
package/dist/main.d.ts
ADDED
package/dist/main.js
ADDED
package/dist/main.js.map
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"main.js","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AACA,OAAO,EAAE,IAAI,EAAE,MAAM,UAAU,CAAC;AAEhC,OAAO,CAAC,QAAQ,GAAG,MAAM,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,CAAC,EAAE,OAAO,CAAC,GAAG,EAAE,CAAC,CAAC"}
|
package/dist/server.d.ts
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { type Server } from "node:http";
|
|
2
|
+
import { type RunState } from "@clemrig/flow";
|
|
3
|
+
export interface DashboardOptions {
|
|
4
|
+
/** Where the runs are kept. */
|
|
5
|
+
runsDir: string;
|
|
6
|
+
/** The page's title. */
|
|
7
|
+
title: string;
|
|
8
|
+
/** Shown beside the title: what the runs belong to. */
|
|
9
|
+
label: string;
|
|
10
|
+
/** A run's result lines; by default, its validated data as JSON. */
|
|
11
|
+
presentResult?(state: Readonly<RunState>): string[];
|
|
12
|
+
}
|
|
13
|
+
/** A read-only view of the runs on 127.0.0.1, on `port` or one chosen by the system. */
|
|
14
|
+
export declare function startDashboard(options: DashboardOptions, listen?: number): Promise<{
|
|
15
|
+
url: string;
|
|
16
|
+
server: Server;
|
|
17
|
+
close(): Promise<void>;
|
|
18
|
+
}>;
|
package/dist/server.js
ADDED
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createServer, } from "node:http";
|
|
3
|
+
import { listRuns, loadRun, loadRunEvents, presentResult, summarizeCorrections, summarizeRun, UnknownRunError, } from "@clemrig/flow";
|
|
4
|
+
/** The runs, newest first; an unreadable run with its error. */
|
|
5
|
+
async function runs(options) {
|
|
6
|
+
return {
|
|
7
|
+
title: options.title,
|
|
8
|
+
label: options.label,
|
|
9
|
+
runs: (await listRuns(options.runsDir))
|
|
10
|
+
.reverse()
|
|
11
|
+
.map((entry) => ("state" in entry ? summarizeRun(entry.state) : entry)),
|
|
12
|
+
};
|
|
13
|
+
}
|
|
14
|
+
/** A run with its events from the `after`-th: the page asks only for what it has not received. */
|
|
15
|
+
async function detail(options, id, after) {
|
|
16
|
+
const state = await loadRun(options.runsDir, id);
|
|
17
|
+
const events = await loadRunEvents(options.runsDir, id);
|
|
18
|
+
return {
|
|
19
|
+
run: summarizeRun(state),
|
|
20
|
+
runtimes: state.runtimes ?? {},
|
|
21
|
+
error: state.error ?? null,
|
|
22
|
+
events: events.slice(after),
|
|
23
|
+
next: events.length,
|
|
24
|
+
// Each correction's maximum comes from all its events, not only those sent.
|
|
25
|
+
corrections: summarizeCorrections(state, events),
|
|
26
|
+
result: presentResult(options, state),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
/** The page's files, served as they are: no script or style is ever inline. */
|
|
30
|
+
const pageFiles = {
|
|
31
|
+
"/": ["index.html", "text/html; charset=utf-8"],
|
|
32
|
+
"/style.css": ["style.css", "text/css; charset=utf-8"],
|
|
33
|
+
"/dashboard.js": ["dashboard.js", "text/javascript; charset=utf-8"],
|
|
34
|
+
"/timeline.js": ["timeline.js", "text/javascript; charset=utf-8"],
|
|
35
|
+
"/follow.js": ["follow.js", "text/javascript; charset=utf-8"],
|
|
36
|
+
};
|
|
37
|
+
async function loadPage() {
|
|
38
|
+
const page = new Map();
|
|
39
|
+
for (const [path, [file, type]] of Object.entries(pageFiles))
|
|
40
|
+
page.set(path, {
|
|
41
|
+
body: await readFile(new URL(`./static/${file}`, import.meta.url), "utf8"),
|
|
42
|
+
type,
|
|
43
|
+
});
|
|
44
|
+
return page;
|
|
45
|
+
}
|
|
46
|
+
function send(response, status, body, type = "text/plain; charset=utf-8") {
|
|
47
|
+
response.writeHead(status, {
|
|
48
|
+
"content-type": type,
|
|
49
|
+
"cache-control": "no-store",
|
|
50
|
+
"x-content-type-options": "nosniff",
|
|
51
|
+
"content-security-policy": "default-src 'none'; script-src 'self'; style-src 'self'; connect-src 'self'; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
|
|
52
|
+
});
|
|
53
|
+
response.end(body);
|
|
54
|
+
}
|
|
55
|
+
/** A read-only view of the runs on 127.0.0.1, on `port` or one chosen by the system. */
|
|
56
|
+
export async function startDashboard(options, listen = 0) {
|
|
57
|
+
let port = 0;
|
|
58
|
+
const page = await loadPage();
|
|
59
|
+
const handle = async (request, response) => {
|
|
60
|
+
// Another Host means another site reached this port (DNS rebinding).
|
|
61
|
+
if (request.headers.host !== `127.0.0.1:${port}` &&
|
|
62
|
+
request.headers.host !== `localhost:${port}`)
|
|
63
|
+
return send(response, 403, "Forbidden");
|
|
64
|
+
if (request.method !== "GET")
|
|
65
|
+
return send(response, 405, "Method Not Allowed");
|
|
66
|
+
const url = new URL(request.url ?? "/", "http://127.0.0.1");
|
|
67
|
+
const file = page.get(url.pathname);
|
|
68
|
+
if (file)
|
|
69
|
+
return send(response, 200, file.body, file.type);
|
|
70
|
+
if (url.pathname === "/api/runs")
|
|
71
|
+
return send(response, 200, JSON.stringify(await runs(options)), "application/json");
|
|
72
|
+
const id = /^\/api\/runs\/([^/]+)$/.exec(url.pathname)?.[1];
|
|
73
|
+
if (id === undefined)
|
|
74
|
+
return send(response, 404, "Not Found");
|
|
75
|
+
const after = url.searchParams.get("after") ?? "0";
|
|
76
|
+
if (!/^\d+$/.test(after))
|
|
77
|
+
return send(response, 400, "Bad Request");
|
|
78
|
+
let body;
|
|
79
|
+
try {
|
|
80
|
+
body = await detail(options, decodeURIComponent(id), Number(after));
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
// A run that cannot be read now may be read at the next request.
|
|
84
|
+
return error instanceof UnknownRunError
|
|
85
|
+
? send(response, 404, "Not Found")
|
|
86
|
+
: send(response, 500, error instanceof Error ? error.message : String(error));
|
|
87
|
+
}
|
|
88
|
+
return send(response, 200, JSON.stringify(body), "application/json");
|
|
89
|
+
};
|
|
90
|
+
const server = createServer((request, response) => {
|
|
91
|
+
handle(request, response).catch((error) => send(response, 500, error instanceof Error ? error.message : String(error)));
|
|
92
|
+
});
|
|
93
|
+
await new Promise((resolve, reject) => {
|
|
94
|
+
server.once("error", reject);
|
|
95
|
+
server.listen(listen, "127.0.0.1", () => resolve());
|
|
96
|
+
});
|
|
97
|
+
port = server.address().port;
|
|
98
|
+
return {
|
|
99
|
+
url: `http://127.0.0.1:${port}/`,
|
|
100
|
+
server,
|
|
101
|
+
close: () => new Promise((resolve, reject) => {
|
|
102
|
+
server.close((error) => (error ? reject(error) : resolve()));
|
|
103
|
+
server.closeAllConnections();
|
|
104
|
+
}),
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
//# sourceMappingURL=server.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"server.js","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,268 @@
|
|
|
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 { follower } from "./follow.js";
|
|
7
|
+
import { cost, duration, timeline } from "./timeline.js";
|
|
8
|
+
|
|
9
|
+
/** @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
|
+
*/
|
|
35
|
+
|
|
36
|
+
const LIST_EVERY_MS = 2000;
|
|
37
|
+
const DETAIL_EVERY_MS = 1000;
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* @param {string} id
|
|
41
|
+
* @returns {HTMLElement}
|
|
42
|
+
*/
|
|
43
|
+
function byId(id) {
|
|
44
|
+
const found = document.getElementById(id);
|
|
45
|
+
if (!found) throw new Error(`Missing element #${id}`);
|
|
46
|
+
return found;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* @param {string} tag
|
|
51
|
+
* @param {string} [text]
|
|
52
|
+
* @param {string} [className]
|
|
53
|
+
* @returns {HTMLElement}
|
|
54
|
+
*/
|
|
55
|
+
function el(tag, text, className) {
|
|
56
|
+
const node = document.createElement(tag);
|
|
57
|
+
if (text !== undefined) node.textContent = text;
|
|
58
|
+
if (className) node.className = className;
|
|
59
|
+
return node;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** @param {string} iso */
|
|
63
|
+
function time(iso) {
|
|
64
|
+
const date = new Date(iso);
|
|
65
|
+
return Number.isNaN(date.getTime())
|
|
66
|
+
? iso
|
|
67
|
+
: date.toLocaleString(undefined, {
|
|
68
|
+
dateStyle: "short",
|
|
69
|
+
timeStyle: "medium",
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
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
|
+
/**
|
|
79
|
+
* @param {string} path
|
|
80
|
+
* @returns {Promise<any>}
|
|
81
|
+
*/
|
|
82
|
+
async function load(path) {
|
|
83
|
+
const response = await fetch(path, { cache: "no-store" });
|
|
84
|
+
if (!response.ok)
|
|
85
|
+
throw Object.assign(
|
|
86
|
+
new Error((await response.text()) || `HTTP ${response.status}`),
|
|
87
|
+
{ status: response.status },
|
|
88
|
+
);
|
|
89
|
+
return response.json();
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** The run the address names, or none when it cannot be read. */
|
|
93
|
+
function fromHash() {
|
|
94
|
+
try {
|
|
95
|
+
return decodeURIComponent(location.hash.slice(1));
|
|
96
|
+
} catch {
|
|
97
|
+
return "";
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
let selected = fromHash();
|
|
102
|
+
|
|
103
|
+
/** @param {Summary[]} runs */
|
|
104
|
+
function showRuns(runs) {
|
|
105
|
+
byId("none").hidden = runs.length > 0;
|
|
106
|
+
byId("runs").replaceChildren(
|
|
107
|
+
...runs.map((run) => {
|
|
108
|
+
const row = el("tr");
|
|
109
|
+
row.title = run.id;
|
|
110
|
+
if (run.id === selected) row.className = "selected";
|
|
111
|
+
if (run.error !== undefined) {
|
|
112
|
+
const cause = el("td", run.error);
|
|
113
|
+
cause.setAttribute("colspan", "4");
|
|
114
|
+
row.append(el("td", run.id), el("td", "unreadable", "failed"), cause);
|
|
115
|
+
} else
|
|
116
|
+
row.append(
|
|
117
|
+
el("td", run.workflow),
|
|
118
|
+
el("td", run.status, run.status),
|
|
119
|
+
el("td", run.stage),
|
|
120
|
+
el("td", duration(run.durationMs ?? null)),
|
|
121
|
+
el("td", cost(run.costUsd ?? null, run.partial ?? false)),
|
|
122
|
+
el("td", time(run.updatedAt ?? "")),
|
|
123
|
+
);
|
|
124
|
+
row.addEventListener("click", () => select(run.id));
|
|
125
|
+
return row;
|
|
126
|
+
}),
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
|
|
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
|
+
for (const tool of block.tools)
|
|
146
|
+
line(
|
|
147
|
+
tool.status === "running"
|
|
148
|
+
? `${tool.name} …`
|
|
149
|
+
: `${tool.name}${tool.status === "failed" ? " failed" : ""} ${((tool.durationMs ?? 0) / 1000).toFixed(1)} s`,
|
|
150
|
+
);
|
|
151
|
+
if (call?.usage)
|
|
152
|
+
line(
|
|
153
|
+
`${call.scope === "session" ? "Session total" : "Call"}: ${tokenLine(call.usage)} · ${cost(call.usage.costUsd, false)}`,
|
|
154
|
+
);
|
|
155
|
+
else if (block.tokens)
|
|
156
|
+
line(
|
|
157
|
+
`So far: ${tokenLine(block.tokens)}${block.provisional ? " (provisional)" : ""}`,
|
|
158
|
+
);
|
|
159
|
+
for (const warning of block.warnings) line(warning, "line warning");
|
|
160
|
+
for (const entry of block.journal) line(`${time(entry.at)} ${entry.message}`);
|
|
161
|
+
if (block.decision) {
|
|
162
|
+
const { verdict, reason, destination } = block.decision;
|
|
163
|
+
const retry = block.retry
|
|
164
|
+
? ` [${block.retry.key} ${block.retry.count}/${block.retry.max}]`
|
|
165
|
+
: "";
|
|
166
|
+
line(`${verdict} — ${reason} → ${destination ?? "end"}${retry}`);
|
|
167
|
+
}
|
|
168
|
+
if (block.detail) line(block.detail, "line failed");
|
|
169
|
+
return item;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* @param {Detail} detail
|
|
174
|
+
* @param {RunEvent[]} events Every event received for the run.
|
|
175
|
+
*/
|
|
176
|
+
function showDetail(detail, events) {
|
|
177
|
+
const { run } = detail;
|
|
178
|
+
const summary = el("section");
|
|
179
|
+
const line = el("p", undefined, "empty");
|
|
180
|
+
line.append(
|
|
181
|
+
el("span", run.status, run.status),
|
|
182
|
+
` · ${run.stage} · ${duration(run.durationMs)} · ${cost(run.costUsd, run.partial)}`,
|
|
183
|
+
);
|
|
184
|
+
summary.append(el("h3", `${run.workflow} · ${run.id}`), line);
|
|
185
|
+
for (const runtime of Object.values(detail.runtimes))
|
|
186
|
+
summary.append(el("p", runtime, "line"));
|
|
187
|
+
if (detail.error) summary.append(el("pre", detail.error, "failed"));
|
|
188
|
+
const stages = el("section");
|
|
189
|
+
const blocks = el("ol");
|
|
190
|
+
blocks.append(...timeline(events).map(showBlock));
|
|
191
|
+
stages.append(el("h2", "Timeline"), blocks);
|
|
192
|
+
const sections = [summary, stages];
|
|
193
|
+
if (detail.corrections.length) {
|
|
194
|
+
const corrections = el("section");
|
|
195
|
+
corrections.append(el("h2", "Corrections"));
|
|
196
|
+
for (const { key, count, max } of detail.corrections)
|
|
197
|
+
corrections.append(el("p", `${key} ${count}/${max ?? "?"}`, "line"));
|
|
198
|
+
sections.push(corrections);
|
|
199
|
+
}
|
|
200
|
+
const result = el("section");
|
|
201
|
+
result.append(el("h2", "Result"), el("pre", detail.result.join("\n")));
|
|
202
|
+
sections.push(result);
|
|
203
|
+
byId("detail").replaceChildren(...sections);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/** @param {string} message */
|
|
207
|
+
function updated(message) {
|
|
208
|
+
byId("updated").textContent = message;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function refreshRuns() {
|
|
212
|
+
try {
|
|
213
|
+
const { title, label, runs } = await load("/api/runs");
|
|
214
|
+
byId("title").textContent = title;
|
|
215
|
+
document.title = title;
|
|
216
|
+
byId("label").textContent = label;
|
|
217
|
+
showRuns(runs);
|
|
218
|
+
updated(`Updated ${new Date().toLocaleTimeString()}`);
|
|
219
|
+
} catch (error) {
|
|
220
|
+
unavailable(error);
|
|
221
|
+
}
|
|
222
|
+
setTimeout(refreshRuns, LIST_EVERY_MS);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** @param {unknown} error */
|
|
226
|
+
const unavailable = (error) =>
|
|
227
|
+
updated(
|
|
228
|
+
`Unavailable: ${error instanceof Error ? error.message : String(error)}`,
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
/** @param {string} text */
|
|
232
|
+
const placeholder = (text) =>
|
|
233
|
+
byId("detail").replaceChildren(el("p", text, "empty"));
|
|
234
|
+
|
|
235
|
+
const follow = follower({
|
|
236
|
+
load,
|
|
237
|
+
schedule: (work) => setTimeout(work, DETAIL_EVERY_MS),
|
|
238
|
+
cancel: (timer) => clearTimeout(timer),
|
|
239
|
+
show: (detail, events) => showDetail(detail, events),
|
|
240
|
+
fail: (error) => {
|
|
241
|
+
const { status } = /** @type {{ status?: number }} */ (error);
|
|
242
|
+
// The follower stops on a run that does not exist and retries the rest.
|
|
243
|
+
if (status === 404) placeholder("This run does not exist.");
|
|
244
|
+
else if (status)
|
|
245
|
+
placeholder(
|
|
246
|
+
`This run cannot be read: ${error instanceof Error ? error.message : String(error)}`,
|
|
247
|
+
);
|
|
248
|
+
else unavailable(error);
|
|
249
|
+
},
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
/** @param {string} id */
|
|
253
|
+
function select(id) {
|
|
254
|
+
if (id === selected) return;
|
|
255
|
+
selected = id;
|
|
256
|
+
if (fromHash() !== id) location.hash = encodeURIComponent(id);
|
|
257
|
+
placeholder(id ? "Loading…" : "Select a run.");
|
|
258
|
+
for (const row of byId("runs").children)
|
|
259
|
+
row.className =
|
|
260
|
+
/** @type {HTMLElement} */ (row).title === id ? "selected" : "";
|
|
261
|
+
follow.select(id);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// A link or the history can change the selected run without reloading the page.
|
|
265
|
+
window.addEventListener("hashchange", () => select(fromHash()));
|
|
266
|
+
refreshRuns();
|
|
267
|
+
if (selected) placeholder("Loading…");
|
|
268
|
+
follow.select(selected);
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* Follows the selected run: asks only for the events not received yet, one
|
|
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.
|
|
6
|
+
* Requests and timers are given, so that this logic is tested without a browser.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
/** @typedef {import("@clemrig/flow").RunEvent} RunEvent */
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* @typedef {object} Answer
|
|
13
|
+
* @property {{ status: string }} run
|
|
14
|
+
* @property {RunEvent[]} events Events from the index the request asked for.
|
|
15
|
+
* @property {number} next The index to ask for next.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* @template Timer
|
|
20
|
+
* @param {object} options
|
|
21
|
+
* @param {(path: string) => Promise<any>} options.load Rejects with `status` set on an HTTP error.
|
|
22
|
+
* @param {(work: () => void) => Timer} options.schedule Runs `work` after the polling interval.
|
|
23
|
+
* @param {(timer: Timer | undefined) => void} options.cancel
|
|
24
|
+
* @param {(answer: any, events: RunEvent[]) => void} options.show The latest answer and every event received.
|
|
25
|
+
* @param {(error: unknown) => void} options.fail
|
|
26
|
+
*/
|
|
27
|
+
export function follower({ load, schedule, cancel, show, fail }) {
|
|
28
|
+
/** One selection: a new object each time, so that an old answer never matches. */
|
|
29
|
+
let current = { id: "", events: /** @type {RunEvent[]} */ ([]), next: 0 };
|
|
30
|
+
/** @type {Timer | undefined} */
|
|
31
|
+
let timer;
|
|
32
|
+
|
|
33
|
+
/** @param {typeof current} target */
|
|
34
|
+
async function refresh(target) {
|
|
35
|
+
if (!target.id) return;
|
|
36
|
+
/** @type {Answer} */
|
|
37
|
+
let answer;
|
|
38
|
+
try {
|
|
39
|
+
answer = await load(
|
|
40
|
+
`/api/runs/${encodeURIComponent(target.id)}?after=${target.next}`,
|
|
41
|
+
);
|
|
42
|
+
} catch (error) {
|
|
43
|
+
if (target !== current) return;
|
|
44
|
+
fail(error);
|
|
45
|
+
// A run that does not exist will not appear later.
|
|
46
|
+
const status = /** @type {{ status?: number }} */ (error).status;
|
|
47
|
+
if (status !== 404) timer = schedule(() => refresh(target));
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
if (target !== current) return;
|
|
51
|
+
target.events.push(...answer.events);
|
|
52
|
+
target.next = answer.next;
|
|
53
|
+
show(answer, target.events);
|
|
54
|
+
if (answer.run.status === "running")
|
|
55
|
+
timer = schedule(() => refresh(target));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
/** @param {string} id */
|
|
60
|
+
select(id) {
|
|
61
|
+
if (id === current.id) return;
|
|
62
|
+
cancel(timer);
|
|
63
|
+
current = { id, events: [], next: 0 };
|
|
64
|
+
refresh(current);
|
|
65
|
+
},
|
|
66
|
+
};
|
|
67
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
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
|
+
<link rel="stylesheet" href="/style.css">
|
|
8
|
+
<script type="module" src="/dashboard.js"></script>
|
|
9
|
+
</head>
|
|
10
|
+
<body>
|
|
11
|
+
<header><h1 id="title">runs</h1><span id="label"></span><span id="updated"></span></header>
|
|
12
|
+
<main>
|
|
13
|
+
<div>
|
|
14
|
+
<div class="table"><table><thead><tr><th>Workflow</th><th>Status</th><th>Stage</th><th>Duration</th><th>Cost</th><th>Updated</th></tr></thead><tbody id="runs"></tbody></table></div>
|
|
15
|
+
<p id="none" class="empty" hidden>No run yet.</p>
|
|
16
|
+
</div>
|
|
17
|
+
<div id="detail"><p class="empty">Select a run.</p></div>
|
|
18
|
+
</main>
|
|
19
|
+
</body>
|
|
20
|
+
</html>
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
:root {
|
|
2
|
+
color-scheme: light dark;
|
|
3
|
+
--bg: #f7f7f5;
|
|
4
|
+
--panel: #fff;
|
|
5
|
+
--fg: #1d1d1b;
|
|
6
|
+
--muted: #6f6e69;
|
|
7
|
+
--line: #e4e3df;
|
|
8
|
+
--accent: #2557d6;
|
|
9
|
+
--ok: #19773a;
|
|
10
|
+
--bad: #b42318;
|
|
11
|
+
--run: #a15c07;
|
|
12
|
+
}
|
|
13
|
+
@media (prefers-color-scheme: dark) {
|
|
14
|
+
:root {
|
|
15
|
+
--bg: #111110;
|
|
16
|
+
--panel: #1b1b19;
|
|
17
|
+
--fg: #e8e7e3;
|
|
18
|
+
--muted: #9b9a94;
|
|
19
|
+
--line: #2c2c29;
|
|
20
|
+
--accent: #7aa2ff;
|
|
21
|
+
--ok: #5bd28a;
|
|
22
|
+
--bad: #ff7a6b;
|
|
23
|
+
--run: #f2b14c;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
* {
|
|
27
|
+
box-sizing: border-box;
|
|
28
|
+
}
|
|
29
|
+
body {
|
|
30
|
+
margin: 0;
|
|
31
|
+
background: var(--bg);
|
|
32
|
+
color: var(--fg);
|
|
33
|
+
font:
|
|
34
|
+
14px / 1.45 ui-sans-serif,
|
|
35
|
+
system-ui,
|
|
36
|
+
-apple-system,
|
|
37
|
+
sans-serif;
|
|
38
|
+
}
|
|
39
|
+
header {
|
|
40
|
+
display: flex;
|
|
41
|
+
flex-wrap: wrap;
|
|
42
|
+
gap: 4px 12px;
|
|
43
|
+
align-items: baseline;
|
|
44
|
+
padding: 12px 16px;
|
|
45
|
+
border-bottom: 1px solid var(--line);
|
|
46
|
+
}
|
|
47
|
+
header h1 {
|
|
48
|
+
margin: 0;
|
|
49
|
+
font-size: 16px;
|
|
50
|
+
}
|
|
51
|
+
header span {
|
|
52
|
+
color: var(--muted);
|
|
53
|
+
font-size: 13px;
|
|
54
|
+
}
|
|
55
|
+
#updated {
|
|
56
|
+
margin-left: auto;
|
|
57
|
+
}
|
|
58
|
+
main {
|
|
59
|
+
display: grid;
|
|
60
|
+
grid-template-columns: minmax(0, 1fr) minmax(0, 1.4fr);
|
|
61
|
+
gap: 16px;
|
|
62
|
+
padding: 16px;
|
|
63
|
+
}
|
|
64
|
+
@media (max-width: 900px) {
|
|
65
|
+
main {
|
|
66
|
+
grid-template-columns: minmax(0, 1fr);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
.table {
|
|
70
|
+
overflow-x: auto;
|
|
71
|
+
background: var(--panel);
|
|
72
|
+
border: 1px solid var(--line);
|
|
73
|
+
border-radius: 6px;
|
|
74
|
+
}
|
|
75
|
+
table {
|
|
76
|
+
width: 100%;
|
|
77
|
+
border-collapse: collapse;
|
|
78
|
+
}
|
|
79
|
+
th,
|
|
80
|
+
td {
|
|
81
|
+
padding: 7px 10px;
|
|
82
|
+
border-bottom: 1px solid var(--line);
|
|
83
|
+
text-align: left;
|
|
84
|
+
white-space: nowrap;
|
|
85
|
+
}
|
|
86
|
+
th {
|
|
87
|
+
color: var(--muted);
|
|
88
|
+
font-size: 12px;
|
|
89
|
+
font-weight: 500;
|
|
90
|
+
}
|
|
91
|
+
tbody tr {
|
|
92
|
+
cursor: pointer;
|
|
93
|
+
}
|
|
94
|
+
tbody tr:hover,
|
|
95
|
+
tbody tr.selected {
|
|
96
|
+
background: color-mix(in srgb, var(--accent) 12%, transparent);
|
|
97
|
+
}
|
|
98
|
+
.completed,
|
|
99
|
+
.passed {
|
|
100
|
+
color: var(--ok);
|
|
101
|
+
}
|
|
102
|
+
.failed {
|
|
103
|
+
color: var(--bad);
|
|
104
|
+
}
|
|
105
|
+
.running {
|
|
106
|
+
color: var(--run);
|
|
107
|
+
}
|
|
108
|
+
section {
|
|
109
|
+
margin-bottom: 12px;
|
|
110
|
+
padding: 10px 12px;
|
|
111
|
+
background: var(--panel);
|
|
112
|
+
border: 1px solid var(--line);
|
|
113
|
+
border-radius: 6px;
|
|
114
|
+
}
|
|
115
|
+
h2 {
|
|
116
|
+
margin: 0 0 8px;
|
|
117
|
+
color: var(--muted);
|
|
118
|
+
font-size: 12px;
|
|
119
|
+
font-weight: 600;
|
|
120
|
+
letter-spacing: 0.04em;
|
|
121
|
+
text-transform: uppercase;
|
|
122
|
+
}
|
|
123
|
+
h3 {
|
|
124
|
+
margin: 0 0 4px;
|
|
125
|
+
font-size: 14px;
|
|
126
|
+
overflow-wrap: anywhere;
|
|
127
|
+
}
|
|
128
|
+
ol {
|
|
129
|
+
margin: 0;
|
|
130
|
+
padding: 0;
|
|
131
|
+
list-style: none;
|
|
132
|
+
}
|
|
133
|
+
.block {
|
|
134
|
+
padding: 6px 0;
|
|
135
|
+
border-bottom: 1px dotted var(--line);
|
|
136
|
+
}
|
|
137
|
+
.block:last-child {
|
|
138
|
+
border-bottom: 0;
|
|
139
|
+
}
|
|
140
|
+
.block > p {
|
|
141
|
+
margin: 0;
|
|
142
|
+
}
|
|
143
|
+
.line,
|
|
144
|
+
pre {
|
|
145
|
+
font:
|
|
146
|
+
12px / 1.45 ui-monospace,
|
|
147
|
+
SFMono-Regular,
|
|
148
|
+
Menlo,
|
|
149
|
+
monospace;
|
|
150
|
+
overflow-wrap: anywhere;
|
|
151
|
+
}
|
|
152
|
+
.line {
|
|
153
|
+
margin: 2px 0 0 16px;
|
|
154
|
+
color: var(--muted);
|
|
155
|
+
}
|
|
156
|
+
.warning {
|
|
157
|
+
color: var(--run);
|
|
158
|
+
}
|
|
159
|
+
pre {
|
|
160
|
+
margin: 0;
|
|
161
|
+
white-space: pre-wrap;
|
|
162
|
+
}
|
|
163
|
+
.empty {
|
|
164
|
+
margin: 0;
|
|
165
|
+
color: var(--muted);
|
|
166
|
+
}
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
// @ts-check
|
|
2
|
+
/**
|
|
3
|
+
* The dashboard's pure logic: a run's events grouped into one block per stage
|
|
4
|
+
* execution, and the formats of durations and costs. Served as is to the browser.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
/** @typedef {import("@clemrig/flow").RunEvent} RunEvent */
|
|
8
|
+
/** @typedef {import("@clemrig/flow").AgentActivity} AgentActivity */
|
|
9
|
+
/** @typedef {import("@clemrig/flow").TokenCounts} TokenCounts */
|
|
10
|
+
/** @typedef {import("@clemrig/flow").AgentUsage} AgentUsage */
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* @typedef {object} Call
|
|
14
|
+
* @property {string} [runtime]
|
|
15
|
+
* @property {string} [model]
|
|
16
|
+
* @property {string} [session]
|
|
17
|
+
* @property {"running" | "completed" | "failed"} status
|
|
18
|
+
* @property {AgentUsage} [usage] As the runtime announced it at the end of the call.
|
|
19
|
+
* @property {"call" | "session"} [scope] Whether `usage` is the call's own or its session's total.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* @typedef {object} Tool
|
|
24
|
+
* @property {string} id
|
|
25
|
+
* @property {string} name
|
|
26
|
+
* @property {"running" | "completed" | "failed"} status
|
|
27
|
+
* @property {number} [durationMs]
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* @typedef {object} Block
|
|
32
|
+
* @property {string} stage
|
|
33
|
+
* @property {string} kind
|
|
34
|
+
* @property {"running" | "passed" | "failed" | "completed"} status
|
|
35
|
+
* @property {string} startedAt
|
|
36
|
+
* @property {number | null} durationMs
|
|
37
|
+
* @property {{ verdict: string; reason: string; destination: string | null }} [decision]
|
|
38
|
+
* @property {{ key: string; count: number; max: number }} [retry]
|
|
39
|
+
* @property {string} [detail]
|
|
40
|
+
* @property {Call} [call]
|
|
41
|
+
* @property {Tool[]} tools
|
|
42
|
+
* @property {TokenCounts | null} tokens The call's responses so far.
|
|
43
|
+
* @property {boolean} provisional Whether `tokens` includes figures replaced at the end of the call.
|
|
44
|
+
* @property {string[]} warnings
|
|
45
|
+
* @property {{ at: string; message: string }[]} journal
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* @param {string} stage
|
|
50
|
+
* @param {string} kind
|
|
51
|
+
* @param {string} at
|
|
52
|
+
* @returns {Block}
|
|
53
|
+
*/
|
|
54
|
+
const block = (stage, kind, at) => ({
|
|
55
|
+
stage,
|
|
56
|
+
kind,
|
|
57
|
+
status: "running",
|
|
58
|
+
startedAt: at,
|
|
59
|
+
durationMs: null,
|
|
60
|
+
tools: [],
|
|
61
|
+
tokens: null,
|
|
62
|
+
provisional: false,
|
|
63
|
+
warnings: [],
|
|
64
|
+
journal: [],
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @param {Block} target
|
|
69
|
+
* @param {AgentActivity} activity
|
|
70
|
+
*/
|
|
71
|
+
function apply(target, activity) {
|
|
72
|
+
switch (activity.kind) {
|
|
73
|
+
case "call":
|
|
74
|
+
if (activity.status === "started") {
|
|
75
|
+
const { runtime, model, session } = activity;
|
|
76
|
+
target.call = { runtime, model, session, status: "running" };
|
|
77
|
+
} else
|
|
78
|
+
target.call = {
|
|
79
|
+
...target.call,
|
|
80
|
+
runtime: activity.runtime,
|
|
81
|
+
status: activity.status,
|
|
82
|
+
usage: activity.usage,
|
|
83
|
+
scope: activity.scope,
|
|
84
|
+
};
|
|
85
|
+
break;
|
|
86
|
+
case "tool": {
|
|
87
|
+
const tool = target.tools.find(({ id }) => id === activity.id);
|
|
88
|
+
const update =
|
|
89
|
+
activity.status === "started"
|
|
90
|
+
? { id: activity.id, name: activity.name, status: "running" }
|
|
91
|
+
: {
|
|
92
|
+
id: activity.id,
|
|
93
|
+
name: activity.name,
|
|
94
|
+
status: activity.status,
|
|
95
|
+
durationMs: activity.durationMs,
|
|
96
|
+
};
|
|
97
|
+
if (tool) Object.assign(tool, update);
|
|
98
|
+
else target.tools.push(/** @type {Tool} */ (update));
|
|
99
|
+
break;
|
|
100
|
+
}
|
|
101
|
+
case "response": {
|
|
102
|
+
const sum = target.tokens ?? {
|
|
103
|
+
input: 0,
|
|
104
|
+
output: 0,
|
|
105
|
+
cacheRead: 0,
|
|
106
|
+
cacheWrite: 0,
|
|
107
|
+
};
|
|
108
|
+
for (const field of /** @type {const} */ ([
|
|
109
|
+
"input",
|
|
110
|
+
"output",
|
|
111
|
+
"cacheRead",
|
|
112
|
+
"cacheWrite",
|
|
113
|
+
]))
|
|
114
|
+
sum[field] += activity.tokens[field];
|
|
115
|
+
target.tokens = sum;
|
|
116
|
+
target.provisional ||= activity.provisional;
|
|
117
|
+
break;
|
|
118
|
+
}
|
|
119
|
+
case "warning":
|
|
120
|
+
target.warnings.push(activity.message);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* One block per stage execution, in order: a stage's journal, agent activity
|
|
126
|
+
* and decision go to its latest block.
|
|
127
|
+
* @param {RunEvent[]} events
|
|
128
|
+
* @returns {Block[]}
|
|
129
|
+
*/
|
|
130
|
+
export function timeline(events) {
|
|
131
|
+
/** @type {Block[]} */
|
|
132
|
+
const blocks = [];
|
|
133
|
+
const latest = (/** @type {string} */ stage) =>
|
|
134
|
+
blocks.findLast((candidate) => candidate.stage === stage);
|
|
135
|
+
for (const event of events) {
|
|
136
|
+
if (event.type === "stage" && event.status === "started") {
|
|
137
|
+
blocks.push(block(event.stage, event.kind, event.at));
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
let target = latest(event.stage);
|
|
141
|
+
if (!target || (event.type === "stage" && target.status !== "running")) {
|
|
142
|
+
// A stage reported without a start, such as one failed by an interruption.
|
|
143
|
+
target = block(event.stage, event.kind, event.at);
|
|
144
|
+
blocks.push(target);
|
|
145
|
+
}
|
|
146
|
+
if (event.type === "stage") {
|
|
147
|
+
target.status = event.status === "started" ? "running" : event.status;
|
|
148
|
+
target.durationMs = Date.parse(event.at) - Date.parse(target.startedAt);
|
|
149
|
+
if (event.decision) target.decision = event.decision;
|
|
150
|
+
if (event.retry) target.retry = event.retry;
|
|
151
|
+
if (event.detail) target.detail = event.detail;
|
|
152
|
+
} else if (event.type === "log")
|
|
153
|
+
target.journal.push({ at: event.at, message: event.message });
|
|
154
|
+
else apply(target, event.activity);
|
|
155
|
+
}
|
|
156
|
+
return blocks;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* @param {number | null} ms
|
|
161
|
+
* @returns {string}
|
|
162
|
+
*/
|
|
163
|
+
export function duration(ms) {
|
|
164
|
+
if (ms === null) return "—";
|
|
165
|
+
const seconds = Math.floor(ms / 1000);
|
|
166
|
+
const hours = Math.floor(seconds / 3600);
|
|
167
|
+
const minutes = Math.floor((seconds % 3600) / 60);
|
|
168
|
+
return `${hours ? `${hours} h ` : ""}${minutes} min ${seconds % 60} s`;
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* An estimated cost as the runtimes announced it; unknown without any measure.
|
|
173
|
+
* @param {number | null} usd
|
|
174
|
+
* @param {boolean} partial Some agent call announced no usage.
|
|
175
|
+
* @returns {string}
|
|
176
|
+
*/
|
|
177
|
+
export function cost(usd, partial) {
|
|
178
|
+
const value = usd === null ? "—" : `${usd.toFixed(4)} USD`;
|
|
179
|
+
return partial ? `${value} (partial)` : value;
|
|
180
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,39 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@clemrig/flow-ui",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "A local read-only dashboard of the runs of @clemrig/flow workflows",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/clementchesneau/clemrig-factory-v2.git",
|
|
9
|
+
"directory": "packages/flow-ui"
|
|
10
|
+
},
|
|
11
|
+
"type": "module",
|
|
12
|
+
"bin": {
|
|
13
|
+
"flow-ui": "dist/main.js"
|
|
14
|
+
},
|
|
15
|
+
"exports": {
|
|
16
|
+
".": {
|
|
17
|
+
"default": "./dist/index.js"
|
|
18
|
+
}
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist"
|
|
22
|
+
],
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=24"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"scripts": {
|
|
30
|
+
"build": "node -e \"require('node:fs').rmSync('dist',{recursive:true,force:true})\" && tsc -p tsconfig.build.json && node -e \"require('node:fs').cpSync('src/static','dist/static',{recursive:true})\"",
|
|
31
|
+
"typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.ui.json",
|
|
32
|
+
"test": "vitest run",
|
|
33
|
+
"test:coverage": "vitest run --coverage",
|
|
34
|
+
"prepack": "npm run build"
|
|
35
|
+
},
|
|
36
|
+
"dependencies": {
|
|
37
|
+
"@clemrig/flow": "0.2.0"
|
|
38
|
+
}
|
|
39
|
+
}
|