@netnodeag/kraftwerk 0.65.0 → 0.66.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/README.md +93 -23
- package/dist/acp.js +3 -1
- package/dist/cli/daemon.d.ts +14 -0
- package/dist/cli/daemon.js +60 -0
- package/dist/cli/doctor.js +9 -1
- package/dist/cli/init.js +1 -0
- package/dist/cli/kraftwerk.js +4 -0
- package/dist/cli/ui.d.ts +8 -0
- package/dist/cli/ui.js +35 -8
- package/dist/cli/workspaces.js +5 -3
- package/dist/client/index.d.ts +8 -0
- package/dist/client/index.js +2 -1
- package/dist/client/live.d.ts +1 -2
- package/dist/client/live.js +2 -1
- package/dist/client/routes.d.ts +3 -0
- package/dist/client/routes.js +3 -0
- package/dist/config.d.ts +16 -0
- package/dist/config.js +23 -1
- package/dist/core/chat/pi.js +2 -1
- package/dist/core/chat/sessions.d.ts +1 -1
- package/dist/core/chat/sessions.js +1 -1
- package/dist/core/chat/state.d.ts +2 -0
- package/dist/core/chat/state.js +5 -0
- package/dist/core/daemon.d.ts +27 -0
- package/dist/core/daemon.js +61 -0
- package/dist/core/env.d.ts +16 -0
- package/dist/core/env.js +45 -0
- package/dist/core/git.d.ts +2 -0
- package/dist/core/git.js +20 -4
- package/dist/core/instances.d.ts +32 -12
- package/dist/core/instances.js +136 -64
- package/dist/core/routines.d.ts +6 -0
- package/dist/core/routines.js +11 -0
- package/dist/core/runner.js +7 -3
- package/dist/core/vibeables.d.ts +2 -0
- package/dist/core/vibeables.js +17 -5
- package/dist/core/workspace.d.ts +13 -0
- package/dist/core/workspace.js +25 -0
- package/dist/server/api/hub.d.ts +25 -0
- package/dist/server/api/hub.js +32 -0
- package/dist/server/api/index.d.ts +24 -1
- package/dist/server/api/index.js +2 -0
- package/dist/server/api/socket.d.ts +9 -1
- package/dist/server/api/socket.js +27 -7
- package/dist/server/api/workspace.d.ts +1 -0
- package/dist/server/api/workspace.js +2 -1
- package/dist/server/cloud.d.ts +5 -1
- package/dist/server/cloud.js +86 -54
- package/dist/server/hub-context.d.ts +7 -0
- package/dist/server/hub-context.js +18 -0
- package/dist/server/live.d.ts +2 -0
- package/dist/server/live.js +9 -0
- package/dist/server/preview.js +2 -1
- package/dist/server/server.d.ts +52 -1
- package/dist/server/server.js +249 -52
- package/dist/server/target.d.ts +20 -0
- package/dist/server/target.js +18 -0
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +5 -0
- package/web/dist/assets/{dist-CAWmDGJC.js → dist-1bNkaWWA.js} +1 -1
- package/web/dist/assets/dist-BU1SOB5S.js +1 -0
- package/web/dist/assets/{dist-BCfXUgtf.js → dist-Bk3f-J5U.js} +1 -1
- package/web/dist/assets/{dist-DnMgiPT1.js → dist-Bx99Puvv.js} +1 -1
- package/web/dist/assets/{dist-v6-49xpW.js → dist-C0M2aITo.js} +1 -1
- package/web/dist/assets/{dist-C-CclD1E.js → dist-C0z19Zge.js} +1 -1
- package/web/dist/assets/{dist-nA7q2-h7.js → dist-COEgBKG6.js} +1 -1
- package/web/dist/assets/{dist-Bnxg6V5L.js → dist-CYRrdlae.js} +1 -1
- package/web/dist/assets/{dist-hevbMnoj.js → dist-CbEFnSCD.js} +1 -1
- package/web/dist/assets/{dist-DS1R2xKJ.js → dist-DCYVKU-H.js} +1 -1
- package/web/dist/assets/{dist-10uPVFvu.js → dist-XyTHWtoN.js} +1 -1
- package/web/dist/assets/{editor-C8yy8YS9.js → editor-DPcGvSPy.js} +3 -3
- package/web/dist/assets/index-DKLJdwYE.js +229 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/dist-yoh47aAL.js +0 -1
- package/web/dist/assets/index-DWS1u71Y.js +0 -229
package/dist/core/instances.js
CHANGED
|
@@ -3,8 +3,10 @@ import { createHash } from "node:crypto";
|
|
|
3
3
|
import fsSync, { promises as fs } from "node:fs";
|
|
4
4
|
import os from "node:os";
|
|
5
5
|
import path from "node:path";
|
|
6
|
-
import { absolutePath, isDir, resolveWorkspace } from "../config.js";
|
|
6
|
+
import { absolutePath, isDir, resolveWorkspace, slugFromFolder, workspaceSlug } from "../config.js";
|
|
7
7
|
import { selfCommand } from "./self-command.js";
|
|
8
|
+
import { DAEMON_PORT, daemonCall, findDaemon } from "./daemon.js";
|
|
9
|
+
import { currentWorkspace } from "./workspace.js";
|
|
8
10
|
/**
|
|
9
11
|
* Two registries under ~/.kraftwerk, two lifecycles:
|
|
10
12
|
*
|
|
@@ -31,9 +33,16 @@ const workspacesDir = () => path.join(kwHome(), "workspaces");
|
|
|
31
33
|
/** Where the records lived before 0.49 (`kraftwerk projects`); moved over once, see migrateRegistry. */
|
|
32
34
|
export const legacyWorkspacesDir = () => path.join(kwHome(), "projects");
|
|
33
35
|
const logsDir = () => path.join(kwHome(), "logs");
|
|
34
|
-
const
|
|
35
|
-
|
|
36
|
-
|
|
36
|
+
const own = new Map();
|
|
37
|
+
/** The workspace the caller runs in (a request, a timer), or null outside any. */
|
|
38
|
+
function currentRoot() {
|
|
39
|
+
try {
|
|
40
|
+
return currentWorkspace().root;
|
|
41
|
+
}
|
|
42
|
+
catch {
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
37
46
|
/** File name of a root's record: a hash of the absolute root, the same under the old and the new directory. */
|
|
38
47
|
export const workspaceRecordName = (root) => `${createHash("sha1").update(absolutePath(root)).digest("hex").slice(0, 16)}.json`;
|
|
39
48
|
const recordFile = (root) => path.join(workspacesDir(), workspaceRecordName(root));
|
|
@@ -43,42 +52,61 @@ export const tildify = (p) => {
|
|
|
43
52
|
return p === home || p.startsWith(home + path.sep) ? "~" + p.slice(home.length) : p;
|
|
44
53
|
};
|
|
45
54
|
/* ---------- instances (ephemeral) ---------- */
|
|
46
|
-
/**
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
55
|
+
/**
|
|
56
|
+
* Write a served workspace's registry file (call once the server listens).
|
|
57
|
+
* A daemon's workspaces carry their slug and address and get a file each;
|
|
58
|
+
* a standalone server keeps the one `<pid>.json` it always had.
|
|
59
|
+
*/
|
|
60
|
+
export async function registerInstance(port, root, opts = {}) {
|
|
61
|
+
const abs = absolutePath(root);
|
|
62
|
+
const file = path.join(instancesDir(), opts.hub ? `${process.pid}-${workspaceRecordName(abs)}` : `${process.pid}.json`);
|
|
63
|
+
const url = opts.url ?? `http://localhost:${port}`;
|
|
64
|
+
const mine = { port, url, file };
|
|
65
|
+
own.set(abs, mine);
|
|
50
66
|
try {
|
|
51
67
|
await fs.mkdir(instancesDir(), { recursive: true });
|
|
52
|
-
const rec = { pid: process.pid, port, startedAt: new Date().toISOString(), root:
|
|
53
|
-
await fs.writeFile(
|
|
68
|
+
const rec = { pid: process.pid, port, startedAt: new Date().toISOString(), root: abs, ...(opts.hub ? { url, slug: opts.slug, hub: true } : {}) };
|
|
69
|
+
await fs.writeFile(file, JSON.stringify(rec));
|
|
70
|
+
// Closed while this wrote (an open and a close in quick succession): the file must not outlive it.
|
|
71
|
+
if (own.get(abs) !== mine)
|
|
72
|
+
await fs.unlink(file).catch(() => { });
|
|
54
73
|
}
|
|
55
74
|
catch { } // best-effort — without it discovery just won't see us
|
|
75
|
+
cache = null;
|
|
76
|
+
wsCache.clear();
|
|
56
77
|
}
|
|
57
|
-
/** Where
|
|
58
|
-
export const currentInstanceUrl = () => (
|
|
59
|
-
/** Remove
|
|
60
|
-
export function unregisterInstance() {
|
|
61
|
-
|
|
62
|
-
|
|
78
|
+
/** Where the current workspace answers, as the switcher links it (null before listen). */
|
|
79
|
+
export const currentInstanceUrl = () => own.get(currentRoot() ?? "")?.url ?? null;
|
|
80
|
+
/** Remove a served workspace's registry file — every one this process wrote when no root is given. Sync so exit handlers can call it. */
|
|
81
|
+
export function unregisterInstance(root) {
|
|
82
|
+
const roots = root ? [absolutePath(root)] : [...own.keys()];
|
|
83
|
+
for (const r of roots) {
|
|
84
|
+
try {
|
|
85
|
+
fsSync.unlinkSync(own.get(r)?.file ?? "");
|
|
86
|
+
}
|
|
87
|
+
catch { }
|
|
88
|
+
own.delete(r);
|
|
63
89
|
}
|
|
64
|
-
|
|
90
|
+
cache = null;
|
|
91
|
+
wsCache.clear();
|
|
65
92
|
}
|
|
66
93
|
/**
|
|
67
94
|
* Probe one registered port: is a kraftwerk inspector answering? `probe=1`
|
|
68
95
|
* tells the other side to skip its own discovery — two instances probing
|
|
69
96
|
* each other's /api/meta must not recurse.
|
|
70
97
|
*/
|
|
71
|
-
async function probe(port) {
|
|
98
|
+
async function probe(port, slug) {
|
|
72
99
|
// `listen(port, "localhost")` binds one address family, whichever the
|
|
73
100
|
// resolver returns first (::1 on this platform), so an instance started
|
|
74
101
|
// with KRAFTWERK_UI_HOST=localhost or ::1 is not on 127.0.0.1. A refused
|
|
75
102
|
// connection returns at once, so the second try costs nothing when the
|
|
76
103
|
// first one answers.
|
|
77
|
-
return (await probeAt("127.0.0.1", port)) ?? (await probeAt("[::1]", port));
|
|
104
|
+
return (await probeAt("127.0.0.1", port, slug)) ?? (await probeAt("[::1]", port, slug));
|
|
78
105
|
}
|
|
79
|
-
async function probeAt(host, port) {
|
|
106
|
+
async function probeAt(host, port, slug) {
|
|
80
107
|
try {
|
|
81
|
-
|
|
108
|
+
// A daemon's workspace answers under its slug; the path form needs no name resolution.
|
|
109
|
+
const r = await fetch(`http://${host}:${port}${slug ? `/w/${slug}` : ""}/api/meta?probe=1`, {
|
|
82
110
|
signal: AbortSignal.timeout(400),
|
|
83
111
|
});
|
|
84
112
|
if (!r.ok)
|
|
@@ -110,7 +138,7 @@ let cache = null;
|
|
|
110
138
|
*/
|
|
111
139
|
export async function discoverInstances(opts = {}) {
|
|
112
140
|
if (!opts.fresh && cache && Date.now() - cache.at < 5_000)
|
|
113
|
-
return cache.entries;
|
|
141
|
+
return notSelf(cache.entries);
|
|
114
142
|
let files;
|
|
115
143
|
try {
|
|
116
144
|
files = await fs.readdir(instancesDir());
|
|
@@ -128,21 +156,25 @@ export async function discoverInstances(opts = {}) {
|
|
|
128
156
|
catch {
|
|
129
157
|
return null;
|
|
130
158
|
}
|
|
131
|
-
|
|
132
|
-
return null;
|
|
133
|
-
const found = await probe(rec.port);
|
|
159
|
+
const found = await probe(rec.port, rec.hub ? rec.slug : undefined);
|
|
134
160
|
if (!found) {
|
|
135
161
|
if (!opts.readOnly)
|
|
136
162
|
await fs.unlink(path.join(instancesDir(), f)).catch(() => { }); // stale — prune
|
|
137
163
|
return null;
|
|
138
164
|
}
|
|
139
|
-
return { ...found, root: rec.root, pid: rec.pid };
|
|
165
|
+
return { ...found, root: rec.root, pid: rec.pid, ...(rec.hub ? { url: rec.url ?? found.url, slug: rec.slug, hub: true } : {}) };
|
|
140
166
|
}));
|
|
141
167
|
const entries = probed
|
|
142
168
|
.filter((e) => e != null)
|
|
143
169
|
.sort((a, b) => a.name.localeCompare(b.name));
|
|
144
170
|
cache = { at: Date.now(), entries };
|
|
145
|
-
return entries;
|
|
171
|
+
return notSelf(entries);
|
|
172
|
+
}
|
|
173
|
+
/** Leave out the asking workspace itself (an older instance file without a root: by this process and port). */
|
|
174
|
+
function notSelf(entries) {
|
|
175
|
+
const me = currentRoot();
|
|
176
|
+
const port = me ? own.get(me)?.port : undefined;
|
|
177
|
+
return entries.filter((e) => (e.root ? e.root !== me : !(e.pid === process.pid && e.url === `http://localhost:${port}`)));
|
|
146
178
|
}
|
|
147
179
|
/* ---------- workspaces (durable) ---------- */
|
|
148
180
|
let migrated = null;
|
|
@@ -182,6 +214,7 @@ async function readRecord(file) {
|
|
|
182
214
|
return null;
|
|
183
215
|
return {
|
|
184
216
|
root: rec.root,
|
|
217
|
+
...(typeof rec.slug === "string" ? { slug: rec.slug } : {}),
|
|
185
218
|
firstSeen: rec.firstSeen ?? rec.lastStarted ?? "",
|
|
186
219
|
lastStarted: rec.lastStarted ?? rec.firstSeen ?? "",
|
|
187
220
|
lastStopped: rec.lastStopped,
|
|
@@ -197,14 +230,19 @@ async function readRecord(file) {
|
|
|
197
230
|
}
|
|
198
231
|
/** Upsert the project record for a root (call on every inspector start). */
|
|
199
232
|
export async function registerWorkspace(root) {
|
|
200
|
-
await migrateRegistry();
|
|
201
233
|
const abs = absolutePath(root);
|
|
234
|
+
// Where it goes is decided now: the registry of the home this was called in, whatever HOME is by the time it writes.
|
|
235
|
+
const dir = workspacesDir();
|
|
236
|
+
const file = path.join(dir, workspaceRecordName(abs));
|
|
237
|
+
await migrateRegistry();
|
|
202
238
|
const now = new Date().toISOString();
|
|
203
239
|
try {
|
|
204
|
-
await fs.mkdir(
|
|
205
|
-
const prev = await readRecord(
|
|
240
|
+
await fs.mkdir(dir, { recursive: true });
|
|
241
|
+
const prev = await readRecord(file);
|
|
242
|
+
const slug = await resolveWorkspace(abs).then(workspaceSlug, () => slugFromFolder(path.basename(abs)));
|
|
206
243
|
const rec = {
|
|
207
244
|
root: abs,
|
|
245
|
+
slug,
|
|
208
246
|
firstSeen: prev?.firstSeen || now,
|
|
209
247
|
lastStarted: now,
|
|
210
248
|
startCount: (prev?.startCount ?? 0) + 1,
|
|
@@ -212,7 +250,7 @@ export async function registerWorkspace(root) {
|
|
|
212
250
|
...(prev?.channels ? { channels: prev.channels } : {}),
|
|
213
251
|
...(prev?.projects ? { projects: prev.projects } : {}),
|
|
214
252
|
};
|
|
215
|
-
await fs.writeFile(
|
|
253
|
+
await fs.writeFile(file, JSON.stringify(rec, null, 2));
|
|
216
254
|
}
|
|
217
255
|
catch { } // best-effort, like the instance file
|
|
218
256
|
}
|
|
@@ -241,17 +279,17 @@ async function syncWorkspaceList(root, key, list) {
|
|
|
241
279
|
export const syncWorkspaceAgents = (root, agents) => syncWorkspaceList(root, "agents", agents);
|
|
242
280
|
export const syncWorkspaceChannels = (root, channels) => syncWorkspaceList(root, "channels", channels);
|
|
243
281
|
export const syncWorkspaceProjects = (root, projects) => syncWorkspaceList(root, "projects", projects);
|
|
244
|
-
/** Stamp lastStopped on clean
|
|
245
|
-
export function markWorkspaceStopped() {
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
282
|
+
/** Stamp lastStopped on a clean stop — every workspace this process serves when no root is given. Sync so exit handlers can call it. */
|
|
283
|
+
export function markWorkspaceStopped(root) {
|
|
284
|
+
for (const r of root ? [absolutePath(root)] : [...own.keys()]) {
|
|
285
|
+
try {
|
|
286
|
+
const file = recordFile(r);
|
|
287
|
+
const rec = JSON.parse(fsSync.readFileSync(file, "utf8"));
|
|
288
|
+
rec.lastStopped = new Date().toISOString();
|
|
289
|
+
fsSync.writeFileSync(file, JSON.stringify(rec, null, 2));
|
|
290
|
+
}
|
|
291
|
+
catch { }
|
|
253
292
|
}
|
|
254
|
-
catch { }
|
|
255
293
|
}
|
|
256
294
|
/** All known workspaces, most recently started first. */
|
|
257
295
|
export async function listWorkspaceRecords() {
|
|
@@ -268,6 +306,13 @@ export async function listWorkspaceRecords() {
|
|
|
268
306
|
.filter((r) => r != null)
|
|
269
307
|
.sort((a, b) => b.lastStarted.localeCompare(a.lastStarted));
|
|
270
308
|
}
|
|
309
|
+
/** A record's slug: as registered, or (records from before 0.66) its folder name made into one. */
|
|
310
|
+
export const recordSlug = (r) => r.slug ?? slugFromFolder(path.basename(r.root));
|
|
311
|
+
/** Other registered workspaces with this slug — two workspaces cannot share an address. */
|
|
312
|
+
export async function slugClashes(slug, root) {
|
|
313
|
+
const abs = absolutePath(root);
|
|
314
|
+
return (await listWorkspaceRecords()).filter((r) => r.root !== abs && recordSlug(r) === slug);
|
|
315
|
+
}
|
|
271
316
|
/** Drop a workspace record (the root itself is untouched) — from the legacy directory too, see migrateRegistry. */
|
|
272
317
|
export async function forgetWorkspace(root) {
|
|
273
318
|
await migrateRegistry();
|
|
@@ -310,7 +355,8 @@ async function describeRoot(root) {
|
|
|
310
355
|
return fallback;
|
|
311
356
|
}
|
|
312
357
|
}
|
|
313
|
-
|
|
358
|
+
/** Per asking workspace: each leaves itself out of its list. */
|
|
359
|
+
const wsCache = new Map();
|
|
314
360
|
/**
|
|
315
361
|
* The switcher's view: every known project joined with the live instances,
|
|
316
362
|
* plus running instances with no project record yet. The calling instance
|
|
@@ -318,13 +364,15 @@ let wsCache = null;
|
|
|
318
364
|
* discoverInstances — /api/meta is polled and this stats every root.
|
|
319
365
|
*/
|
|
320
366
|
export async function discoverWorkspaces() {
|
|
321
|
-
|
|
322
|
-
|
|
367
|
+
const me = currentRoot() ?? "";
|
|
368
|
+
const hit = wsCache.get(me);
|
|
369
|
+
if (hit && Date.now() - hit.at < 5_000)
|
|
370
|
+
return hit.entries;
|
|
323
371
|
const [projects, live] = await Promise.all([listWorkspaceRecords(), discoverInstances()]);
|
|
324
372
|
const liveByRoot = new Map(live.filter((i) => i.root).map((i) => [i.root, i]));
|
|
325
373
|
const consumed = new Set();
|
|
326
374
|
const entries = (await Promise.all(projects
|
|
327
|
-
.filter((p) => p.root !==
|
|
375
|
+
.filter((p) => p.root !== me)
|
|
328
376
|
.map(async (p) => {
|
|
329
377
|
const base = { root: p.root, rootLabel: tildify(p.root), lastStarted: p.lastStarted, lastStopped: p.lastStopped };
|
|
330
378
|
let running = liveByRoot.get(p.root);
|
|
@@ -371,7 +419,7 @@ export async function discoverWorkspaces() {
|
|
|
371
419
|
return a.live ? -1 : 1;
|
|
372
420
|
return (b.lastStarted ?? "").localeCompare(a.lastStarted ?? "") || a.name.localeCompare(b.name);
|
|
373
421
|
});
|
|
374
|
-
wsCache
|
|
422
|
+
wsCache.set(me, { at: Date.now(), entries });
|
|
375
423
|
return entries;
|
|
376
424
|
}
|
|
377
425
|
const countDirs = async (dir, marker) => {
|
|
@@ -403,25 +451,27 @@ const countEntries = async (dir) => {
|
|
|
403
451
|
export async function listWorkspacesDetailed() {
|
|
404
452
|
const others = await discoverWorkspaces();
|
|
405
453
|
const all = [...others];
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
454
|
+
const me = currentRoot();
|
|
455
|
+
const mine = me ? own.get(me) : undefined;
|
|
456
|
+
if (me && mine) {
|
|
457
|
+
const d = await describeRoot(me);
|
|
458
|
+
const rec = await readRecord(recordFile(me));
|
|
409
459
|
all.unshift({
|
|
410
460
|
name: d.name,
|
|
411
461
|
icon: d.icon,
|
|
412
462
|
color: d.color,
|
|
413
463
|
named: d.named,
|
|
414
|
-
url:
|
|
464
|
+
url: mine.url,
|
|
415
465
|
live: true,
|
|
416
|
-
root:
|
|
417
|
-
rootLabel: tildify(
|
|
466
|
+
root: me,
|
|
467
|
+
rootLabel: tildify(me),
|
|
418
468
|
exists: true,
|
|
419
469
|
lastStarted: rec?.lastStarted,
|
|
420
470
|
lastStopped: rec?.lastStopped,
|
|
421
471
|
});
|
|
422
472
|
}
|
|
423
473
|
return Promise.all(all.map(async (e) => {
|
|
424
|
-
const current = !!
|
|
474
|
+
const current = !!me && e.root === me;
|
|
425
475
|
const base = { ...e, current, state: e.live ? "running" : "stopped" };
|
|
426
476
|
if (!e.root)
|
|
427
477
|
return base;
|
|
@@ -476,15 +526,25 @@ export async function startWorkspace(root) {
|
|
|
476
526
|
if (!exists)
|
|
477
527
|
return { ok: false, error: `not a project directory (missing or moved): ${abs}` };
|
|
478
528
|
const url = `http://localhost:${port}`;
|
|
479
|
-
if (abs ===
|
|
529
|
+
if (abs === currentRoot())
|
|
480
530
|
return { ok: false, url, live: true, error: "that is this workspace" };
|
|
481
531
|
// Already running, or the port is taken by another kraftwerk?
|
|
482
|
-
cache =
|
|
532
|
+
cache = null;
|
|
533
|
+
wsCache.clear();
|
|
483
534
|
const live = await discoverInstances();
|
|
484
535
|
const byRoot = live.find((i) => i.root === abs);
|
|
485
536
|
if (byRoot)
|
|
486
537
|
return { ok: true, url: byRoot.url, live: true };
|
|
487
|
-
|
|
538
|
+
// The machine runs a daemon: it serves this workspace next to the others.
|
|
539
|
+
const daemon = await findDaemon();
|
|
540
|
+
if (daemon) {
|
|
541
|
+
const r = await daemonCall(daemon.port, "open", abs).catch((err) => ({ ok: false, status: 0, data: { error: err.message } }));
|
|
542
|
+
cache = null;
|
|
543
|
+
wsCache.clear();
|
|
544
|
+
return r.ok ? { ok: true, url: r.data.url, live: true, pid: daemon.pid } : { ok: false, error: r.data.error ?? `the daemon refused (${r.status})` };
|
|
545
|
+
}
|
|
546
|
+
const mine = own.get(currentRoot() ?? "");
|
|
547
|
+
if (mine && port === mine.port)
|
|
488
548
|
return { ok: false, url, error: `port ${port} is used by this workspace` };
|
|
489
549
|
const byPort = live.find((i) => i.url === url);
|
|
490
550
|
if (byPort)
|
|
@@ -526,7 +586,8 @@ export async function startWorkspace(root) {
|
|
|
526
586
|
if (exited)
|
|
527
587
|
return { ok: false, url, pid: child.pid, log, error: `kraftwerk ui ${exited}` };
|
|
528
588
|
if (await probe(port)) {
|
|
529
|
-
cache =
|
|
589
|
+
cache = null;
|
|
590
|
+
wsCache.clear();
|
|
530
591
|
return { ok: true, url, live: true, pid: child.pid, log };
|
|
531
592
|
}
|
|
532
593
|
}
|
|
@@ -534,20 +595,30 @@ export async function startWorkspace(root) {
|
|
|
534
595
|
}
|
|
535
596
|
/* ---------- stop a project ---------- */
|
|
536
597
|
/**
|
|
537
|
-
* Stop a running workspace
|
|
538
|
-
*
|
|
539
|
-
*
|
|
540
|
-
*
|
|
598
|
+
* Stop a running workspace. A daemon's workspace is closed there — the
|
|
599
|
+
* daemon and every other workspace in it keep running. A standalone one
|
|
600
|
+
* gets SIGTERM to its server process, which unregisters, stamps
|
|
601
|
+
* lastStopped and exits; the `kraftwerk ui` supervisor follows. Matched by
|
|
602
|
+
* root, or by url for instances from older versions that registered no
|
|
603
|
+
* root. Waits up to ~5s for it to go quiet.
|
|
541
604
|
*/
|
|
542
605
|
export async function stopWorkspace(target) {
|
|
543
|
-
if (target.root && absolutePath(target.root) ===
|
|
606
|
+
if (target.root && absolutePath(target.root) === currentRoot()) {
|
|
544
607
|
return { ok: false, error: "that is this workspace — stop it from its own terminal or pid" };
|
|
545
608
|
}
|
|
546
|
-
cache =
|
|
609
|
+
cache = null;
|
|
610
|
+
wsCache.clear();
|
|
547
611
|
const live = await discoverInstances();
|
|
548
612
|
const inst = live.find((i) => (target.root && i.root === absolutePath(target.root)) || (target.url && i.url === target.url));
|
|
549
613
|
if (!inst)
|
|
550
614
|
return { ok: false, error: "not running" };
|
|
615
|
+
if (inst.hub && inst.root) {
|
|
616
|
+
const port = Number(new URL(inst.url).port) || DAEMON_PORT;
|
|
617
|
+
const r = await daemonCall(port, "close", inst.root).catch((err) => ({ ok: false, status: 0, data: { error: err.message } }));
|
|
618
|
+
cache = null;
|
|
619
|
+
wsCache.clear();
|
|
620
|
+
return r.ok ? { ok: true } : { ok: false, error: r.data.error ?? `the daemon refused (${r.status})` };
|
|
621
|
+
}
|
|
551
622
|
try {
|
|
552
623
|
process.kill(inst.pid, "SIGTERM");
|
|
553
624
|
}
|
|
@@ -559,7 +630,8 @@ export async function stopWorkspace(target) {
|
|
|
559
630
|
while (Date.now() < deadline) {
|
|
560
631
|
await new Promise((r) => setTimeout(r, 250));
|
|
561
632
|
if (!(await probe(port))) {
|
|
562
|
-
cache =
|
|
633
|
+
cache = null;
|
|
634
|
+
wsCache.clear();
|
|
563
635
|
return { ok: true };
|
|
564
636
|
}
|
|
565
637
|
}
|
package/dist/core/routines.d.ts
CHANGED
|
@@ -76,4 +76,10 @@ export declare function runRoutineNow(slug: string, id: string): Promise<{
|
|
|
76
76
|
}>;
|
|
77
77
|
/** Start the current workspace's scheduler; safe to call more than once. Ticks run in that workspace. */
|
|
78
78
|
export declare function startRoutineScheduler(): void;
|
|
79
|
+
/** Stop the current workspace's scheduler (the workspace closes): no routine of it fires any more. */
|
|
80
|
+
export declare function stopRoutineScheduler(): void;
|
|
81
|
+
/** Whether the current workspace's scheduler runs (tests, the hub's view). */
|
|
82
|
+
export declare const routineSchedulerRunning: () => boolean;
|
|
83
|
+
/** The current workspace's scheduler timer, or null — for tests that check it really stops (a cleared slot would read null either way). */
|
|
84
|
+
export declare const routineSchedulerTimer: () => ReturnType<typeof setInterval> | null;
|
|
79
85
|
export {};
|
package/dist/core/routines.js
CHANGED
|
@@ -305,3 +305,14 @@ export function startRoutineScheduler() {
|
|
|
305
305
|
s.timer = setInterval(() => void tick().catch(() => { }), 20_000);
|
|
306
306
|
s.timer.unref?.();
|
|
307
307
|
}
|
|
308
|
+
/** Stop the current workspace's scheduler (the workspace closes): no routine of it fires any more. */
|
|
309
|
+
export function stopRoutineScheduler() {
|
|
310
|
+
const s = scheduler();
|
|
311
|
+
if (s.timer)
|
|
312
|
+
clearInterval(s.timer);
|
|
313
|
+
s.timer = null;
|
|
314
|
+
}
|
|
315
|
+
/** Whether the current workspace's scheduler runs (tests, the hub's view). */
|
|
316
|
+
export const routineSchedulerRunning = () => scheduler().timer !== null;
|
|
317
|
+
/** The current workspace's scheduler timer, or null — for tests that check it really stops (a cleared slot would read null either way). */
|
|
318
|
+
export const routineSchedulerTimer = () => scheduler().timer;
|
package/dist/core/runner.js
CHANGED
|
@@ -2,10 +2,11 @@ import { spawn, spawnSync } from "node:child_process";
|
|
|
2
2
|
import { mkdirSync, openSync, closeSync, writeFileSync } from "node:fs";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { workspaceRoot } from "./context.js";
|
|
5
|
-
import { perWorkspace } from "./workspace.js";
|
|
5
|
+
import { perWorkspace, workspaceOpen } from "./workspace.js";
|
|
6
6
|
import { RUN_ID_RE, getRun, safeRunDir } from "./runs.js";
|
|
7
7
|
import { pushNotification } from "./notifications.js";
|
|
8
8
|
import { newRunId } from "../workflow.js";
|
|
9
|
+
import { workspaceEnv } from "./env.js";
|
|
9
10
|
/**
|
|
10
11
|
* Workflow trigger for the web UI. The inspector stays decoupled from the
|
|
11
12
|
* framework runtime: it shells out to `npx kraftwerk run` in the consumer
|
|
@@ -54,7 +55,7 @@ export function triggerRun(opts) {
|
|
|
54
55
|
cwd: workspaceRoot(),
|
|
55
56
|
detached: true,
|
|
56
57
|
stdio: ["ignore", log, log],
|
|
57
|
-
env: { ...
|
|
58
|
+
env: { ...workspaceEnv(), FORCE_COLOR: "0" },
|
|
58
59
|
});
|
|
59
60
|
child.unref();
|
|
60
61
|
closeSync(log);
|
|
@@ -66,13 +67,16 @@ export function triggerRun(opts) {
|
|
|
66
67
|
// framework wrote a trace (missing env var, npx failure) shows as failed
|
|
67
68
|
// instead of running until the stale timeout.
|
|
68
69
|
child.on("exit", (code, signal) => {
|
|
69
|
-
launchers().delete(runId);
|
|
70
70
|
try {
|
|
71
71
|
writeFileSync(path.join(runDir, "trigger.json"), JSON.stringify({ exitCode: code ?? 1, signal, finishedAt: new Date().toISOString() }, null, 2) + "\n");
|
|
72
72
|
}
|
|
73
73
|
catch {
|
|
74
74
|
/* run dir removed meanwhile */
|
|
75
75
|
}
|
|
76
|
+
// A run outlives its workspace closing (it is detached): the file above is all it leaves then.
|
|
77
|
+
if (!workspaceOpen())
|
|
78
|
+
return;
|
|
79
|
+
launchers().delete(runId);
|
|
76
80
|
void getRun(runId)
|
|
77
81
|
.then((run) => {
|
|
78
82
|
const ok = run?.status === "ok";
|
package/dist/core/vibeables.d.ts
CHANGED
|
@@ -140,3 +140,5 @@ export declare function startDev(slug: string): Promise<VibeableStatus>;
|
|
|
140
140
|
export declare function stopDev(slug: string): Promise<VibeableStatus>;
|
|
141
141
|
/** Server shutdown: no dev server may outlive the inspector. */
|
|
142
142
|
export declare function disposeAllDevs(): void;
|
|
143
|
+
/** The current workspace's dev servers and file watchers end (the workspace closes). */
|
|
144
|
+
export declare function disposeWorkspaceDevs(): void;
|
package/dist/core/vibeables.js
CHANGED
|
@@ -6,9 +6,10 @@ import path from "node:path";
|
|
|
6
6
|
import { parse } from "yaml";
|
|
7
7
|
import { resolveWorkspace, vibeablesRootFor } from "../config.js";
|
|
8
8
|
import { workspaceRoot } from "./context.js";
|
|
9
|
-
import { perWorkspace } from "./workspace.js";
|
|
9
|
+
import { perWorkspace, workspaceOpen } from "./workspace.js";
|
|
10
10
|
import { moveToTrash } from "./trash.js";
|
|
11
11
|
import { newestMtime } from "./mtime.js";
|
|
12
|
+
import { workspaceEnv } from "./env.js";
|
|
12
13
|
export const VIBEABLE_CONFIG_FILE = "vibeable.yml";
|
|
13
14
|
const SLUG_RE = /^[A-Za-z0-9][A-Za-z0-9._-]{0,63}$/;
|
|
14
15
|
export function safeVibeableSlug(slug) {
|
|
@@ -353,7 +354,7 @@ async function startDevNow(slug) {
|
|
|
353
354
|
detached: process.platform !== "win32",
|
|
354
355
|
stdio: ["ignore", "pipe", "pipe"],
|
|
355
356
|
env: {
|
|
356
|
-
...
|
|
357
|
+
...workspaceEnv(),
|
|
357
358
|
PORT: String(port),
|
|
358
359
|
BASE_PATH: basePath,
|
|
359
360
|
PUBLIC_URL: basePath,
|
|
@@ -371,19 +372,22 @@ async function startDevNow(slug) {
|
|
|
371
372
|
pushLog(d, Buffer.from(`spawn failed: ${err.message}\n`));
|
|
372
373
|
d.exitCode = null;
|
|
373
374
|
d.ready = false;
|
|
374
|
-
|
|
375
|
+
if (workspaceOpen())
|
|
376
|
+
broadcast(slug, { type: "dev", dev: devView(slug) });
|
|
375
377
|
});
|
|
376
378
|
child.on("exit", (code) => {
|
|
377
379
|
d.exitCode = code;
|
|
378
380
|
d.ready = false;
|
|
379
381
|
pushLog(d, Buffer.from(`[exited with ${code ?? "signal"}]\n`));
|
|
380
|
-
|
|
382
|
+
// Closing the workspace ends its dev servers: their exit then has nobody to tell.
|
|
383
|
+
if (workspaceOpen())
|
|
384
|
+
broadcast(slug, { type: "dev", dev: devView(slug) });
|
|
381
385
|
});
|
|
382
386
|
broadcast(slug, { type: "dev", dev: devView(slug) });
|
|
383
387
|
// Readiness runs in the background; the pane follows the dev events.
|
|
384
388
|
void (async () => {
|
|
385
389
|
const until = Date.now() + READY_TIMEOUT;
|
|
386
|
-
while (Date.now() < until && d.exitCode === undefined && devs().get(slug) === d) {
|
|
390
|
+
while (Date.now() < until && d.exitCode === undefined && workspaceOpen() && devs().get(slug) === d) {
|
|
387
391
|
if (await probe(port)) {
|
|
388
392
|
d.ready = true;
|
|
389
393
|
broadcast(slug, { type: "dev", dev: devView(slug) });
|
|
@@ -432,3 +436,11 @@ export function disposeAllDevs() {
|
|
|
432
436
|
if (d.exitCode === undefined)
|
|
433
437
|
killGroup(d, "SIGTERM");
|
|
434
438
|
}
|
|
439
|
+
/** The current workspace's dev servers and file watchers end (the workspace closes). */
|
|
440
|
+
export function disposeWorkspaceDevs() {
|
|
441
|
+
for (const d of devs().values())
|
|
442
|
+
if (d.exitCode === undefined)
|
|
443
|
+
killGroup(d, "SIGTERM");
|
|
444
|
+
for (const slug of [...channels().keys()])
|
|
445
|
+
dropChannel(slug);
|
|
446
|
+
}
|
package/dist/core/workspace.d.ts
CHANGED
|
@@ -18,19 +18,32 @@
|
|
|
18
18
|
export declare class Workspace {
|
|
19
19
|
readonly root: string;
|
|
20
20
|
readonly outputDir: string;
|
|
21
|
+
/** Its address on this machine (kraftwerk.yml `slug`, else the folder name), when known. */
|
|
22
|
+
readonly slug?: string;
|
|
23
|
+
/** Its `.env` (parsed, not applied — see env.ts); {} when none was given. */
|
|
24
|
+
readonly env: Record<string, string>;
|
|
21
25
|
private readonly slots;
|
|
22
26
|
/** `root` defaults to the parent of the output dir (wrong for nested output dirs — pass it when known). */
|
|
23
27
|
constructor(opts: {
|
|
24
28
|
outputDir: string;
|
|
25
29
|
root?: string;
|
|
30
|
+
slug?: string;
|
|
31
|
+
env?: Record<string, string>;
|
|
26
32
|
});
|
|
27
33
|
/** Run `fn` (and everything it starts) in this workspace. */
|
|
28
34
|
run<T>(fn: () => T): T;
|
|
35
|
+
/** Closed (no longer served): its state is gone and may not come back — see close(). */
|
|
36
|
+
get closed(): boolean;
|
|
37
|
+
private isClosed;
|
|
29
38
|
slot<T>(key: object, init: () => T): T;
|
|
39
|
+
/** No longer served: sweeps over all workspaces (shutdown, the idle reaper) skip it. Its slots are dropped. */
|
|
40
|
+
close(): void;
|
|
30
41
|
peek<T>(key: object): T | undefined;
|
|
31
42
|
}
|
|
32
43
|
/** The workspace a single-workspace process serves; used outside any `ws.run`. */
|
|
33
44
|
export declare function setDefaultWorkspace(ws: Workspace): Workspace;
|
|
45
|
+
/** Whether the caller's workspace is still served (a background loop checks before its next round); true outside any. */
|
|
46
|
+
export declare function workspaceOpen(): boolean;
|
|
34
47
|
export declare function currentWorkspace(): Workspace;
|
|
35
48
|
export interface PerWorkspace<T> {
|
|
36
49
|
/** The current workspace's value, created on first use. */
|
package/dist/core/workspace.js
CHANGED
|
@@ -20,22 +20,42 @@ import path from "node:path";
|
|
|
20
20
|
export class Workspace {
|
|
21
21
|
root;
|
|
22
22
|
outputDir;
|
|
23
|
+
/** Its address on this machine (kraftwerk.yml `slug`, else the folder name), when known. */
|
|
24
|
+
slug;
|
|
25
|
+
/** Its `.env` (parsed, not applied — see env.ts); {} when none was given. */
|
|
26
|
+
env;
|
|
23
27
|
slots = new Map();
|
|
24
28
|
/** `root` defaults to the parent of the output dir (wrong for nested output dirs — pass it when known). */
|
|
25
29
|
constructor(opts) {
|
|
26
30
|
this.outputDir = path.resolve(opts.outputDir);
|
|
27
31
|
this.root = opts.root ? path.resolve(opts.root) : path.dirname(this.outputDir);
|
|
32
|
+
this.slug = opts.slug;
|
|
33
|
+
this.env = opts.env ?? {};
|
|
28
34
|
known.add(this);
|
|
29
35
|
}
|
|
30
36
|
/** Run `fn` (and everything it starts) in this workspace. */
|
|
31
37
|
run(fn) {
|
|
32
38
|
return storage.run(this, fn);
|
|
33
39
|
}
|
|
40
|
+
/** Closed (no longer served): its state is gone and may not come back — see close(). */
|
|
41
|
+
get closed() {
|
|
42
|
+
return this.isClosed;
|
|
43
|
+
}
|
|
44
|
+
isClosed = false;
|
|
34
45
|
slot(key, init) {
|
|
46
|
+
// Something still running for a closed workspace (a fetch in flight, a heartbeat) must not bring its state back.
|
|
47
|
+
if (this.isClosed)
|
|
48
|
+
throw new Error("workspace closed");
|
|
35
49
|
if (!this.slots.has(key))
|
|
36
50
|
this.slots.set(key, init());
|
|
37
51
|
return this.slots.get(key);
|
|
38
52
|
}
|
|
53
|
+
/** No longer served: sweeps over all workspaces (shutdown, the idle reaper) skip it. Its slots are dropped. */
|
|
54
|
+
close() {
|
|
55
|
+
this.isClosed = true;
|
|
56
|
+
known.delete(this);
|
|
57
|
+
this.slots.clear();
|
|
58
|
+
}
|
|
39
59
|
peek(key) {
|
|
40
60
|
return this.slots.get(key);
|
|
41
61
|
}
|
|
@@ -48,6 +68,11 @@ export function setDefaultWorkspace(ws) {
|
|
|
48
68
|
fallback = ws;
|
|
49
69
|
return ws;
|
|
50
70
|
}
|
|
71
|
+
/** Whether the caller's workspace is still served (a background loop checks before its next round); true outside any. */
|
|
72
|
+
export function workspaceOpen() {
|
|
73
|
+
const ws = storage.getStore() ?? fallback;
|
|
74
|
+
return !ws?.closed;
|
|
75
|
+
}
|
|
51
76
|
export function currentWorkspace() {
|
|
52
77
|
const ws = storage.getStore() ?? fallback;
|
|
53
78
|
if (!ws)
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
/** What a hub serves (server.ts Hub): the machine's open workspaces, and opening or closing one. Need no workspace. */
|
|
3
|
+
export declare const hubRoutes: (import("./router.js").Route<"hub.close", {
|
|
4
|
+
ok: boolean;
|
|
5
|
+
}, "/api/hub/close", z.ZodObject<{
|
|
6
|
+
root: z.ZodString;
|
|
7
|
+
}, z.core.$strip>> | import("./router.js").Route<"hub.list", {
|
|
8
|
+
daemon: boolean;
|
|
9
|
+
port: number;
|
|
10
|
+
workspaces: {
|
|
11
|
+
slug: string;
|
|
12
|
+
name: string;
|
|
13
|
+
url: string;
|
|
14
|
+
aliasPort?: number | undefined;
|
|
15
|
+
root?: string | undefined;
|
|
16
|
+
rootLabel?: string | undefined;
|
|
17
|
+
}[];
|
|
18
|
+
}, "/api/hub", undefined> | import("./router.js").Route<"hub.open", {
|
|
19
|
+
slug: string;
|
|
20
|
+
name: string;
|
|
21
|
+
url: string;
|
|
22
|
+
aliasPort?: number | undefined;
|
|
23
|
+
}, "/api/hub/open", z.ZodObject<{
|
|
24
|
+
root: z.ZodString;
|
|
25
|
+
}, z.core.$strip>>)[];
|