@netnodeag/kraftwerk 0.32.1 → 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.
- package/dist/cli/doctor.js +2 -3
- package/dist/cli/kraftwerk.js +3 -0
- package/dist/cli/projects.d.ts +2 -0
- package/dist/cli/projects.js +148 -0
- package/dist/cli/ui.js +28 -11
- package/dist/config.d.ts +2 -0
- package/dist/config.js +2 -1
- package/dist/inspector/instances.d.ts +104 -1
- package/dist/inspector/instances.js +376 -15
- package/dist/inspector/self-command.d.ts +15 -0
- package/dist/inspector/self-command.js +18 -0
- package/dist/inspector/server.js +60 -7
- package/inspector/dist/assets/{editor-DhNlc5vd.js → editor-CXrDJOv_.js} +3 -3
- package/inspector/dist/assets/{index-Dgi1UC16.js → index-7J4zAS6J.js} +1 -1
- package/inspector/dist/assets/index-B7rwNdVj.css +1 -0
- package/inspector/dist/assets/index-BBGjdXk1.js +213 -0
- package/inspector/dist/assets/{index-2RQVAOu3.js → index-BIDsfw8C.js} +1 -1
- package/inspector/dist/assets/{index-xM_-dBjg.js → index-BNcXQYbv.js} +1 -1
- package/inspector/dist/assets/{index-DmZE5psD.js → index-BQMgADlc.js} +1 -1
- package/inspector/dist/assets/{index-C22iDz8w.js → index-BULB2rC_.js} +1 -1
- package/inspector/dist/assets/{index-DYnto4lP.js → index-Bumwg9SM.js} +1 -1
- package/inspector/dist/assets/{index-BmdO9i46.js → index-BxEG-IdY.js} +1 -1
- package/inspector/dist/assets/{index-CT73nPrH.js → index-C3hpUVfp.js} +1 -1
- package/inspector/dist/assets/{index-CK6xHVuA.js → index-CMMYSoL3.js} +1 -1
- package/inspector/dist/assets/{index-BvrVgIw_.js → index-CNJyAniG.js} +1 -1
- package/inspector/dist/assets/{index-CWIXOY1D.js → index-CUCCMUMY.js} +1 -1
- package/inspector/dist/assets/{index-BWQugB-m.js → index-ChuqpBnv.js} +1 -1
- package/inspector/dist/assets/{index-BanLa7PQ.js → index-CszaAR9T.js} +1 -1
- package/inspector/dist/assets/{index-Dx4HOh5k.js → index-DAi1VtJ8.js} +1 -1
- package/inspector/dist/assets/{index--vNjDAEm.js → index-DrSWAGft.js} +1 -1
- package/inspector/dist/assets/{index-Hlokid_B.js → index-Upf-BEsx.js} +1 -1
- package/inspector/dist/assets/{index-C9y82Xkr.js → index-x5G6Np7T.js} +1 -1
- package/inspector/dist/index.html +2 -2
- package/package.json +1 -1
- package/inspector/dist/assets/index-IK4Vsg-q.js +0 -213
- package/inspector/dist/assets/index-iGaBYIin.css +0 -1
|
@@ -1,23 +1,48 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
1
3
|
import fsSync, { promises as fs } from "node:fs";
|
|
2
4
|
import os from "node:os";
|
|
3
5
|
import path from "node:path";
|
|
6
|
+
import { isDir, resolveProject } from "../config.js";
|
|
7
|
+
import { selfCommand } from "./self-command.js";
|
|
4
8
|
/**
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
9
|
+
* Two registries under ~/.kraftwerk, two lifecycles:
|
|
10
|
+
*
|
|
11
|
+
* instances/<pid>.json — ephemeral. Every running inspector writes one so
|
|
12
|
+
* other local instances can discover it (the workspace switcher lists
|
|
13
|
+
* running workspaces automatically, no kraftwerk.yml `switcher:` needed).
|
|
14
|
+
* Registered ports are verified by probing /api/meta on read; entries
|
|
15
|
+
* that stop answering are pruned, so crashes leave no ghosts.
|
|
16
|
+
*
|
|
17
|
+
* projects/<hash(root)>.json — durable. One record per project root that
|
|
18
|
+
* ever ran the inspector on this machine, keyed by the root and never
|
|
19
|
+
* pruned automatically. Everything else (name, icon, port) is derived from
|
|
20
|
+
* the root's kraftwerk.yml at read time, so it is always current. A
|
|
21
|
+
* project that is not running can be started again from the switcher or
|
|
22
|
+
* `kraftwerk projects start` — that is what makes a killed UI findable.
|
|
11
23
|
*/
|
|
12
|
-
const
|
|
13
|
-
const
|
|
24
|
+
const HOME = path.join(os.homedir(), ".kraftwerk");
|
|
25
|
+
const INSTANCES_DIR = path.join(HOME, "instances");
|
|
26
|
+
const PROJECTS_DIR = path.join(HOME, "projects");
|
|
27
|
+
const LOGS_DIR = path.join(HOME, "logs");
|
|
28
|
+
const selfFile = () => path.join(INSTANCES_DIR, `${process.pid}.json`);
|
|
14
29
|
let selfPort = null;
|
|
30
|
+
let selfRoot = null;
|
|
31
|
+
const projectKey = (root) => createHash("sha1").update(path.resolve(root)).digest("hex").slice(0, 16);
|
|
32
|
+
const projectFile = (root) => path.join(PROJECTS_DIR, `${projectKey(root)}.json`);
|
|
33
|
+
/** ~/… for display. */
|
|
34
|
+
export const tildify = (p) => {
|
|
35
|
+
const home = os.homedir();
|
|
36
|
+
return p === home || p.startsWith(home + path.sep) ? "~" + p.slice(home.length) : p;
|
|
37
|
+
};
|
|
38
|
+
/* ---------- instances (ephemeral) ---------- */
|
|
15
39
|
/** Write this instance's registry file (call once the server listens). */
|
|
16
|
-
export async function registerInstance(port) {
|
|
40
|
+
export async function registerInstance(port, root) {
|
|
17
41
|
selfPort = port;
|
|
42
|
+
selfRoot = path.resolve(root);
|
|
18
43
|
try {
|
|
19
|
-
await fs.mkdir(
|
|
20
|
-
const rec = { pid: process.pid, port, startedAt: new Date().toISOString() };
|
|
44
|
+
await fs.mkdir(INSTANCES_DIR, { recursive: true });
|
|
45
|
+
const rec = { pid: process.pid, port, startedAt: new Date().toISOString(), root: selfRoot };
|
|
21
46
|
await fs.writeFile(selfFile(), JSON.stringify(rec));
|
|
22
47
|
}
|
|
23
48
|
catch { } // best-effort — without it discovery just won't see us
|
|
@@ -62,7 +87,7 @@ export async function discoverInstances() {
|
|
|
62
87
|
return cache.entries;
|
|
63
88
|
let files;
|
|
64
89
|
try {
|
|
65
|
-
files = await fs.readdir(
|
|
90
|
+
files = await fs.readdir(INSTANCES_DIR);
|
|
66
91
|
}
|
|
67
92
|
catch {
|
|
68
93
|
return [];
|
|
@@ -72,7 +97,7 @@ export async function discoverInstances() {
|
|
|
72
97
|
.map(async (f) => {
|
|
73
98
|
let rec;
|
|
74
99
|
try {
|
|
75
|
-
rec = JSON.parse(await fs.readFile(path.join(
|
|
100
|
+
rec = JSON.parse(await fs.readFile(path.join(INSTANCES_DIR, f), "utf8"));
|
|
76
101
|
}
|
|
77
102
|
catch {
|
|
78
103
|
return null;
|
|
@@ -81,10 +106,10 @@ export async function discoverInstances() {
|
|
|
81
106
|
return null;
|
|
82
107
|
const found = await probe(rec.port);
|
|
83
108
|
if (!found) {
|
|
84
|
-
await fs.unlink(path.join(
|
|
109
|
+
await fs.unlink(path.join(INSTANCES_DIR, f)).catch(() => { }); // stale — prune
|
|
85
110
|
return null;
|
|
86
111
|
}
|
|
87
|
-
return found;
|
|
112
|
+
return { ...found, root: rec.root, pid: rec.pid };
|
|
88
113
|
}));
|
|
89
114
|
const entries = probed
|
|
90
115
|
.filter((e) => e != null)
|
|
@@ -92,3 +117,339 @@ export async function discoverInstances() {
|
|
|
92
117
|
cache = { at: Date.now(), entries };
|
|
93
118
|
return entries;
|
|
94
119
|
}
|
|
120
|
+
/* ---------- projects (durable) ---------- */
|
|
121
|
+
async function readProject(file) {
|
|
122
|
+
try {
|
|
123
|
+
const rec = JSON.parse(await fs.readFile(file, "utf8"));
|
|
124
|
+
if (typeof rec.root !== "string")
|
|
125
|
+
return null;
|
|
126
|
+
return {
|
|
127
|
+
root: rec.root,
|
|
128
|
+
firstSeen: rec.firstSeen ?? rec.lastStarted ?? "",
|
|
129
|
+
lastStarted: rec.lastStarted ?? rec.firstSeen ?? "",
|
|
130
|
+
lastStopped: rec.lastStopped,
|
|
131
|
+
startCount: rec.startCount ?? 1,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
return null;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
/** Upsert the project record for a root (call on every inspector start). */
|
|
139
|
+
export async function registerProject(root) {
|
|
140
|
+
const abs = path.resolve(root);
|
|
141
|
+
const now = new Date().toISOString();
|
|
142
|
+
try {
|
|
143
|
+
await fs.mkdir(PROJECTS_DIR, { recursive: true });
|
|
144
|
+
const prev = await readProject(projectFile(abs));
|
|
145
|
+
const rec = {
|
|
146
|
+
root: abs,
|
|
147
|
+
firstSeen: prev?.firstSeen || now,
|
|
148
|
+
lastStarted: now,
|
|
149
|
+
startCount: (prev?.startCount ?? 0) + 1,
|
|
150
|
+
};
|
|
151
|
+
await fs.writeFile(projectFile(abs), JSON.stringify(rec, null, 2));
|
|
152
|
+
}
|
|
153
|
+
catch { } // best-effort, like the instance file
|
|
154
|
+
}
|
|
155
|
+
/** Stamp lastStopped on clean shutdown. Sync so exit handlers can call it. */
|
|
156
|
+
export function markProjectStopped() {
|
|
157
|
+
if (!selfRoot)
|
|
158
|
+
return;
|
|
159
|
+
try {
|
|
160
|
+
const file = projectFile(selfRoot);
|
|
161
|
+
const rec = JSON.parse(fsSync.readFileSync(file, "utf8"));
|
|
162
|
+
rec.lastStopped = new Date().toISOString();
|
|
163
|
+
fsSync.writeFileSync(file, JSON.stringify(rec, null, 2));
|
|
164
|
+
}
|
|
165
|
+
catch { }
|
|
166
|
+
}
|
|
167
|
+
/** All known projects, most recently started first. */
|
|
168
|
+
export async function listProjects() {
|
|
169
|
+
let files;
|
|
170
|
+
try {
|
|
171
|
+
files = await fs.readdir(PROJECTS_DIR);
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
return [];
|
|
175
|
+
}
|
|
176
|
+
const recs = await Promise.all(files.filter((f) => f.endsWith(".json")).map((f) => readProject(path.join(PROJECTS_DIR, f))));
|
|
177
|
+
return recs
|
|
178
|
+
.filter((r) => r != null)
|
|
179
|
+
.sort((a, b) => b.lastStarted.localeCompare(a.lastStarted));
|
|
180
|
+
}
|
|
181
|
+
/** Drop a project record (the root itself is untouched). */
|
|
182
|
+
export async function forgetProject(root) {
|
|
183
|
+
try {
|
|
184
|
+
await fs.unlink(projectFile(root));
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
catch {
|
|
188
|
+
return false;
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
/**
|
|
192
|
+
* Name, icon and port a root would run with right now — read from its
|
|
193
|
+
* kraftwerk.yml. `exists` is false when the directory is gone or when the
|
|
194
|
+
* project resolves to an ancestor (its kraftwerk.yml was removed, or it
|
|
195
|
+
* sits inside another project): resolveProject walks up and never fails,
|
|
196
|
+
* so without this check a stale record would wear its parent's identity.
|
|
197
|
+
*/
|
|
198
|
+
async function describeRoot(root) {
|
|
199
|
+
const fallback = { name: path.basename(root), port: 1981, exists: false };
|
|
200
|
+
if (!(await isDir(root)))
|
|
201
|
+
return fallback;
|
|
202
|
+
try {
|
|
203
|
+
const project = await resolveProject(root);
|
|
204
|
+
if (path.resolve(project.root) !== path.resolve(root))
|
|
205
|
+
return fallback;
|
|
206
|
+
return {
|
|
207
|
+
name: project.config.name ?? path.basename(project.root),
|
|
208
|
+
icon: project.config.icon || undefined,
|
|
209
|
+
port: project.config.port ?? 1981,
|
|
210
|
+
exists: true,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
catch {
|
|
214
|
+
return fallback;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
let wsCache = null;
|
|
218
|
+
/**
|
|
219
|
+
* The switcher's view: every known project joined with the live instances,
|
|
220
|
+
* plus running instances with no project record yet. The calling instance
|
|
221
|
+
* itself is left out. Live entries first, then by last start. Cached like
|
|
222
|
+
* discoverInstances — /api/meta is polled and this stats every root.
|
|
223
|
+
*/
|
|
224
|
+
export async function discoverWorkspaces() {
|
|
225
|
+
if (wsCache && Date.now() - wsCache.at < 5_000)
|
|
226
|
+
return wsCache.entries;
|
|
227
|
+
const [projects, live] = await Promise.all([listProjects(), discoverInstances()]);
|
|
228
|
+
const liveByRoot = new Map(live.filter((i) => i.root).map((i) => [i.root, i]));
|
|
229
|
+
const consumed = new Set();
|
|
230
|
+
const entries = (await Promise.all(projects
|
|
231
|
+
.filter((p) => p.root !== selfRoot)
|
|
232
|
+
.map(async (p) => {
|
|
233
|
+
const base = { root: p.root, rootLabel: tildify(p.root), lastStarted: p.lastStarted, lastStopped: p.lastStopped };
|
|
234
|
+
let running = liveByRoot.get(p.root);
|
|
235
|
+
let d;
|
|
236
|
+
if (!running) {
|
|
237
|
+
d = await describeRoot(p.root);
|
|
238
|
+
// A pre-0.33 inspector still serving this root registered no
|
|
239
|
+
// root — match it by the port the root would use, else the
|
|
240
|
+
// switcher shows the same project twice (live + stopped).
|
|
241
|
+
running = live.find((i) => !i.root && i.url === `http://localhost:${d.port}`);
|
|
242
|
+
}
|
|
243
|
+
if (running) {
|
|
244
|
+
consumed.add(running);
|
|
245
|
+
return { ...base, name: running.name, url: running.url, icon: running.icon, live: true, exists: true };
|
|
246
|
+
}
|
|
247
|
+
return { ...base, name: d.name, url: `http://localhost:${d.port}`, icon: d.icon, live: false, exists: d.exists };
|
|
248
|
+
}))).concat(await Promise.all(live
|
|
249
|
+
.filter((i) => !consumed.has(i))
|
|
250
|
+
.map(async (i) => ({
|
|
251
|
+
name: i.name,
|
|
252
|
+
url: i.url,
|
|
253
|
+
icon: i.icon,
|
|
254
|
+
live: true,
|
|
255
|
+
root: i.root,
|
|
256
|
+
rootLabel: i.root ? tildify(i.root) : undefined,
|
|
257
|
+
exists: i.root ? await isDir(i.root) : undefined,
|
|
258
|
+
}))));
|
|
259
|
+
entries.sort((a, b) => {
|
|
260
|
+
if (a.live !== b.live)
|
|
261
|
+
return a.live ? -1 : 1;
|
|
262
|
+
return (b.lastStarted ?? "").localeCompare(a.lastStarted ?? "") || a.name.localeCompare(b.name);
|
|
263
|
+
});
|
|
264
|
+
wsCache = { at: Date.now(), entries };
|
|
265
|
+
return entries;
|
|
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
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Launch `kraftwerk ui` for a root as a detached process (selfCommand:
|
|
355
|
+
* the bin this process runs from), logging to ~/.kraftwerk/logs/<key>.log.
|
|
356
|
+
* The reported pid is the `kraftwerk ui` supervisor, which forwards
|
|
357
|
+
* SIGTERM/SIGINT to its server child, so `kill <pid>` stops the whole UI.
|
|
358
|
+
* Waits up to ~5s for the new inspector to answer so callers can link
|
|
359
|
+
* straight to it.
|
|
360
|
+
*/
|
|
361
|
+
export async function startProject(root) {
|
|
362
|
+
const abs = path.resolve(root);
|
|
363
|
+
const { name, port, exists } = await describeRoot(abs);
|
|
364
|
+
if (!exists)
|
|
365
|
+
return { ok: false, error: `not a project directory (missing or moved): ${abs}` };
|
|
366
|
+
const url = `http://localhost:${port}`;
|
|
367
|
+
if (abs === selfRoot)
|
|
368
|
+
return { ok: false, url, live: true, error: "that is this workspace" };
|
|
369
|
+
// Already running, or the port is taken by another kraftwerk?
|
|
370
|
+
cache = wsCache = null;
|
|
371
|
+
const live = await discoverInstances();
|
|
372
|
+
const byRoot = live.find((i) => i.root === abs);
|
|
373
|
+
if (byRoot)
|
|
374
|
+
return { ok: true, url: byRoot.url, live: true };
|
|
375
|
+
if (port === selfPort)
|
|
376
|
+
return { ok: false, url, error: `port ${port} is used by this workspace` };
|
|
377
|
+
const byPort = live.find((i) => i.url === url);
|
|
378
|
+
if (byPort)
|
|
379
|
+
return { ok: false, url, error: `port ${port} is used by "${byPort.name}"` };
|
|
380
|
+
let log;
|
|
381
|
+
let stdio = ["ignore", "ignore", "ignore"];
|
|
382
|
+
try {
|
|
383
|
+
await fs.mkdir(LOGS_DIR, { recursive: true });
|
|
384
|
+
log = path.join(LOGS_DIR, `${projectKey(abs)}.log`);
|
|
385
|
+
const fd = fsSync.openSync(log, "a");
|
|
386
|
+
fsSync.writeSync(fd, `\n--- ${new Date().toISOString()} start ${name} (${abs}) ---\n`);
|
|
387
|
+
stdio = ["ignore", fd, fd];
|
|
388
|
+
}
|
|
389
|
+
catch { }
|
|
390
|
+
const { cmd, args } = selfCommand(["ui"]);
|
|
391
|
+
let child;
|
|
392
|
+
try {
|
|
393
|
+
const env = { ...process.env };
|
|
394
|
+
delete env.KRAFTWERK_UI_SUPERVISED; // the new one runs its own supervisor
|
|
395
|
+
child = spawn(cmd, args, { cwd: abs, detached: true, stdio, env });
|
|
396
|
+
child.unref();
|
|
397
|
+
}
|
|
398
|
+
catch (err) {
|
|
399
|
+
return { ok: false, url, log, error: err.message };
|
|
400
|
+
}
|
|
401
|
+
if (typeof stdio[1] === "number") {
|
|
402
|
+
try {
|
|
403
|
+
fsSync.closeSync(stdio[1]);
|
|
404
|
+
}
|
|
405
|
+
catch { }
|
|
406
|
+
}
|
|
407
|
+
let exited = null;
|
|
408
|
+
child.once("exit", (code, signal) => {
|
|
409
|
+
exited = signal ? `killed by ${signal}` : `exited with code ${code}`;
|
|
410
|
+
});
|
|
411
|
+
const deadline = Date.now() + 5_000;
|
|
412
|
+
while (Date.now() < deadline) {
|
|
413
|
+
await new Promise((r) => setTimeout(r, 300));
|
|
414
|
+
if (exited)
|
|
415
|
+
return { ok: false, url, pid: child.pid, log, error: `kraftwerk ui ${exited}` };
|
|
416
|
+
if (await probe(port)) {
|
|
417
|
+
cache = wsCache = null;
|
|
418
|
+
return { ok: true, url, live: true, pid: child.pid, log };
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
return { ok: true, url, live: false, pid: child.pid, log };
|
|
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
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How to run "this same kraftwerk" as a new process: the node we run on
|
|
3
|
+
* plus the bin script we were started from. Identical for global installs,
|
|
4
|
+
* local node_modules, npx caches and a dev checkout (bin/kraftwerk.js
|
|
5
|
+
* picks src/ vs dist/ itself). Shared by the `kraftwerk ui` supervisor and
|
|
6
|
+
* the project-start feature so bin resolution lives in one place.
|
|
7
|
+
*
|
|
8
|
+
* If the bin file is gone (npx cache pruned, package removed) the
|
|
9
|
+
* fallback is `npx @netnodeag/kraftwerk`, i.e. the registry's latest —
|
|
10
|
+
* not the running version, but better than nothing.
|
|
11
|
+
*/
|
|
12
|
+
export declare function selfCommand(args: string[]): {
|
|
13
|
+
cmd: string;
|
|
14
|
+
args: string[];
|
|
15
|
+
};
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { existsSync } from "node:fs";
|
|
2
|
+
/**
|
|
3
|
+
* How to run "this same kraftwerk" as a new process: the node we run on
|
|
4
|
+
* plus the bin script we were started from. Identical for global installs,
|
|
5
|
+
* local node_modules, npx caches and a dev checkout (bin/kraftwerk.js
|
|
6
|
+
* picks src/ vs dist/ itself). Shared by the `kraftwerk ui` supervisor and
|
|
7
|
+
* the project-start feature so bin resolution lives in one place.
|
|
8
|
+
*
|
|
9
|
+
* If the bin file is gone (npx cache pruned, package removed) the
|
|
10
|
+
* fallback is `npx @netnodeag/kraftwerk`, i.e. the registry's latest —
|
|
11
|
+
* not the running version, but better than nothing.
|
|
12
|
+
*/
|
|
13
|
+
export function selfCommand(args) {
|
|
14
|
+
const bin = process.argv[1];
|
|
15
|
+
if (bin && existsSync(bin))
|
|
16
|
+
return { cmd: process.execPath, args: [bin, ...args] };
|
|
17
|
+
return { cmd: "npx", args: ["-y", "@netnodeag/kraftwerk", ...args] };
|
|
18
|
+
}
|
package/dist/inspector/server.js
CHANGED
|
@@ -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 {
|
|
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
|
/**
|
|
@@ -98,15 +98,17 @@ async function handleApi(req, res, url) {
|
|
|
98
98
|
const project = await resolveProject(getProjectRoot()).catch(() => null);
|
|
99
99
|
const projectName = project?.config.name ?? (project ? path.basename(project.root) : "");
|
|
100
100
|
const manual = project?.config.switcher ?? [];
|
|
101
|
-
const discovered = url.searchParams.get("probe") === "1" ? [] : await
|
|
101
|
+
const discovered = url.searchParams.get("probe") === "1" ? [] : await discoverWorkspaces();
|
|
102
102
|
// Manual switcher entries keep their configured name/icon; discovered
|
|
103
|
-
//
|
|
103
|
+
// workspaces that duplicate one (same url, running) only contribute
|
|
104
|
+
// the live flag. Stopped projects never collide — they carry a root,
|
|
105
|
+
// and a manual entry pointing at the same port is just a link.
|
|
104
106
|
const norm = (u) => u.replace(/\/+$/, "").replace("127.0.0.1", "localhost").toLowerCase();
|
|
105
107
|
const manualUrls = new Set(manual.map((e) => norm(e.url)));
|
|
106
|
-
const liveUrls = new Set(discovered.map((d) => norm(d.url)));
|
|
108
|
+
const liveUrls = new Set(discovered.filter((d) => d.live).map((d) => norm(d.url)));
|
|
107
109
|
const switcher = [
|
|
108
110
|
...manual.map((e) => (liveUrls.has(norm(e.url)) ? { ...e, live: true } : e)),
|
|
109
|
-
...discovered.filter((d) => !manualUrls.has(norm(d.url))),
|
|
111
|
+
...discovered.filter((d) => !(d.live && manualUrls.has(norm(d.url)))),
|
|
110
112
|
];
|
|
111
113
|
return json(res, {
|
|
112
114
|
version: await getPkgVersion(),
|
|
@@ -116,9 +118,54 @@ async function handleApi(req, res, url) {
|
|
|
116
118
|
restartable: supervised(),
|
|
117
119
|
projectName,
|
|
118
120
|
projectIcon: project?.config.icon ?? "",
|
|
121
|
+
projectRoot: project?.root ?? getProjectRoot(),
|
|
122
|
+
projectRootLabel: tildify(project?.root ?? getProjectRoot()),
|
|
119
123
|
switcher,
|
|
120
124
|
});
|
|
121
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
|
+
}
|
|
130
|
+
// POST /api/projects/start {root} — launch `kraftwerk ui` for a known
|
|
131
|
+
// project as a detached process (the switcher's Start button).
|
|
132
|
+
if (seg.length === 3 && seg[1] === "projects" && seg[2] === "start" && method === "POST") {
|
|
133
|
+
try {
|
|
134
|
+
const { root } = JSON.parse(await readBody(req));
|
|
135
|
+
if (!root)
|
|
136
|
+
return json(res, { error: "root required" }, 400);
|
|
137
|
+
const result = await startProject(root);
|
|
138
|
+
return json(res, result, result.ok ? 200 : 409);
|
|
139
|
+
}
|
|
140
|
+
catch (err) {
|
|
141
|
+
return json(res, { error: err.message }, 400);
|
|
142
|
+
}
|
|
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
|
+
}
|
|
157
|
+
// POST /api/projects/forget {root} — drop a project from the registry
|
|
158
|
+
if (seg.length === 3 && seg[1] === "projects" && seg[2] === "forget" && method === "POST") {
|
|
159
|
+
try {
|
|
160
|
+
const { root } = JSON.parse(await readBody(req));
|
|
161
|
+
if (!root)
|
|
162
|
+
return json(res, { error: "root required" }, 400);
|
|
163
|
+
return json(res, { ok: await forgetProject(root) });
|
|
164
|
+
}
|
|
165
|
+
catch (err) {
|
|
166
|
+
return json(res, { error: err.message }, 400);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
122
169
|
// GET /api/settings — kraftwerk.yml (parsed + resolved paths) for the settings page
|
|
123
170
|
if (seg.length === 2 && seg[1] === "settings" && method === "GET") {
|
|
124
171
|
return json(res, await getSettings());
|
|
@@ -618,12 +665,16 @@ export function startInspector(opts) {
|
|
|
618
665
|
process.once(sig, () => {
|
|
619
666
|
disposeAllBackends();
|
|
620
667
|
unregisterInstance();
|
|
668
|
+
markProjectStopped();
|
|
621
669
|
process.exit(sig === "SIGINT" ? 130 : 143);
|
|
622
670
|
});
|
|
623
671
|
}
|
|
624
|
-
process.once("exit", () => {
|
|
672
|
+
process.once("exit", (code) => {
|
|
625
673
|
disposeAllBackends();
|
|
626
674
|
unregisterInstance();
|
|
675
|
+
// A self-restart (new version) is not a stop — the project stays "running".
|
|
676
|
+
if (code !== RESTART_EXIT_CODE)
|
|
677
|
+
markProjectStopped();
|
|
627
678
|
});
|
|
628
679
|
const server = http.createServer(async (req, res) => {
|
|
629
680
|
try {
|
|
@@ -641,7 +692,9 @@ export function startInspector(opts) {
|
|
|
641
692
|
server.once("error", reject);
|
|
642
693
|
server.listen(opts.port, () => {
|
|
643
694
|
const addr = server.address();
|
|
644
|
-
|
|
695
|
+
const port = typeof addr === "object" && addr ? addr.port : opts.port;
|
|
696
|
+
void registerInstance(port, getProjectRoot());
|
|
697
|
+
void registerProject(getProjectRoot());
|
|
645
698
|
resolve(server);
|
|
646
699
|
});
|
|
647
700
|
});
|