@netnodeag/kraftwerk 0.2.0 → 0.3.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.
@@ -0,0 +1,313 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+
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
+
10
+ export const OUTPUT_DIR = process.env.KRAFTWERK_OUTPUT
11
+ ? path.resolve(process.env.KRAFTWERK_OUTPUT)
12
+ : path.resolve(process.cwd(), "../../agent-playground/output");
13
+
14
+ /** A run with no trace update for this long and no summary counts as aborted. */
15
+ const STALE_MS = 15 * 60 * 1000;
16
+
17
+ export type RunStatus = "running" | "ok" | "failed" | "aborted";
18
+
19
+ export interface GateView {
20
+ gate: string;
21
+ passed: boolean;
22
+ failure: string | null;
23
+ }
24
+
25
+ export interface PhaseView {
26
+ phase: string;
27
+ kind: "agent" | "script";
28
+ agent?: string;
29
+ model?: string;
30
+ harness?: string;
31
+ status: "running" | "ok" | "failed" | "blocked" | "pending";
32
+ attempts: number;
33
+ startedAt?: string;
34
+ endedAt?: string;
35
+ durationMs?: number;
36
+ costUsd?: number;
37
+ tokensIn?: number;
38
+ tokensOut?: number;
39
+ gates: GateView[];
40
+ summary?: string;
41
+ lastActivity?: string;
42
+ stdout?: string;
43
+ stderr?: string;
44
+ }
45
+
46
+ export interface FileView {
47
+ name: string;
48
+ size: number;
49
+ mtime: string;
50
+ }
51
+
52
+ export interface RunListItem {
53
+ id: string;
54
+ workflow?: string;
55
+ request?: string;
56
+ status: RunStatus;
57
+ startedAt?: string;
58
+ updatedAt: string;
59
+ phasesDone: number;
60
+ phasesTotal?: number;
61
+ currentPhase?: string;
62
+ durationMs?: number;
63
+ costUsd?: number;
64
+ }
65
+
66
+ export interface RunDetail extends RunListItem {
67
+ description?: string;
68
+ steps?: string[];
69
+ phases: PhaseView[];
70
+ files: FileView[];
71
+ }
72
+
73
+ type TraceEvent = Record<string, any> & { ts: string; event: string };
74
+
75
+ async function readTrace(runDir: string): Promise<TraceEvent[]> {
76
+ let raw: string;
77
+ try {
78
+ raw = await fs.readFile(path.join(runDir, "trace.jsonl"), "utf8");
79
+ } catch {
80
+ return [];
81
+ }
82
+ const events: TraceEvent[] = [];
83
+ for (const line of raw.split("\n")) {
84
+ if (!line.trim()) continue;
85
+ try {
86
+ events.push(JSON.parse(line));
87
+ } catch {
88
+ /* partially written last line of a live run */
89
+ }
90
+ }
91
+ return events;
92
+ }
93
+
94
+ function analyse(events: TraceEvent[]) {
95
+ const runStart = events.find((e) => e.event === "run_start");
96
+ const summary = events.find((e) => e.event === "run_summary");
97
+
98
+ const phases: PhaseView[] = [];
99
+ const byName = new Map<string, PhaseView>();
100
+ for (const e of events) {
101
+ if (e.event === "phase_start") {
102
+ const p: PhaseView = {
103
+ phase: e.phase,
104
+ kind: e.kind === "script" ? "script" : "agent",
105
+ agent: e.agent,
106
+ model: e.model,
107
+ harness: e.harness,
108
+ status: "running",
109
+ attempts: 0,
110
+ startedAt: e.ts,
111
+ gates: [],
112
+ };
113
+ phases.push(p);
114
+ byName.set(e.phase, p);
115
+ continue;
116
+ }
117
+ const p = byName.get(e.phase);
118
+ if (!p) continue;
119
+ switch (e.event) {
120
+ case "tool_use":
121
+ p.lastActivity = `${e.tool} ${e.target ? shortenPath(e.target) : ""}`.trim();
122
+ break;
123
+ case "agent_result":
124
+ p.attempts = (e.attempt ?? 0) + 1;
125
+ break;
126
+ case "script_result":
127
+ p.attempts += 1;
128
+ p.stdout = e.stdout || undefined;
129
+ p.stderr = e.stderr || undefined;
130
+ break;
131
+ case "envelope":
132
+ p.summary = e.envelope?.summary;
133
+ break;
134
+ case "gate_result":
135
+ p.gates.push({ gate: e.gate, passed: e.passed, failure: e.failure ?? null });
136
+ break;
137
+ case "phase_end":
138
+ p.status = e.status === "ok" ? "ok" : e.status === "blocked" ? "blocked" : "failed";
139
+ p.endedAt = e.ts;
140
+ if (e.stats) {
141
+ p.durationMs = e.stats.durationMs;
142
+ p.costUsd = e.stats.costUsd;
143
+ p.tokensIn =
144
+ (e.stats.inputTokens ?? 0) +
145
+ (e.stats.cacheReadTokens ?? 0) +
146
+ (e.stats.cacheCreationTokens ?? 0);
147
+ p.tokensOut = e.stats.outputTokens ?? 0;
148
+ p.attempts = e.stats.attempts ?? p.attempts;
149
+ }
150
+ break;
151
+ }
152
+ }
153
+
154
+ // Only keep the last attempt's gate results per gate name.
155
+ for (const p of phases) {
156
+ const last = new Map<string, GateView>();
157
+ for (const g of p.gates) last.set(g.gate, g);
158
+ p.gates = [...last.values()];
159
+ }
160
+
161
+ // Steps declared at run_start that have not started yet are pending.
162
+ // run_start.steps is either string[] (older traces) or {name, kind, agent, model}[].
163
+ const rawSteps: any[] | undefined = runStart?.steps;
164
+ const steps: string[] | undefined = rawSteps?.map((s) => (typeof s === "string" ? s : s.name));
165
+ if (rawSteps) {
166
+ for (const s of rawSteps) {
167
+ const name = typeof s === "string" ? s : s.name;
168
+ if (!byName.has(name)) {
169
+ phases.push({
170
+ phase: name,
171
+ kind: typeof s === "string" ? "script" : s.kind,
172
+ agent: typeof s === "string" ? undefined : s.agent,
173
+ model: typeof s === "string" ? undefined : s.model,
174
+ status: "pending",
175
+ attempts: 0,
176
+ gates: [],
177
+ });
178
+ }
179
+ }
180
+ }
181
+
182
+ const failed = phases.some((p) => p.status === "failed" || p.status === "blocked");
183
+ const lastTs = events.length ? events[events.length - 1].ts : undefined;
184
+ let status: RunStatus;
185
+ if (summary) status = failed ? "failed" : "ok";
186
+ else if (failed) status = "failed";
187
+ else if (lastTs && Date.now() - Date.parse(lastTs) > STALE_MS) status = "aborted";
188
+ else status = "running";
189
+
190
+ return { runStart, summary, phases, steps, status, lastTs };
191
+ }
192
+
193
+ /**
194
+ * Sandboxed runs write runner.json (exit code recorded when the container
195
+ * ends). If the trace looks "running" but the container already exited —
196
+ * e.g. it crashed before any phase_end — trust the exit code instead of
197
+ * waiting for the 15-minute stale timeout.
198
+ */
199
+ async function applyRunnerVerdict(
200
+ runDir: string,
201
+ a: ReturnType<typeof analyse>
202
+ ): Promise<ReturnType<typeof analyse>> {
203
+ if (a.status !== "running") return a;
204
+ try {
205
+ const meta = JSON.parse(await fs.readFile(path.join(runDir, "runner.json"), "utf8"));
206
+ if (meta.exitCode != null) {
207
+ a.status = meta.exitCode === 0 ? "ok" : "failed";
208
+ if (meta.exitCode !== 0) {
209
+ for (const p of a.phases) if (p.status === "running") p.status = "failed";
210
+ }
211
+ }
212
+ } catch {
213
+ /* no runner.json (local run) or unreadable — keep trace-based status */
214
+ }
215
+ return a;
216
+ }
217
+
218
+ function shortenPath(p: string): string {
219
+ const parts = p.split("/");
220
+ return parts.length > 2 ? parts.slice(-2).join("/") : p;
221
+ }
222
+
223
+ export async function listRuns(): Promise<RunListItem[]> {
224
+ let entries: string[];
225
+ try {
226
+ entries = (await fs.readdir(OUTPUT_DIR)).filter((e) => e.startsWith("run-"));
227
+ } catch {
228
+ return [];
229
+ }
230
+ const items = await Promise.all(
231
+ entries.map(async (id): Promise<RunListItem | null> => {
232
+ const runDir = path.join(OUTPUT_DIR, id);
233
+ const st = await fs.stat(runDir).catch(() => null);
234
+ if (!st?.isDirectory()) return null;
235
+ const events = await readTrace(runDir);
236
+ const { runStart, summary, phases, steps, status, lastTs } = await applyRunnerVerdict(
237
+ runDir,
238
+ analyse(events)
239
+ );
240
+ const done = phases.filter((p) => p.status === "ok").length;
241
+ const current = phases.find((p) => p.status === "running")?.phase;
242
+ return {
243
+ id,
244
+ workflow: runStart?.workflow,
245
+ request: runStart?.request,
246
+ status,
247
+ startedAt: events[0]?.ts,
248
+ updatedAt: lastTs ?? st.mtime.toISOString(),
249
+ phasesDone: done,
250
+ phasesTotal: steps?.length ?? (summary ? phases.length : undefined),
251
+ currentPhase: current,
252
+ durationMs: summary?.total?.durationMs,
253
+ costUsd: summary?.total?.costUsd,
254
+ };
255
+ })
256
+ );
257
+ return (items.filter(Boolean) as RunListItem[]).sort((a, b) => b.id.localeCompare(a.id));
258
+ }
259
+
260
+ export function safeRunDir(id: string): string {
261
+ if (!/^run-[0-9-]+$/.test(id)) throw new Error("invalid run id");
262
+ return path.join(OUTPUT_DIR, id);
263
+ }
264
+
265
+ export async function getRun(id: string): Promise<RunDetail | null> {
266
+ const runDir = safeRunDir(id);
267
+ const st = await fs.stat(runDir).catch(() => null);
268
+ if (!st?.isDirectory()) return null;
269
+
270
+ const events = await readTrace(runDir);
271
+ const { runStart, summary, phases, steps, status, lastTs } = await applyRunnerVerdict(
272
+ runDir,
273
+ analyse(events)
274
+ );
275
+
276
+ const names = await fs.readdir(runDir);
277
+ const files: FileView[] = [];
278
+ for (const name of names) {
279
+ const fst = await fs.stat(path.join(runDir, name)).catch(() => null);
280
+ if (fst?.isFile()) files.push({ name, size: fst.size, mtime: fst.mtime.toISOString() });
281
+ }
282
+ files.sort((a, b) => a.name.localeCompare(b.name));
283
+
284
+ return {
285
+ id,
286
+ workflow: runStart?.workflow,
287
+ description: runStart?.description,
288
+ request: runStart?.request,
289
+ status,
290
+ startedAt: events[0]?.ts,
291
+ updatedAt: lastTs ?? st.mtime.toISOString(),
292
+ phasesDone: phases.filter((p) => p.status === "ok").length,
293
+ phasesTotal: steps?.length ?? (summary ? phases.length : undefined),
294
+ currentPhase: phases.find((p) => p.status === "running")?.phase,
295
+ durationMs: summary?.total?.durationMs,
296
+ costUsd: summary?.total?.costUsd,
297
+ steps,
298
+ phases,
299
+ files,
300
+ };
301
+ }
302
+
303
+ export async function readRunFile(
304
+ id: string,
305
+ name: string
306
+ ): Promise<{ absPath: string; size: number } | null> {
307
+ const runDir = safeRunDir(id);
308
+ const absPath = path.resolve(runDir, name);
309
+ if (absPath !== path.join(runDir, path.basename(name))) throw new Error("invalid file name");
310
+ const st = await fs.stat(absPath).catch(() => null);
311
+ if (!st?.isFile()) return null;
312
+ return { absPath, size: st.size };
313
+ }
@@ -0,0 +1,240 @@
1
+ import { promises as fs } from "node:fs";
2
+ import path from "node:path";
3
+ import { parse as parseYaml } from "yaml";
4
+ import { OUTPUT_DIR } from "./runs";
5
+
6
+ /**
7
+ * Workflow discovery + parsing for the inspector. Mirrors the kraftwerk CLI
8
+ * conventions (src/workflows/ or workflows/ under the project root, folder
9
+ * mode with workflow.yml or single .yml files) but parses the YAML raw, so
10
+ * even a workflow the framework would reject still renders — with its error.
11
+ */
12
+
13
+ /** The consumer project root is the parent of the output dir. */
14
+ export const PROJECT_ROOT = path.dirname(OUTPUT_DIR);
15
+
16
+ async function workflowsRoot(): Promise<string | undefined> {
17
+ for (const candidate of ["src/workflows", "workflows"]) {
18
+ const p = path.join(PROJECT_ROOT, candidate);
19
+ const st = await fs.stat(p).catch(() => null);
20
+ if (st?.isDirectory()) return p;
21
+ }
22
+ return undefined;
23
+ }
24
+
25
+ export interface AgentInfo {
26
+ id: string;
27
+ name?: string;
28
+ model?: string;
29
+ harness?: string;
30
+ effort?: string;
31
+ tools: string[];
32
+ persona?: string;
33
+ }
34
+
35
+ export interface StepInfo {
36
+ name: string;
37
+ kind: "agent" | "script";
38
+ agent?: string;
39
+ /** Resolved prompt text (agent steps). */
40
+ prompt?: string;
41
+ /** The file reference as written in the yml, if the value was a file ref. */
42
+ sourceRef?: string;
43
+ /** Resolved script text (script steps). */
44
+ script?: string;
45
+ gates: string[];
46
+ }
47
+
48
+ export interface WorkflowSummary {
49
+ slug: string;
50
+ name?: string;
51
+ description?: string;
52
+ agents: number;
53
+ steps: number;
54
+ error?: string;
55
+ }
56
+
57
+ export interface WorkflowDetail {
58
+ slug: string;
59
+ dir: string;
60
+ name?: string;
61
+ description?: string;
62
+ workspace?: string;
63
+ agents: AgentInfo[];
64
+ steps: StepInfo[];
65
+ files: string[];
66
+ error?: string;
67
+ }
68
+
69
+ function gateLabel(g: any): string {
70
+ if (g == null) return "?";
71
+ if (typeof g.file_non_empty === "string") return `file_non_empty(${g.file_non_empty})`;
72
+ if (typeof g.slots_filled === "string") return `slots_filled(${g.slots_filled})`;
73
+ if (g.contains) return `contains(${g.contains.file}, ${g.contains.label ?? g.contains.text})`;
74
+ return JSON.stringify(g);
75
+ }
76
+
77
+ /** Folder mode: a single-line value references a file inside the folder. */
78
+ async function resolveText(
79
+ value: unknown,
80
+ baseDir: string | null
81
+ ): Promise<{ text?: string; sourceRef?: string }> {
82
+ if (typeof value !== "string") return {};
83
+ const line = value.trim();
84
+ if (!baseDir || line.includes("\n")) return { text: line };
85
+ const candidate = path.resolve(baseDir, line);
86
+ if (!candidate.startsWith(path.resolve(baseDir) + path.sep)) return { text: line };
87
+ const content = await fs.readFile(candidate, "utf8").catch(() => null);
88
+ if (content !== null) return { text: content.trim(), sourceRef: line };
89
+ return { text: line };
90
+ }
91
+
92
+ async function listDirFiles(dir: string, prefix = ""): Promise<string[]> {
93
+ const out: string[] = [];
94
+ const entries = await fs.readdir(dir, { withFileTypes: true }).catch(() => []);
95
+ for (const e of entries) {
96
+ if (e.name.startsWith(".")) continue;
97
+ if (e.isDirectory()) {
98
+ out.push(...(await listDirFiles(path.join(dir, e.name), `${prefix}${e.name}/`)));
99
+ } else {
100
+ out.push(`${prefix}${e.name}`);
101
+ }
102
+ }
103
+ return out.sort();
104
+ }
105
+
106
+ interface Located {
107
+ slug: string;
108
+ yamlPath: string;
109
+ baseDir: string | null;
110
+ }
111
+
112
+ async function locate(): Promise<Located[]> {
113
+ const root = await workflowsRoot();
114
+ if (!root) return [];
115
+ const found: Located[] = [];
116
+ for (const entry of await fs.readdir(root, { withFileTypes: true })) {
117
+ const entryPath = path.join(root, entry.name);
118
+ if (entry.isDirectory()) {
119
+ for (const f of ["workflow.yml", "workflow.yaml"]) {
120
+ const st = await fs.stat(path.join(entryPath, f)).catch(() => null);
121
+ if (st?.isFile()) {
122
+ found.push({ slug: entry.name, yamlPath: path.join(entryPath, f), baseDir: entryPath });
123
+ break;
124
+ }
125
+ }
126
+ } else if (/\.ya?ml$/.test(entry.name)) {
127
+ found.push({ slug: entry.name.replace(/\.ya?ml$/, ""), yamlPath: entryPath, baseDir: null });
128
+ }
129
+ }
130
+ return found.sort((a, b) => a.slug.localeCompare(b.slug));
131
+ }
132
+
133
+ async function parseRaw(l: Located): Promise<any> {
134
+ const text = await fs.readFile(l.yamlPath, "utf8");
135
+ return parseYaml(text);
136
+ }
137
+
138
+ export async function listWorkflows(): Promise<{ root?: string; workflows: WorkflowSummary[] }> {
139
+ const root = await workflowsRoot();
140
+ const located = await locate();
141
+ const workflows = await Promise.all(
142
+ located.map(async (l): Promise<WorkflowSummary> => {
143
+ try {
144
+ const raw = await parseRaw(l);
145
+ return {
146
+ slug: l.slug,
147
+ name: raw?.name,
148
+ description: raw?.description,
149
+ agents: Object.keys(raw?.agents ?? {}).length,
150
+ steps: (raw?.steps ?? []).length,
151
+ };
152
+ } catch (err) {
153
+ return { slug: l.slug, agents: 0, steps: 0, error: (err as Error).message };
154
+ }
155
+ })
156
+ );
157
+ return { root, workflows };
158
+ }
159
+
160
+ export async function getWorkflow(slug: string): Promise<WorkflowDetail | null> {
161
+ const located = await locate();
162
+ const l =
163
+ located.find((x) => x.slug === slug) ??
164
+ // Fallback: match by workflow name (run traces carry the name, not the slug).
165
+ (await (async () => {
166
+ for (const x of located) {
167
+ try {
168
+ if ((await parseRaw(x))?.name === slug) return x;
169
+ } catch {}
170
+ }
171
+ return undefined;
172
+ })());
173
+ if (!l) return null;
174
+
175
+ let raw: any;
176
+ try {
177
+ raw = await parseRaw(l);
178
+ } catch (err) {
179
+ return {
180
+ slug: l.slug,
181
+ dir: path.dirname(l.yamlPath),
182
+ agents: [],
183
+ steps: [],
184
+ files: [],
185
+ error: (err as Error).message,
186
+ };
187
+ }
188
+
189
+ const agents: AgentInfo[] = [];
190
+ for (const [id, a] of Object.entries<any>(raw?.agents ?? {})) {
191
+ const persona = await resolveText(a?.persona, l.baseDir);
192
+ agents.push({
193
+ id,
194
+ name: a?.name,
195
+ model: a?.model,
196
+ harness: a?.harness,
197
+ effort: a?.effort,
198
+ tools: a?.tools ?? [],
199
+ persona: persona.text,
200
+ });
201
+ }
202
+
203
+ const steps: StepInfo[] = [];
204
+ for (const s of raw?.steps ?? []) {
205
+ const gates = (s?.gates ?? []).map(gateLabel);
206
+ if (s?.run !== undefined) {
207
+ const script = await resolveText(s.run, l.baseDir);
208
+ steps.push({
209
+ name: s.name,
210
+ kind: "script",
211
+ script: script.text,
212
+ sourceRef: script.sourceRef,
213
+ gates,
214
+ });
215
+ } else {
216
+ const prompt = await resolveText(s?.prompt, l.baseDir);
217
+ steps.push({
218
+ name: s?.name,
219
+ kind: "agent",
220
+ agent: s?.agent,
221
+ prompt: prompt.text,
222
+ sourceRef: prompt.sourceRef,
223
+ gates,
224
+ });
225
+ }
226
+ }
227
+
228
+ const workspace = await resolveText(raw?.workspace, l.baseDir);
229
+
230
+ return {
231
+ slug: l.slug,
232
+ dir: path.dirname(l.yamlPath),
233
+ name: raw?.name,
234
+ description: raw?.description,
235
+ workspace: workspace.text,
236
+ agents,
237
+ steps,
238
+ files: l.baseDir ? await listDirFiles(l.baseDir) : [path.basename(l.yamlPath)],
239
+ };
240
+ }
@@ -0,0 +1,5 @@
1
+ import type { NextConfig } from "next";
2
+
3
+ const nextConfig: NextConfig = {};
4
+
5
+ export default nextConfig;