@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.
package/README.md CHANGED
@@ -22,6 +22,12 @@ npx @netnodeag/kraftwerk init # scaffold kraftwerk.yml + workflows/ + examp
22
22
  npx @netnodeag/kraftwerk run hello "Was ist kraftwerk?"
23
23
  ```
24
24
 
25
+ Your own workflows are just more folders under `workflows/` — a
26
+ `workflow.yml` plus prompt files, discovered automatically (see
27
+ [YAML workflows](#yaml-workflows)). Or let a coding agent build one:
28
+ `npx @netnodeag/kraftwerk create "<what it should do>"` prints a
29
+ self-contained brief that Claude Code / Codex follows end to end.
30
+
25
31
  For a local checkout / programmatic consumer (TS workflows, custom gates,
26
32
  approval loops), add the dependency (the `kraftwerk` alias keeps imports
27
33
  short):
@@ -50,6 +56,7 @@ kraftwerk list # table: workflows, steps, agents (with
50
56
  kraftwerk run tagline "https://..." # run; --yes, --verbose
51
57
  kraftwerk run # interactive: pick workflow, type the request
52
58
  kraftwerk runs # past runs from output/*/trace.jsonl; runs show <id> for detail
59
+ kraftwerk ui # inspector web UI on http://localhost:4499; --port, --output
53
60
  kraftwerk doctor # preflight: harness CLIs, docker, workflows, declared env vars
54
61
  kraftwerk validate # all discovered — schema + semantics + files, exit 1 on failure
55
62
  kraftwerk validate src/workflows/pitch # specific paths
@@ -333,7 +340,8 @@ npm publish # runs npm run build first via prepublishOnly
333
340
  ```
334
341
 
335
342
  The tarball is whitelisted via `files`: `bin/`, `dist/`, `runner/`
336
- (Dockerfile for sandboxed runs), `schema/` (workflow JSON schema) no
337
- `src/`, examples, or inspector. Check with `npm pack --dry-run` before a
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
338
346
  release. Runtime deps stay regular `dependencies`; `tsx` and `typescript`
339
347
  are dev-only, so consumers install neither.
@@ -12,6 +12,7 @@ import { renderCreateBrief } from "./create-brief.js";
12
12
  import { runDoctor } from "./doctor.js";
13
13
  import { runInit } from "./init.js";
14
14
  import { listRuns, showRun } from "./runs.js";
