@netnodeag/kraftwerk 0.75.1 → 0.77.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 (63) hide show
  1. package/README.md +33 -27
  2. package/dist/cli/cloud.d.ts +2 -0
  3. package/dist/cli/cloud.js +262 -0
  4. package/dist/cli/daemon-control.js +4 -2
  5. package/dist/cli/daemon.js +9 -0
  6. package/dist/cli/doctor.d.ts +3 -1
  7. package/dist/cli/doctor.js +51 -9
  8. package/dist/cli/init.js +7 -4
  9. package/dist/cli/kraftwerk.js +13 -5
  10. package/dist/cli/routines.d.ts +2 -1
  11. package/dist/cli/routines.js +40 -4
  12. package/dist/cli/whoami.d.ts +9 -0
  13. package/dist/cli/whoami.js +48 -0
  14. package/dist/client/routes.d.ts +7 -0
  15. package/dist/client/routes.js +7 -0
  16. package/dist/config.d.ts +35 -28
  17. package/dist/config.js +43 -22
  18. package/dist/core/attention.d.ts +8 -1
  19. package/dist/core/attention.js +38 -2
  20. package/dist/core/cloud.d.ts +115 -0
  21. package/dist/core/cloud.js +94 -0
  22. package/dist/core/identity.d.ts +46 -0
  23. package/dist/core/identity.js +106 -0
  24. package/dist/core/routines.d.ts +81 -0
  25. package/dist/core/routines.js +174 -33
  26. package/dist/server/api/agents.d.ts +5 -1
  27. package/dist/server/api/agents.js +19 -2
  28. package/dist/server/api/cloud.d.ts +72 -0
  29. package/dist/server/api/cloud.js +38 -0
  30. package/dist/server/api/devices.d.ts +8 -8
  31. package/dist/server/api/index.d.ts +102 -13
  32. package/dist/server/api/index.js +2 -0
  33. package/dist/server/api/runs.d.ts +3 -1
  34. package/dist/server/api/runs.js +9 -1
  35. package/dist/server/api/workspace.d.ts +22 -2
  36. package/dist/server/api/workspace.js +7 -1
  37. package/dist/server/cloud.d.ts +65 -33
  38. package/dist/server/cloud.js +207 -188
  39. package/dist/server/hub-context.d.ts +1 -1
  40. package/dist/server/hub-context.js +3 -3
  41. package/dist/server/push.js +1 -1
  42. package/dist/server/relay.d.ts +13 -0
  43. package/dist/server/relay.js +29 -1
  44. package/dist/server/server.d.ts +3 -2
  45. package/dist/server/server.js +6 -6
  46. package/package.json +1 -1
  47. package/schema/kraftwerk.schema.json +9 -4
  48. package/web/dist/assets/{dist-CezfArgg.js → dist-BGHweZKi.js} +1 -1
  49. package/web/dist/assets/{dist-Y9aHncjU.js → dist-BVPxOqcx.js} +1 -1
  50. package/web/dist/assets/{dist-CLZjnhZZ.js → dist-CCNyRSAA.js} +1 -1
  51. package/web/dist/assets/{dist-QqFmuPda.js → dist-CXoAMU1b.js} +1 -1
  52. package/web/dist/assets/{dist-BI4umsk6.js → dist-DCFREMXk.js} +1 -1
  53. package/web/dist/assets/{dist-YNCNGGgK.js → dist-DT2Tjvwx.js} +1 -1
  54. package/web/dist/assets/{dist-CzM6tn0f.js → dist-DdA5PoVJ.js} +1 -1
  55. package/web/dist/assets/{dist-CYicby0c.js → dist-DoD3qX1P.js} +1 -1
  56. package/web/dist/assets/{dist-CODxhtAK.js → dist-FFA4-S6d.js} +1 -1
  57. package/web/dist/assets/{dist-DaGnyH_l.js → dist-yGx0qYbw.js} +1 -1
  58. package/web/dist/assets/dist-zjUUP5a9.js +1 -0
  59. package/web/dist/assets/{editor-DEb8z5-Q.js → editor-CyWapLQr.js} +3 -3
  60. package/web/dist/assets/index-CNXcNoKJ.js +231 -0
  61. package/web/dist/index.html +1 -1
  62. package/web/dist/assets/dist-DtktO5hc.js +0 -1
  63. package/web/dist/assets/index-kmyK4tFm.js +0 -231
