@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.
Files changed (57) hide show
  1. package/README.md +7 -5
  2. package/dist/cli/ui.js +36 -32
  3. package/dist/inspector/context.d.ts +5 -0
  4. package/dist/inspector/context.js +19 -0
  5. package/dist/inspector/runner.d.ts +13 -0
  6. package/dist/inspector/runner.js +66 -0
  7. package/dist/inspector/runs.d.ts +57 -0
  8. package/dist/inspector/runs.js +240 -0
  9. package/dist/inspector/server.d.ts +8 -0
  10. package/dist/inspector/server.js +186 -0
  11. package/dist/inspector/workflows.d.ts +45 -0
  12. package/dist/inspector/workflows.js +186 -0
  13. package/inspector/dist/assets/ibm-plex-mono-latin-400-normal-CvHOgSBP.woff +0 -0
  14. package/inspector/dist/assets/ibm-plex-mono-latin-400-normal-DMJ8VG8y.woff2 +0 -0
  15. package/inspector/dist/assets/ibm-plex-mono-latin-500-normal-CB9ihrfo.woff +0 -0
  16. package/inspector/dist/assets/ibm-plex-mono-latin-500-normal-DSY6xOcd.woff2 +0 -0
  17. package/inspector/dist/assets/ibm-plex-mono-latin-600-normal-BgSNZQsw.woff2 +0 -0
  18. package/inspector/dist/assets/ibm-plex-mono-latin-600-normal-DWFSQ4vo.woff +0 -0
  19. package/inspector/dist/assets/ibm-plex-mono-latin-700-normal-7sUh57Bg.woff2 +0 -0
  20. package/inspector/dist/assets/ibm-plex-mono-latin-700-normal-CNHXzs6v.woff +0 -0
  21. package/inspector/dist/assets/ibm-plex-sans-latin-400-normal-CDDApCn2.woff2 +0 -0
  22. package/inspector/dist/assets/ibm-plex-sans-latin-400-normal-CYLoc0-x.woff +0 -0
  23. package/inspector/dist/assets/ibm-plex-sans-latin-500-normal-6ng42L7E.woff2 +0 -0
  24. package/inspector/dist/assets/ibm-plex-sans-latin-500-normal-BgVn5rGT.woff +0 -0
  25. package/inspector/dist/assets/ibm-plex-sans-latin-600-normal-Cu4Hd6ag.woff +0 -0
  26. package/inspector/dist/assets/ibm-plex-sans-latin-600-normal-CuJfVYMP.woff2 +0 -0
  27. package/inspector/dist/assets/ibm-plex-sans-latin-700-normal-Bth3BMcD.woff +0 -0
  28. package/inspector/dist/assets/ibm-plex-sans-latin-700-normal-Bxkt5Cjx.woff2 +0 -0
  29. package/inspector/dist/assets/index-BCSWkslF.css +1 -0
  30. package/inspector/dist/assets/index-D6WuziFQ.js +9 -0
  31. package/inspector/dist/index.html +24 -0
  32. package/package.json +4 -10
  33. package/inspector/README.md +0 -67
  34. package/inspector/app/api/runs/[id]/file/route.ts +0 -56
  35. package/inspector/app/api/runs/[id]/route.ts +0 -15
  36. package/inspector/app/api/runs/[id]/stop/route.ts +0 -15
  37. package/inspector/app/api/runs/route.ts +0 -9
  38. package/inspector/app/api/workflows/[slug]/route.ts +0 -11
  39. package/inspector/app/api/workflows/[slug]/run/route.ts +0 -37
  40. package/inspector/app/api/workflows/route.ts +0 -8
  41. package/inspector/app/globals.css +0 -460
  42. package/inspector/app/layout.tsx +0 -54
  43. package/inspector/app/page.tsx +0 -16
  44. package/inspector/app/runs/[id]/page.tsx +0 -6
  45. package/inspector/app/runs/[id]/run-detail.tsx +0 -343
  46. package/inspector/app/shared.tsx +0 -81
  47. package/inspector/app/theme-toggle.tsx +0 -19
  48. package/inspector/app/workflows/[slug]/page.tsx +0 -6
  49. package/inspector/app/workflows/[slug]/workflow-view.tsx +0 -271
  50. package/inspector/app/workflows/page.tsx +0 -43
  51. package/inspector/lib/runner.ts +0 -78
  52. package/inspector/lib/runs.ts +0 -313
  53. package/inspector/lib/workflows.ts +0 -240
  54. package/inspector/next.config.ts +0 -5
  55. package/inspector/package-lock.json +0 -982
  56. package/inspector/package.json +0 -24
  57. 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 the
344
- inspector sources (for `kraftwerk ui`; its deps install on first launch) —
345
- no `src/` or examples. Check with `npm pack --dry-run` before a
346
- release. Runtime deps stay regular `dependencies`; `tsx` and `typescript`
347
- are dev-only, so consumers install neither.
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 { spawn, spawnSync } from "node:child_process";
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 (Next.js app shipped in the
9
- * package under inspector/) pointed at the current project's output dir.
10
- * First launch installs the inspector's dependencies; the server then runs
11
- * in the foreground until Ctrl-C.
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 inspectorDir = () => path.join(path.dirname(fileURLToPath(import.meta.url)), "../../inspector");
15
- export async function runUi(cwd, opts) {
16
- const dir = inspectorDir();
17
- if (!existsSync(path.join(dir, "package.json"))) {
18
- console.error(chalk.red(`Inspector not found at ${dir} — broken install?`));
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
- const outputDir = opts.output
22
- ? path.resolve(cwd, opts.output)
23
- : (await resolveProject(cwd)).outputDir;
24
- const port = opts.port ?? "4499";
25
- if (!existsSync(path.join(dir, "node_modules"))) {
26
- console.log(chalk.dim("First launch installing inspector dependencies ..."));
27
- const install = spawnSync("npm", ["install", "--no-fund", "--no-audit"], {
28
- cwd: dir,
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>;