15
+ import { runUi } from "./ui.js";
15
16
  /**
16
17
  * kraftwerk — the kraftwerk CLI.
17
18
  *
@@ -19,6 +20,7 @@ import { listRuns, showRun } from "./runs.js";
19
20
  * kraftwerk list discover + list workflows (--json, --from)
20
21
  * kraftwerk run [workflow] [text] run one (prompts interactively if omitted)
21
22
  * kraftwerk runs [show <id>] inspect past runs from their traces
23
+ * kraftwerk ui start the inspector web UI (localhost:4499)
22
24
  * kraftwerk doctor preflight: harness CLIs, docker, workflows, env
23
25
  * kraftwerk validate [paths...] validate without executing
24
26
  *
@@ -249,6 +251,14 @@ runs
249
251
  .action(async (runId, opts) => {
250
252
  await showRun(process.cwd(), runId, opts);
251
253
  });
254
+ program
255
+ .command("ui")
256
+ .description("Start the inspector web UI for this project's runs and workflows")
257
+ .option("--port <port>", "Port for the web UI", "4499")
258
+ .option("--output <dir>", "Output directory to inspect (default: the project's output dir)")
259
+ .action(async (opts) => {
260
+ await runUi(process.cwd(), opts);
261
+ });
252
262
  const runner = program
253
263
  .command("runner")
254
264
  .description("Manage the Docker sandbox runner (build the image, see/stop running runs)");
@@ -0,0 +1,4 @@
1
+ export declare function runUi(cwd: string, opts: {
2
+ port?: string;
3
+ output?: string;
4
+ }): Promise<void>;
package/dist/cli/ui.js ADDED
@@ -0,0 +1,75 @@
1
+ import { existsSync } from "node:fs";
2
+ import { cp, readFile } from "node:fs/promises";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+ import { spawn, spawnSync } from "node:child_process";
7
+ import chalk from "chalk";
8
+ import { resolveProject } from "../config.js";
9
+ /**
10
+ * `kraftwerk ui` — start the inspector web UI (Next.js app shipped in the
11
+ * package under inspector/) pointed at the current project's output dir.
12
+ * First launch installs the inspector's dependencies; the server then runs
13
+ * in the foreground until Ctrl-C.
14
+ */
15
+ /** Package root is two levels up from this file (src/cli/ or dist/cli/). */
16
+ const packageRoot = () => path.join(path.dirname(fileURLToPath(import.meta.url)), "../..");
17
+ const inNodeModules = (p) => p.split(path.sep).includes("node_modules");
18
+ /**
19
+ * The inspector cannot run in place from an installed package: Next.js
20
+ * excludes everything under node_modules/ from compilation, so its .ts/.tsx
21
+ * sources would be served raw. Installed copies are materialized (sources
22
+ * only) into ~/.cache/kraftwerk/inspector-<version>/ and run from there;
23
+ * dev checkouts run in place.
24
+ */
25
+ async function materializeInspector() {
26
+ const src = path.join(packageRoot(), "inspector");
27
+ if (!inNodeModules(src))
28
+ return src;
29
+ const pkg = JSON.parse(await readFile(path.join(packageRoot(), "package.json"), "utf8"));
30
+ const dest = path.join(os.homedir(), ".cache", "kraftwerk", `inspector-${pkg.version}`);
31
+ if (!existsSync(path.join(dest, "package.json"))) {
32
+ await cp(src, dest, {
33
+ recursive: true,
34
+ filter: (s) => {
35
+ const parts = path.relative(src, s).split(path.sep);
36
+ return !parts.includes("node_modules") && !parts.includes(".next");
37
+ },
38
+ });
39
+ }
40
+ return dest;
41
+ }
42
+ export async function runUi(cwd, opts) {
43
+ if (!existsSync(path.join(packageRoot(), "inspector", "package.json"))) {
44
+ console.error(chalk.red(`Inspector not found at ${path.join(packageRoot(), "inspector")} — broken install?`));
45
+ process.exit(1);
46
+ }
47
+ const dir = await materializeInspector();
48
+ const outputDir = opts.output
49
+ ? path.resolve(cwd, opts.output)
50
+ : (await resolveProject(cwd)).outputDir;
51
+ const port = opts.port ?? "4499";
52
+ if (!existsSync(path.join(dir, "node_modules"))) {
53
+ console.log(chalk.dim("First launch — installing inspector dependencies ..."));
54
+ const install = spawnSync("npm", ["install", "--no-fund", "--no-audit"], {
55
+ cwd: dir,
56
+ stdio: "inherit",
57
+ });
58
+ if (install.status !== 0) {
59
+ console.error(chalk.red("npm install failed in the inspector directory."));
60
+ process.exit(1);
61
+ }
62
+ }
63
+ console.log(`${chalk.green("✔")} Inspector: ${chalk.cyan(`http://localhost:${port}`)} ` +
64
+ chalk.dim(`(output: ${outputDir})`));
65
+ const nextBin = path.join(dir, "node_modules", "next", "dist", "bin", "next");
66
+ const child = spawn(process.execPath, [nextBin, "dev", "-p", port], {
67
+ cwd: dir,
68
+ stdio: "inherit",
69
+ env: { ...process.env, KRAFTWERK_OUTPUT: outputDir },
70
+ });
71
+ for (const signal of ["SIGINT", "SIGTERM"]) {
72
+ process.on(signal, () => child.kill(signal));
73
+ }
74
+ child.on("exit", (code) => process.exit(code ?? 0));
75
+ }
@@ -0,0 +1,67 @@
1
+ # kraftwerk inspector
2
+
3
+ Web UI to look into the `output/` folder of a kraftwerk consumer project:
4
+ all runs at a glance, live phase timelines while a run executes, and every
5
+ file per run with inline preview (HTML reports render in place, text tails
6
+ live, images display).
7
+
8
+ ## Run
9
+
10
+ ```bash
11
+ kraftwerk ui # from any consumer project — installs deps on first launch,
12
+ # points at the project's output dir; --port, --output
13
+ ```
14
+
15
+ Or directly from a checkout:
16
+
17
+ ```bash
18
+ cd inspector
19
+ npm install
20
+ npm run dev # http://localhost:4499
21
+ ```
22
+
23
+ By default it inspects `../../agent-playground/output` (the playground next
24
+ to this repo). Point it anywhere else with:
25
+
26
+ ```bash
27
+ KRAFTWERK_OUTPUT=/path/to/your-project/output npm run dev
28
+ ```
29
+
30
+ ## What it shows
31
+
32
+ - **Run index** — every `run-*` folder: workflow, request, status lamp
33
+ (ok / running / failed / aborted), phase progress, duration, cost.
34
+ Running runs tick live.
35
+ - **Run detail** — the phase timeline parsed from `trace.jsonl`: agent +
36
+ model chips, attempts, duration, token and cost figures, gate results
37
+ (including failure messages), envelope summaries, and for the running
38
+ phase the last tool activity. Steps that have not started yet appear as
39
+ pending — the `run_start` trace event declares them.
40
+ - **Files** — all files of the run dir with size; click to view. `.html`
41
+ renders in a sandboxed iframe (reports look like reports), images render
42
+ inline, everything else is text with live tail while the run is active.
43
+ - **Workflows** — browses `src/workflows/` (or `workflows/`) of the same
44
+ project: every workflow as a card, and per workflow a visualization of
45
+ how it is built — agent cards (model, tools, persona) with identity
46
+ colors, the step pipeline in order with each step's resolved prompt or
47
+ script (file references are inlined), the gates per step, the workflow
48
+ folder contents, and links to recent runs of that workflow. Broken
49
+ YAML still shows up, flagged with its parse error.
50
+
51
+ - **Trigger runs** — every workflow page has a "trigger run" panel: enter a
52
+ request and launch. Default is the **Docker sandbox** (one
53
+ `kraftwerk-runner` container per run, workflow mounted read-only, run dir
54
+ bind-mounted back into `output/` so the live timeline works unchanged;
55
+ build the image once with `kraftwerk runner build`). Optional: forward
56
+ the SSH agent, or run locally instead. Sandboxed runs show a "sandbox"
57
+ chip and a stop button while running (`docker stop` under the hood).
58
+ Env vars for sandboxed runs go into `<project>/runner.env`.
59
+
60
+ Realtime is plain polling (1.5 s while something runs, 6 s otherwise) —
61
+ no daemon, no socket, works on a plain filesystem.
62
+
63
+ ## Requirements
64
+
65
+ Traces written by kraftwerk ≥ the `run_start` event carry workflow
66
+ name, request, and the declared step list; older traces still render, only
67
+ without those labels.
@@ -0,0 +1,56 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { NextResponse } from "next/server";
3
+ import { readRunFile } from "@/lib/runs";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ const TYPES: Record<string, string> = {
8
+ ".html": "text/html; charset=utf-8",
9
+ ".svg": "image/svg+xml",
10
+ ".png": "image/png",
11
+ ".jpg": "image/jpeg",
12
+ ".jpeg": "image/jpeg",
13
+ ".gif": "image/gif",
14
+ ".webp": "image/webp",
15
+ ".pdf": "application/pdf",
16
+ ".json": "application/json; charset=utf-8",
17
+ };
18
+
19
+ /** Text preview payloads are capped; the tail matters most for logs. */
20
+ const MAX_TEXT = 400_000;
21
+
22
+ export async function GET(req: Request, ctx: { params: Promise<{ id: string }> }) {
23
+ const { id } = await ctx.params;
24
+ const url = new URL(req.url);
25
+ const name = url.searchParams.get("name") ?? "";
26
+ const raw = url.searchParams.get("raw") === "1";
27
+
28
+ let file;
29
+ try {
30
+ file = await readRunFile(id, name);
31
+ } catch {
32
+ return NextResponse.json({ error: "invalid request" }, { status: 400 });
33
+ }
34
+ if (!file) return NextResponse.json({ error: "not found" }, { status: 404 });
35
+
36
+ const ext = name.slice(name.lastIndexOf(".")).toLowerCase();
37
+
38
+ if (raw) {
39
+ const buf = await fs.readFile(file.absPath);
40
+ return new NextResponse(new Uint8Array(buf), {
41
+ headers: {
42
+ "content-type": TYPES[ext] ?? "text/plain; charset=utf-8",
43
+ "cache-control": "no-store",
44
+ },
45
+ });
46
+ }
47
+
48
+ const buf = await fs.readFile(file.absPath);
49
+ let text = buf.toString("utf8");
50
+ let truncated = false;
51
+ if (text.length > MAX_TEXT) {
52
+ text = text.slice(-MAX_TEXT);
53
+ truncated = true;
54
+ }
55
+ return NextResponse.json({ name, size: file.size, truncated, content: text });
56
+ }
@@ -0,0 +1,15 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getRun } from "@/lib/runs";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET(_req: Request, ctx: { params: Promise<{ id: string }> }) {
7
+ const { id } = await ctx.params;
8
+ try {
9
+ const run = await getRun(id);
10
+ if (!run) return NextResponse.json({ error: "not found" }, { status: 404 });
11
+ return NextResponse.json(run);
12
+ } catch {
13
+ return NextResponse.json({ error: "invalid run id" }, { status: 400 });
14
+ }
15
+ }
@@ -0,0 +1,15 @@
1
+ import { NextResponse } from "next/server";
2
+ import { stopRun } from "@/lib/runner";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function POST(_req: Request, ctx: { params: Promise<{ id: string }> }) {
7
+ const { id } = await ctx.params;
8
+ const stopped = stopRun(id);
9
+ return stopped
10
+ ? NextResponse.json({ stopped: true })
11
+ : NextResponse.json(
12
+ { error: "no running sandbox container for this run (local runs cannot be stopped here)" },
13
+ { status: 404 }
14
+ );
15
+ }
@@ -0,0 +1,9 @@
1
+ import { NextResponse } from "next/server";
2
+ import { listRuns, OUTPUT_DIR } from "@/lib/runs";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET() {
7
+ const runs = await listRuns();
8
+ return NextResponse.json({ outputDir: OUTPUT_DIR, runs });
9
+ }
@@ -0,0 +1,11 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getWorkflow } from "@/lib/workflows";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET(_req: Request, ctx: { params: Promise<{ slug: string }> }) {
7
+ const { slug } = await ctx.params;
8
+ const wf = await getWorkflow(slug);
9
+ if (!wf) return NextResponse.json({ error: "not found" }, { status: 404 });
10
+ return NextResponse.json(wf);
11
+ }
@@ -0,0 +1,37 @@
1
+ import { NextResponse } from "next/server";
2
+ import { getWorkflow } from "@/lib/workflows";
3
+ import { triggerRun, dockerStatus } from "@/lib/runner";
4
+
5
+ export const dynamic = "force-dynamic";
6
+
7
+ export async function GET() {
8
+ return NextResponse.json(dockerStatus());
9
+ }
10
+
11
+ export async function POST(req: Request, ctx: { params: Promise<{ slug: string }> }) {
12
+ const { slug } = await ctx.params;
13
+ const wf = await getWorkflow(decodeURIComponent(slug));
14
+ if (!wf || wf.error || !wf.name) {
15
+ return NextResponse.json({ error: "workflow not found or broken" }, { status: 404 });
16
+ }
17
+ let body: { request?: string; sandbox?: boolean; ssh?: boolean };
18
+ try {
19
+ body = await req.json();
20
+ } catch {
21
+ return NextResponse.json({ error: "invalid JSON body" }, { status: 400 });
22
+ }
23
+ const request = (body.request ?? "").trim();
24
+ if (!request) return NextResponse.json({ error: "request text is required" }, { status: 400 });
25
+
26
+ try {
27
+ const { runId } = triggerRun({
28
+ workflowName: wf.name,
29
+ request,
30
+ sandbox: body.sandbox ?? true,
31
+ ssh: !!body.ssh,
32
+ });
33
+ return NextResponse.json({ runId });
34
+ } catch (err) {
35
+ return NextResponse.json({ error: (err as Error).message }, { status: 503 });
36
+ }
37
+ }
@@ -0,0 +1,8 @@
1
+ import { NextResponse } from "next/server";
2
+ import { listWorkflows } from "@/lib/workflows";
3
+
4
+ export const dynamic = "force-dynamic";
5
+
6
+ export async function GET() {
7
+ return NextResponse.json(await listWorkflows());
8
+ }