@netnodeag/kraftwerk 0.2.0 → 0.3.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.
@@ -0,0 +1,271 @@
1
+ "use client";
2
+
3
+ import { useEffect, useState } from "react";
4
+ import { useRouter } from "next/navigation";
5
+ import type { WorkflowDetail, AgentInfo, StepInfo } from "@/lib/workflows";
6
+ import type { RunListItem } from "@/lib/runs";
7
+ import { usePoll, fmtDuration, fmtCost, fmtWhen, Lamp } from "../../shared";
8
+
9
+ export function WorkflowView({ slug }: { slug: string }) {
10
+ const wf = usePoll<WorkflowDetail>(`/api/workflows/${slug}`, false);
11
+ const runsData = usePoll<{ runs: RunListItem[] }>("/api/runs", false);
12
+
13
+ if (!wf) return <div className="empty">loading…</div>;
14
+ if (wf.error) {
15
+ return (
16
+ <>
17
+ <Crumbs slug={wf.slug} />
18
+ <div className="empty">
19
+ <span className="status-word failed">broken workflow</span>
20
+ <pre style={{ marginTop: 12, textAlign: "left" }}>{wf.error}</pre>
21
+ </div>
22
+ </>
23
+ );
24
+ }
25
+
26
+ const agentIdx = new Map(wf.agents.map((a, i) => [a.id, i % 4]));
27
+ const runs = (runsData?.runs ?? []).filter((r) => r.workflow === wf.name).slice(0, 8);
28
+
29
+ return (
30
+ <>
31
+ <Crumbs slug={wf.slug} />
32
+
33
+ <div className="detail-head">
34
+ <h1>{wf.name ?? wf.slug}</h1>
35
+ <span className="rid mono">{wf.dir}</span>
36
+ </div>
37
+ {wf.description && <p className="detail-req">{wf.description}</p>}
38
+
39
+ <div className="statgrid">
40
+ <div className="stat">
41
+ <div className="microlabel">agents</div>
42
+ <div className="v num">{wf.agents.length}</div>
43
+ </div>
44
+ <div className="stat">
45
+ <div className="microlabel">steps</div>
46
+ <div className="v num">
47
+ {wf.steps.length}{" "}
48
+ <span style={{ color: "var(--muted)", fontWeight: 400 }}>
49
+ ({wf.steps.filter((s) => s.kind === "agent").length} agent ·{" "}
50
+ {wf.steps.filter((s) => s.kind === "script").length} script)
51
+ </span>
52
+ </div>
53
+ </div>
54
+ {wf.workspace && (
55
+ <div className="stat" style={{ maxWidth: 420 }}>
56
+ <div className="microlabel">workspace</div>
57
+ <div className="wf-workspace mono">{wf.workspace}</div>
58
+ </div>
59
+ )}
60
+ </div>
61
+
62
+ <RunPanel slug={wf.slug} lastRequest={runs[0]?.request} />
63
+
64
+ <section className="panel" style={{ marginBottom: 18 }}>
65
+ <div className="panel-head">
66
+ <span className="microlabel">agents</span>
67
+ </div>
68
+ <div className="agent-grid">
69
+ {wf.agents.map((a) => (
70
+ <AgentCard key={a.id} a={a} idx={agentIdx.get(a.id) ?? 0} />
71
+ ))}
72
+ {wf.agents.length === 0 && <div className="viewer-note">no agents — script-only workflow</div>}
73
+ </div>
74
+ </section>
75
+
76
+ <div className="columns">
77
+ <section className="panel">
78
+ <div className="panel-head">
79
+ <span className="microlabel">pipeline</span>
80
+ </div>
81
+ <div className="pipeline">
82
+ {wf.steps.map((s, i) => (
83
+ <StepNode key={s.name} s={s} i={i} idx={s.agent ? agentIdx.get(s.agent) ?? 0 : null} />
84
+ ))}
85
+ </div>
86
+ </section>
87
+
88
+ <div style={{ display: "grid", gap: 18 }}>
89
+ <section className="panel">
90
+ <div className="panel-head">
91
+ <span className="microlabel">
92
+ folder <span className="num">({wf.files.length} files)</span>
93
+ </span>
94
+ </div>
95
+ <div className="file-list">
96
+ {wf.files.map((f) => (
97
+ <div key={f} className="file-row" style={{ cursor: "default" }}>
98
+ <span className="fname">{f}</span>
99
+ </div>
100
+ ))}
101
+ </div>
102
+ </section>
103
+
104
+ <section className="panel">
105
+ <div className="panel-head">
106
+ <span className="microlabel">
107
+ recent runs <span className="num">({runs.length})</span>
108
+ </span>
109
+ </div>
110
+ <div className="file-list">
111
+ {runs.map((r) => (
112
+ <a key={r.id} href={`/runs/${r.id}`} className="file-row">
113
+ <Lamp status={r.status} />
114
+ <span className="fname">{r.id.replace(/^run-/, "")}</span>
115
+ <span className="fsize num">
116
+ {fmtDuration(r.durationMs)} · {fmtCost(r.costUsd)} · {fmtWhen(r.startedAt)}
117
+ </span>
118
+ </a>
119
+ ))}
120
+ {runs.length === 0 && <div className="viewer-note">no runs of this workflow yet</div>}
121
+ </div>
122
+ </section>
123
+ </div>
124
+ </div>
125
+ </>
126
+ );
127
+ }
128
+
129
+ function RunPanel({ slug, lastRequest }: { slug: string; lastRequest?: string }) {
130
+ const router = useRouter();
131
+ const [request, setRequest] = useState("");
132
+ const [sandbox, setSandbox] = useState(true);
133
+ const [ssh, setSsh] = useState(false);
134
+ const [busy, setBusy] = useState(false);
135
+ const [error, setError] = useState<string | null>(null);
136
+ const docker = usePoll<{ available: boolean; image: boolean }>(
137
+ `/api/workflows/${encodeURIComponent(slug)}/run`,
138
+ false
139
+ );
140
+
141
+ useEffect(() => {
142
+ if (!request && lastRequest) setRequest(lastRequest);
143
+ // eslint-disable-next-line react-hooks/exhaustive-deps
144
+ }, [lastRequest]);
145
+
146
+ const sandboxReady = docker?.available && docker?.image;
147
+ const sandboxHint = !docker
148
+ ? ""
149
+ : !docker.available
150
+ ? "Docker not running — sandbox unavailable"
151
+ : !docker.image
152
+ ? "image missing — run `kraftwerk runner build`"
153
+ : "isolated container per run";
154
+
155
+ async function launch() {
156
+ if (!request.trim() || busy) return;
157
+ setBusy(true);
158
+ setError(null);
159
+ try {
160
+ const res = await fetch(`/api/workflows/${encodeURIComponent(slug)}/run`, {
161
+ method: "POST",
162
+ headers: { "Content-Type": "application/json" },
163
+ body: JSON.stringify({ request: request.trim(), sandbox, ssh }),
164
+ });
165
+ const data = await res.json();
166
+ if (!res.ok) throw new Error(data.error ?? `HTTP ${res.status}`);
167
+ router.push(`/runs/${data.runId}`);
168
+ } catch (err) {
169
+ setError((err as Error).message);
170
+ setBusy(false);
171
+ }
172
+ }
173
+
174
+ return (
175
+ <section className="panel run-panel" style={{ marginBottom: 18 }}>
176
+ <div className="panel-head">
177
+ <span className="microlabel">trigger run</span>
178
+ </div>
179
+ <div className="run-form">
180
+ <input
181
+ type="text"
182
+ value={request}
183
+ placeholder="request — topic, URL, host …"
184
+ onChange={(e) => setRequest(e.target.value)}
185
+ onKeyDown={(e) => e.key === "Enter" && launch()}
186
+ />
187
+ <button className="run-btn" onClick={launch} disabled={busy || !request.trim() || (sandbox && !sandboxReady)}>
188
+ {busy ? "starting…" : sandbox ? "▶ run in sandbox" : "▶ run locally"}
189
+ </button>
190
+ </div>
191
+ <div className="run-opts">
192
+ <label>
193
+ <input type="checkbox" checked={sandbox} onChange={(e) => setSandbox(e.target.checked)} />
194
+ docker sandbox {sandboxHint && <span className="opt-hint">— {sandboxHint}</span>}
195
+ </label>
196
+ <label>
197
+ <input type="checkbox" checked={ssh} onChange={(e) => setSsh(e.target.checked)} disabled={!sandbox} />
198
+ forward SSH agent
199
+ </label>
200
+ </div>
201
+ {error && <div className="gate-fail-msg">{error}</div>}
202
+ </section>
203
+ );
204
+ }
205
+
206
+ function Crumbs({ slug }: { slug: string }) {
207
+ return (
208
+ <nav className="crumbs">
209
+ <a href="/workflows">workflows</a>
210
+ <span className="sep">/</span>
211
+ <span className="mono">{slug}</span>
212
+ </nav>
213
+ );
214
+ }
215
+
216
+ function AgentCard({ a, idx }: { a: AgentInfo; idx: number }) {
217
+ return (
218
+ <div className={`agent-card aid-${idx}`}>
219
+ <div className="agent-head">
220
+ <span className="agent-dot" />
221
+ <b>{a.name ?? a.id}</b>
222
+ <code className="agent-id">[{a.id}]</code>
223
+ <span className="spacer" />
224
+ <span className="chip">
225
+ {a.model ?? "default"}
226
+ {a.effort ? ` · ${a.effort}` : ""}
227
+ </span>
228
+ </div>
229
+ {a.tools.length > 0 && (
230
+ <div className="agent-tools">
231
+ {a.tools.map((t) => (
232
+ <span key={t} className="tool-chip">{t}</span>
233
+ ))}
234
+ </div>
235
+ )}
236
+ {a.persona && <p className="agent-persona">{a.persona}</p>}
237
+ </div>
238
+ );
239
+ }
240
+
241
+ function StepNode({ s, i, idx }: { s: StepInfo; i: number; idx: number | null }) {
242
+ const body = s.kind === "script" ? s.script : s.prompt;
243
+ return (
244
+ <div className="step-node">
245
+ <div className={`step-index num ${idx != null ? `aid-${idx}` : "is-script"}`}>{i + 1}</div>
246
+ <div className="step-body">
247
+ <div className="phase-top">
248
+ <span className="phase-name">{s.name}</span>
249
+ {s.kind === "agent" ? (
250
+ <span className={`chip agent-chip aid-${idx ?? 0}`}>agent · {s.agent}</span>
251
+ ) : (
252
+ <span className="chip">script{s.sourceRef ? ` · ${s.sourceRef}` : ""}</span>
253
+ )}
254
+ </div>
255
+ {body && (
256
+ <details className="step-source">
257
+ <summary>{s.kind === "agent" ? "prompt" : "script"}</summary>
258
+ <pre>{body}</pre>
259
+ </details>
260
+ )}
261
+ {s.gates.length > 0 && (
262
+ <div className="gate-line">
263
+ {s.gates.map((g) => (
264
+ <span key={g} className="gate neutral">⛨ {g}</span>
265
+ ))}
266
+ </div>
267
+ )}
268
+ </div>
269
+ </div>
270
+ );
271
+ }
@@ -0,0 +1,43 @@
1
+ "use client";
2
+
3
+ import type { WorkflowSummary } from "@/lib/workflows";
4
+ import { usePoll } from "../shared";
5
+
6
+ export default function WorkflowIndex() {
7
+ const data = usePoll<{ root?: string; workflows: WorkflowSummary[] }>("/api/workflows", false);
8
+ const wfs = data?.workflows ?? [];
9
+
10
+ return (
11
+ <>
12
+ <div className="page-head">
13
+ <h1>Workflows</h1>
14
+ <span className="count num">{wfs.length} discovered</span>
15
+ {data?.root && <span className="count mono">{data.root}</span>}
16
+ </div>
17
+
18
+ {data && wfs.length === 0 && (
19
+ <div className="empty">
20
+ No workflows found — expected <code>src/workflows/</code> or <code>workflows/</code>{" "}
21
+ next to the output folder.
22
+ </div>
23
+ )}
24
+
25
+ <div className="wf-grid">
26
+ {wfs.map((w) => (
27
+ <a key={w.slug} href={`/workflows/${w.slug}`} className="wf-card">
28
+ <div className="wf-card-head">
29
+ <span className="wf-name">{w.name ?? w.slug}</span>
30
+ {w.error && <span className="status-word failed">broken</span>}
31
+ </div>
32
+ <p className="wf-desc">{w.error ?? w.description ?? ""}</p>
33
+ <div className="wf-meta num">
34
+ <span>{w.agents} agents</span>
35
+ <span>·</span>
36
+ <span>{w.steps} steps</span>
37
+ </div>
38
+ </a>
39
+ ))}
40
+ </div>
41
+ </>
42
+ );
43
+ }
@@ -0,0 +1,78 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { mkdirSync, openSync, closeSync, existsSync } from "node:fs";
3
+ import path from "node:path";
4
+ import { PROJECT_ROOT } from "./workflows";
5
+
6
+ /**
7
+ * Workflow trigger for the web UI. The inspector stays decoupled from the
8
+ * framework runtime: it shells out to `npx kraftwerk run` in the consumer
9
+ * project, detached, with a pre-chosen --run-id so the browser can jump to
10
+ * /runs/<id> immediately and watch the (already polling) live timeline.
11
+ *
12
+ * Sandbox mode adds --sandbox: one Docker container per run, workflow
13
+ * mounted read-only, run dir bind-mounted back into output/ (see
14
+ * kraftwerk/src/runner/docker.ts).
15
+ */
16
+
17
+ function stamp(d = new Date()): string {
18
+ const p = (n: number) => String(n).padStart(2, "0");
19
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())}-${p(d.getHours())}${p(d.getMinutes())}-${p(d.getSeconds())}`;
20
+ }
21
+
22
+ export function dockerStatus(): { available: boolean; image: boolean } {
23
+ const daemon = spawnSync("docker", ["version", "--format", "ok"], {
24
+ stdio: "ignore",
25
+ timeout: 8000,
26
+ });
27
+ if (daemon.status !== 0) return { available: false, image: false };
28
+ const image = spawnSync("docker", ["image", "inspect", "kraftwerk-runner"], {
29
+ stdio: "ignore",
30
+ timeout: 8000,
31
+ });
32
+ return { available: true, image: image.status === 0 };
33
+ }
34
+
35
+ export function triggerRun(opts: {
36
+ workflowName: string;
37
+ request: string;
38
+ sandbox: boolean;
39
+ ssh: boolean;
40
+ }): { runId: string } {
41
+ if (opts.sandbox) {
42
+ const docker = dockerStatus();
43
+ if (!docker.available) throw new Error("Docker daemon not reachable — start Docker first.");
44
+ if (!docker.image) throw new Error('Image "kraftwerk-runner" missing — run `kraftwerk runner build`.');
45
+ }
46
+
47
+ const runId = `run-${stamp()}`;
48
+ const runDir = path.join(PROJECT_ROOT, "output", runId);
49
+ mkdirSync(runDir, { recursive: true });
50
+ const log = openSync(path.join(runDir, "trigger.log"), "a");
51
+
52
+ const args = ["kraftwerk", "run", "--yes", "--run-id", runId];
53
+ if (opts.sandbox) args.push("--sandbox");
54
+ if (opts.ssh) args.push("--ssh");
55
+ args.push(opts.workflowName, opts.request);
56
+
57
+ const child = spawn("npx", args, {
58
+ cwd: PROJECT_ROOT,
59
+ detached: true,
60
+ stdio: ["ignore", log, log],
61
+ env: { ...process.env, FORCE_COLOR: "0" },
62
+ });
63
+ child.unref();
64
+ closeSync(log);
65
+ return { runId };
66
+ }
67
+
68
+ export function stopRun(runId: string): boolean {
69
+ // Sandboxed runs run in container kw-<runId>; docker stop ends them.
70
+ if (!/^run-[0-9-]+$/.test(runId)) return false;
71
+ return (
72
+ spawnSync("docker", ["stop", `kw-${runId}`], { stdio: "ignore", timeout: 30_000 }).status === 0
73
+ );
74
+ }
75
+
76
+ export function hasRunnerMeta(runId: string): boolean {
77
+ return existsSync(path.join(PROJECT_ROOT, "output", runId, "runner.json"));
78
+ }