@@ -1,232 +1,251 @@
1
- import { createHash } from "node:crypto";
2
1
  import { promises as fs } from "node:fs";
3
2
  import os from "node:os";
4
- import path from "node:path";
5
- import { cloudFor, resolveWorkspace } from "../config.js";
6
- import { workspaceRoot } from "../core/context.js";
7
- import { perWorkspace, workspaceOpen } from "../core/workspace.js";
8
- import { listAgents, toSummary } from "../core/agents.js";
9
- import { listChannels, toChannelSummary } from "../core/channels.js";
10
- import { tildify } from "../core/instances.js";
11
- const claimUrlFor = (url, code) => (code ? `${url}/claim/${encodeURIComponent(code)}` : null);
12
- const REQUEST_TIMEOUT_MS = 8_000;
13
- const GOODBYE_TIMEOUT_MS = 800;
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,
3
+ import { resolveWorkspace } from "../config.js";
4
+ import { claimUrlFor, machineCloud, machineGoodbye, machineHeartbeat, machineRegister, machineUnlink, retiredCloudDir, unknownMachine, } from "../core/cloud.js";
5
+ import { identityString, readIdentity } from "../core/identity.js";
6
+ import { machineCredentials } from "./relay.js";
7
+ const DEFAULT_INTERVAL = 60;
8
+ const STANDALONE = { url: "", state: "off", reason: "only the daemon registers this machine with the cloud (`kraftwerk daemon start`)" };
9
+ const m = {
10
+ status: { ...STANDALONE },
11
+ creds: null,
12
+ registered: false,
18
13
  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;
27
- const cloudDir = () => path.join(os.homedir(), ".kraftwerk", "cloud");
28
- const identityFile = (url, root) => path.join(cloudDir(), `${createHash("sha1").update(`${url}\n${root}`).digest("hex").slice(0, 16)}.json`);
29
- async function readIdentity(file) {
30
- try {
31
- const rec = JSON.parse(await fs.readFile(file, "utf8"));
32
- return typeof rec.id === "string" && typeof rec.secret === "string" ? { id: rec.id, secret: rec.secret } : null;
33
- }
34
- catch {
35
- return null;
36
- }
14
+ started: false,
15
+ owner: null,
16
+ source: (() => []),
17
+ version: "",
18
+ /** The workspace list the cloud last acknowledged, as JSON: sent again only when it differs. */
19
+ reported: "",
20
+ /** One round at a time: a timer tick, a sync from the CLI and a workspace change never overlap. */
21
+ lock: Promise.resolve(),
22
+ warned: new Set(),
23
+ };
24
+ function serial(fn) {
25
+ const run = m.lock.then(fn, fn);
26
+ m.lock = run.catch(() => { });
27
+ return run;
37
28
  }
38
- async function writeIdentity(file, url, root, id) {
39
- try {
40
- await fs.mkdir(cloudDir(), { recursive: true });
41
- await fs.writeFile(file, JSON.stringify({ ...id, url, root, updatedAt: new Date().toISOString() }), { mode: 0o600 });
29
+ export const cloudStatus = () => m.status;
30
+ /** The open workspaces as the cloud lists them: vetoed ones left out, roots only once claimed. */
31
+ async function entries(withRoots) {
32
+ const list = [];
33
+ const ymlUrls = [];
34
+ for (const o of m.source()) {
35
+ const ws = await resolveWorkspace(o.root).catch(() => null);
36
+ const c = ws?.config.cloud;
37
+ if (c?.enabled === false)
38
+ continue;
39
+ if (c?.url)
40
+ ymlUrls.push(c.url.replace(/\/+$/, ""));
41
+ list.push({ ...(ws?.config.id ? { id: ws.config.id } : {}), name: o.name, slug: o.slug, ...(withRoots ? { root: o.root } : {}) });
42
42
  }
43
- catch { } // best-effort — the next start registers again
44
- }
45
- /** The agents and channels of this workspace, as the cloud lists them. */
46
- async function roster() {
47
- const [agents, channels] = await Promise.all([
48
- listAgents().then((l) => l.map(toSummary)).catch(() => []),
49
- listChannels().then((l) => l.map(toChannelSummary)).catch(() => []),
50
- ]);
51
- return { agents, channels };
43
+ return { list, ymlUrls };
52
44
  }
53
- async function post(url, body, timeout = REQUEST_TIMEOUT_MS) {
54
- const c = client();
55
- const r = await fetch(url, {
56
- method: "POST",
57
- headers: { "content-type": "application/json", "user-agent": `kraftwerk/${c.selfVersion}` },
58
- body: JSON.stringify(body),
59
- signal: AbortSignal.timeout(timeout),
60
- });
61
- const text = await r.text();
62
- let data;
63
- try {
64
- data = text ? JSON.parse(text) : {};
45
+ const claimed = () => typeof m.status.account === "string" && m.status.account.length > 0;
46
+ function warnYml(cloud, ymlUrls) {
47
+ if (!ymlUrls.length)
48
+ return;
49
+ const key = `${cloud.source}:${ymlUrls[0]}`;
50
+ if (m.warned.has(key))
51
+ return;
52
+ m.warned.add(key);
53
+ if (cloud.source === "kraftwerk.yml") {
54
+ console.warn(`⚠ kraftwerk.yml cloud.url is deprecated — the cloud is the machine's now: \`kraftwerk cloud on --url ${cloud.url}\` (using ${cloud.url} for now)`);
65
55
  }
66
- catch {
67
- throw new Error(`${r.status} from ${url}: not JSON`);
56
+ else {
57
+ console.warn(`⚠ kraftwerk.yml cloud.url (${ymlUrls[0]}) is ignored: the machine's cloud is ${cloud.url} (${cloud.source}); remove it from kraftwerk.yml`);
68
58
  }
69
- if (!r.ok)
70
- throw new Error(data.error ?? `${r.status} from ${url}`);
71
- return data;
72
59
  }
73
- /** Register (or re-register) this instance. Returns false when the cloud refused or is unreachable. */
74
60
  async function register() {
75
- const c = client();
76
- const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
77
- const cloud = project ? cloudFor(project) : undefined;
78
- if (!project || !cloud)
61
+ const { list, ymlUrls } = await entries(false);
62
+ const cloud = await machineCloud(ymlUrls);
63
+ warnYml(cloud, ymlUrls);
64
+ if (!cloud.enabled) {
65
+ m.status = { url: cloud.url, state: "off", source: cloud.source, ...(cloud.reason ? { reason: cloud.reason } : {}) };
79
66
  return false;
80
- const file = identityFile(cloud.url, project.root);
81
- c.identity ??= await readIdentity(file);
82
- const { agents, channels } = await roster();
83
- c.lastRoster = JSON.stringify({ agents, channels });
84
- const body = {
85
- ...(c.identity ?? {}),
86
- name: project.config.name ?? path.basename(project.root),
87
- icon: project.config.icon ?? "",
88
- color: project.config.color ?? "",
89
- root: project.root,
90
- rootLabel: tildify(project.root),
91
- hostname: os.hostname(),
92
- platform: `${process.platform}-${process.arch}`,
93
- version: c.selfVersion,
94
- url: c.selfUrl,
95
- // Part of the cloud's register contract; always "" since the Cloudflare Tunnel was removed (0.65).
96
- publicUrl: "",
97
- agents,
98
- channels,
99
- interval: cloud.interval,
100
- startedAt: c.startedAt,
101
- ...(cloud.token ? { accountToken: cloud.token } : {}),
102
- };
67
+ }
68
+ const identity = await readIdentity();
103
69
  try {
104
- const r = await post(`${cloud.url}/api/instances/register`, body);
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);
108
- }
109
- c.status = {
110
- url: cloud.url, state: "connected", id: r.id, account: r.account, interval: r.interval, lastSeen: new Date().toISOString(),
111
- claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(cloud.url, r.claimCode),
70
+ m.creds ??= await machineCredentials();
71
+ }
72
+ catch (err) {
73
+ m.status = { url: cloud.url, source: cloud.source, state: "error", error: err.message };
74
+ return false;
75
+ }
76
+ const base = { url: cloud.url, source: cloud.source, id: m.creds.id, machine: identityString(identity) };
77
+ if (m.status.state === "off" || m.status.url !== cloud.url)
78
+ m.status = { ...base, state: "connecting" };
79
+ try {
80
+ const r = await machineRegister(cloud.url, m.creds, {
81
+ name: identity.machine,
82
+ handle: identity.handle,
83
+ hostname: os.hostname(),
84
+ platform: `${process.platform}-${process.arch}`,
85
+ version: m.version,
86
+ }, list);
87
+ m.registered = true;
88
+ // An unclaimed register carries no roots: the list counts as reported only when that is all the cloud should know.
89
+ m.reported = r.account ? "" : JSON.stringify(list);
90
+ m.status = {
91
+ ...base, state: "connected", account: r.account ?? null, interval: r.interval || DEFAULT_INTERVAL, lastSeen: new Date().toISOString(),
92
+ claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(cloud.url, r.claimCode), workspaces: list.length,
112
93
  };
94
+ // Already claimed: the roots follow at once.
95
+ if (claimed())
96
+ await heartbeat();
113
97
  return true;
114
98
  }
115
99
  catch (err) {
116
- c.status = { url: cloud.url, state: "error", error: err.message, ...(c.identity ? { id: c.identity.id } : {}) };
100
+ m.registered = false;
101
+ m.status = { ...m.status, ...base, state: "error", error: err.message };
117
102
  return false;
118
103
  }
119
104
  }
120
105
  async function heartbeat() {
121
- const c = client();
122
- if (!c.identity || c.status.state === "off")
106
+ if (!m.creds || !m.registered)
123
107
  return;
124
- const { agents, channels } = await roster();
125
- const rosterNow = JSON.stringify({ agents, channels });
126
- const body = {
127
- ...c.identity,
128
- version: c.selfVersion,
129
- ...(rosterNow !== c.lastRoster ? { agents, channels } : {}),
130
- };
108
+ const wasClaimed = claimed();
109
+ const { list } = await entries(wasClaimed);
110
+ const now = JSON.stringify(list);
131
111
  try {
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(),
112
+ const r = await machineHeartbeat(m.status.url, m.creds, { version: m.version, ...(now !== m.reported ? { workspaces: list } : {}) });
113
+ m.reported = now;
114
+ m.status = {
115
+ ...m.status, state: "connected", error: undefined, interval: r.interval || m.status.interval || DEFAULT_INTERVAL, lastSeen: new Date().toISOString(),
136
116
  ...(r.account !== undefined ? { account: r.account } : {}),
137
- ...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(c.status.url, r.claimCode) } : {}),
117
+ ...(r.claimCode !== undefined ? { claimCode: r.claimCode, claimUrl: claimUrlFor(m.status.url, r.claimCode) } : {}),
118
+ workspaces: list.length,
138
119
  };
120
+ // Just claimed: the roots go out now, not a whole interval later.
121
+ if (!wasClaimed && claimed())
122
+ await heartbeat();
139
123
  }
140
124
  catch (err) {
141
- const message = err.message;
142
- c.status = { ...c.status, state: "error", error: message };
143
- // An unknown identity (the cloud lost it) is fixed by registering again, which hands out a new one.
144
- if (/unknown instance|wrong instance secret|id and secret required/.test(message)) {
145
- c.identity = null;
146
- // Not after a goodbye: a stopped client registers nothing again.
147
- if (!c.stopped && workspaceOpen())
125
+ m.status = { ...m.status, state: "error", error: err.message };
126
+ if (unknownMachine(err)) {
127
+ m.registered = false;
128
+ if (m.started)
148
129
  await register();
149
130
  }
150
131
  }
151
132
  }
152
- /** Schedule the next tick after the effective interval; a failed cloud is retried at the same cadence. */
133
+ /** One round: register when not registered, else heartbeat. */
134
+ const round = () => serial(async () => {
135
+ if (!m.started)
136
+ return;
137
+ if (m.registered)
138
+ await heartbeat();
139
+ else
140
+ await register();
141
+ });
153
142
  function schedule() {
154
- const c = client();
155
- if (c.stopped)
143
+ if (m.timer)
144
+ clearTimeout(m.timer);
145
+ m.timer = null;
146
+ if (!m.started || m.status.state === "off")
156
147
  return;
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)
164
- await heartbeat();
165
- else
166
- await register();
167
- }
168
- if (!c.stopped && workspaceOpen())
169
- schedule();
148
+ const seconds = m.status.interval && m.status.interval > 0 ? m.status.interval : DEFAULT_INTERVAL;
149
+ m.timer = setTimeout(async () => {
150
+ m.timer = null;
151
+ await round();
152
+ schedule();
170
153
  }, seconds * 1000);
171
- c.timer.unref();
154
+ m.timer.unref();
172
155
  }
173
156
  /**
174
- * Start the client once the inspector listens. Resolves after the first
175
- * registration attempt (success or not); the heartbeat loop runs on its
176
- * own afterwards.
157
+ * Start the machine client (the daemon, once its workspaces are open).
158
+ * Resolves after the first registration attempt; the heartbeat runs on its
159
+ * own afterwards. A second start in the same process is ignored.
177
160
  */
178
- export async function startCloudSync(url, version) {
179
- const c = client();
180
- c.selfUrl = url;
181
- c.selfVersion = version;
182
- c.stopped = false;
183
- const project = await resolveWorkspace(workspaceRoot()).catch(() => null);
184
- const cloud = project ? cloudFor(project) : undefined;
185
- if (!cloud) {
186
- c.status = { url: "", state: "off" };
187
- return;
188
- }
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 };
193
- await register();
194
- if (c.stopped || !workspaceOpen())
195
- return;
196
- // Until the cloud answers, retry at the configured cadence; once it does, at what it asked for.
197
- if (c.status.state !== "connected")
198
- c.status.interval = cloud.interval;
161
+ export async function startMachineCloud(opts) {
162
+ if (m.started)
163
+ return m.status;
164
+ m.started = true;
165
+ m.owner = opts.owner ?? null;
166
+ m.source = opts.workspaces;
167
+ m.version = opts.version;
168
+ m.status = { url: "", state: "off" };
169
+ // The per-workspace identities of before (one record per workspace) are retired with the machine record.
170
+ await fs.rm(retiredCloudDir(), { recursive: true, force: true }).catch(() => { });
171
+ // The machine's id exists from the daemon's first start on, with the relay still off.
172
+ m.creds = await machineCredentials().catch(() => null);
173
+ await serial(register);
199
174
  schedule();
200
- }
201
- /** Stop the heartbeat loop (server close, tests). */
202
- export function stopCloudSync() {
203
- const c = client();
204
- c.stopped = true;
205
- if (c.timer)
206
- clearTimeout(c.timer);
207
- c.timer = null;
175
+ return m.status;
208
176
  }
209
177
  /**
210
- * Tell the cloud we are going away, so the record flips to "stopped" now
211
- * instead of after the grace period. Bounded: an exit handler waits for
212
- * this, and a slow cloud must not hold the process.
178
+ * Settings changed (`kraftwerk cloud on|off`, a URL): turn the client on or
179
+ * off to match; off says goodbye first. Without a started client (a
180
+ * standalone server) nothing happens.
213
181
  */
214
- export async function cloudGoodbye() {
215
- const c = client();
216
- stopCloudSync();
217
- if (!c.identity || c.status.state === "off" || !c.status.url)
218
- return;
219
- try {
220
- await post(`${c.status.url}/api/instances/goodbye`, c.identity, GOODBYE_TIMEOUT_MS);
182
+ export async function applyMachineCloud() {
183
+ if (!m.started)
184
+ return m.status;
185
+ const cloud = await machineCloud((await entries(false)).ymlUrls);
186
+ if (!cloud.enabled || (m.registered && cloud.url !== m.status.url)) {
187
+ await serial(async () => {
188
+ if (m.registered && m.creds)
189
+ await machineGoodbye(m.status.url, m.creds, m.version).catch(() => { });
190
+ m.registered = false;
191
+ m.reported = "";
192
+ });
221
193
  }
222
- catch { }
194
+ if (!cloud.enabled) {
195
+ if (m.timer)
196
+ clearTimeout(m.timer);
197
+ m.timer = null;
198
+ m.status = { url: cloud.url, state: "off", source: cloud.source, ...(cloud.reason ? { reason: cloud.reason } : {}) };
199
+ return m.status;
200
+ }
201
+ if (!m.registered)
202
+ await serial(register);
203
+ schedule();
204
+ return m.status;
205
+ }
206
+ /** A round now (the CLI's `login` waits for a claim): registers when needed, else heartbeats. */
207
+ export async function syncMachineCloud() {
208
+ if (!m.started)
209
+ return m.status;
210
+ if (m.status.state === "off")
211
+ return applyMachineCloud();
212
+ await round();
213
+ schedule();
214
+ return m.status;
223
215
  }
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);
216
+ /** Release the claim (`kraftwerk logout`): the cloud hands out a fresh claim code. */
217
+ export async function unlinkMachineCloud() {
218
+ if (!m.started || m.status.state === "off")
219
+ return m.status;
220
+ return serial(async () => {
221
+ if (!m.registered)
222
+ await register();
223
+ if (!m.creds || !m.registered)
224
+ throw new Error(m.status.error ?? "not registered with the cloud");
225
+ const r = await machineUnlink(m.status.url, m.creds, m.version);
226
+ // The next heartbeat sends the list again — without roots now.
227
+ m.reported = "";
228
+ m.status = { ...m.status, account: null, claimCode: r.claimCode ?? null, claimUrl: claimUrlFor(m.status.url, r.claimCode) };
229
+ return m.status;
230
+ });
228
231
  }
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
+ /**
233
+ * Stop the client and tell the cloud the machine is going away (bounded:
234
+ * an exit handler waits for this). `owner`: only the client that owner
235
+ * started (a hub stopping in a test leaves another's alone).
236
+ */
237
+ export async function stopMachineCloud(owner) {
238
+ if (!m.started || (owner !== undefined && owner !== m.owner))
239
+ return;
240
+ m.started = false;
241
+ if (m.timer)
242
+ clearTimeout(m.timer);
243
+ m.timer = null;
244
+ const { creds, registered } = m;
245
+ const url = m.status.url;
246
+ m.registered = false;
247
+ m.reported = "";
248
+ m.status = { ...STANDALONE };
249
+ if (registered && creds && url)
250
+ await machineGoodbye(url, creds, m.version).catch(() => { });
232
251
  }
@@ -1,7 +1,7 @@
1
1
  import type { Hub } from "./server.js";
2
2
  export declare const runInHub: <T>(hub: Hub, fn: () => T) => T;
3
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). */
4
+ /** API paths that need no workspace: the machine's own (the hub, devices and pairing, the cloud record, the protocol, the socket). */
5
5
  export declare const MACHINE_PATHS: RegExp;
6
6
  /** Route names callable over the socket without a workspace. */
7
7
  export declare const MACHINE_ROUTES: RegExp;
@@ -12,7 +12,7 @@ export function currentHub() {
12
12
  throw new Error("no hub here");
13
13
  return hub;
14
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$)/;
15
+ /** API paths that need no workspace: the machine's own (the hub, devices and pairing, the cloud record, the protocol, the socket). */
16
+ export const MACHINE_PATHS = /^\/api\/(hub(\/|$)|pair$|devices(\/|$)|cloud(\/|$)|protocol$|ws$)/;
17
17
  /** Route names callable over the socket without a workspace. */
18
- export const MACHINE_ROUTES = /^(hub|devices|protocol)\./;
18
+ export const MACHINE_ROUTES = /^(hub|devices|cloud|protocol)\./;
@@ -11,7 +11,7 @@ export function sealPush(keyBase64, content) {
11
11
  const ct = Buffer.concat([cipher.update(JSON.stringify(content), "utf8"), cipher.final()]);
12
12
  return Buffer.concat([nonce, ct, cipher.getAuthTag()]).toString("base64");
13
13
  }
14
- const LABEL = { approval: "Approval", question: "Question", failed: "Failed" };
14
+ const LABEL = { approval: "Approval", question: "Question", failed: "Failed", runner: "Routines" };
15
15
  export function contentOf(item, workspace) {
16
16
  return {
17
17
  title: `${LABEL[item.kind] ?? "Needs you"} · ${workspace.name}`,
@@ -44,6 +44,19 @@ export interface RelayStatus {
44
44
  export declare function readRelaySettings(): Promise<RelaySettings | null>;
45
45
  /** Turn the relay on or off (creating the machine's key on first use); `url` switches relays. */
46
46
  export declare function setRelay(enabled: boolean, url?: string): Promise<RelaySettings>;
47
+ /**
48
+ * The machine's key pair and secret, created (with the relay staying off)
49
+ * when relay.json is missing: the daemon's first start, `kraftwerk login`.
50
+ * The cloud knows the machine by the same id and secret the relay does; a
51
+ * damaged file is refused, never overwritten (it holds the id).
52
+ */
53
+ export declare function ensureRelayKeys(): Promise<RelaySettings>;
54
+ /** The machine's id (its relay id), public key and secret: what the cloud knows it by. */
55
+ export declare function machineCredentials(): Promise<{
56
+ id: string;
57
+ key: string;
58
+ secret: string;
59
+ }>;
47
60
  /** wss://host/api/relay/<role> for a cloud URL. */
48
61
  export declare const relayEndpoint: (cloudUrl: string, role: "server" | "client") => string;
49
62
  /**
@@ -3,7 +3,7 @@ import { promises as fs } from "node:fs";
3
3
  import http from "node:http";
4
4
  import os from "node:os";
5
5
  import path from "node:path";
6
- import { daemonHandshake, exportKeyPair, generateKeyPair, importKeyPair, relayId, toBase64, fromBase64, } from "../client/relay.js";
6
+ import { daemonHandshake, exportKeyPair, generateKeyPair, importKeyPair, relayId, toBase64, fromBase64, fromBase64Url, } from "../client/relay.js";
7
7
  import { CLOUD_DEFAULT_URL } from "../config.js";
8
8
  import { dropPushToken } from "../core/devices.js";
9
9
  const settingsFile = () => path.join(os.homedir(), ".kraftwerk", "relay.json");
@@ -36,6 +36,34 @@ export async function setRelay(enabled, url) {
36
36
  await writeRelaySettings(s);
37
37
  return s;
38
38
  }
39
+ /**
40
+ * The machine's key pair and secret, created (with the relay staying off)
41
+ * when relay.json is missing: the daemon's first start, `kraftwerk login`.
42
+ * The cloud knows the machine by the same id and secret the relay does; a
43
+ * damaged file is refused, never overwritten (it holds the id).
44
+ */
45
+ export async function ensureRelayKeys() {
46
+ const s = await readRelaySettings();
47
+ if (s)
48
+ return s;
49
+ try {
50
+ await fs.access(settingsFile());
51
+ throw new Error(`${settingsFile()} is damaged — fix it or remove it (removing gives this machine a new id)`);
52
+ }
53
+ catch (err) {
54
+ if (err.code !== "ENOENT")
55
+ throw err;
56
+ }
57
+ const pair = await exportKeyPair(await generateKeyPair());
58
+ const fresh = { enabled: false, url: CLOUD_DEFAULT_URL, ...pair, secret: randomBytes(32).toString("base64url") };
59
+ await writeRelaySettings(fresh);
60
+ return fresh;
61
+ }
62
+ /** The machine's id (its relay id), public key and secret: what the cloud knows it by. */
63
+ export async function machineCredentials() {
64
+ const s = await ensureRelayKeys();
65
+ return { id: await relayId(fromBase64Url(s.publicKey)), key: s.publicKey, secret: s.secret };
66
+ }
39
67
  /** wss://host/api/relay/<role> for a cloud URL. */
40
68
  export const relayEndpoint = (cloudUrl, role) => `${cloudUrl.replace(/\/+$/, "").replace(/^http/, "ws")}/api/relay/${role}`;
41
69
  /**
@@ -34,8 +34,9 @@ export interface OpenWorkspace {
34
34
  * a standalone `kraftwerk ui` exactly one — its default, also reached
35
35
  * without a slug, as before. Each open workspace runs in its own Workspace
36
36
  * (core/workspace.ts) with its own .env, background jobs (routines, git
37
- * sync, cloud), registry entry and live state; closing one ends all of that
38
- * and leaves the others running.
37
+ * sync), registry entry and live state; closing one ends all of that
38
+ * and leaves the others running. The daemon's hub also carries the
39
+ * machine's cloud record (server/cloud.ts), which lists the open workspaces.
39
40
  */
40
41
  export interface Hub {
41
42
  readonly server: http.Server;
@@ -3,7 +3,7 @@ import http from "node:http";
3
3
  import net from "node:net";
4
4
  import path from "node:path";
5
5
  import { setDefaultWorkspace, Workspace } from "../core/workspace.js";
6
- import { cloudGoodbye, cloudGoodbyeAll, startCloudSync, stopCloudSync } from "./cloud.js";
6
+ import { stopMachineCloud } from "./cloud.js";
7
7
  import { disposeAllBackends, disposeWorkspaceBackends } from "../core/chat/sessions.js";
8
8
  import { markWorkspaceStopped, registerInstance, registerWorkspace, unregisterInstance } from "../core/instances.js";
9
9
  import { startGitSync, stopGitSync } from "../core/git.js";
@@ -156,8 +156,8 @@ function installExitHandlers() {
156
156
  // Stamp first: unregistering forgets which workspaces this process served.
157
157
  markWorkspaceStopped();
158
158
  unregisterInstance();
159
- // The cloud goodbye is bounded and a no-op without cloud:, so the exit stays prompt.
160
- void cloudGoodbyeAll().finally(() => process.exit(sig === "SIGINT" ? 130 : 143));
159
+ // The machine's cloud goodbye is bounded and a no-op outside the daemon, so the exit stays prompt.
160
+ void stopMachineCloud().finally(() => process.exit(sig === "SIGINT" ? 130 : 143));
161
161
  });
162
162
  }
163
163
  process.once("exit", (code) => {
@@ -345,6 +345,9 @@ export async function startHub(opts) {
345
345
  await hub.close(o.root);
346
346
  opts.onChange = notify;
347
347
  hubs.delete(hub);
348
+ // The daemon's machine client (started by `kraftwerk daemon`) says goodbye with the hub it reports.
349
+ if (opts.daemon)
350
+ await stopMachineCloud(hub);
348
351
  // A connected browser tab (its socket, a keep-alive connection) must not hold the stop open.
349
352
  for (const s of [...sockets])
350
353
  s.close();
@@ -408,7 +411,6 @@ export async function startHub(opts) {
408
411
  // Best-effort, in the background: none of them may hold up (or, failing, break) the open.
409
412
  entry.registered = registerInstance(entry.aliasPort ?? port, abs, opts.daemon ? { hub: true, slug, url: entry.url } : {}).catch(() => { });
410
413
  void registerWorkspace(abs).catch(() => { });
411
- void startCloudSync(entry.url, version).catch(() => { });
412
414
  }));
413
415
  await noteChange();
414
416
  return entry;
@@ -434,8 +436,6 @@ export async function startHub(opts) {
434
436
  closeWatches();
435
437
  disposeWorkspaceBackends();
436
438
  disposeWorkspaceDevs();
437
- await cloudGoodbye();
438
- stopCloudSync();
439
439
  }));
440
440
  await entry.registered;
441
441
  unregisterInstance(abs);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@netnodeag/kraftwerk",
3
- "version": "0.75.1",
3
+ "version": "0.77.0",
4
4
  "description": "Open-source agentic workspace for teams — where people and AI agents get work done together: agents (Claude Code, Codex, Pi), skills, shared knowledge, repeatable workflows, and deterministic verification",
5
5
  "keywords": [
6
6
  "agents",