@netnodeag/kraftwerk 0.33.0 → 0.34.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 (28) hide show
  1. package/dist/cli/projects.js +29 -7
  2. package/dist/inspector/instances.d.ts +40 -0
  3. package/dist/inspector/instances.js +120 -1
  4. package/dist/inspector/server.js +18 -1
  5. package/inspector/dist/assets/{editor-BlAeAqt0.js → editor-CXrDJOv_.js} +3 -3
  6. package/inspector/dist/assets/{index-Dnqjpy0h.js → index-7J4zAS6J.js} +1 -1
  7. package/inspector/dist/assets/index-B7rwNdVj.css +1 -0
  8. package/inspector/dist/assets/index-BBGjdXk1.js +213 -0
  9. package/inspector/dist/assets/{index-e-11MUIo.js → index-BIDsfw8C.js} +1 -1
  10. package/inspector/dist/assets/{index--wwp-KD0.js → index-BNcXQYbv.js} +1 -1
  11. package/inspector/dist/assets/{index-BvCAG0mQ.js → index-BQMgADlc.js} +1 -1
  12. package/inspector/dist/assets/{index-AR7uGZPa.js → index-BULB2rC_.js} +1 -1
  13. package/inspector/dist/assets/{index-BKnMNAOc.js → index-Bumwg9SM.js} +1 -1
  14. package/inspector/dist/assets/{index-DcXZX6dj.js → index-BxEG-IdY.js} +1 -1
  15. package/inspector/dist/assets/{index--9LQl_DL.js → index-C3hpUVfp.js} +1 -1
  16. package/inspector/dist/assets/{index-LBoNKP4N.js → index-CMMYSoL3.js} +1 -1
  17. package/inspector/dist/assets/{index-D3O4auYk.js → index-CNJyAniG.js} +1 -1
  18. package/inspector/dist/assets/{index-9rElziKS.js → index-CUCCMUMY.js} +1 -1
  19. package/inspector/dist/assets/{index-C3ugp_Wf.js → index-ChuqpBnv.js} +1 -1
  20. package/inspector/dist/assets/{index-CdkhUV5I.js → index-CszaAR9T.js} +1 -1
  21. package/inspector/dist/assets/{index-CV4Aiss-.js → index-DAi1VtJ8.js} +1 -1
  22. package/inspector/dist/assets/{index-B0qGMqfM.js → index-DrSWAGft.js} +1 -1
  23. package/inspector/dist/assets/{index-BvGhLr64.js → index-Upf-BEsx.js} +1 -1
  24. package/inspector/dist/assets/{index-BxPjb1aG.js → index-x5G6Np7T.js} +1 -1
  25. package/inspector/dist/index.html +2 -2
  26. package/package.json +1 -1
  27. package/inspector/dist/assets/index-B7W4QbA2.css +0 -1
  28. package/inspector/dist/assets/index-C5IBvIVa.js +0 -213
@@ -1,7 +1,7 @@
1
1
  import path from "node:path";
2
2
  import chalk from "chalk";
3
3
  import Table from "cli-table3";
4
- import { discoverWorkspaces, forgetProject, startProject, tildify, } from "../inspector/instances.js";
4
+ import { discoverWorkspaces, forgetProject, startProject, stopProject, tildify, } from "../inspector/instances.js";
5
5
  /**
6
6
  * `kraftwerk projects` — every project that ever ran the inspector on this
7
7
  * machine (~/.kraftwerk/projects), joined with what is running right now.
@@ -9,9 +9,11 @@ import { discoverWorkspaces, forgetProject, startProject, tildify, } from "../in
9
9
  *
10
10
  * kraftwerk projects list: name, root, running/stopped
11
11
  * kraftwerk projects start <ref> relaunch `kraftwerk ui` in that root, detached
12
+ * kraftwerk projects stop <ref> SIGTERM a running UI (also ones started in a terminal)
12
13
  * kraftwerk projects forget <ref> drop the record (the folder stays)
13
14
  *
14
- * <ref> is a project name, the root's folder name, or the root path.
15
+ * <ref> is a project name, the root's folder name, the root path, or a
16
+ * port / localhost:port for running instances that recorded no root.
15
17
  */
