@netnodeag/kraftwerk 0.65.0 → 0.67.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/README.md +112 -27
  2. package/dist/acp.js +3 -1
  3. package/dist/cli/daemon.d.ts +14 -0
  4. package/dist/cli/daemon.js +60 -0
  5. package/dist/cli/doctor.js +9 -1
  6. package/dist/cli/init.js +1 -0
  7. package/dist/cli/kraftwerk.js +4 -0
  8. package/dist/cli/ui.d.ts +8 -0
  9. package/dist/cli/ui.js +35 -8
  10. package/dist/cli/workspaces.js +5 -3
  11. package/dist/client/index.d.ts +8 -0
  12. package/dist/client/index.js +2 -1
  13. package/dist/client/live.d.ts +1 -2
  14. package/dist/client/live.js +2 -1
  15. package/dist/client/routes.d.ts +3 -0
  16. package/dist/client/routes.js +3 -0
  17. package/dist/config.d.ts +16 -0
  18. package/dist/config.js +23 -1
  19. package/dist/core/chat/pi.js +2 -1
  20. package/dist/core/chat/sessions.d.ts +1 -1
  21. package/dist/core/chat/sessions.js +1 -1
  22. package/dist/core/chat/state.d.ts +2 -0
  23. package/dist/core/chat/state.js +5 -0
  24. package/dist/core/daemon.d.ts +27 -0
  25. package/dist/core/daemon.js +61 -0
  26. package/dist/core/env.d.ts +16 -0
  27. package/dist/core/env.js +45 -0
  28. package/dist/core/git.d.ts +2 -0
  29. package/dist/core/git.js +20 -4
  30. package/dist/core/instances.d.ts +34 -12
  31. package/dist/core/instances.js +142 -66
  32. package/dist/core/routines.d.ts +6 -0
  33. package/dist/core/routines.js +11 -0
  34. package/dist/core/runner.js +7 -3
  35. package/dist/core/search.d.ts +2 -0
  36. package/dist/core/search.js +1 -0
  37. package/dist/core/vibeables.d.ts +2 -0
  38. package/dist/core/vibeables.js +17 -5
  39. package/dist/core/workspace.d.ts +13 -0
  40. package/dist/core/workspace.js +25 -0
  41. package/dist/server/api/hub.d.ts +25 -0
  42. package/dist/server/api/hub.js +32 -0
  43. package/dist/server/api/index.d.ts +35 -3
  44. package/dist/server/api/index.js +2 -0
  45. package/dist/server/api/socket.d.ts +9 -1
  46. package/dist/server/api/socket.js +27 -7
  47. package/dist/server/api/workspace.d.ts +16 -2
  48. package/dist/server/api/workspace.js +44 -4
  49. package/dist/server/cloud.d.ts +5 -1
  50. package/dist/server/cloud.js +86 -54
  51. package/dist/server/hub-context.d.ts +7 -0
  52. package/dist/server/hub-context.js +18 -0
  53. package/dist/server/live.d.ts +2 -0
  54. package/dist/server/live.js +9 -0
  55. package/dist/server/preview.js +2 -1
  56. package/dist/server/server.d.ts +52 -1
  57. package/dist/server/server.js +283 -52
  58. package/dist/server/target.d.ts +20 -0
  59. package/dist/server/target.js +18 -0
  60. package/package.json +1 -1
  61. package/schema/kraftwerk.schema.json +5 -0
  62. package/web/dist/assets/{dist-10uPVFvu.js → dist-BMZBs9W4.js} +1 -1
  63. package/web/dist/assets/{dist-v6-49xpW.js → dist-BkZKygT6.js} +1 -1
  64. package/web/dist/assets/{dist-Bnxg6V5L.js → dist-CZjwFXw-.js} +1 -1
  65. package/web/dist/assets/{dist-DS1R2xKJ.js → dist-D7BYSDIr.js} +1 -1
  66. package/web/dist/assets/dist-DZLlNafq.js +1 -0
  67. package/web/dist/assets/{dist-BCfXUgtf.js → dist-D_nqqdyh.js} +1 -1
  68. package/web/dist/assets/{dist-nA7q2-h7.js → dist-IO8GeoYh.js} +1 -1
  69. package/web/dist/assets/{dist-hevbMnoj.js → dist-LUKHNpC7.js} +1 -1
  70. package/web/dist/assets/{dist-DnMgiPT1.js → dist-Y0KLvK_g.js} +1 -1
  71. package/web/dist/assets/{dist-C-CclD1E.js → dist-aMqhK50K.js} +1 -1
  72. package/web/dist/assets/{dist-CAWmDGJC.js → dist-jyZ9dxfk.js} +1 -1
  73. package/web/dist/assets/{editor-C8yy8YS9.js → editor-DQouCIUq.js} +3 -3
  74. package/web/dist/assets/index-D57IrV_V.js +229 -0
  75. package/web/dist/index.html +1 -1
  76. package/web/dist/assets/dist-yoh47aAL.js +0 -1
  77. package/web/dist/assets/index-DWS1u71Y.js +0 -229
@@ -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
- let status = { url: "", state: "off" };
14
- let identity = null;
15
- let timer = null;
16
- let stopped = false;
17
- let lastRoster = "";
18
- let selfPort = 0;
19
- let selfVersion = "";
20
- const startedAt = new Date().toISOString();
21
- export const cloudStatus = () => status;
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: `http://localhost:${selfPort}`,
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
- if (!identity || status.state === "off")
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
- await register();
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
- if (stopped)
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
- if (status.state === "connected" || status.state === "error") {
149
- if (identity)
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
- schedule();
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(port, version) {
164
- selfPort = port;
165
- selfVersion = version;
166
- stopped = false;
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
- status = { url: cloud.url, state: "connecting", interval: cloud.interval };
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
- stopped = true;
183
- if (timer)
184
- clearTimeout(timer);
185
- timer = null;
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)\./;
@@ -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 {};
@@ -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.
@@ -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
- res.writeHead(302, { location: `${url.pathname}/${url.search}`, "cache-control": "no-store" });
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");
@@ -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
- /** Start the server; resolves once it listens. Runs until the process ends. */
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>;