@netnodeag/kraftwerk 0.3.1 → 0.4.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/README.md +8 -6
- package/dist/cli/kraftwerk.js +2 -2
- package/dist/cli/ui.js +32 -55
- package/dist/inspector/chat/acp.d.ts +2 -0
- package/dist/inspector/chat/acp.js +122 -0
- package/dist/inspector/chat/backend.d.ts +28 -0
- package/dist/inspector/chat/backend.js +1 -0
- package/dist/inspector/chat/types.d.ts +70 -0
- package/dist/inspector/chat/types.js +8 -0
- 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 +7 -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
|
@@ -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>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import http from "node:http";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { setOutputDir, getOutputDir } from "./context.js";
|
|
5
|
+
import { listRuns, getRun, readRunFile } from "./runs.js";
|
|
6
|
+
import { listWorkflows, getWorkflow } from "./workflows.js";
|
|
7
|
+
import { dockerStatus, triggerRun, stopRun } from "./runner.js";
|
|
8
|
+
/**
|
|
9
|
+
* The inspector server: a plain node:http server with no dependencies.
|
|
10
|
+
* Serves the prebuilt SPA (inspector/dist) plus the JSON/file API the
|
|
11
|
+
* frontend polls. Realtime is polling — no daemon, no socket, works on a
|
|
12
|
+
* plain filesystem.
|
|
13
|
+
*/
|
|
14
|
+
const MIME = {
|
|
15
|
+
".html": "text/html; charset=utf-8",
|
|
16
|
+
".js": "text/javascript; charset=utf-8",
|
|
17
|
+
".css": "text/css; charset=utf-8",
|
|
18
|
+
".svg": "image/svg+xml",
|
|
19
|
+
".png": "image/png",
|
|
20
|
+
".jpg": "image/jpeg",
|
|
21
|
+
".jpeg": "image/jpeg",
|
|
22
|
+
".gif": "image/gif",
|
|
23
|
+
".webp": "image/webp",
|
|
24
|
+
".ico": "image/x-icon",
|
|
25
|
+
".pdf": "application/pdf",
|
|
26
|
+
".json": "application/json; charset=utf-8",
|
|
27
|
+
".woff": "font/woff",
|
|
28
|
+
".woff2": "font/woff2",
|
|
29
|
+
".txt": "text/plain; charset=utf-8",
|
|
30
|
+
};
|
|
31
|
+
/** Text preview payloads are capped; the tail matters most for logs. */
|
|
32
|
+
const MAX_TEXT = 400_000;
|
|
33
|
+
function json(res, body, status = 200) {
|
|
34
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" });
|
|
35
|
+
res.end(JSON.stringify(body));
|
|
36
|
+
}
|
|
37
|
+
function readBody(req) {
|
|
38
|
+
return new Promise((resolve, reject) => {
|
|
39
|
+
let data = "";
|
|
40
|
+
req.on("data", (c) => {
|
|
41
|
+
data += c;
|
|
42
|
+
if (data.length > 1_000_000)
|
|
43
|
+
reject(new Error("body too large"));
|
|
44
|
+
});
|
|
45
|
+
req.on("end", () => resolve(data));
|
|
46
|
+
req.on("error", reject);
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
async function handleApi(req, res, url) {
|
|
50
|
+
const seg = url.pathname.split("/").filter(Boolean); // ["api", ...]
|
|
51
|
+
const method = req.method ?? "GET";
|
|
52
|
+
// GET /api/runs
|
|
53
|
+
if (seg.length === 2 && seg[1] === "runs" && method === "GET") {
|
|
54
|
+
return json(res, { outputDir: getOutputDir(), runs: await listRuns() });
|
|
55
|
+
}
|
|
56
|
+
// GET /api/runs/:id
|
|
57
|
+
if (seg.length === 3 && seg[1] === "runs" && method === "GET") {
|
|
58
|
+
try {
|
|
59
|
+
const run = await getRun(seg[2]);
|
|
60
|
+
return run ? json(res, run) : json(res, { error: "not found" }, 404);
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
return json(res, { error: "invalid run id" }, 400);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
// GET /api/runs/:id/file?name=...&raw=1
|
|
67
|
+
if (seg.length === 4 && seg[1] === "runs" && seg[3] === "file" && method === "GET") {
|
|
68
|
+
const name = url.searchParams.get("name") ?? "";
|
|
69
|
+
const raw = url.searchParams.get("raw") === "1";
|
|
70
|
+
let file;
|
|
71
|
+
try {
|
|
72
|
+
file = await readRunFile(seg[2], name);
|
|
73
|
+
}
|
|
74
|
+
catch {
|
|
75
|
+
return json(res, { error: "invalid request" }, 400);
|
|
76
|
+
}
|
|
77
|
+
if (!file)
|
|
78
|
+
return json(res, { error: "not found" }, 404);
|
|
79
|
+
const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
|
|
80
|
+
const buf = await fs.readFile(file.absPath);
|
|
81
|
+
if (raw) {
|
|
82
|
+
res.writeHead(200, {
|
|
83
|
+
"content-type": MIME[ext] ?? "text/plain; charset=utf-8",
|
|
84
|
+
"cache-control": "no-store",
|
|
85
|
+
});
|
|
86
|
+
return void res.end(buf);
|
|
87
|
+
}
|
|
88
|
+
let text = buf.toString("utf8");
|
|
89
|
+
let truncated = false;
|
|
90
|
+
if (text.length > MAX_TEXT) {
|
|
91
|
+
text = text.slice(-MAX_TEXT);
|
|
92
|
+
truncated = true;
|
|
93
|
+
}
|
|
94
|
+
return json(res, { name, size: file.size, truncated, content: text });
|
|
95
|
+
}
|
|
96
|
+
// POST /api/runs/:id/stop
|
|
97
|
+
if (seg.length === 4 && seg[1] === "runs" && seg[3] === "stop" && method === "POST") {
|
|
98
|
+
return stopRun(seg[2])
|
|
99
|
+
? json(res, { stopped: true })
|
|
100
|
+
: json(res, { error: "no running sandbox container for this run (local runs cannot be stopped here)" }, 404);
|
|
101
|
+
}
|
|
102
|
+
// GET /api/workflows
|
|
103
|
+
if (seg.length === 2 && seg[1] === "workflows" && method === "GET") {
|
|
104
|
+
return json(res, await listWorkflows());
|
|
105
|
+
}
|
|
106
|
+
// GET /api/workflows/:slug
|
|
107
|
+
if (seg.length === 3 && seg[1] === "workflows" && method === "GET") {
|
|
108
|
+
const wf = await getWorkflow(decodeURIComponent(seg[2]));
|
|
109
|
+
return wf ? json(res, wf) : json(res, { error: "not found" }, 404);
|
|
110
|
+
}
|
|
111
|
+
// GET/POST /api/workflows/:slug/run
|
|
112
|
+
if (seg.length === 4 && seg[1] === "workflows" && seg[3] === "run") {
|
|
113
|
+
if (method === "GET")
|
|
114
|
+
return json(res, dockerStatus());
|
|
115
|
+
if (method === "POST") {
|
|
116
|
+
const wf = await getWorkflow(decodeURIComponent(seg[2]));
|
|
117
|
+
if (!wf || wf.error || !wf.name)
|
|
118
|
+
return json(res, { error: "workflow not found or broken" }, 404);
|
|
119
|
+
let body;
|
|
120
|
+
try {
|
|
121
|
+
body = JSON.parse(await readBody(req));
|
|
122
|
+
}
|
|
123
|
+
catch {
|
|
124
|
+
return json(res, { error: "invalid JSON body" }, 400);
|
|
125
|
+
}
|
|
126
|
+
const request = (body.request ?? "").trim();
|
|
127
|
+
if (!request)
|
|
128
|
+
return json(res, { error: "request text is required" }, 400);
|
|
129
|
+
try {
|
|
130
|
+
const { runId } = triggerRun({
|
|
131
|
+
workflowName: wf.name,
|
|
132
|
+
request,
|
|
133
|
+
sandbox: body.sandbox ?? true,
|
|
134
|
+
ssh: !!body.ssh,
|
|
135
|
+
});
|
|
136
|
+
return json(res, { runId });
|
|
137
|
+
}
|
|
138
|
+
catch (err) {
|
|
139
|
+
return json(res, { error: err.message }, 503);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
json(res, { error: "not found" }, 404);
|
|
144
|
+
}
|
|
145
|
+
async function serveStatic(res, staticDir, pathname) {
|
|
146
|
+
// SPA: unknown paths fall back to index.html (routing is client-side).
|
|
147
|
+
const rel = pathname === "/" ? "index.html" : pathname.slice(1);
|
|
148
|
+
let abs = path.resolve(staticDir, rel);
|
|
149
|
+
if (!abs.startsWith(path.resolve(staticDir) + path.sep) && abs !== path.resolve(staticDir)) {
|
|
150
|
+
return json(res, { error: "not found" }, 404);
|
|
151
|
+
}
|
|
152
|
+
let buf = await fs.readFile(abs).catch(() => null);
|
|
153
|
+
if (buf === null) {
|
|
154
|
+
abs = path.join(staticDir, "index.html");
|
|
155
|
+
buf = await fs.readFile(abs).catch(() => null);
|
|
156
|
+
}
|
|
157
|
+
if (buf === null)
|
|
158
|
+
return json(res, { error: "inspector assets missing" }, 500);
|
|
159
|
+
const ext = path.extname(abs).toLowerCase();
|
|
160
|
+
res.writeHead(200, {
|
|
161
|
+
"content-type": MIME[ext] ?? "application/octet-stream",
|
|
162
|
+
// Vite emits content-hashed asset names; index.html must stay fresh.
|
|
163
|
+
"cache-control": abs.endsWith("index.html") ? "no-store" : "public, max-age=31536000, immutable",
|
|
164
|
+
});
|
|
165
|
+
res.end(buf);
|
|
166
|
+
}
|
|
167
|
+
/** Start the server; resolves once it listens. Runs until the process ends. */
|
|
168
|
+
export function startInspector(opts) {
|
|
169
|
+
setOutputDir(opts.outputDir);
|
|
170
|
+
const server = http.createServer(async (req, res) => {
|
|
171
|
+
try {
|
|
172
|
+
const url = new URL(req.url ?? "/", "http://localhost");
|
|
173
|
+
if (url.pathname.startsWith("/api/"))
|
|
174
|
+
await handleApi(req, res, url);
|
|
175
|
+
else
|
|
176
|
+
await serveStatic(res, opts.staticDir, url.pathname);
|
|
177
|
+
}
|
|
178
|
+
catch (err) {
|
|
179
|
+
json(res, { error: err.message }, 500);
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
return new Promise((resolve, reject) => {
|
|
183
|
+
server.once("error", reject);
|
|
184
|
+
server.listen(opts.port, () => resolve(server));
|
|
185
|
+
});
|
|
186
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
export interface AgentInfo {
|
|
2
|
+
id: string;
|
|
3
|
+
name?: string;
|
|
4
|
+
model?: string;
|
|
5
|
+
harness?: string;
|
|
6
|
+
effort?: string;
|
|
7
|
+
tools: string[];
|
|
8
|
+
persona?: string;
|
|
9
|
+
}
|
|
10
|
+
export interface StepInfo {
|
|
11
|
+
name: string;
|
|
12
|
+
kind: "agent" | "script";
|
|
13
|
+
agent?: string;
|
|
14
|
+
/** Resolved prompt text (agent steps). */
|
|
15
|
+
prompt?: string;
|
|
16
|
+
/** The file reference as written in the yml, if the value was a file ref. */
|
|
17
|
+
sourceRef?: string;
|
|
18
|
+
/** Resolved script text (script steps). */
|
|
19
|
+
script?: string;
|
|
20
|
+
gates: string[];
|
|
21
|
+
}
|
|
22
|
+
export interface WorkflowSummary {
|
|
23
|
+
slug: string;
|
|
24
|
+
name?: string;
|
|
25
|
+
description?: string;
|
|
26
|
+
agents: number;
|
|
27
|
+
steps: number;
|
|
28
|
+
error?: string;
|
|
29
|
+
}
|
|
30
|
+
export interface WorkflowDetail {
|
|
31
|
+
slug: string;
|
|
32
|
+
dir: string;
|
|
33
|
+
name?: string;
|
|
34
|
+
description?: string;
|
|
35
|
+
workspace?: string;
|
|
36
|
+
agents: AgentInfo[];
|
|
37
|
+
steps: StepInfo[];
|
|
38
|
+
files: string[];
|
|
39
|
+
error?: string;
|
|
40
|
+
}
|
|
41
|
+
export declare function listWorkflows(): Promise<{
|
|
42
|
+
root?: string;
|
|
43
|
+
workflows: WorkflowSummary[];
|
|
44
|
+
}>;
|
|
45
|
+
export declare function getWorkflow(slug: string): Promise<WorkflowDetail | null>;
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import { promises as fs } from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { parse as parseYaml } from "yaml";
|
|
4
|
+
import { getProjectRoot } from "./context.js";
|
|
5
|
+
/**
|
|
6
|
+
* Workflow discovery + parsing for the inspector. Mirrors the kraftwerk CLI
|
|
7
|
+
* conventions (src/workflows/ or workflows/ under the project root, folder
|
|
8
|
+
* mode with workflow.yml or single .yml files) but parses the YAML raw, so
|
|
9
|
+
* even a workflow the framework would reject still renders — with its error.
|
|
10
|
+
*/
|
|
11
|
+
async function workflowsRoot() {
|
|
12
|
+
for (const candidate of ["src/workflows", "workflows"]) {
|
|
13
|
+
const p = path.join(getProjectRoot(), candidate);
|
|
14
|
+
const st = await fs.stat(p).catch(() => null);
|
|
15
|
+
if (st?.isDirectory())
|
|
16
|
+
return p;
|
|
17
|
+
}
|
|
18
|
+
return undefined;
|
|
19
|
+
}
|
|
20
|
+
function gateLabel(g) {
|
|
21
|
+
if (g == null)
|
|
22
|
+
return "?";
|
|
23
|
+
if (typeof g.file_non_empty === "string")
|
|
24
|
+
return `file_non_empty(${g.file_non_empty})`;
|
|
25
|
+
if (typeof g.slots_filled === "string")
|
|
26
|
+
return `slots_filled(${g.slots_filled})`;
|
|
27
|
+
if (g.contains)
|
|
28
|
+
return `contains(${g.contains.file}, ${g.contains.label ?? g.contains.text})`;
|
|
29
|
+
return JSON.stringify(g);
|
|
30
|
+
}
|
|
31
|
+
/** Folder mode: a single-line value references a file inside the folder. */
|
|
32
|
+
async function resolveText(value, baseDir) {
|
|
33
|
+
if (typeof value !== "string")
|
|
34
|
+
return {};
|
|
35
|
+
const line = value.trim();
|
|
36
|
+
if (!baseDir || line.includes("\n"))
|
|
37
|
+
return { text: line };
|
|
38
|
+
const candidate = path.resolve(baseDir, line);
|
|
39
|
+
if (!candidate.startsWith(path.resolve(baseDir) + path.sep))
|
|
40
|
+
return { text: line };
|
|
41
|
+
const content = await fs.readFile(candidate, "utf8").catch(() => null);
|
|
42
|
+
if (content !== null)
|
|
43
|
+
return { text: content.trim(), sourceRef: line };
|
|
44
|
+
return { text: line };
|
|
45
|
+
}
|
|
46
|
+
async function listDirFiles(dir, prefix = "") {
|
|
47
|
+
const out = [];
|
|
48
|
+
const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
|
|
49
|
+
for (const e of entries) {
|
|
50
|
+
if (e.name.startsWith("."))
|
|
51
|
+
continue;
|
|
52
|
+
if (e.isDirectory()) {
|
|
53
|
+
out.push(...(await listDirFiles(path.join(dir, e.name), `${prefix}${e.name}/`)));
|
|
54
|
+
}
|
|
55
|
+
else {
|
|
56
|
+
out.push(`${prefix}${e.name}`);
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
return out.sort();
|
|
60
|
+
}
|
|
61
|
+
async function locate() {
|
|
62
|
+
const root = await workflowsRoot();
|
|
63
|
+
if (!root)
|
|
64
|
+
return [];
|
|
65
|
+
const found = [];
|
|
66
|
+
for (const entry of await fs.readdir(root, { withFileTypes: true })) {
|
|
67
|
+
const entryPath = path.join(root, entry.name);
|
|
68
|
+
if (entry.isDirectory()) {
|
|
69
|
+
for (const f of ["workflow.yml", "workflow.yaml"]) {
|
|
70
|
+
const st = await fs.stat(path.join(entryPath, f)).catch(() => null);
|
|
71
|
+
if (st?.isFile()) {
|
|
72
|
+
found.push({ slug: entry.name, yamlPath: path.join(entryPath, f), baseDir: entryPath });
|
|
73
|
+
break;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
else if (/\.ya?ml$/.test(entry.name)) {
|
|
78
|
+
found.push({ slug: entry.name.replace(/\.ya?ml$/, ""), yamlPath: entryPath, baseDir: null });
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return found.sort((a, b) => a.slug.localeCompare(b.slug));
|
|
82
|
+
}
|
|
83
|
+
async function parseRaw(l) {
|
|
84
|
+
const text = await fs.readFile(l.yamlPath, "utf8");
|
|
85
|
+
return parseYaml(text);
|
|
86
|
+
}
|
|
87
|
+
export async function listWorkflows() {
|
|
88
|
+
const root = await workflowsRoot();
|
|
89
|
+
const located = await locate();
|
|
90
|
+
const workflows = await Promise.all(located.map(async (l) => {
|
|
91
|
+
try {
|
|
92
|
+
const raw = await parseRaw(l);
|
|
93
|
+
return {
|
|
94
|
+
slug: l.slug,
|
|
95
|
+
name: raw?.name,
|
|
96
|
+
description: raw?.description,
|
|
97
|
+
agents: Object.keys(raw?.agents ?? {}).length,
|
|
98
|
+
steps: (raw?.steps ?? []).length,
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
catch (err) {
|
|
102
|
+
return { slug: l.slug, agents: 0, steps: 0, error: err.message };
|
|
103
|
+
}
|
|
104
|
+
}));
|
|
105
|
+
return { root, workflows };
|
|
106
|
+
}
|
|
107
|
+
export async function getWorkflow(slug) {
|
|
108
|
+
const located = await locate();
|
|
109
|
+
const l = located.find((x) => x.slug === slug) ??
|
|
110
|
+
// Fallback: match by workflow name (run traces carry the name, not the slug).
|
|
111
|
+
(await (async () => {
|
|
112
|
+
for (const x of located) {
|
|
113
|
+
try {
|
|
114
|
+
if ((await parseRaw(x))?.name === slug)
|
|
115
|
+
return x;
|
|
116
|
+
}
|
|
117
|
+
catch { }
|
|
118
|
+
}
|
|
119
|
+
return undefined;
|
|
120
|
+
})());
|
|
121
|
+
if (!l)
|
|
122
|
+
return null;
|
|
123
|
+
let raw;
|
|
124
|
+
try {
|
|
125
|
+
raw = await parseRaw(l);
|
|
126
|
+
}
|
|
127
|
+
catch (err) {
|
|
128
|
+
return {
|
|
129
|
+
slug: l.slug,
|
|
130
|
+
dir: path.dirname(l.yamlPath),
|
|
131
|
+
agents: [],
|
|
132
|
+
steps: [],
|
|
133
|
+
files: [],
|
|
134
|
+
error: err.message,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
const agents = [];
|
|
138
|
+
for (const [id, a] of Object.entries(raw?.agents ?? {})) {
|
|
139
|
+
const persona = await resolveText(a?.persona, l.baseDir);
|
|
140
|
+
agents.push({
|
|
141
|
+
id,
|
|
142
|
+
name: a?.name,
|
|
143
|
+
model: a?.model,
|
|
144
|
+
harness: a?.harness,
|
|
145
|
+
effort: a?.effort,
|
|
146
|
+
tools: a?.tools ?? [],
|
|
147
|
+
persona: persona.text,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
const steps = [];
|
|
151
|
+
for (const s of raw?.steps ?? []) {
|
|
152
|
+
const gates = (s?.gates ?? []).map(gateLabel);
|
|
153
|
+
if (s?.run !== undefined) {
|
|
154
|
+
const script = await resolveText(s.run, l.baseDir);
|
|
155
|
+
steps.push({
|
|
156
|
+
name: s.name,
|
|
157
|
+
kind: "script",
|
|
158
|
+
script: script.text,
|
|
159
|
+
sourceRef: script.sourceRef,
|
|
160
|
+
gates,
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
else {
|
|
164
|
+
const prompt = await resolveText(s?.prompt, l.baseDir);
|
|
165
|
+
steps.push({
|
|
166
|
+
name: s?.name,
|
|
167
|
+
kind: "agent",
|
|
168
|
+
agent: s?.agent,
|
|
169
|
+
prompt: prompt.text,
|
|
170
|
+
sourceRef: prompt.sourceRef,
|
|
171
|
+
gates,
|
|
172
|
+
});
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
const workspace = await resolveText(raw?.workspace, l.baseDir);
|
|
176
|
+
return {
|
|
177
|
+
slug: l.slug,
|
|
178
|
+
dir: path.dirname(l.yamlPath),
|
|
179
|
+
name: raw?.name,
|
|
180
|
+
description: raw?.description,
|
|
181
|
+
workspace: workspace.text,
|
|
182
|
+
agents,
|
|
183
|
+
steps,
|
|
184
|
+
files: l.baseDir ? await listDirFiles(l.baseDir) : [path.basename(l.yamlPath)],
|
|
185
|
+
};
|
|
186
|
+
}
|