16
18
  const fmtAgo = (iso) => {
17
19
  if (!iso)
@@ -38,22 +40,25 @@ const status = (e) => {
38
40
  const clean = !!e.lastStopped && !!e.lastStarted && e.lastStopped >= e.lastStarted;
39
41
  return clean ? chalk.dim("stopped") : chalk.yellow("died");
40
42
  };
41
- async function resolveRef(ref) {
42
- const all = (await discoverWorkspaces()).filter((e) => e.root);
43
+ /** Match a ref against known workspaces; `needRoot` excludes rootless live instances (start/forget need a root). */
44
+ async function resolveRef(ref, needRoot = true) {
45
+ const all = (await discoverWorkspaces()).filter((e) => e.root || !needRoot);
43
46
  const abs = path.resolve(ref);
47
+ const port = /^\d+$/.test(ref) ? `http://localhost:${ref}` : `http://${ref.replace(/^https?:\/\//, "")}`;
44
48
  const hits = all.filter((e) => e.root === abs ||
45
49
  e.root === ref ||
46
50
  e.name === ref ||
47
- path.basename(e.root) === ref);
51
+ (e.root && path.basename(e.root) === ref) ||
52
+ e.url === port);
48
53
  if (hits.length === 1)
49
54
  return hits[0];
50
55
  if (hits.length === 0) {
51
56
  console.error(chalk.red(`No known project "${ref}". See \`kraftwerk projects\`.`));
52
57
  process.exit(2);
53
58
  }
54
- console.error(chalk.red(`"${ref}" is ambiguous — use the root path:`));
59
+ console.error(chalk.red(`"${ref}" is ambiguous — use the root path or port:`));
55
60
  for (const h of hits)
56
- console.error(` ${h.root}`);
61
+ console.error(` ${h.root ?? h.url}`);
57
62
  process.exit(2);
58
63
  }
59
64
  export function registerProjectCommands(program) {
@@ -109,6 +114,23 @@ export function registerProjectCommands(program) {
109
114
  console.log(`${chalk.green("✔")} ${entry.name} ${r.live ? "running" : "starting"}: ${chalk.cyan(r.url)}` +
110
115
  chalk.dim(` (pid ${r.pid}${r.log ? `, log ${tildify(r.log)}` : ""})`));
111
116
  });
117
+ projects
118
+ .command("stop")
119
+ .description("Stop a running UI (SIGTERM to its server; works for terminal-started ones too)")
120
+ .argument("<ref>", "Project name, folder name, root path, or port")
121
+ .action(async (ref) => {
122
+ const entry = await resolveRef(ref, false);
123
+ if (!entry.live) {
124
+ console.log(chalk.dim(`${entry.name} is not running.`));
125
+ return;
126
+ }
127
+ const r = await stopProject({ root: entry.root, url: entry.url });
128
+ if (!r.ok) {
129
+ console.error(chalk.red(`Could not stop ${entry.name}: ${r.error}`));
130
+ process.exit(1);
131
+ }
132
+ console.log(`${chalk.green("✔")} ${entry.name} stopped ${chalk.dim(`(${entry.url.replace(/^https?:\/\//, "")})`)}`);
133
+ });
112
134
  projects
113
135
  .command("forget")
114
136
  .description("Remove a project from the registry (its folder is untouched)")
@@ -14,6 +14,8 @@ export interface DiscoveredInstance {
14
14
  icon?: string;
15
15
  live: true;
16
16
  root?: string;
17
+ /** Server process id (what to signal to stop it). */
18
+ pid: number;
17
19
  }
18
20
  /**
19
21
  * One workspace as the switcher shows it: a known project (running or
@@ -56,6 +58,31 @@ export declare function forgetProject(root: string): Promise<boolean>;
56
58
  * discoverInstances — /api/meta is polled and this stats every root.
57
59
  */
58
60
  export declare function discoverWorkspaces(): Promise<WorkspaceEntry[]>;
61
+ export type WorkspaceState = "running" | "stopped" | "died" | "missing" | "orphaned";
62
+ /** A workspace with everything the admin screen shows. */
63
+ export interface WorkspaceDetail extends WorkspaceEntry {
64
+ /** The instance answering this request. */
65
+ current?: boolean;
66
+ /** kraftwerk.yml present in the root itself (not inherited from an ancestor). */
67
+ hasConfig?: boolean;
68
+ /** The root directory itself is gone (vs. still there but no longer a project). */
69
+ dirGone?: boolean;
70
+ state: WorkspaceState;
71
+ firstSeen?: string;
72
+ startCount?: number;
73
+ counts?: {
74
+ agents: number;
75
+ workflows: number;
76
+ runs: number;
77
+ chats: number;
78
+ };
79
+ }
80
+ /**
81
+ * Every workspace including this one, with config presence, counts of
82
+ * what lives in the root, and a single derived state. Not cached — the
83
+ * admin screen polls slowly and wants fresh counts.
84
+ */
85
+ export declare function listWorkspacesDetailed(): Promise<WorkspaceDetail[]>;
59
86
  export interface StartResult {
60
87
  ok: boolean;
61
88
  url?: string;
@@ -74,3 +101,16 @@ export interface StartResult {
74
101
  * straight to it.
75
102
  */
76
103
  export declare function startProject(root: string): Promise<StartResult>;
104
+ /**
105
+ * Stop a running workspace: SIGTERM to its server process, which
106
+ * unregisters, stamps lastStopped and exits; the `kraftwerk ui` supervisor
107
+ * follows. Matched by root, or by url for instances from older versions
108
+ * that registered no root. Waits up to ~5s for the port to go quiet.
109
+ */
110
+ export declare function stopProject(target: {
111
+ root?: string;
112
+ url?: string;
113
+ }): Promise<{
114
+ ok: boolean;
115
+ error?: string;
116
+ }>;
@@ -109,7 +109,7 @@ export async function discoverInstances() {
109
109
  await fs.unlink(path.join(INSTANCES_DIR, f)).catch(() => { }); // stale — prune
110
110
  return null;
111
111
  }
112
- return { ...found, root: rec.root };
112
+ return { ...found, root: rec.root, pid: rec.pid };
113
113
  }));
114
114
  const entries = probed
115
115
  .filter((e) => e != null)
@@ -264,6 +264,92 @@ export async function discoverWorkspaces() {
264
264
  wsCache = { at: Date.now(), entries };
265
265
  return entries;
266
266
  }
267
+ const countDirs = async (dir, marker) => {
268
+ try {
269
+ const entries = await fs.readdir(dir, { withFileTypes: true });
270
+ const dirs = entries.filter((e) => e.isDirectory() && !e.name.startsWith("."));
271
+ if (!marker)
272
+ return dirs.length;
273
+ const flags = await Promise.all(dirs.map((d) => fs.stat(path.join(dir, d.name, marker)).then(() => true, () => false)));
274
+ return flags.filter(Boolean).length;
275
+ }
276
+ catch {
277
+ return 0;
278
+ }
279
+ };
280
+ const countEntries = async (dir) => {
281
+ try {
282
+ return (await fs.readdir(dir)).filter((n) => !n.startsWith(".")).length;
283
+ }
284
+ catch {
285
+ return 0;
286
+ }
287
+ };
288
+ /**
289
+ * Every workspace including this one, with config presence, counts of
290
+ * what lives in the root, and a single derived state. Not cached — the
291
+ * admin screen polls slowly and wants fresh counts.
292
+ */
293
+ export async function listWorkspacesDetailed() {
294
+ const others = await discoverWorkspaces();
295
+ const all = [...others];
296
+ if (selfRoot && selfPort) {
297
+ const d = await describeRoot(selfRoot);
298
+ const rec = await readProject(projectFile(selfRoot));
299
+ all.unshift({
300
+ name: d.name,
301
+ icon: d.icon,
302
+ url: `http://localhost:${selfPort}`,
303
+ live: true,
304
+ root: selfRoot,
305
+ rootLabel: tildify(selfRoot),
306
+ exists: true,
307
+ lastStarted: rec?.lastStarted,
308
+ lastStopped: rec?.lastStopped,
309
+ });
310
+ }
311
+ return Promise.all(all.map(async (e) => {
312
+ const current = !!selfRoot && e.root === selfRoot;
313
+ const base = { ...e, current, state: e.live ? "running" : "stopped" };
314
+ if (!e.root)
315
+ return base;
316
+ const rec = await readProject(projectFile(e.root));
317
+ base.firstSeen = rec?.firstSeen;
318
+ base.startCount = rec?.startCount;
319
+ // "missing" = the folder is gone; "orphaned" = it is still there but
320
+ // no longer a project of its own (kraftwerk.yml removed, or it now
321
+ // resolves to an ancestor). Both are removable from the registry.
322
+ const dirGone = !(await isDir(e.root));
323
+ base.dirGone = dirGone;
324
+ if (!e.live) {
325
+ if (dirGone)
326
+ base.state = "missing";
327
+ else if (e.exists === false)
328
+ base.state = "orphaned";
329
+ else if (!(rec?.lastStopped && rec.lastStarted && rec.lastStopped >= rec.lastStarted))
330
+ base.state = "died";
331
+ }
332
+ if (e.exists === false) {
333
+ base.hasConfig = false;
334
+ return base;
335
+ }
336
+ try {
337
+ const project = await resolveProject(e.root);
338
+ base.hasConfig = !!project.configPath && path.dirname(project.configPath) === path.resolve(e.root);
339
+ const [agents, workflows, runs, chats] = await Promise.all([
340
+ countDirs(path.resolve(project.root, project.config.agents ?? "agents"), "agent.yml"),
341
+ project.workflowsRoot ? countDirs(project.workflowsRoot, "workflow.yml") : Promise.resolve(0),
342
+ countDirs(path.join(project.outputDir, "runs")),
343
+ countEntries(path.join(project.outputDir, "chats")),
344
+ ]);
345
+ base.counts = { agents, workflows, runs, chats };
346
+ }
347
+ catch {
348
+ base.hasConfig = false;
349
+ }
350
+ return base;
351
+ }));
352
+ }
267
353
  /**
268
354
  * Launch `kraftwerk ui` for a root as a detached process (selfCommand:
269
355
  * the bin this process runs from), logging to ~/.kraftwerk/logs/<key>.log.
@@ -334,3 +420,36 @@ export async function startProject(root) {
334
420
  }
335
421
  return { ok: true, url, live: false, pid: child.pid, log };
336
422
  }
423
+ /* ---------- stop a project ---------- */
424
+ /**
425
+ * Stop a running workspace: SIGTERM to its server process, which
426
+ * unregisters, stamps lastStopped and exits; the `kraftwerk ui` supervisor
427
+ * follows. Matched by root, or by url for instances from older versions
428
+ * that registered no root. Waits up to ~5s for the port to go quiet.
429
+ */
430
+ export async function stopProject(target) {
431
+ if (target.root && path.resolve(target.root) === selfRoot) {
432
+ return { ok: false, error: "that is this workspace — stop it from its own terminal or pid" };
433
+ }
434
+ cache = wsCache = null;
435
+ const live = await discoverInstances();
436
+ const inst = live.find((i) => (target.root && i.root === path.resolve(target.root)) || (target.url && i.url === target.url));
437
+ if (!inst)
438
+ return { ok: false, error: "not running" };
439
+ try {
440
+ process.kill(inst.pid, "SIGTERM");
441
+ }
442
+ catch (err) {
443
+ return { ok: false, error: `could not signal pid ${inst.pid}: ${err.message}` };
444
+ }
445
+ const port = Number(new URL(inst.url).port);
446
+ const deadline = Date.now() + 5_000;
447
+ while (Date.now() < deadline) {
448
+ await new Promise((r) => setTimeout(r, 250));
449
+ if (!(await probe(port))) {
450
+ cache = wsCache = null;
451
+ return { ok: true };
452
+ }
453
+ }
454
+ return { ok: false, error: `pid ${inst.pid} still answers on ${inst.url} after 5s` };
455
+ }
@@ -10,7 +10,7 @@ import { cancelChat, createChat, deleteChat, disposeAllBackends, getChat, listCh
10
10
  import { bundleDetail, conceptDetail, createBundle, knowledgeIndex, putConcept, verifyFromUi, } from "./knowledge.js";
11
11
  import { deleteMember, getMember, listMembers, saveMember, setMemberArchived, teamRoot, } from "./team.js";
12
12
  import { deleteAgentSkill, getSkill, listAgentSkills, listSkills, readSkill, saveAgentSkill, skillsRoot, } from "./skills.js";
13
- import { discoverWorkspaces, forgetProject, markProjectStopped, registerInstance, registerProject, startProject, tildify, unregisterInstance, } from "./instances.js";
13
+ import { discoverWorkspaces, forgetProject, listWorkspacesDetailed, markProjectStopped, registerInstance, registerProject, startProject, stopProject, tildify, unregisterInstance, } from "./instances.js";
14
14
  import { getSettings, saveSettings } from "./settings.js";
15
15
  import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine, startRoutineScheduler, } from "./routines.js";
16
16
  /**
@@ -123,6 +123,10 @@ async function handleApi(req, res, url) {
123
123
  switcher,
124
124
  });
125
125
  }
126
+ // GET /api/projects — every known workspace incl. this one, with state + counts (admin screen)
127
+ if (seg.length === 2 && seg[1] === "projects" && method === "GET") {
128
+ return json(res, await listWorkspacesDetailed());
129
+ }
126
130
  // POST /api/projects/start {root} — launch `kraftwerk ui` for a known
127
131
  // project as a detached process (the switcher's Start button).
128
132
  if (seg.length === 3 && seg[1] === "projects" && seg[2] === "start" && method === "POST") {
@@ -137,6 +141,19 @@ async function handleApi(req, res, url) {
137
141
  return json(res, { error: err.message }, 400);
138
142
  }
139
143
  }
144
+ // POST /api/projects/stop {root | url} — SIGTERM a running workspace's server
145
+ if (seg.length === 3 && seg[1] === "projects" && seg[2] === "stop" && method === "POST") {
146
+ try {
147
+ const target = JSON.parse(await readBody(req));
148
+ if (!target.root && !target.url)
149
+ return json(res, { error: "root or url required" }, 400);
150
+ const result = await stopProject(target);
151
+ return json(res, result, result.ok ? 200 : 409);
152
+ }
153
+ catch (err) {
154
+ return json(res, { error: err.message }, 400);
155
+ }
156
+ }
140
157
  // POST /api/projects/forget {root} — drop a project from the registry
141
158
  if (seg.length === 3 && seg[1] === "projects" && seg[2] === "forget" && method === "POST") {
142
159
  try {