@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
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as z from "zod";
|
|
2
|
+
import { tildify } from "../../core/instances.js";
|
|
3
|
+
import { currentHub } from "../hub-context.js";
|
|
4
|
+
import { fail, route } from "./router.js";
|
|
5
|
+
const Root = z.object({ root: z.string().min(1, "root is required") });
|
|
6
|
+
/** What a hub serves (server.ts Hub): the machine's open workspaces, and opening or closing one. Need no workspace. */
|
|
7
|
+
export const hubRoutes = [
|
|
8
|
+
// The folder is this machine's business; a paired device sees names and addresses.
|
|
9
|
+
route({ name: "hub.list", method: "GET", path: "/api/hub", summary: "the workspaces this kraftwerk serves: slug, name, address" }, async (c) => {
|
|
10
|
+
const hub = currentHub();
|
|
11
|
+
return {
|
|
12
|
+
daemon: hub.daemon,
|
|
13
|
+
port: hub.port,
|
|
14
|
+
workspaces: hub.list().map((o) => ({
|
|
15
|
+
slug: o.slug,
|
|
16
|
+
name: o.name,
|
|
17
|
+
url: o.url,
|
|
18
|
+
...(o.aliasPort ? { aliasPort: o.aliasPort } : {}),
|
|
19
|
+
...(c.trust.kind === "local" ? { root: o.root, rootLabel: tildify(o.root) } : {}),
|
|
20
|
+
})),
|
|
21
|
+
};
|
|
22
|
+
}),
|
|
23
|
+
route({ name: "hub.open", method: "POST", path: "/api/hub/open", summary: "serve a workspace {root} next to the others", access: "local", body: Root, errors: 400 }, async (c) => {
|
|
24
|
+
const { root } = await c.body();
|
|
25
|
+
const o = await currentHub().open(root);
|
|
26
|
+
return { slug: o.slug, name: o.name, url: o.url, ...(o.aliasPort ? { aliasPort: o.aliasPort } : {}) };
|
|
27
|
+
}),
|
|
28
|
+
route({ name: "hub.close", method: "POST", path: "/api/hub/close", summary: "stop serving a workspace {root}; the others keep running", access: "local", body: Root }, async (c) => {
|
|
29
|
+
const { root } = await c.body();
|
|
30
|
+
return (await currentHub().close(root)) ? { ok: true } : fail(404, "not open here");
|
|
31
|
+
}),
|
|
32
|
+
];
|
|
@@ -337,7 +337,29 @@ export declare const routes: (Route<"agents.addJournal", {
|
|
|
337
337
|
}>, "/api/git/pull", undefined> | Route<"git.push", import("./router.js").Reply<{
|
|
338
338
|
ok: boolean;
|
|
339
339
|
error?: string;
|
|
340
|
-
}>, "/api/git/push", undefined> | Route<"git.status", import("../../core/git.js").GitStatus, "/api/git", undefined> | Route<"
|
|
340
|
+
}>, "/api/git/push", undefined> | Route<"git.status", import("../../core/git.js").GitStatus, "/api/git", undefined> | Route<"hub.close", {
|
|
341
|
+
ok: boolean;
|
|
342
|
+
}, "/api/hub/close", z.ZodObject<{
|
|
343
|
+
root: z.ZodString;
|
|
344
|
+
}, z.core.$strip>> | Route<"hub.list", {
|
|
345
|
+
daemon: boolean;
|
|
346
|
+
port: number;
|
|
347
|
+
workspaces: {
|
|
348
|
+
slug: string;
|
|
349
|
+
name: string;
|
|
350
|
+
url: string;
|
|
351
|
+
aliasPort?: number | undefined;
|
|
352
|
+
root?: string | undefined;
|
|
353
|
+
rootLabel?: string | undefined;
|
|
354
|
+
}[];
|
|
355
|
+
}, "/api/hub", undefined> | Route<"hub.open", {
|
|
356
|
+
slug: string;
|
|
357
|
+
name: string;
|
|
358
|
+
url: string;
|
|
359
|
+
aliasPort?: number | undefined;
|
|
360
|
+
}, "/api/hub/open", z.ZodObject<{
|
|
361
|
+
root: z.ZodString;
|
|
362
|
+
}, z.core.$strip>> | Route<"knowledge.concept", import("../../okf.js").ConceptDetail, "/api/knowledge/:bundle/concept", undefined> | Route<"knowledge.create", import("../../okf.js").BundleInfo, "/api/knowledge", z.ZodObject<{
|
|
341
363
|
name: z.ZodDefault<z.ZodString>;
|
|
342
364
|
}, z.core.$strip>> | Route<"knowledge.delete", {
|
|
343
365
|
ok: boolean;
|
|
@@ -348,6 +370,7 @@ export declare const routes: (Route<"agents.addJournal", {
|
|
|
348
370
|
id: z.ZodDefault<z.ZodString>;
|
|
349
371
|
}, z.core.$strip>> | Route<"meta.get", {
|
|
350
372
|
workspaceName: string;
|
|
373
|
+
workspaceSlug: string;
|
|
351
374
|
workspaceIcon: string;
|
|
352
375
|
workspaceColor: string;
|
|
353
376
|
workspaceNamed: boolean;
|
package/dist/server/api/index.js
CHANGED
|
@@ -4,6 +4,7 @@ import { attentionRoutes } from "./attention.js";
|
|
|
4
4
|
import { channelRoutes } from "./channels.js";
|
|
5
5
|
import { chatRoutes } from "./chats.js";
|
|
6
6
|
import { deviceRoutes } from "./devices.js";
|
|
7
|
+
import { hubRoutes } from "./hub.js";
|
|
7
8
|
import { fileRoutes } from "./files.js";
|
|
8
9
|
import { gitRoutes } from "./git.js";
|
|
9
10
|
import { knowledgeRoutes } from "./knowledge.js";
|
|
@@ -34,6 +35,7 @@ const domainRoutes = [
|
|
|
34
35
|
...gitRoutes,
|
|
35
36
|
...vibeableRoutes,
|
|
36
37
|
...deviceRoutes,
|
|
38
|
+
...hubRoutes,
|
|
37
39
|
];
|
|
38
40
|
const protocolRoute = route({ name: "protocol.get", method: "GET", path: "/api/protocol", summary: "the protocol version and every route", access: "public" }, async () => ({
|
|
39
41
|
version: PROTOCOL_VERSION,
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { Workspace } from "../../core/workspace.js";
|
|
2
|
+
import type { Hub } from "../server.js";
|
|
2
3
|
import type { Trust } from "../trust.js";
|
|
3
4
|
import type { Socket } from "../ws.js";
|
|
4
5
|
/**
|
|
@@ -6,4 +7,11 @@ import type { Socket } from "../ws.js";
|
|
|
6
7
|
* been revoked since it connected): a device whose answer is no longer
|
|
7
8
|
* `device` is disconnected at once (close code 4401).
|
|
8
9
|
*/
|
|
9
|
-
|
|
10
|
+
/**
|
|
11
|
+
* `workspaceOf` finds the workspace a message is for: its `ws` (a slug), or
|
|
12
|
+
* the connection's own (the one the socket was opened for) when it names
|
|
13
|
+
* none. A daemon's socket opened at its root has no workspace of its own:
|
|
14
|
+
* there, a message without `ws` reaches only the machine's routes (hub,
|
|
15
|
+
* devices, protocol).
|
|
16
|
+
*/
|
|
17
|
+
export declare function serveSocket(conn: Socket, workspaceOf: (slug?: string) => Workspace | undefined, version: string, trust: Trust, recheck: () => Promise<Trust>, hub: Hub): void;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { subscribeChat } from "../../core/chat/sessions.js";
|
|
2
2
|
import { watch } from "../live.js";
|
|
3
3
|
import { resolveVibeable, subscribeVibeable } from "../../core/vibeables.js";
|
|
4
|
+
import { MACHINE_ROUTES, runInHub } from "../hub-context.js";
|
|
4
5
|
import { apiRouter, PROTOCOL_VERSION, routes } from "./index.js";
|
|
5
6
|
/**
|
|
6
7
|
* The control plane over one WebSocket (`/api/ws`): every JSON route by
|
|
@@ -30,7 +31,14 @@ const RECHECK_MS = 5_000;
|
|
|
30
31
|
* been revoked since it connected): a device whose answer is no longer
|
|
31
32
|
* `device` is disconnected at once (close code 4401).
|
|
32
33
|
*/
|
|
33
|
-
|
|
34
|
+
/**
|
|
35
|
+
* `workspaceOf` finds the workspace a message is for: its `ws` (a slug), or
|
|
36
|
+
* the connection's own (the one the socket was opened for) when it names
|
|
37
|
+
* none. A daemon's socket opened at its root has no workspace of its own:
|
|
38
|
+
* there, a message without `ws` reaches only the machine's routes (hub,
|
|
39
|
+
* devices, protocol).
|
|
40
|
+
*/
|
|
41
|
+
export function serveSocket(conn, workspaceOf, version, trust, recheck, hub) {
|
|
34
42
|
const trustKey = trust.kind === "device" ? `device:${trust.device.id}` : trust.kind;
|
|
35
43
|
const stops = new Map();
|
|
36
44
|
const send = (msg) => conn.send(JSON.stringify(msg));
|
|
@@ -72,6 +80,8 @@ export function serveSocket(conn, workspace, version, trust, recheck) {
|
|
|
72
80
|
return unsubscribe();
|
|
73
81
|
stops.set(key, unsubscribe);
|
|
74
82
|
}
|
|
83
|
+
/** A refusal answered the way the message expects one (its id, watch or sub key). */
|
|
84
|
+
const refuse = (msg, status, error) => send({ id: msg.id, ...(typeof msg.watch === "string" ? { watch: msg.watch } : {}), ...(typeof msg.sub === "string" ? { sub: msg.sub } : {}), status, data: { error }, error });
|
|
75
85
|
async function handle(msg) {
|
|
76
86
|
if (typeof msg.call === "string") {
|
|
77
87
|
const r = await apiRouter.invoke(msg.call, objectOf(msg.input), trust);
|
|
@@ -129,17 +139,27 @@ export function serveSocket(conn, workspace, version, trust, recheck) {
|
|
|
129
139
|
return send({ error: "invalid JSON" });
|
|
130
140
|
}
|
|
131
141
|
// Every message runs in the connection's workspace, whatever started the read; a revoked device's message runs nowhere.
|
|
132
|
-
|
|
133
|
-
|
|
142
|
+
const slug = typeof msg.ws === "string" ? msg.ws : undefined;
|
|
143
|
+
const ws = workspaceOf(slug);
|
|
144
|
+
if (msg.ws !== undefined && !ws)
|
|
145
|
+
return refuse(msg, 404, `no workspace "${String(msg.ws)}" here`);
|
|
146
|
+
if (!ws && !(typeof msg.call === "string" && MACHINE_ROUTES.test(msg.call)))
|
|
147
|
+
return refuse(msg, 404, "name a workspace: `ws` on the message");
|
|
148
|
+
const run = (fn) => runInHub(hub, () => (ws ? ws.run(fn) : fn()));
|
|
149
|
+
void run(async () => {
|
|
134
150
|
if (await stillPaired())
|
|
135
151
|
await handle(msg);
|
|
136
|
-
})
|
|
137
|
-
.catch((err) => send({ id: msg.id, error: err.message }));
|
|
152
|
+
}).catch((err) => send({ id: msg.id, error: err.message }));
|
|
138
153
|
});
|
|
139
154
|
conn.onClose(() => {
|
|
140
155
|
clearInterval(watchdog);
|
|
141
|
-
|
|
142
|
-
|
|
156
|
+
// A subscription of a workspace that closed meanwhile has nothing left to let go of.
|
|
157
|
+
for (const fn of stops.values()) {
|
|
158
|
+
try {
|
|
159
|
+
fn();
|
|
160
|
+
}
|
|
161
|
+
catch { }
|
|
162
|
+
}
|
|
143
163
|
stops.clear();
|
|
144
164
|
});
|
|
145
165
|
send({ hello: { protocol: PROTOCOL_VERSION, version } });
|
|
@@ -2,6 +2,7 @@ import * as z from "zod";
|
|
|
2
2
|
/** The workspace itself: identity and features, settings, the other workspaces, updates. */
|
|
3
3
|
export declare const workspaceRoutes: (import("./router.js").Route<"meta.get", {
|
|
4
4
|
workspaceName: string;
|
|
5
|
+
workspaceSlug: string;
|
|
5
6
|
workspaceIcon: string;
|
|
6
7
|
workspaceColor: string;
|
|
7
8
|
workspaceNamed: boolean;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import * as z from "zod";
|
|
3
|
-
import { resolveWorkspace } from "../../config.js";
|
|
3
|
+
import { resolveWorkspace, slugFromFolder, workspaceSlug } from "../../config.js";
|
|
4
4
|
import { disposeAllBackends } from "../../core/chat/sessions.js";
|
|
5
5
|
import { cloudStatus } from "../cloud.js";
|
|
6
6
|
import { workspaceRoot } from "../../core/context.js";
|
|
@@ -56,6 +56,7 @@ export const workspaceRoutes = [
|
|
|
56
56
|
];
|
|
57
57
|
const identity = {
|
|
58
58
|
workspaceName: name,
|
|
59
|
+
workspaceSlug: ws ? workspaceSlug(ws) : slugFromFolder(path.basename(root)),
|
|
59
60
|
workspaceIcon: ws?.config.icon ?? "",
|
|
60
61
|
workspaceColor: ws?.config.color ?? "",
|
|
61
62
|
workspaceNamed: !!ws?.config.name,
|
package/dist/server/cloud.d.ts
CHANGED
|
@@ -38,7 +38,7 @@ export declare const cloudStatus: () => CloudStatus;
|
|
|
38
38
|
* registration attempt (success or not); the heartbeat loop runs on its
|
|
39
39
|
* own afterwards.
|
|
40
40
|
*/
|
|
41
|
-
export declare function startCloudSync(
|
|
41
|
+
export declare function startCloudSync(url: string, version: string): Promise<void>;
|
|
42
42
|
/** Stop the heartbeat loop (server close, tests). */
|
|
43
43
|
export declare function stopCloudSync(): void;
|
|
44
44
|
/**
|
|
@@ -47,3 +47,7 @@ export declare function stopCloudSync(): void;
|
|
|
47
47
|
* this, and a slow cloud must not hold the process.
|
|
48
48
|
*/
|
|
49
49
|
export declare function cloudGoodbye(): Promise<void>;
|
|
50
|
+
/** Every served workspace: stop its heartbeat (the server closes). */
|
|
51
|
+
export declare function stopAllCloudSync(): void;
|
|
52
|
+
/** Every served workspace says goodbye, in parallel (still bounded per workspace). */
|
|
53
|
+
export declare function cloudGoodbyeAll(): Promise<void>;
|
package/dist/server/cloud.js
CHANGED
|
@@ -4,21 +4,26 @@ import os from "node:os";
|
|
|
4
4
|
import path from "node:path";
|
|
5
5
|
import { cloudFor, resolveWorkspace } from "../config.js";
|
|
6
6
|
import { workspaceRoot } from "../core/context.js";
|
|
7
|
+
import { perWorkspace, workspaceOpen } from "../core/workspace.js";
|
|
7
8
|
import { listAgents, toSummary } from "../core/agents.js";
|
|
8
9
|
import { listChannels, toChannelSummary } from "../core/channels.js";
|
|
9
10
|
import { tildify } from "../core/instances.js";
|
|
10
11
|
const claimUrlFor = (url, code) => (code ? `${url}/claim/${encodeURIComponent(code)}` : null);
|
|
11
12
|
const REQUEST_TIMEOUT_MS = 8_000;
|
|
12
13
|
const GOODBYE_TIMEOUT_MS = 800;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
14
|
+
/** The client's state, one per workspace: each served workspace registers and heartbeats on its own. */
|
|
15
|
+
const client = perWorkspace(() => ({
|
|
16
|
+
status: { url: "", state: "off" },
|
|
17
|
+
identity: null,
|
|
18
|
+
timer: null,
|
|
19
|
+
stopped: false,
|
|
20
|
+
lastRoster: "",
|
|
21
|
+
/** Where this workspace is reached on this machine, as the cloud lists it. */
|
|
22
|
+
selfUrl: "",
|
|
23
|
+
selfVersion: "",
|
|
24
|
+
startedAt: new Date().toISOString(),
|
|
25
|
+
}));
|
|
26
|
+
export const cloudStatus = () => client().status;
|
|
22
27
|
const cloudDir = () => path.join(os.homedir(), ".kraftwerk", "cloud");
|
|
23
28
|
const identityFile = (url, root) => path.join(cloudDir(), `${createHash("sha1").update(`${url}\n${root}`).digest("hex").slice(0, 16)}.json`);
|
|
24
29
|
async function readIdentity(file) {
|
|
@@ -46,9 +51,10 @@ async function roster() {
|
|
|
46
51
|
return { agents, channels };
|
|
47
52
|
}
|
|
48
53
|
async function post(url, body, timeout = REQUEST_TIMEOUT_MS) {
|
|
54
|
+
const c = client();
|
|
49
55
|
const r = await fetch(url, {
|
|
50
56
|
method: "POST",
|
|
51
|
-
headers: { "content-type": "application/json", "user-agent": `kraftwerk/${selfVersion}` },
|
|
57
|
+
headers: { "content-type": "application/json", "user-agent": `kraftwerk/${c.selfVersion}` },
|
|
52
58
|
body: JSON.stringify(body),
|
|
53
59
|
signal: AbortSignal.timeout(timeout),
|
|
54
60
|
});
|
|
@@ -66,16 +72,17 @@ async function post(url, body, timeout = REQUEST_TIMEOUT_MS) {
|
|
|
66
72
|
}
|
|
67
73
|
/** Register (or re-register) this instance. Returns false when the cloud refused or is unreachable. */
|
|
68
74
|
async function register() {
|
|
75
|
+
const c = client();
|
|
69
76
|
const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
|
|
70
77
|
const cloud = project ? cloudFor(project) : undefined;
|
|
71
78
|
if (!project || !cloud)
|
|
72
79
|
return false;
|
|
73
80
|
const file = identityFile(cloud.url, project.root);
|
|
74
|
-
identity ??= await readIdentity(file);
|
|
81
|
+
c.identity ??= await readIdentity(file);
|
|
75
82
|
const { agents, channels } = await roster();
|
|
76
|
-
lastRoster = JSON.stringify({ agents, channels });
|
|
83
|
+
c.lastRoster = JSON.stringify({ agents, channels });
|
|
77
84
|
const body = {
|
|
78
|
-
...(identity ?? {}),
|
|
85
|
+
...(c.identity ?? {}),
|
|
79
86
|
name: project.config.name ?? path.basename(project.root),
|
|
80
87
|
icon: project.config.icon ?? "",
|
|
81
88
|
color: project.config.color ?? "",
|
|
@@ -83,106 +90,121 @@ async function register() {
|
|
|
83
90
|
rootLabel: tildify(project.root),
|
|
84
91
|
hostname: os.hostname(),
|
|
85
92
|
platform: `${process.platform}-${process.arch}`,
|
|
86
|
-
version: selfVersion,
|
|
87
|
-
url:
|
|
93
|
+
version: c.selfVersion,
|
|
94
|
+
url: c.selfUrl,
|
|
88
95
|
// Part of the cloud's register contract; always "" since the Cloudflare Tunnel was removed (0.65).
|
|
89
96
|
publicUrl: "",
|
|
90
97
|
agents,
|
|
91
98
|
channels,
|
|
92
99
|
interval: cloud.interval,
|
|
93
|
-
startedAt,
|
|
100
|
+
startedAt: c.startedAt,
|
|
94
101
|
...(cloud.token ? { accountToken: cloud.token } : {}),
|
|
95
102
|
};
|
|
96
103
|
try {
|
|
97
104
|
const r = await post(`${cloud.url}/api/instances/register`, body);
|
|
98
|
-
if (!identity || identity.id !== r.id || identity.secret !== r.secret) {
|
|
99
|
-
identity = { id: r.id, secret: r.secret };
|
|
100
|
-
await writeIdentity(file, cloud.url, project.root, identity);
|
|
105
|
+
if (!c.identity || c.identity.id !== r.id || c.identity.secret !== r.secret) {
|
|
106
|
+
c.identity = { id: r.id, secret: r.secret };
|
|
107
|
+
await writeIdentity(file, cloud.url, project.root, c.identity);
|
|
101
108
|
}
|
|
102
|
-
status = {
|
|
109
|
+
c.status = {
|
|
103
110
|
url: cloud.url, state: "connected", id: r.id, account: r.account, interval: r.interval, lastSeen: new Date().toISOString(),
|
|
104
111
|
claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(cloud.url, r.claimCode),
|
|
105
112
|
};
|
|
106
113
|
return true;
|
|
107
114
|
}
|
|
108
115
|
catch (err) {
|
|
109
|
-
status = { url: cloud.url, state: "error", error: err.message, ...(identity ? { id: identity.id } : {}) };
|
|
116
|
+
c.status = { url: cloud.url, state: "error", error: err.message, ...(c.identity ? { id: c.identity.id } : {}) };
|
|
110
117
|
return false;
|
|
111
118
|
}
|
|
112
119
|
}
|
|
113
120
|
async function heartbeat() {
|
|
114
|
-
|
|
121
|
+
const c = client();
|
|
122
|
+
if (!c.identity || c.status.state === "off")
|
|
115
123
|
return;
|
|
116
124
|
const { agents, channels } = await roster();
|
|
117
125
|
const rosterNow = JSON.stringify({ agents, channels });
|
|
118
126
|
const body = {
|
|
119
|
-
...identity,
|
|
120
|
-
version: selfVersion,
|
|
121
|
-
...(rosterNow !== lastRoster ? { agents, channels } : {}),
|
|
127
|
+
...c.identity,
|
|
128
|
+
version: c.selfVersion,
|
|
129
|
+
...(rosterNow !== c.lastRoster ? { agents, channels } : {}),
|
|
122
130
|
};
|
|
123
131
|
try {
|
|
124
|
-
const r = await post(`${status.url}/api/instances/heartbeat`, body);
|
|
125
|
-
lastRoster = rosterNow;
|
|
126
|
-
status = {
|
|
127
|
-
...status, state: "connected", error: undefined, interval: r.interval, lastSeen: new Date().toISOString(),
|
|
132
|
+
const r = await post(`${c.status.url}/api/instances/heartbeat`, body);
|
|
133
|
+
c.lastRoster = rosterNow;
|
|
134
|
+
c.status = {
|
|
135
|
+
...c.status, state: "connected", error: undefined, interval: r.interval, lastSeen: new Date().toISOString(),
|
|
128
136
|
...(r.account !== undefined ? { account: r.account } : {}),
|
|
129
|
-
...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(status.url, r.claimCode) } : {}),
|
|
137
|
+
...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(c.status.url, r.claimCode) } : {}),
|
|
130
138
|
};
|
|
131
139
|
}
|
|
132
140
|
catch (err) {
|
|
133
141
|
const message = err.message;
|
|
134
|
-
status = { ...status, state: "error", error: message };
|
|
142
|
+
c.status = { ...c.status, state: "error", error: message };
|
|
135
143
|
// An unknown identity (the cloud lost it) is fixed by registering again, which hands out a new one.
|
|
136
144
|
if (/unknown instance|wrong instance secret|id and secret required/.test(message)) {
|
|
137
|
-
identity = null;
|
|
138
|
-
|
|
145
|
+
c.identity = null;
|
|
146
|
+
// Not after a goodbye: a stopped client registers nothing again.
|
|
147
|
+
if (!c.stopped && workspaceOpen())
|
|
148
|
+
await register();
|
|
139
149
|
}
|
|
140
150
|
}
|
|
141
151
|
}
|
|
142
152
|
/** Schedule the next tick after the effective interval; a failed cloud is retried at the same cadence. */
|
|
143
153
|
function schedule() {
|
|
144
|
-
|
|
154
|
+
const c = client();
|
|
155
|
+
if (c.stopped)
|
|
145
156
|
return;
|
|
146
|
-
const seconds = status.interval && status.interval > 0 ? status.interval : 60;
|
|
147
|
-
timer = setTimeout(async () => {
|
|
148
|
-
|
|
149
|
-
|
|
157
|
+
const seconds = c.status.interval && c.status.interval > 0 ? c.status.interval : 60;
|
|
158
|
+
c.timer = setTimeout(async () => {
|
|
159
|
+
// Stopped (or the workspace closed) while this timer waited, or while the call below ran: no next round.
|
|
160
|
+
if (c.stopped || !workspaceOpen())
|
|
161
|
+
return;
|
|
162
|
+
if (c.status.state === "connected" || c.status.state === "error") {
|
|
163
|
+
if (c.identity)
|
|
150
164
|
await heartbeat();
|
|
151
165
|
else
|
|
152
166
|
await register();
|
|
153
167
|
}
|
|
154
|
-
|
|
168
|
+
if (!c.stopped && workspaceOpen())
|
|
169
|
+
schedule();
|
|
155
170
|
}, seconds * 1000);
|
|
156
|
-
timer.unref();
|
|
171
|
+
c.timer.unref();
|
|
157
172
|
}
|
|
158
173
|
/**
|
|
159
174
|
* Start the client once the inspector listens. Resolves after the first
|
|
160
175
|
* registration attempt (success or not); the heartbeat loop runs on its
|
|
161
176
|
* own afterwards.
|
|
162
177
|
*/
|
|
163
|
-
export async function startCloudSync(
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
178
|
+
export async function startCloudSync(url, version) {
|
|
179
|
+
const c = client();
|
|
180
|
+
c.selfUrl = url;
|
|
181
|
+
c.selfVersion = version;
|
|
182
|
+
c.stopped = false;
|
|
167
183
|
const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
|
|
168
184
|
const cloud = project ? cloudFor(project) : undefined;
|
|
169
185
|
if (!cloud) {
|
|
170
|
-
status = { url: "", state: "off" };
|
|
186
|
+
c.status = { url: "", state: "off" };
|
|
171
187
|
return;
|
|
172
188
|
}
|
|
173
|
-
|
|
189
|
+
// The workspace may close (or the client stop) while this waits: then it does not register or schedule at all.
|
|
190
|
+
if (c.stopped || !workspaceOpen())
|
|
191
|
+
return;
|
|
192
|
+
c.status = { url: cloud.url, state: "connecting", interval: cloud.interval };
|
|
174
193
|
await register();
|
|
194
|
+
if (c.stopped || !workspaceOpen())
|
|
195
|
+
return;
|
|
175
196
|
// Until the cloud answers, retry at the configured cadence; once it does, at what it asked for.
|
|
176
|
-
if (status.state !== "connected")
|
|
177
|
-
status.interval = cloud.interval;
|
|
197
|
+
if (c.status.state !== "connected")
|
|
198
|
+
c.status.interval = cloud.interval;
|
|
178
199
|
schedule();
|
|
179
200
|
}
|
|
180
201
|
/** Stop the heartbeat loop (server close, tests). */
|
|
181
202
|
export function stopCloudSync() {
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
203
|
+
const c = client();
|
|
204
|
+
c.stopped = true;
|
|
205
|
+
if (c.timer)
|
|
206
|
+
clearTimeout(c.timer);
|
|
207
|
+
c.timer = null;
|
|
186
208
|
}
|
|
187
209
|
/**
|
|
188
210
|
* Tell the cloud we are going away, so the record flips to "stopped" now
|
|
@@ -190,11 +212,21 @@ export function stopCloudSync() {
|
|
|
190
212
|
* this, and a slow cloud must not hold the process.
|
|
191
213
|
*/
|
|
192
214
|
export async function cloudGoodbye() {
|
|
215
|
+
const c = client();
|
|
193
216
|
stopCloudSync();
|
|
194
|
-
if (!identity || status.state === "off" || !status.url)
|
|
217
|
+
if (!c.identity || c.status.state === "off" || !c.status.url)
|
|
195
218
|
return;
|
|
196
219
|
try {
|
|
197
|
-
await post(`${status.url}/api/instances/goodbye`, identity, GOODBYE_TIMEOUT_MS);
|
|
220
|
+
await post(`${c.status.url}/api/instances/goodbye`, c.identity, GOODBYE_TIMEOUT_MS);
|
|
198
221
|
}
|
|
199
222
|
catch { }
|
|
200
223
|
}
|
|
224
|
+
/** Every served workspace: stop its heartbeat (the server closes). */
|
|
225
|
+
export function stopAllCloudSync() {
|
|
226
|
+
for (const [ws] of client.entries())
|
|
227
|
+
ws.run(stopCloudSync);
|
|
228
|
+
}
|
|
229
|
+
/** Every served workspace says goodbye, in parallel (still bounded per workspace). */
|
|
230
|
+
export async function cloudGoodbyeAll() {
|
|
231
|
+
await Promise.all(client.entries().map(([ws]) => ws.run(cloudGoodbye)));
|
|
232
|
+
}
|
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
import type { Hub } from "./server.js";
|
|
2
|
+
export declare const runInHub: <T>(hub: Hub, fn: () => T) => T;
|
|
3
|
+
export declare function currentHub(): Hub;
|
|
4
|
+
/** API paths that need no workspace: the machine's own (the hub, devices and pairing, the protocol, the socket). */
|
|
5
|
+
export declare const MACHINE_PATHS: RegExp;
|
|
6
|
+
/** Route names callable over the socket without a workspace. */
|
|
7
|
+
export declare const MACHINE_ROUTES: RegExp;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
|
+
/**
|
|
3
|
+
* The hub a request runs in, like the workspace (core/workspace.ts): the
|
|
4
|
+
* machine-level routes (hub.*) find theirs here, so several hubs in one
|
|
5
|
+
* process (tests) never act on each other's workspaces.
|
|
6
|
+
*/
|
|
7
|
+
const storage = new AsyncLocalStorage();
|
|
8
|
+
export const runInHub = (hub, fn) => storage.run(hub, fn);
|
|
9
|
+
export function currentHub() {
|
|
10
|
+
const hub = storage.getStore();
|
|
11
|
+
if (!hub)
|
|
12
|
+
throw new Error("no hub here");
|
|
13
|
+
return hub;
|
|
14
|
+
}
|
|
15
|
+
/** API paths that need no workspace: the machine's own (the hub, devices and pairing, the protocol, the socket). */
|
|
16
|
+
export const MACHINE_PATHS = /^\/api\/(hub(\/|$)|pair$|devices(\/|$)|protocol$|ws$)/;
|
|
17
|
+
/** Route names callable over the socket without a workspace. */
|
|
18
|
+
export const MACHINE_ROUTES = /^(hub|devices|protocol)\./;
|
package/dist/server/live.d.ts
CHANGED
|
@@ -37,6 +37,8 @@ interface Watch {
|
|
|
37
37
|
* every change, and at least every `interval` ms. Returns the unwatch.
|
|
38
38
|
*/
|
|
39
39
|
export declare function watch(key: string, evaluate: Watch["evaluate"], interval: number, push: (data: unknown) => void): () => void;
|
|
40
|
+
/** The current workspace closes: its watches stop re-evaluating (their sockets close with it). */
|
|
41
|
+
export declare function closeWatches(): void;
|
|
40
42
|
/** Something changed: re-evaluate every watch of this workspace soon (debounced). */
|
|
41
43
|
export declare function touch(): void;
|
|
42
44
|
export {};
|
package/dist/server/live.js
CHANGED
|
@@ -71,6 +71,15 @@ export function watch(key, evaluate, interval, push) {
|
|
|
71
71
|
schedule(current);
|
|
72
72
|
};
|
|
73
73
|
}
|
|
74
|
+
/** The current workspace closes: its watches stop re-evaluating (their sockets close with it). */
|
|
75
|
+
export function closeWatches() {
|
|
76
|
+
const s = state();
|
|
77
|
+
for (const w of s.watches.values())
|
|
78
|
+
clearTimeout(w.timer);
|
|
79
|
+
s.watches.clear();
|
|
80
|
+
clearTimeout(s.touchTimer);
|
|
81
|
+
s.touchTimer = undefined;
|
|
82
|
+
}
|
|
74
83
|
/** Something changed: re-evaluate every watch of this workspace soon (debounced). */
|
|
75
84
|
export function touch() {
|
|
76
85
|
// Outside any workspace (a unit test, a CLI command) nobody can be watching.
|
package/dist/server/preview.js
CHANGED
|
@@ -108,7 +108,8 @@ export async function serveVibeable(req, res, url) {
|
|
|
108
108
|
let st = await fs.stat(abs).catch(() => null);
|
|
109
109
|
if (st?.isDirectory()) {
|
|
110
110
|
if (!url.pathname.endsWith("/")) {
|
|
111
|
-
|
|
111
|
+
// Relative, so it holds under a /w/<slug> prefix too.
|
|
112
|
+
res.writeHead(302, { location: `${url.pathname.split("/").pop()}/${url.search}`, "cache-control": "no-store" });
|
|
112
113
|
return void res.end();
|
|
113
114
|
}
|
|
114
115
|
abs = path.join(abs, "index.html");
|
package/dist/server/server.d.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import http from "node:http";
|
|
2
|
+
import { Workspace } from "../core/workspace.js";
|
|
2
3
|
import { RESTART_EXIT_CODE } from "./version.js";
|
|
3
4
|
export { RESTART_EXIT_CODE };
|
|
4
5
|
export interface InspectorOptions {
|
|
@@ -14,5 +15,55 @@ export interface InspectorOptions {
|
|
|
14
15
|
*/
|
|
15
16
|
trustLoopback?: boolean;
|
|
16
17
|
}
|
|
17
|
-
/**
|
|
18
|
+
/** A workspace a hub serves. */
|
|
19
|
+
export interface OpenWorkspace {
|
|
20
|
+
slug: string;
|
|
21
|
+
root: string;
|
|
22
|
+
name: string;
|
|
23
|
+
/** Where it is reached: <slug>.localhost:<port> under a daemon, localhost:<port> standalone. */
|
|
24
|
+
url: string;
|
|
25
|
+
/** Its own port too (kraftwerk.yml `port`), when the hub listens there for it — old links keep working. */
|
|
26
|
+
aliasPort?: number;
|
|
27
|
+
ws: Workspace;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* One HTTP server serving workspaces by slug (see target.ts): a daemon
|
|
31
|
+
* (`kraftwerk daemon`) serves every workspace that is open on the machine,
|
|
32
|
+
* a standalone `kraftwerk ui` exactly one — its default, also reached
|
|
33
|
+
* without a slug, as before. Each open workspace runs in its own Workspace
|
|
34
|
+
* (core/workspace.ts) with its own .env, background jobs (routines, git
|
|
35
|
+
* sync, cloud), registry entry and live state; closing one ends all of that
|
|
36
|
+
* and leaves the others running.
|
|
37
|
+
*/
|
|
38
|
+
export interface Hub {
|
|
39
|
+
readonly server: http.Server;
|
|
40
|
+
/** The port it listens on. */
|
|
41
|
+
readonly port: number;
|
|
42
|
+
/** Whether it is the machine's daemon (workspaces addressed by slug, none by default). */
|
|
43
|
+
readonly daemon: boolean;
|
|
44
|
+
/** Serve a workspace (idempotent: an open one is returned as it is). */
|
|
45
|
+
open(root: string, opts?: {
|
|
46
|
+
outputDir?: string;
|
|
47
|
+
}): Promise<OpenWorkspace>;
|
|
48
|
+
/** Stop serving one; false when it was not open. */
|
|
49
|
+
close(root: string): Promise<boolean>;
|
|
50
|
+
list(): OpenWorkspace[];
|
|
51
|
+
/** Close every workspace and the server. */
|
|
52
|
+
stop(): Promise<void>;
|
|
53
|
+
}
|
|
54
|
+
export interface HubOptions {
|
|
55
|
+
port: number;
|
|
56
|
+
staticDir: string;
|
|
57
|
+
trustLoopback?: boolean;
|
|
58
|
+
/** The machine's daemon: workspaces get `hub` instance files, their own ports as aliases, and no default. */
|
|
59
|
+
daemon?: boolean;
|
|
60
|
+
/** After a workspace opened or closed (the daemon notes what to reopen after a restart). */
|
|
61
|
+
onChange?: (hub: Hub) => void | Promise<void>;
|
|
62
|
+
}
|
|
63
|
+
export declare function startHub(opts: HubOptions): Promise<Hub>;
|
|
64
|
+
/**
|
|
65
|
+
* A standalone server for one workspace (`kraftwerk ui`, tests): a hub
|
|
66
|
+
* serving it as its default, reached with or without its slug. Resolves
|
|
67
|
+
* once it listens.
|
|
68
|
+
*/
|
|
18
69
|
export declare function startInspector(opts: InspectorOptions): Promise<http.Server>;
|