@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
package/README.md CHANGED
@@ -296,8 +296,8 @@ fields are optional. `workflows:` sets the workflows root, `output:` the
296
296
  run-artifact directory (default `output/`), `knowledge:` the OKF bundle root
297
297
  (default `knowledge/`), and `agents:` the agent-definition root (default
298
298
  `agents/`). `repos:` turns on the [repositories](#repositories) folder.
299
- `cloud:` tunes (or turns off) the registration with the
300
- [kraftwerk cloud](#inspector-in-the-kraftwerk-cloud), which is on by default.
299
+ `cloud: { enabled: false }` leaves the workspace out of this machine's
300
+ record in the [kraftwerk cloud](#this-machine-in-the-kraftwerk-cloud).
301
301
 
302
302
  `name:` is the title shown in the UI and can change freely. `slug:` is the
303
303
  workspace's address on this machine. It defaults to the folder name
@@ -486,38 +486,44 @@ repo. Agent logins made inside the container persist in the `agent-home`
486
486
  volume. This is a different image from the `kraftwerk-runner` sandbox
487
487
  (`runner/Dockerfile`) used by `run --sandbox`.
488
488
 
489
- ### Inspector in the kraftwerk cloud
489
+ ### This machine in the kraftwerk cloud
490
490
 
491
491
  The kraftwerk cloud is the `~/.kraftwerk` registry made reachable over the
492
- internet: every `kraftwerk ui` registers there when it starts and sends a
493
- heartbeat while it runs, so you can see from anywhere which of your
494
- workspaces are up, on which machine, with which agents — and an admin sees
495
- every instance at once. **This is on by default.** The record carries the
496
- workspace name, icon and colour, the machine, the root, the version, and
497
- the agent and channel roster. Registration
498
- is best-effort: a cloud that is down never affects the UI.
499
-
500
- No key goes into kraftwerk.yml. The cloud hands the instance an identity
501
- (kept under `~/.kraftwerk/cloud/`, so a restart updates the record) and a
502
- **claim code**, shown under Settings → Cloud in the UI. Sign in at the
503
- cloud, type the code (or follow the "claim in the cloud" link), and the
504
- workspace appears under *My instances*; the instance learns about the claim
505
- with its next heartbeat.
492
+ internet: the daemon (`kraftwerk daemon`) registers **this machine** once
493
+ when it starts and sends a heartbeat while it runs; the workspaces it serves
494
+ ride along as children of that record, so you can see from anywhere which
495
+ of your workspaces are up, and where. **This is on by default.** A
496
+ standalone `kraftwerk ui` never registers. Registration is best-effort: a
497
+ cloud that is down never affects the UI.
498
+
499
+ The machine's id is its relay id (the key in `~/.kraftwerk/relay.json`,
500
+ created at the daemon's first start with the relay still off), proven with
501
+ the relay secret. Until someone claims the machine it registers
502
+ anonymously: workspace names, slugs and ids only, no paths. Claiming
503
+ happens in the browser — no password passes through the CLI:
504
+
505
+ ```bash
506
+ kraftwerk login --open # prints (and opens) the claim link and code, waits for the claim
507
+ kraftwerk cloud # registered? claimed? url, machine id, last heartbeat
508
+ kraftwerk logout # release the machine; a fresh claim code follows
509
+ kraftwerk cloud off # turn it off for this machine (the daemon says goodbye); `on [--url]` back on
510
+ ```
511
+
512
+ The claim code is also shown under Settings → Cloud. The switch and the
513
+ cloud's origin live in `~/.kraftwerk/cloud.json` (`{ "enabled": false,
514
+ "url": "…" }`); `KRAFTWERK_CLOUD_URL` in the environment wins (`off` turns
515
+ the feature off — what the test suites set). In kraftwerk.yml, `cloud:` is
516
+ a per-workspace veto only:
506
517
 
507
518
  ```yaml
508
519
  cloud:
509
- url: https://srv.kraftwerk-cloud.netnode.cloud # default
510
- interval: 60 # seconds between heartbeats (default 60)
511
- enabled: false # opt out of the cloud for this workspace
520
+ enabled: false # leave this workspace out of the machine's record
512
521
  ```
513
522
 
514
- `KRAFTWERK_CLOUD_URL` in the environment wins over the file (`off` turns the
515
- feature off everywhere — what the test suites set). A server that starts
516
- unattended and should register under an account without a claim puts an
517
- account token from the cloud UI into `.env` as `KRAFTWERK_CLOUD_TOKEN`; a
518
- `token:` key in kraftwerk.yml is refused. `/api/meta` reports the
519
- connection state and the claim code; `kraftwerk doctor` prints what the
520
- block will do.
523
+ (`cloud.url` there is deprecated — honored with a warning when the machine
524
+ names no origin — and `cloud.interval` is ignored: the cloud sets the
525
+ cadence.) `/api/meta` and `/api/cloud` report the machine's state and claim
526
+ code; `kraftwerk doctor` prints what happens to the workspace.
521
527
 
522
528
  ## Projects
523
529
 
@@ -0,0 +1,2 @@
1
+ import type { Command } from "commander";
2
+ export declare function registerCloudCommands(program: Command): void;
@@ -0,0 +1,262 @@
1
+ import { spawn } from "node:child_process";
2
+ import os from "node:os";
3
+ import chalk from "chalk";
4
+ import { CloudError, claimUrlFor, envCloudOff, machineCloud, machineGoodbye, machineHeartbeat, machineRegister, machineUnlink, writeCloudFile, } from "../core/cloud.js";
5
+ import { findDaemon } from "../core/daemon.js";
6
+ import { identityString, readIdentity } from "../core/identity.js";
7
+ import { relayId, fromBase64Url } from "../client/relay.js";
8
+ import { machineCredentials, readRelaySettings } from "../server/relay.js";
9
+ import { getPkgVersion } from "../server/version.js";
10
+ const POLL_MS = 2000;
11
+ async function daemonCloud(port, method, route, body) {
12
+ const r = await fetch(`http://127.0.0.1:${port}${route}`, {
13
+ method,
14
+ headers: body ? { "content-type": "application/json" } : {},
15
+ ...(body ? { body: JSON.stringify(body) } : {}),
16
+ signal: AbortSignal.timeout(15_000),
17
+ });
18
+ const data = (await r.json().catch(() => ({})));
19
+ if (!r.ok)
20
+ throw new Error(data.error ?? `${r.status} from the daemon`);
21
+ return data;
22
+ }
23
+ const fail = (message, code = 1) => {
24
+ console.error(chalk.red(`✖ ${message}`));
25
+ process.exit(code);
26
+ };
27
+ function openBrowser(url) {
28
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
29
+ try {
30
+ spawn(cmd, args, { stdio: "ignore", detached: true }).unref();
31
+ }
32
+ catch { }
33
+ }
34
+ /** The machine's id without creating a key: null before the daemon's first start (or `kraftwerk login`). */
35
+ async function machineIdIfAny() {
36
+ const s = await readRelaySettings();
37
+ return s ? relayId(fromBase64Url(s.publicKey)) : null;
38
+ }
39
+ async function hello() {
40
+ const id = await readIdentity();
41
+ return { name: id.machine, handle: id.handle, hostname: os.hostname(), platform: `${process.platform}-${process.arch}`, version: await getPkgVersion() };
42
+ }
43
+ function printClaim(st) {
44
+ if (!st.claimCode)
45
+ return;
46
+ console.log(` claim code ${chalk.bold(st.claimCode)}`);
47
+ console.log(` claim link ${chalk.cyan(st.claimUrl ?? claimUrlFor(st.url, st.claimCode) ?? "")}`);
48
+ }
49
+ async function showStatus(opts) {
50
+ const [cloud, identity, id, daemon] = await Promise.all([machineCloud(), readIdentity(), machineIdIfAny(), findDaemon()]);
51
+ const live = daemon ? await daemonCloud(daemon.port, "GET", "/api/cloud").catch(() => null) : null;
52
+ const out = {
53
+ enabled: cloud.enabled,
54
+ url: cloud.url,
55
+ source: cloud.source,
56
+ ...(cloud.reason ? { reason: cloud.reason } : {}),
57
+ machine: identityString(identity),
58
+ id,
59
+ daemon: !!daemon,
60
+ state: live?.state ?? "off",
61
+ registered: live?.state === "connected",
62
+ claimed: !!live?.account,
63
+ account: live?.account ?? null,
64
+ claimCode: live?.claimCode ?? null,
65
+ claimUrl: live?.claimUrl ?? null,
66
+ lastSeen: live?.lastSeen ?? null,
67
+ interval: live?.interval ?? null,
68
+ workspaces: live?.workspaces ?? null,
69
+ ...(live?.error ? { error: live.error } : {}),
70
+ };
71
+ if (opts.json)
72
+ return void console.log(JSON.stringify(out, null, 2));
73
+ const dot = !cloud.enabled ? chalk.dim("○") : out.claimed ? chalk.green("●") : out.registered ? chalk.yellow("●") : chalk.dim("●");
74
+ console.log(`${dot} kraftwerk cloud ${cloud.enabled ? chalk.green("on") : chalk.dim("off")} ${chalk.dim(`${cloud.url} (${cloud.source})`)}`);
75
+ if (!cloud.enabled)
76
+ console.log(chalk.dim(` ${cloud.reason}`));
77
+ console.log(` machine ${identityString(identity)} ${chalk.dim(id ?? "no id yet — created at the daemon's first start")}`);
78
+ if (!cloud.enabled)
79
+ return;
80
+ if (!daemon) {
81
+ console.log(` state ${chalk.dim("not reporting — the daemon registers this machine (`kraftwerk daemon start`)")}`);
82
+ return;
83
+ }
84
+ if (!live)
85
+ return void console.log(` state ${chalk.yellow("the daemon did not answer")}`);
86
+ if (live.state === "error")
87
+ console.log(` state ${chalk.red("error")} ${chalk.dim(live.error ?? "")}`);
88
+ else if (live.state !== "connected")
89
+ console.log(` state ${live.state}`);
90
+ else if (live.account)
91
+ console.log(` state ${chalk.green("claimed")} by ${chalk.bold(live.account)}`);
92
+ else
93
+ console.log(` state ${chalk.yellow("registered, unclaimed")} ${chalk.dim("— `kraftwerk login` claims it")}`);
94
+ if (live.state === "connected" && !live.account)
95
+ printClaim(live);
96
+ if (live.lastSeen)
97
+ console.log(` heartbeat ${new Date(live.lastSeen).toLocaleTimeString()} ${chalk.dim(`every ${live.interval ?? 60}s · ${live.workspaces ?? 0} workspace${live.workspaces === 1 ? "" : "s"} reported`)}`);
98
+ }
99
+ async function setEnabled(enabled, url) {
100
+ if (url && !/^https?:\/\/[^/?#]+\/?$/.test(url))
101
+ fail("--url is the cloud's origin, e.g. https://srv.kraftwerk-cloud.netnode.cloud", 2);
102
+ await writeCloudFile({ enabled, url });
103
+ const cloud = await machineCloud();
104
+ const daemon = await findDaemon();
105
+ // The daemon follows at once (off: goodbye); best-effort — the file is what counts at its next start.
106
+ const live = daemon ? await daemonCloud(daemon.port, "POST", "/api/cloud", { enabled, ...(url ? { url } : {}) }).catch(() => null) : null;
107
+ if (!enabled) {
108
+ console.log(`${chalk.green("✔")} cloud off for this machine ${chalk.dim(daemon ? (live ? "— the daemon said goodbye" : "— the daemon did not answer; it stops reporting at its next start") : "")}`);
109
+ return;
110
+ }
111
+ if (envCloudOff())
112
+ console.log(chalk.yellow("⚠ KRAFTWERK_CLOUD_URL=off in this environment keeps it off here"));
113
+ console.log(`${chalk.green("✔")} cloud on for this machine: ${chalk.cyan(cloud.url)}`);
114
+ if (!daemon)
115
+ console.log(chalk.dim(" the daemon registers this machine when it runs (`kraftwerk daemon start`); `kraftwerk login` claims it"));
116
+ else if (live?.state === "connected")
117
+ console.log(chalk.dim(live.account ? ` registered, claimed by ${live.account}` : " registered — `kraftwerk login` claims it"));
118
+ else if (live?.state === "error")
119
+ console.log(chalk.yellow(` the cloud did not answer: ${live.error ?? ""} (the daemon retries)`));
120
+ }
121
+ async function session() {
122
+ const daemon = await findDaemon();
123
+ if (daemon) {
124
+ const port = daemon.port;
125
+ // Off in the daemon (it read cloud.json before `login` turned it on): turning it on registers.
126
+ let first = true;
127
+ return {
128
+ status: async () => {
129
+ const st = first ? await daemonCloud(port, "POST", "/api/cloud", { enabled: true }) : await daemonCloud(port, "POST", "/api/cloud/sync");
130
+ first = false;
131
+ return st;
132
+ },
133
+ end: async () => { },
134
+ };
135
+ }
136
+ const cloud = await machineCloud();
137
+ const version = await getPkgVersion();
138
+ const creds = await machineCredentials();
139
+ let registered = false;
140
+ return {
141
+ status: async () => {
142
+ try {
143
+ const r = registered ? await machineHeartbeat(cloud.url, creds, { version }) : await machineRegister(cloud.url, creds, await hello(), []);
144
+ registered = true;
145
+ return { state: "connected", url: cloud.url, account: r.account, claimCode: r.claimCode, claimUrl: claimUrlFor(cloud.url, r.claimCode) };
146
+ }
147
+ catch (err) {
148
+ if (err instanceof CloudError && err.status === 403)
149
+ throw new Error(`${cloud.url} refuses this machine's secret (403) — another machine holds this id`);
150
+ return { state: "error", url: cloud.url, error: err.message };
151
+ }
152
+ },
153
+ // Without a daemon nothing reports afterwards: the record says so now instead of after the grace period.
154
+ end: async () => {
155
+ if (registered)
156
+ await machineGoodbye(cloud.url, creds, version).catch(() => { });
157
+ },
158
+ };
159
+ }
160
+ async function login(opts) {
161
+ if (envCloudOff())
162
+ fail("KRAFTWERK_CLOUD_URL=off in this environment — unset it to log in", 2);
163
+ const timeout = Number(opts.timeout ?? 600);
164
+ if (!Number.isFinite(timeout) || timeout <= 0)
165
+ fail("--timeout must be a positive number of seconds", 2);
166
+ const before = await machineCloud();
167
+ if (!before.enabled) {
168
+ await writeCloudFile({ enabled: true });
169
+ console.log(chalk.dim("cloud turned on for this machine (`kraftwerk cloud off` turns it off)"));
170
+ }
171
+ const s = await session().catch((err) => fail(err.message));
172
+ try {
173
+ let st = await s.status();
174
+ if (st.state === "off")
175
+ fail(`the daemon keeps the cloud off: ${st.reason ?? "off"}`);
176
+ if (st.state !== "connected")
177
+ fail(`could not reach ${st.url}: ${st.error ?? st.state}`);
178
+ if (st.account) {
179
+ console.log(`${chalk.green("✔")} already logged in as ${chalk.bold(st.account)} ${chalk.dim(`(${st.url}) — \`kraftwerk logout\` releases this machine`)}`);
180
+ return;
181
+ }
182
+ if (!st.claimCode)
183
+ fail(`${st.url} handed out no claim code`);
184
+ console.log(`Sign in (or sign up) in the browser and claim this machine:`);
185
+ printClaim(st);
186
+ if (opts.open && st.claimUrl)
187
+ openBrowser(st.claimUrl);
188
+ console.log(chalk.dim(`waiting for the claim … (Ctrl-C stops; the code stays valid)`));
189
+ const until = Date.now() + timeout * 1000;
190
+ while (Date.now() < until) {
191
+ await new Promise((r) => setTimeout(r, Math.min(POLL_MS, Math.max(0, until - Date.now()))));
192
+ st = await s.status().catch((err) => ({ ...st, state: "error", error: err.message }));
193
+ if (st.account) {
194
+ console.log(`${chalk.green("✔")} logged in as ${chalk.bold(st.account)} ${chalk.dim(`— this machine is in your account at ${st.url}`)}`);
195
+ return;
196
+ }
197
+ }
198
+ fail(`not claimed within ${timeout}s — the code stays valid; \`kraftwerk login\` waits again`);
199
+ }
200
+ finally {
201
+ await s.end();
202
+ }
203
+ }
204
+ async function logout() {
205
+ if (envCloudOff())
206
+ fail("KRAFTWERK_CLOUD_URL=off in this environment", 2);
207
+ const cloud = await machineCloud();
208
+ if (!cloud.enabled)
209
+ fail(`the cloud is off for this machine — ${cloud.reason}`, 2);
210
+ const daemon = await findDaemon();
211
+ let st;
212
+ if (daemon) {
213
+ st = await daemonCloud(daemon.port, "POST", "/api/cloud/unlink").catch((err) => fail(err.message));
214
+ }
215
+ else {
216
+ const creds = await machineCredentials();
217
+ const r = await machineUnlink(cloud.url, creds, await getPkgVersion()).catch((err) => fail(err instanceof CloudError && err.status === 404 ? `this machine is not registered at ${cloud.url}` : `${cloud.url}: ${err.message}`));
218
+ st = { url: cloud.url, claimCode: r.claimCode, claimUrl: claimUrlFor(cloud.url, r.claimCode) };
219
+ }
220
+ console.log(`${chalk.green("✔")} logged out — this machine is unclaimed at ${st.url}`);
221
+ printClaim(st);
222
+ console.log(chalk.dim(" `kraftwerk login` claims it again"));
223
+ }
224
+ export function registerCloudCommands(program) {
225
+ const cloud = program
226
+ .command("cloud")
227
+ .description("This machine in the kraftwerk cloud: status, on, off (`kraftwerk login` claims it)")
228
+ .option("--json", "Machine-readable output")
229
+ .addHelpText("after", `
230
+ The daemon registers this machine once; its open workspaces ride along.
231
+ kraftwerk cloud registered? claimed? url, machine id, last heartbeat
232
+ kraftwerk cloud on [--url <u>] turn it on (~/.kraftwerk/cloud.json); the daemon follows at once
233
+ kraftwerk cloud off turn it off; the running daemon says goodbye
234
+ kraftwerk login [--open] claim this machine into your account (in the browser)
235
+ kraftwerk logout release it
236
+ A workspace stays out with \`cloud: { enabled: false }\` in its kraftwerk.yml.`)
237
+ .action(async (opts) => showStatus(opts));
238
+ cloud
239
+ .command("status")
240
+ .description("Registered? claimed? url, machine id, last heartbeat")
241
+ .option("--json", "Machine-readable output")
242
+ .action(async (opts) => showStatus({ json: opts.json ?? cloud.opts().json }));
243
+ cloud
244
+ .command("on")
245
+ .description("Turn the cloud on for this machine (the running daemon registers at once)")
246
+ .option("--url <url>", "The cloud's origin (default: the kraftwerk cloud)")
247
+ .action(async (opts) => setEnabled(true, opts.url));
248
+ cloud
249
+ .command("off")
250
+ .description("Turn the cloud off for this machine (the running daemon says goodbye)")
251
+ .action(async () => setEnabled(false));
252
+ program
253
+ .command("login")
254
+ .description("Claim this machine into your kraftwerk cloud account: prints the claim link and code, waits for the claim")
255
+ .option("--open", "Open the claim link in the browser")
256
+ .option("--timeout <seconds>", "How long to wait for the claim", "600")
257
+ .action(async (opts) => login(opts));
258
+ program
259
+ .command("logout")
260
+ .description("Release this machine from its kraftwerk cloud account (a fresh claim code follows)")
261
+ .action(async () => logout());
262
+ }
@@ -5,6 +5,7 @@ import chalk from "chalk";
5
5
  import { daemonLogPath, daemonState, findDaemon, readDaemonFile } from "../core/daemon.js";
6
6
  import { tildify } from "../core/instances.js";
7
7
  import { selfCommand } from "../core/self-command.js";
8
+ import { localIdentity } from "../core/identity.js";
8
9
  import { getPkgVersion } from "../server/version.js";
9
10
  import { daemonLoginItem, startLoginItem } from "./daemon-login.js";
10
11
  /**
@@ -62,8 +63,8 @@ async function tail(n) {
62
63
  }
63
64
  }
64
65
  async function status() {
65
- const [found, rec, installed] = await Promise.all([daemonState(), readDaemonFile(), getPkgVersion()]);
66
- const base = { installed, loginItem: daemonLoginItem() !== null, log: daemonLogPath() };
66
+ const [found, rec, installed, identity] = await Promise.all([daemonState(), readDaemonFile(), getPkgVersion(), localIdentity()]);
67
+ const base = { installed, loginItem: daemonLoginItem() !== null, log: daemonLogPath(), identity };
67
68
  if (found.state === "stopped")
68
69
  return { running: false, ...base };
69
70
  const running = found;
@@ -119,6 +120,7 @@ export async function daemonStatus(opts) {
119
120
  console.log(chalk.dim(` last trouble in the log: ${crash.trim().slice(0, 200)}`));
120
121
  console.log(chalk.dim(" `kraftwerk daemon start` starts it in the background"));
121
122
  }
123
+ console.log(chalk.dim(` identity: ${s.identity} · routines with runs_on: ${s.identity} fire here (\`kraftwerk whoami\`)`));
122
124
  console.log(chalk.dim(` login item: ${s.loginItem ? "starts at login" : "not installed (`kraftwerk daemon install` starts it at login)"}`));
123
125
  console.log(chalk.dim(` log: ${tildify(s.log)} · \`kraftwerk daemon logs -f\` follows it`));
124
126
  if (!s.running)
@@ -2,6 +2,7 @@ import chalk from "chalk";
2
2
  import { DAEMON_PORT, daemonState, lastOpenRoots, setDaemonVersion, writeDaemonFile } from "../core/daemon.js";
3
3
  import { tildify } from "../core/instances.js";
4
4
  import { networkUrls } from "../server/bind.js";
5
+ import { startMachineCloud } from "../server/cloud.js";
5
6
  import { startPushWatcher } from "../server/push.js";
6
7
  import { applyRelay, relayPush } from "../server/relay.js";
7
8
  import { startHub } from "../server/server.js";
@@ -51,6 +52,14 @@ export async function runDaemon(opts) {
51
52
  console.log(` ${chalk.cyan(o.url)} ${chalk.dim(o.name)}${o.aliasPort ? chalk.dim(` · also :${o.aliasPort}`) : ""}`);
52
53
  if (!hub.list().length)
53
54
  console.log(chalk.dim(" no workspace open yet — `kraftwerk ui` in a workspace folder opens it here"));
55
+ // The machine in the kraftwerk cloud: registered once, the open workspaces as its children (`kraftwerk cloud`, `kraftwerk login`).
56
+ const cloud = await startMachineCloud({ version: await getPkgVersion(), workspaces: () => hub.list(), owner: hub });
57
+ if (cloud.state === "connected") {
58
+ console.log(`${chalk.green("✔")} Cloud: ${chalk.cyan(cloud.url)} ${chalk.dim(cloud.account ? `(claimed by ${cloud.account})` : `(unclaimed — \`kraftwerk login\` claims this machine)`)}`);
59
+ }
60
+ else if (cloud.state === "error") {
61
+ console.log(`${chalk.yellow("⚠")} Cloud: ${cloud.url} unreachable — ${cloud.error ?? "no answer"} ${chalk.dim("(retrying; nothing else depends on it)")}`);
62
+ }
54
63
  // The relay (`kraftwerk remote on`): devices reach this machine from anywhere, end to end encrypted.
55
64
  const relay = await applyRelay(hub.port, await getPkgVersion(), (line) => console.log(chalk.dim(line)));
56
65
  // What needs you, pushed to the phones that asked (through the relay, sealed per device).
@@ -1 +1,3 @@
1
- export declare function runDoctor(cwd: string): Promise<void>;
1
+ export declare function runDoctor(cwd: string, opts?: {
2
+ fix?: boolean;
3
+ }): Promise<void>;
@@ -2,7 +2,8 @@ import { spawnSync } from "node:child_process";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import path from "node:path";
4
4
  import chalk from "chalk";
5
- import { cloudFor, ignoreEntryFor, isDir, reposRootFor, resolveWorkspace, workspaceSlug } from "../config.js";
5
+ import { machineCloud } from "../core/cloud.js";
6
+ import { addWorkspaceId, ignoreEntryFor, isDir, reposRootFor, resolveWorkspace, workspaceSlug } from "../config.js";
6
7
  import { discoverWorkflows } from "../discover.js";
7
8
  import { missingEnv } from "../yaml.js";
8
9
  import { applyDotenv, DOTENV_FILE } from "../dotenv.js";
@@ -12,6 +13,9 @@ import { slugClashes } from "../core/instances.js";
12
13
  import { daemonState } from "../core/daemon.js";
13
14
  import { daemonLoginItem } from "./daemon-login.js";
14
15
  import { readRelaySettings } from "../server/relay.js";
16
+ import { readIdentity, identityString } from "../core/identity.js";
17
+ import { adoptUnassigned, runnerFindings } from "../core/routines.js";
18
+ import { setDefaultWorkspace, Workspace } from "../core/workspace.js";
15
19
  const ICONS = {
16
20
  ok: chalk.green("✔"),
17
21
  warn: chalk.yellow("⚠"),
@@ -42,7 +46,7 @@ function cliVersion(command) {
42
46
  return undefined;
43
47
  return (r.stdout || r.stderr).trim().split("\n")[0];
44
48
  }
45
- export async function runDoctor(cwd) {
49
+ export async function runDoctor(cwd, opts = {}) {
46
50
  let failures = 0;
47
51
  // Runtime.
48
52
  const [major] = process.versions.node.split(".").map(Number);
@@ -100,6 +104,12 @@ export async function runDoctor(cwd) {
100
104
  report("ok", `${path.basename(project.configPath)} well-formed`, keys.length ? keys.map((k) => `${k}: ${show(cfg[k])}`).join(", ") : "empty — defaults apply");
101
105
  if (!cfg.name)
102
106
  report("info", "name not set in kraftwerk.yml", "inspector header falls back to the folder name");
107
+ if (cfg.id)
108
+ report("ok", `workspace id: ${cfg.id}`);
109
+ else if (opts.fix)
110
+ report("ok", `workspace id: ${await addWorkspaceId(project.configPath)}`, `added to ${path.basename(project.configPath)} — commit it`);
111
+ else
112
+ report("warn", "no workspace id in kraftwerk.yml", "the same workspace on every clone is known by it — `kraftwerk doctor --fix` adds one");
103
113
  for (const key of ["workflows", "knowledge", "agents", "output"]) {
104
114
  const value = cfg[key];
105
115
  if (!value)
@@ -143,15 +153,47 @@ export async function runDoctor(cwd) {
143
153
  else
144
154
  report("ok", label, "not inside a git repository");
145
155
  }
146
- // Cloud manager: registration is best-effort at runtime, so doctor only says what would happen.
147
- const cloud = cloudFor(project);
148
- if (cloud) {
149
- report("ok", `cloud: ${cloud.url}`, cloud.token
150
- ? `registers under your kraftwerk account (KRAFTWERK_CLOUD_TOKEN), heartbeat every ${cloud.interval}s`
151
- : `on by default — the UI shows a claim code under Settings → Cloud; opt out with cloud.enabled: false`);
156
+ // Routine runners: a routine fires only on the machine its runs_on names.
157
+ setDefaultWorkspace(new Workspace({ root: project.root, outputDir: project.outputDir }));
158
+ const me = identityString(await readIdentity());
159
+ const runners = await runnerFindings().catch(() => ({ unassigned: [], retired: [] }));
160
+ const names = (rs) => rs.map((r) => `${r.agent}/${r.id}`).join(", ");
161
+ if (!runners.unassigned.length && !runners.retired.length) {
162
+ report("ok", `routines: runner identity ${me}`, "every enabled routine names its runner");
163
+ }
164
+ else if (opts.fix) {
165
+ const r = await adoptUnassigned({ retired: true });
166
+ report("ok", `${r.adopted.length} routine(s) now run here (${me})`, names(r.adopted));
167
+ if (r.commit?.ok)
168
+ report("ok", `committed ${r.files.join(", ")}`, "push so the other clones see who runs them");
169
+ else if (r.commit)
170
+ report("warn", `written, not committed: ${r.commit.error}`, `commit ${r.files.join(", ")} yourself`);
152
171
  }
153
172
  else {
154
- report("info", "cloud: off", process.env.KRAFTWERK_CLOUD_URL?.trim().toLowerCase() === "off" ? "KRAFTWERK_CLOUD_URL=off" : "cloud.enabled: false in kraftwerk.yml");
173
+ if (runners.unassigned.length) {
174
+ report("warn", `${runners.unassigned.length} enabled routine(s) have no runner — they fire nowhere`, `${names(runners.unassigned)} · \`kraftwerk doctor --fix\` (or \`kraftwerk routines adopt\`) runs them here: ${me}`);
175
+ }
176
+ for (const old of [...new Set(runners.retired.map((r) => r.from))]) {
177
+ const these = runners.retired.filter((r) => r.from === old);
178
+ report("warn", `${these.length} routine(s) name the retired identity ${old}`, `${names(these)} · \`kraftwerk doctor --fix\` moves them to ${me} and commits`);
179
+ }
180
+ }
181
+ // The kraftwerk cloud: the daemon registers the machine; doctor only says what would happen to this workspace.
182
+ const ymlCloud = project.config.cloud;
183
+ const cloud = await machineCloud(ymlCloud?.url ? [ymlCloud.url] : []);
184
+ if (ymlCloud?.enabled === false) {
185
+ report("info", "cloud: this workspace is left out", "cloud.enabled: false in kraftwerk.yml — the machine's cloud record does not list it");
186
+ }
187
+ else if (!cloud.enabled) {
188
+ report("info", "cloud: off", cloud.reason ?? "");
189
+ }
190
+ else {
191
+ report("ok", `cloud: ${cloud.url}`, "the daemon registers this machine and lists this workspace; `kraftwerk login` claims it, `kraftwerk cloud off` opts out");
192
+ }
193
+ if (ymlCloud?.url) {
194
+ report("warn", "kraftwerk.yml cloud.url is deprecated", cloud.source === "kraftwerk.yml"
195
+ ? `the cloud is the machine's now — \`kraftwerk cloud on --url ${ymlCloud.url}\`, then remove it from kraftwerk.yml`
196
+ : `ignored: the machine's cloud is ${cloud.url} (${cloud.source}) — remove it from kraftwerk.yml`);
155
197
  }
156
198
  const found = project.workflowsRoot ? await discoverWorkflows(cwd) : [];
157
199
  if (!project.workflowsRoot) {
package/dist/cli/init.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { appendFile, mkdir, readFile, stat, writeFile } from "node:fs/promises";
2
2
  import path from "node:path";
3
3
  import chalk from "chalk";
4
- import { CONFIG_SCHEMA_URL, gitignoreHas, SCHEMA_URL } from "../config.js";
4
+ import { CONFIG_SCHEMA_URL, gitignoreHas, newWorkspaceId, SCHEMA_URL } from "../config.js";
5
5
  import { DOTENV_FILE } from "../dotenv.js";
6
6
  import { initBundle, writeConcept } from "../okf.js";
7
7
  /**
@@ -9,12 +9,15 @@ import { initBundle, writeConcept } from "../okf.js";
9
9
  * command: kraftwerk.yml (root marker + config), a kraftwerk-data/ tree with
10
10
  * a runnable example workflow, a starter agent ("max"), a demo
11
11
  * knowledge bundle, and an output ignore entry. Everything is idempotent:
12
- * existing files are left untouched and reported.
12
+ * existing files are left untouched and reported. A new kraftwerk.yml gets
13
+ * a fresh workspace id (`id: ws_…`); an existing one is not touched —
14
+ * `kraftwerk doctor --fix` adds an id to a file that has none.
13
15
  */
14
16
  const DATA_DIR = "kraftwerk-data";
15
17
  const configTemplate = (name) => `# yaml-language-server: $schema=${CONFIG_SCHEMA_URL}
16
18
  # kraftwerk project config — also marks the project root for the CLI.
17
19
  # All fields optional. Docs: https://github.com/NETNODEAG/kraftwerk
20
+ id: ${newWorkspaceId()} # this workspace's identity on every machine and clone — commit it, keep it (a copy of this workspace needs a new one)
18
21
  name: ${JSON.stringify(name)} # display name, shown in the inspector header + browser tab
19
22
  # slug: my-team # this workspace's address on this machine (my-team.localhost); default: the folder name
20
23
  icon: "⚡" # emoji shown as the inspector favicon
@@ -28,8 +31,8 @@ skills: ${DATA_DIR}/skills # workspace skills (shared instruction packag
28
31
  # root: ${DATA_DIR}/repos # clones land here (git-ignored); a bare \`repos:\` uses repos/ instead
29
32
  # vibeables: # small apps built live in a chat with a preview pane (uncomment to enable; one folder per app under ${DATA_DIR}/vibeables, versioned with the workspace)
30
33
  # projects: # goal-scoped folders — brief, systems of record, links to knowledge/vibeables/repos/workflows/agents — that chats work in (uncomment to enable; one folder per project under ${DATA_DIR}/projects)
31
- # cloud: # the kraftwerk cloud is ON BY DEFAULT: \`kraftwerk ui\` registers this workspace there and shows a claim code under Settings → Cloud
32
- # enabled: false # uncomment both lines to keep this workspace out of the cloud (or set KRAFTWERK_CLOUD_URL=off for the whole machine)
34
+ # cloud: # the daemon registers this MACHINE with the kraftwerk cloud and lists this workspace (\`kraftwerk login\` claims the machine)
35
+ # enabled: false # uncomment both lines to leave this workspace out (\`kraftwerk cloud off\` turns the cloud off for the whole machine)
33
36
  `;
34
37
  const WORKFLOW_TEMPLATE = `# yaml-language-server: $schema=${SCHEMA_URL}
35
38
  name: hello
@@ -21,6 +21,8 @@ import { registerDevicesCommands } from "./devices.js";
21
21
  import { registerDaemonCommand } from "./daemon.js";
22
22
  import { registerRemoteCommand } from "./remote.js";
23
23
  import { registerJournalCommands } from "./journal.js";
24
+ import { registerWhoamiCommand } from "./whoami.js";
25
+ import { registerCloudCommands } from "./cloud.js";
24
26
  import { applyDotenv } from "../dotenv.js";
25
27
  import { resolveWorkspace } from "../config.js";
26
28
  import { registerRoutineCommands } from "./routines.js";
@@ -34,7 +36,8 @@ import { runUi } from "./ui.js";
34
36
  * kraftwerk run [workflow] [text] run one (prompts interactively if omitted)
35
37
  * kraftwerk runs [show <id>] inspect past runs from their traces
36
38
  * kraftwerk knowledge ... Knowledge: OKF bundles (list/get/put/verify/...)
37
- * kraftwerk routines ... per-agent scheduled prompts (list/add/remove/enable/run)
39
+ * kraftwerk routines ... per-agent scheduled prompts (list/add/remove/enable/run/adopt)
40
+ * kraftwerk whoami this machine's identity (handle@machine), the runner routines name
38
41
  * kraftwerk ui start the inspector web UI (localhost:1981)
39
42
  * kraftwerk workspaces ... known workspaces on this machine (list/start/stop/forget)
40
43
  * kraftwerk projects ... goal-scoped project folders (list/create/show/link/log/remove)
@@ -44,7 +47,9 @@ import { runUi } from "./ui.js";
44
47
  * kraftwerk trash ... what was deleted: list, restore, purge, empty
45
48
  * kraftwerk devices ... paired devices: list, pair one, revoke one
46
49
  * kraftwerk daemon the machine's kraftwerk: every open workspace at <slug>.localhost
47
- * kraftwerk doctor preflight: harness CLIs, docker, workflows, env
50
+ * kraftwerk cloud [on|off|status] this machine in the kraftwerk cloud (the daemon registers it)
51
+ * kraftwerk login / logout claim this machine into your cloud account (in the browser) / release it
52
+ * kraftwerk doctor [--fix] preflight: harness CLIs, docker, workflows, env, routine runners
48
53
  * kraftwerk validate [paths...] validate without executing
49
54
  *
50
55
  * Workflows are auto-discovered under src/workflows/ (or workflows/), from
@@ -265,9 +270,10 @@ program
265
270
  });
266
271
  program
267
272
  .command("doctor")
268
- .description("Preflight: harness CLIs, docker, workflows, declared environment variables")
269
- .action(async () => {
270
- await runDoctor(process.cwd());
273
+ .description("Preflight: harness CLIs, docker, workflows, declared environment variables, routine runners, workspace id")
274
+ .option("--fix", "Apply the fixes doctor offers: run unassigned routines here, move routines off a retired identity (both committed), add a workspace id")
275
+ .action(async (opts) => {
276
+ await runDoctor(process.cwd(), { fix: !!opts.fix });
271
277
  });
272
278
  const runs = program
273
279
  .command("runs")
@@ -298,6 +304,8 @@ registerDevicesCommands(program);
298
304
  registerDaemonCommand(program);
299
305
  registerRemoteCommand(program);
300
306
  registerJournalCommands(program);
307
+ registerWhoamiCommand(program);
308
+ registerCloudCommands(program);
301
309
  program
302
310
  .command("ui")
303
311
  .description("Start the inspector web UI for this project's runs and workflows")
@@ -3,7 +3,8 @@ import type { Command } from "commander";
3
3
  * `kraftwerk routines` — CLI surface over per-agent scheduled prompts
4
4
  * (agents/<slug>/routines.yml). Definition CRUD works directly on the
5
5
  * files; `run` fires through the running inspector server, because the
6
- * session (chat) and the scheduler live there.
6
+ * session (chat) and the scheduler live there. `adopt` makes this machine
7
+ * the runner (runs_on) of every unassigned routine and commits routines.yml.
7
8
  */
8
9
  /** Point the inspector context at this project so agent/routine paths resolve. */
9
10
  export declare function initContext(): Promise<{