@netnodeag/kraftwerk 0.3.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 +7 -5
- package/dist/cli/ui.js +36 -32
- package/dist/inspector/context.d.ts +5 -0
- package/dist/inspector/context.js +19 -0
- package/dist/inspector/runner.d.ts +13 -0
- package/dist/inspector/runner.js +66 -0
- package/dist/inspector/runs.d.ts +57 -0
- package/dist/inspector/runs.js +240 -0
- package/dist/inspector/server.d.ts +8 -0
- package/dist/inspector/server.js +186 -0
- package/dist/inspector/workflows.d.ts +45 -0
- package/dist/inspector/workflows.js +186 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-700-normal-7sUh57Bg.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-mono-latin-700-normal-CNHXzs6v.woff +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-700-normal-Bth3BMcD.woff +0 -0
- package/inspector/dist/assets/ibm-plex-sans-latin-700-normal-Bxkt5Cjx.woff2 +0 -0
- package/inspector/dist/assets/index-BCSWkslF.css +1 -0
- package/inspector/dist/assets/index-D6WuziFQ.js +9 -0
- package/inspector/dist/index.html +24 -0
- package/package.json +4 -10
- package/inspector/README.md +0 -67
- package/inspector/app/api/runs/[id]/file/route.ts +0 -56
- package/inspector/app/api/runs/[id]/route.ts +0 -15
- package/inspector/app/api/runs/[id]/stop/route.ts +0 -15
- package/inspector/app/api/runs/route.ts +0 -9
- package/inspector/app/api/workflows/[slug]/route.ts +0 -11
- package/inspector/app/api/workflows/[slug]/run/route.ts +0 -37
- package/inspector/app/api/workflows/route.ts +0 -8
- package/inspector/app/globals.css +0 -460
- package/inspector/app/layout.tsx +0 -54
- package/inspector/app/page.tsx +0 -16
- package/inspector/app/runs/[id]/page.tsx +0 -6
- package/inspector/app/runs/[id]/run-detail.tsx +0 -343
- package/inspector/app/shared.tsx +0 -81
- package/inspector/app/theme-toggle.tsx +0 -19
- package/inspector/app/workflows/[slug]/page.tsx +0 -6
- package/inspector/app/workflows/[slug]/workflow-view.tsx +0 -271
- package/inspector/app/workflows/page.tsx +0 -43
- package/inspector/lib/runner.ts +0 -78
- package/inspector/lib/runs.ts +0 -313
- package/inspector/lib/workflows.ts +0 -240
- package/inspector/next.config.ts +0 -5
- package/inspector/package-lock.json +0 -982
- package/inspector/package.json +0 -24
- package/inspector/tsconfig.json +0 -21
package/README.md
CHANGED
|
@@ -340,8 +340,10 @@ npm publish # runs npm run build first via prepublishOnly
|
|
|
340
340
|
```
|
|
341
341
|
|
|
342
342
|
The tarball is whitelisted via `files`: `bin/`, `dist/`, `runner/`
|
|
343
|
-
(Dockerfile for sandboxed runs), `schema/` (workflow JSON schema), and
|
|
344
|
-
inspector
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
343
|
+
(Dockerfile for sandboxed runs), `schema/` (workflow JSON schema), and
|
|
344
|
+
`inspector/dist/` (the prebuilt web UI for `kraftwerk ui` — built by
|
|
345
|
+
`prepublishOnly`, served by the dependency-free server compiled into
|
|
346
|
+
`dist/inspector/`) — no `src/` or examples. Check with `npm pack
|
|
347
|
+
--dry-run` before a release. Runtime deps stay regular `dependencies`;
|
|
348
|
+
`tsx`, `typescript`, and the inspector's Vite/React toolchain are
|
|
349
|
+
dev-only, so consumers install none of them.
|
package/dist/cli/ui.js
CHANGED
|
@@ -1,48 +1,52 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { fileURLToPath } from "node:url";
|
|
4
|
-
import {
|
|
4
|
+
import { spawnSync } from "node:child_process";
|
|
5
5
|
import chalk from "chalk";
|
|
6
6
|
import { resolveProject } from "../config.js";
|
|
7
|
+
import { startInspector } from "../inspector/server.js";
|
|
7
8
|
/**
|
|
8
|
-
* `kraftwerk ui` — start the inspector web UI
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
9
|
+
* `kraftwerk ui` — start the inspector web UI pointed at the current
|
|
10
|
+
* project's output dir. The server is dependency-free node:http (part of
|
|
11
|
+
* this package); the frontend is a prebuilt Vite bundle shipped in
|
|
12
|
+
* inspector/dist. Nothing to install at runtime — the server starts
|
|
13
|
+
* instantly and runs in the foreground until Ctrl-C.
|
|
12
14
|
*/
|
|
13
15
|
/** Package root is two levels up from this file (src/cli/ or dist/cli/). */
|
|
14
|
-
const
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
16
|
+
const packageRoot = () => path.join(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
17
|
+
/**
|
|
18
|
+
* Published packages ship inspector/dist. A dev checkout builds it on
|
|
19
|
+
* first use (and after frontend edits: `npm run build` in inspector/).
|
|
20
|
+
*/
|
|
21
|
+
function ensureBuilt() {
|
|
22
|
+
const inspector = path.join(packageRoot(), "inspector");
|
|
23
|
+
const dist = path.join(inspector, "dist");
|
|
24
|
+
if (existsSync(path.join(dist, "index.html")))
|
|
25
|
+
return dist;
|
|
26
|
+
if (!existsSync(path.join(inspector, "src"))) {
|
|
27
|
+
console.error(chalk.red(`Inspector assets missing at ${dist} — broken install?`));
|
|
19
28
|
process.exit(1);
|
|
20
29
|
}
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
:
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
stdio: "inherit",
|
|
30
|
-
});
|
|
31
|
-
if (install.status !== 0) {
|
|
32
|
-
console.error(chalk.red("npm install failed in the inspector directory."));
|
|
30
|
+
console.log(chalk.dim("Building the inspector frontend (first use in this checkout) ..."));
|
|
31
|
+
for (const args of [
|
|
32
|
+
...(existsSync(path.join(inspector, "node_modules")) ? [] : [["install", "--no-fund", "--no-audit"]]),
|
|
33
|
+
["run", "build"],
|
|
34
|
+
]) {
|
|
35
|
+
const r = spawnSync("npm", args, { cwd: inspector, stdio: "inherit" });
|
|
36
|
+
if (r.status !== 0) {
|
|
37
|
+
console.error(chalk.red(`npm ${args.join(" ")} failed in inspector/.`));
|
|
33
38
|
process.exit(1);
|
|
34
39
|
}
|
|
35
40
|
}
|
|
41
|
+
return dist;
|
|
42
|
+
}
|
|
43
|
+
export async function runUi(cwd, opts) {
|
|
44
|
+
const staticDir = ensureBuilt();
|
|
45
|
+
const outputDir = opts.output
|
|
46
|
+
? path.resolve(cwd, opts.output)
|
|
47
|
+
: (await resolveProject(cwd)).outputDir;
|
|
48
|
+
const port = Number(opts.port ?? "4499");
|
|
49
|
+
await startInspector({ outputDir, staticDir, port });
|
|
36
50
|
console.log(`${chalk.green("✔")} Inspector: ${chalk.cyan(`http://localhost:${port}`)} ` +
|
|
37
51
|
chalk.dim(`(output: ${outputDir})`));
|
|
38
|
-
const nextBin = path.join(dir, "node_modules", "next", "dist", "bin", "next");
|
|
39
|
-
const child = spawn(process.execPath, [nextBin, "dev", "-p", port], {
|
|
40
|
-
cwd: dir,
|
|
41
|
-
stdio: "inherit",
|
|
42
|
-
env: { ...process.env, KRAFTWERK_OUTPUT: outputDir },
|
|
43
|
-
});
|
|
44
|
-
for (const signal of ["SIGINT", "SIGTERM"]) {
|
|
45
|
-
process.on(signal, () => child.kill(signal));
|
|
46
|
-
}
|
|
47
|
-
child.on("exit", (code) => process.exit(code ?? 0));
|
|
48
52
|
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export declare function setOutputDir(dir: string): void;
|
|
2
|
+
/** Absolute output directory (one run-* folder per run). */
|
|
3
|
+
export declare function getOutputDir(): string;
|
|
4
|
+
/** The consumer project root is the parent of the output dir. */
|
|
5
|
+
export declare function getProjectRoot(): string;
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* Server-side context for the inspector: which consumer project it looks at.
|
|
4
|
+
* Set once by startInspector() before any request is served.
|
|
5
|
+
*/
|
|
6
|
+
let outputDir = "";
|
|
7
|
+
export function setOutputDir(dir) {
|
|
8
|
+
outputDir = path.resolve(dir);
|
|
9
|
+
}
|
|
10
|
+
/** Absolute output directory (one run-* folder per run). */
|
|
11
|
+
export function getOutputDir() {
|
|
12
|
+
if (!outputDir)
|
|
13
|
+
throw new Error("inspector context not initialized");
|
|
14
|
+
return outputDir;
|
|
15
|
+
}
|
|
16
|
+
/** The consumer project root is the parent of the output dir. */
|
|
17
|
+
export function getProjectRoot() {
|
|
18
|
+
return path.dirname(getOutputDir());
|
|
19
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
export declare function dockerStatus(): {
|
|
2
|
+
available: boolean;
|
|
3
|
+
image: boolean;
|
|
4
|
+
};
|
|
5
|
+
export declare function triggerRun(opts: {
|
|
6
|
+
workflowName: string;
|
|
7
|
+
request: string;
|
|
8
|
+
sandbox: boolean;
|
|
9
|
+
ssh: boolean;
|
|
10
|
+
}): {
|
|
11
|
+
runId: string;
|
|
12
|
+
};
|
|
13
|
+
export declare function stopRun(runId: string): boolean;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdirSync, openSync, closeSync } from "node:fs";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { getProjectRoot } from "./context.js";
|
|
5
|
+
/**
|
|
6
|
+
* Workflow trigger for the web UI. The inspector stays decoupled from the
|
|
7
|
+
* framework runtime: it shells out to `npx kraftwerk run` in the consumer
|
|
8
|
+
* project, detached, with a pre-chosen --run-id so the browser can jump to
|
|
9
|
+
* /runs/<id> immediately and watch the (already polling) live timeline.
|
|
10
|
+
*
|
|
11
|
+
* Sandbox mode adds --sandbox: one Docker container per run, workflow
|
|
12
|
+
* mounted read-only, run dir bind-mounted back into output/ (see
|
|
13
|
+
* kraftwerk/src/runner/docker.ts).
|
|
14
|
+
*/
|
|
15
|
+
function stamp(d = new Date()) {
|
|
16
|
+
const p = (n) => String(n).padStart(2, "0");
|
|
17
|
+
return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}-${p(d.getSeconds())}`;
|
|
18
|
+
}
|
|
19
|
+
export function dockerStatus() {
|
|
20
|
+
const daemon = spawnSync("docker", ["version", "--format", "ok"], {
|
|
21
|
+
stdio: "ignore",
|
|
22
|
+
timeout: 8000,
|
|
23
|
+
});
|
|
24
|
+
if (daemon.status !== 0)
|
|
25
|
+
return { available: false, image: false };
|
|
26
|
+
const image = spawnSync("docker", ["image", "inspect", "kraftwerk-runner"], {
|
|
27
|
+
stdio: "ignore",
|
|
28
|
+
timeout: 8000,
|
|
29
|
+
});
|
|
30
|
+
return { available: true, image: image.status === 0 };
|
|
31
|
+
}
|
|
32
|
+
export function triggerRun(opts) {
|
|
33
|
+
if (opts.sandbox) {
|
|
34
|
+
const docker = dockerStatus();
|
|
35
|
+
if (!docker.available)
|
|
36
|
+
throw new Error("Docker daemon not reachable — start Docker first.");
|
|
37
|
+
if (!docker.image)
|
|
38
|
+
throw new Error('Image "kraftwerk-runner" missing — run `kraftwerk runner build`.');
|
|
39
|
+
}
|
|
40
|
+
const runId = `run-${stamp()}`;
|
|
41
|
+
// Matches the CLI's --run-id resolution: output/<id> under the project root.
|
|
42
|
+
const runDir = path.join(getProjectRoot(), "output", runId);
|
|
43
|
+
mkdirSync(runDir, { recursive: true });
|
|
44
|
+
const log = openSync(path.join(runDir, "trigger.log"), "a");
|
|
45
|
+
const args = ["kraftwerk", "run", "--yes", "--run-id", runId];
|
|
46
|
+
if (opts.sandbox)
|
|
47
|
+
args.push("--sandbox");
|
|
48
|
+
if (opts.ssh)
|
|
49
|
+
args.push("--ssh");
|
|
50
|
+
args.push(opts.workflowName, opts.request);
|
|
51
|
+
const child = spawn("npx", args, {
|
|
52
|
+
cwd: getProjectRoot(),
|
|
53
|
+
detached: true,
|
|
54
|
+
stdio: ["ignore", log, log],
|
|
55
|
+
env: { ...process.env, FORCE_COLOR: "0" },
|
|
56
|
+
});
|
|
57
|
+
child.unref();
|
|
58
|
+
closeSync(log);
|
|
59
|
+
return { runId };
|
|
60
|
+
}
|
|
61
|
+
export function stopRun(runId) {
|
|
62
|
+
// Sandboxed runs run in container kw-<runId>; docker stop ends them.
|
|
63
|
+
if (!/^run-[0-9-]+$/.test(runId))
|
|
64
|
+
return false;
|
|
65
|
+
return (spawnSync("docker", ["stop", `kw-${runId}`], { stdio: "ignore", timeout: 30_000 }).status === 0);
|
|
66
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
export type RunStatus = "running" | "ok" | "failed" | "aborted";
|
|
2
|
+
export interface GateView {
|
|
3
|
+
gate: string;
|
|
4
|
+
passed: boolean;
|
|
5
|
+
failure: string | null;
|
|
6
|
+
}
|
|
7
|
+
export interface PhaseView {
|
|
8
|
+
phase: string;
|
|
9
|
+
kind: "agent" | "script";
|
|
10
|
+
agent?: string;
|
|
11
|
+
model?: string;
|
|
12
|
+
harness?: string;
|
|
13
|
+
status: "running" | "ok" | "failed" | "blocked" | "pending";
|
|
14
|
+
attempts: number;
|
|
15
|
+
startedAt?: string;
|
|
16
|
+
endedAt?: string;
|
|
17
|
+
durationMs?: number;
|
|
18
|
+
costUsd?: number;
|
|
19
|
+
tokensIn?: number;
|
|
20
|
+
tokensOut?: number;
|
|
21
|
+
gates: GateView[];
|
|
22
|
+
summary?: string;
|
|
23
|
+
lastActivity?: string;
|
|
24
|
+
stdout?: string;
|
|
25
|
+
stderr?: string;
|
|
26
|
+
}
|
|
27
|
+
export interface FileView {
|
|
28
|
+
name: string;
|
|
29
|
+
size: number;
|
|
30
|
+
mtime: string;
|
|
31
|
+
}
|
|
32
|
+
export interface RunListItem {
|
|
33
|
+
id: string;
|
|
34
|
+
workflow?: string;
|
|
35
|
+
request?: string;
|
|
36
|
+
status: RunStatus;
|
|
37
|
+
startedAt?: string;
|
|
38
|
+
updatedAt: string;
|
|
39
|
+
phasesDone: number;
|
|
40
|
+
phasesTotal?: number;
|
|
41
|
+
currentPhase?: string;
|
|
42
|
+
durationMs?: number;
|
|
43
|
+
costUsd?: number;
|
|
44
|
+
}
|
|
45
|
+
export interface RunDetail extends RunListItem {
|
|
46
|
+
description?: string;
|
|
47
|
+
steps?: string[];
|
|
48
|
+
phases: PhaseView[];
|
|
49
|
+
files: FileView[];
|
|
50
|
+
}
|
|
51
|
+
export declare function listRuns(): Promise<RunListItem[]>;
|
|
52
|
+
export declare function safeRunDir(id: string): string;
|
|
53
|
+
export declare function getRun(id: string): Promise<RunDetail | null>;
|
|
54
|
+
export declare function readRunFile(id: string, name: string): Promise<{
|
|
55
|
+
absPath: string;
|
|
56
|
+
size: number;
|
|
57
|
+
} | null>;
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { getOutputDir } from "./context.js";
|
|
4
|
+
/**
|
|
5
|
+
* Filesystem + trace.jsonl reading for the inspector. The output directory
|
|
6
|
+
* holds one folder per run (run-YYYY-MM-DD-HHMM-SS); every run folder has a
|
|
7
|
+
* trace.jsonl written by the framework plus the run's working files.
|
|
8
|
+
*/
|
|
9
|
+
/** A run with no trace update for this long and no summary counts as aborted. */
|
|
10
|
+
const STALE_MS = 15 * 60 * 1000;
|
|
11
|
+
async function readTrace(runDir) {
|
|
12
|
+
let raw;
|
|
13
|
+
try {
|
|
14
|
+
raw = await fs.readFile(path.join(runDir, "trace.jsonl"), "utf8");
|
|
15
|
+
}
|
|
16
|
+
catch {
|
|
17
|
+
return [];
|
|
18
|
+
}
|
|
19
|
+
const events = [];
|
|
20
|
+
for (const line of raw.split("\n")) {
|
|
21
|
+
if (!line.trim())
|
|
22
|
+
continue;
|
|
23
|
+
try {
|
|
24
|
+
events.push(JSON.parse(line));
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* partially written last line of a live run */
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return events;
|
|
31
|
+
}
|
|
32
|
+
function analyse(events) {
|
|
33
|
+
const runStart = events.find((e) => e.event === "run_start");
|
|
34
|
+
const summary = events.find((e) => e.event === "run_summary");
|
|
35
|
+
const phases = [];
|
|
36
|
+
const byName = new Map();
|
|
37
|
+
for (const e of events) {
|
|
38
|
+
if (e.event === "phase_start") {
|
|
39
|
+
const p = {
|
|
40
|
+
phase: e.phase,
|
|
41
|
+
kind: e.kind === "script" ? "script" : "agent",
|
|
42
|
+
agent: e.agent,
|
|
43
|
+
model: e.model,
|
|
44
|
+
harness: e.harness,
|
|
45
|
+
status: "running",
|
|
46
|
+
attempts: 0,
|
|
47
|
+
startedAt: e.ts,
|
|
48
|
+
gates: [],
|
|
49
|
+
};
|
|
50
|
+
phases.push(p);
|
|
51
|
+
byName.set(e.phase, p);
|
|
52
|
+
continue;
|
|
53
|
+
}
|
|
54
|
+
const p = byName.get(e.phase);
|
|
55
|
+
if (!p)
|
|
56
|
+
continue;
|
|
57
|
+
switch (e.event) {
|
|
58
|
+
case "tool_use":
|
|
59
|
+
p.lastActivity = `${e.tool} ${e.target ? shortenPath(e.target) : ""}`.trim();
|
|
60
|
+
break;
|
|
61
|
+
case "agent_result":
|
|
62
|
+
p.attempts = (e.attempt ?? 0) + 1;
|
|
63
|
+
break;
|
|
64
|
+
case "script_result":
|
|
65
|
+
p.attempts += 1;
|
|
66
|
+
p.stdout = e.stdout || undefined;
|
|
67
|
+
p.stderr = e.stderr || undefined;
|
|
68
|
+
break;
|
|
69
|
+
case "envelope":
|
|
70
|
+
p.summary = e.envelope?.summary;
|
|
71
|
+
break;
|
|
72
|
+
case "gate_result":
|
|
73
|
+
p.gates.push({ gate: e.gate, passed: e.passed, failure: e.failure ?? null });
|
|
74
|
+
break;
|
|
75
|
+
case "phase_end":
|
|
76
|
+
p.status = e.status === "ok" ? "ok" : e.status === "blocked" ? "blocked" : "failed";
|
|
77
|
+
p.endedAt = e.ts;
|
|
78
|
+
if (e.stats) {
|
|
79
|
+
p.durationMs = e.stats.durationMs;
|
|
80
|
+
p.costUsd = e.stats.costUsd;
|
|
81
|
+
p.tokensIn =
|
|
82
|
+
(e.stats.inputTokens ?? 0) +
|
|
83
|
+
(e.stats.cacheReadTokens ?? 0) +
|
|
84
|
+
(e.stats.cacheCreationTokens ?? 0);
|
|
85
|
+
p.tokensOut = e.stats.outputTokens ?? 0;
|
|
86
|
+
p.attempts = e.stats.attempts ?? p.attempts;
|
|
87
|
+
}
|
|
88
|
+
break;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
// Only keep the last attempt's gate results per gate name.
|
|
92
|
+
for (const p of phases) {
|
|
93
|
+
const last = new Map();
|
|
94
|
+
for (const g of p.gates)
|
|
95
|
+
last.set(g.gate, g);
|
|
96
|
+
p.gates = [...last.values()];
|
|
97
|
+
}
|
|
98
|
+
// Steps declared at run_start that have not started yet are pending.
|
|
99
|
+
// run_start.steps is either string[] (older traces) or {name, kind, agent, model}[].
|
|
100
|
+
const rawSteps = runStart?.steps;
|
|
101
|
+
const steps = rawSteps?.map((s) => (typeof s === "string" ? s : s.name));
|
|
102
|
+
if (rawSteps) {
|
|
103
|
+
for (const s of rawSteps) {
|
|
104
|
+
const name = typeof s === "string" ? s : s.name;
|
|
105
|
+
if (!byName.has(name)) {
|
|
106
|
+
phases.push({
|
|
107
|
+
phase: name,
|
|
108
|
+
kind: typeof s === "string" ? "script" : s.kind,
|
|
109
|
+
agent: typeof s === "string" ? undefined : s.agent,
|
|
110
|
+
model: typeof s === "string" ? undefined : s.model,
|
|
111
|
+
status: "pending",
|
|
112
|
+
attempts: 0,
|
|
113
|
+
gates: [],
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const failed = phases.some((p) => p.status === "failed" || p.status === "blocked");
|
|
119
|
+
const lastTs = events.length ? events[events.length - 1].ts : undefined;
|
|
120
|
+
let status;
|
|
121
|
+
if (summary)
|
|
122
|
+
status = failed ? "failed" : "ok";
|
|
123
|
+
else if (failed)
|
|
124
|
+
status = "failed";
|
|
125
|
+
else if (lastTs && Date.now() - Date.parse(lastTs) > STALE_MS)
|
|
126
|
+
status = "aborted";
|
|
127
|
+
else
|
|
128
|
+
status = "running";
|
|
129
|
+
return { runStart, summary, phases, steps, status, lastTs };
|
|
130
|
+
}
|
|
131
|
+
/**
|
|
132
|
+
* Sandboxed runs write runner.json (exit code recorded when the container
|
|
133
|
+
* ends). If the trace looks "running" but the container already exited —
|
|
134
|
+
* e.g. it crashed before any phase_end — trust the exit code instead of
|
|
135
|
+
* waiting for the 15-minute stale timeout.
|
|
136
|
+
*/
|
|
137
|
+
async function applyRunnerVerdict(runDir, a) {
|
|
138
|
+
if (a.status !== "running")
|
|
139
|
+
return a;
|
|
140
|
+
try {
|
|
141
|
+
const meta = JSON.parse(await fs.readFile(path.join(runDir, "runner.json"), "utf8"));
|
|
142
|
+
if (meta.exitCode != null) {
|
|
143
|
+
a.status = meta.exitCode === 0 ? "ok" : "failed";
|
|
144
|
+
if (meta.exitCode !== 0) {
|
|
145
|
+
for (const p of a.phases)
|
|
146
|
+
if (p.status === "running")
|
|
147
|
+
p.status = "failed";
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
/* no runner.json (local run) or unreadable — keep trace-based status */
|
|
153
|
+
}
|
|
154
|
+
return a;
|
|
155
|
+
}
|
|
156
|
+
function shortenPath(p) {
|
|
157
|
+
const parts = p.split("/");
|
|
158
|
+
return parts.length > 2 ? parts.slice(-2).join("/") : p;
|
|
159
|
+
}
|
|
160
|
+
export async function listRuns() {
|
|
161
|
+
let entries;
|
|
162
|
+
try {
|
|
163
|
+
entries = (await fs.readdir(getOutputDir())).filter((e) => e.startsWith("run-"));
|
|
164
|
+
}
|
|
165
|
+
catch {
|
|
166
|
+
return [];
|
|
167
|
+
}
|
|
168
|
+
const items = await Promise.all(entries.map(async (id) => {
|
|
169
|
+
const runDir = path.join(getOutputDir(), id);
|
|
170
|
+
const st = await fs.stat(runDir).catch(() => null);
|
|
171
|
+
if (!st?.isDirectory())
|
|
172
|
+
return null;
|
|
173
|
+
const events = await readTrace(runDir);
|
|
174
|
+
const { runStart, summary, phases, steps, status, lastTs } = await applyRunnerVerdict(runDir, analyse(events));
|
|
175
|
+
const done = phases.filter((p) => p.status === "ok").length;
|
|
176
|
+
const current = phases.find((p) => p.status === "running")?.phase;
|
|
177
|
+
return {
|
|
178
|
+
id,
|
|
179
|
+
workflow: runStart?.workflow,
|
|
180
|
+
request: runStart?.request,
|
|
181
|
+
status,
|
|
182
|
+
startedAt: events[0]?.ts,
|
|
183
|
+
updatedAt: lastTs ?? st.mtime.toISOString(),
|
|
184
|
+
phasesDone: done,
|
|
185
|
+
phasesTotal: steps?.length ?? (summary ? phases.length : undefined),
|
|
186
|
+
currentPhase: current,
|
|
187
|
+
durationMs: summary?.total?.durationMs,
|
|
188
|
+
costUsd: summary?.total?.costUsd,
|
|
189
|
+
};
|
|
190
|
+
}));
|
|
191
|
+
return items.filter(Boolean).sort((a, b) => b.id.localeCompare(a.id));
|
|
192
|
+
}
|
|
193
|
+
export function safeRunDir(id) {
|
|
194
|
+
if (!/^run-[0-9-]+$/.test(id))
|
|
195
|
+
throw new Error("invalid run id");
|
|
196
|
+
return path.join(getOutputDir(), id);
|
|
197
|
+
}
|
|
198
|
+
export async function getRun(id) {
|
|
199
|
+
const runDir = safeRunDir(id);
|
|
200
|
+
const st = await fs.stat(runDir).catch(() => null);
|
|
201
|
+
if (!st?.isDirectory())
|
|
202
|
+
return null;
|
|
203
|
+
const events = await readTrace(runDir);
|
|
204
|
+
const { runStart, summary, phases, steps, status, lastTs } = await applyRunnerVerdict(runDir, analyse(events));
|
|
205
|
+
const names = await fs.readdir(runDir);
|
|
206
|
+
const files = [];
|
|
207
|
+
for (const name of names) {
|
|
208
|
+
const fst = await fs.stat(path.join(runDir, name)).catch(() => null);
|
|
209
|
+
if (fst?.isFile())
|
|
210
|
+
files.push({ name, size: fst.size, mtime: fst.mtime.toISOString() });
|
|
211
|
+
}
|
|
212
|
+
files.sort((a, b) => a.name.localeCompare(b.name));
|
|
213
|
+
return {
|
|
214
|
+
id,
|
|
215
|
+
workflow: runStart?.workflow,
|
|
216
|
+
description: runStart?.description,
|
|
217
|
+
request: runStart?.request,
|
|
218
|
+
status,
|
|
219
|
+
startedAt: events[0]?.ts,
|
|
220
|
+
updatedAt: lastTs ?? st.mtime.toISOString(),
|
|
221
|
+
phasesDone: phases.filter((p) => p.status === "ok").length,
|
|
222
|
+
phasesTotal: steps?.length ?? (summary ? phases.length : undefined),
|
|
223
|
+
currentPhase: phases.find((p) => p.status === "running")?.phase,
|
|
224
|
+
durationMs: summary?.total?.durationMs,
|
|
225
|
+
costUsd: summary?.total?.costUsd,
|
|
226
|
+
steps,
|
|
227
|
+
phases,
|
|
228
|
+
files,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
export async function readRunFile(id, name) {
|
|
232
|
+
const runDir = safeRunDir(id);
|
|
233
|
+
const absPath = path.resolve(runDir, name);
|
|
234
|
+
if (absPath !== path.join(runDir, path.basename(name)))
|
|
235
|
+
throw new Error("invalid file name");
|
|
236
|
+
const st = await fs.stat(absPath).catch(() => null);
|
|
237
|
+
if (!st?.isFile())
|
|
238
|
+
return null;
|
|
239
|
+
return { absPath, size: st.size };
|
|
240
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import http from "node:http";
|
|
2
|
+
export interface InspectorOptions {
|
|
3
|
+
outputDir: string;
|
|
4
|
+
staticDir: string;
|
|
5
|
+
port: number;
|
|
6
|
+
}
|
|
7
|
+
/** Start the server; resolves once it listens. Runs until the process ends. */
|
|
8
|
+
export declare function startInspector(opts: InspectorOptions): Promise<http.Server>;
|