@netnodeag/kraftwerk 0.75.0 → 0.76.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 (49) hide show
  1. package/dist/cli/daemon-control.js +32 -13
  2. package/dist/cli/daemon-login.js +3 -3
  3. package/dist/cli/daemon.js +5 -4
  4. package/dist/cli/doctor.d.ts +3 -1
  5. package/dist/cli/doctor.js +40 -5
  6. package/dist/cli/init.js +5 -2
  7. package/dist/cli/kraftwerk.js +9 -5
  8. package/dist/cli/routines.d.ts +2 -1
  9. package/dist/cli/routines.js +40 -4
  10. package/dist/cli/whoami.d.ts +9 -0
  11. package/dist/cli/whoami.js +48 -0
  12. package/dist/client/routes.d.ts +3 -0
  13. package/dist/client/routes.js +3 -0
  14. package/dist/config.d.ts +20 -0
  15. package/dist/config.js +38 -3
  16. package/dist/core/attention.d.ts +8 -1
  17. package/dist/core/attention.js +38 -2
  18. package/dist/core/daemon.d.ts +15 -1
  19. package/dist/core/daemon.js +46 -8
  20. package/dist/core/identity.d.ts +46 -0
  21. package/dist/core/identity.js +106 -0
  22. package/dist/core/routines.d.ts +81 -0
  23. package/dist/core/routines.js +174 -33
  24. package/dist/server/api/agents.d.ts +5 -1
  25. package/dist/server/api/agents.js +19 -2
  26. package/dist/server/api/index.d.ts +14 -3
  27. package/dist/server/api/runs.d.ts +3 -1
  28. package/dist/server/api/runs.js +9 -1
  29. package/dist/server/api/workspace.d.ts +6 -1
  30. package/dist/server/api/workspace.js +5 -0
  31. package/dist/server/push.js +1 -1
  32. package/package.json +1 -1
  33. package/schema/kraftwerk.schema.json +5 -0
  34. package/web/dist/assets/{dist-YNCNGGgK.js → dist-BQpWYkxm.js} +1 -1
  35. package/web/dist/assets/{dist-DaGnyH_l.js → dist-BTVP-6On.js} +1 -1
  36. package/web/dist/assets/dist-BURf1l-A.js +1 -0
  37. package/web/dist/assets/{dist-CLZjnhZZ.js → dist-BYkcY-Vl.js} +1 -1
  38. package/web/dist/assets/{dist-CzM6tn0f.js → dist-BwIca_Uj.js} +1 -1
  39. package/web/dist/assets/{dist-Y9aHncjU.js → dist-DFXmaDc5.js} +1 -1
  40. package/web/dist/assets/{dist-CezfArgg.js → dist-T1sNLC62.js} +1 -1
  41. package/web/dist/assets/{dist-CYicby0c.js → dist-fgB7OEJy.js} +1 -1
  42. package/web/dist/assets/{dist-QqFmuPda.js → dist-flnLXEzU.js} +1 -1
  43. package/web/dist/assets/{dist-BI4umsk6.js → dist-l7foWlFx.js} +1 -1
  44. package/web/dist/assets/{dist-CODxhtAK.js → dist-vuciRCSz.js} +1 -1
  45. package/web/dist/assets/{editor-DEb8z5-Q.js → editor-C6Ok8NPo.js} +3 -3
  46. package/web/dist/assets/index-z4tpCCzk.js +231 -0
  47. package/web/dist/index.html +1 -1
  48. package/web/dist/assets/dist-DtktO5hc.js +0 -1
  49. package/web/dist/assets/index-kmyK4tFm.js +0 -231
@@ -2,9 +2,10 @@ import { spawn } from "node:child_process";
2
2
  import { existsSync, mkdirSync, openSync, promises as fs } from "node:fs";
3
3
  import path from "node:path";
4
4
  import chalk from "chalk";
5
- import { daemonLogPath, findDaemon, readDaemonFile } from "../core/daemon.js";
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
  /**
@@ -42,7 +43,7 @@ const ago = (iso) => {
42
43
  };
43
44
  async function getJson(port, p) {
44
45
  try {
45
- const r = await fetch(`http://127.0.0.1:${port}${p}`, { signal: AbortSignal.timeout(2000) });
46
+ const r = await fetch(`http://127.0.0.1:${port}${p}`, { signal: AbortSignal.timeout(3000) });
46
47
  return r.ok ? (await r.json()) : null;
47
48
  }
48
49
  catch {
@@ -62,10 +63,14 @@ async function tail(n) {
62
63
  }
63
64
  }
64
65
  async function status() {
65
- const [running, rec, installed] = await Promise.all([findDaemon(), readDaemonFile(), getPkgVersion()]);
66
- const base = { installed, loginItem: daemonLoginItem() !== null, log: daemonLogPath() };
67
- if (!running)
66
+ const [found, rec, installed, identity] = await Promise.all([daemonState(), readDaemonFile(), getPkgVersion(), localIdentity()]);
67
+ const base = { installed, loginItem: daemonLoginItem() !== null, log: daemonLogPath(), identity };
68
+ if (found.state === "stopped")
68
69
  return { running: false, ...base };
70
+ const running = found;
71
+ if (found.state === "busy") {
72
+ return { running: true, busy: true, ...base, pid: found.pid, port: found.port, ...(rec?.version ? { version: rec.version } : {}), ...(rec?.startedAt ? { startedAt: rec.startedAt } : {}) };
73
+ }
69
74
  const [hub, relay] = await Promise.all([
70
75
  getJson(running.port, "/api/hub"),
71
76
  getJson(running.port, "/api/devices/relay"),
@@ -87,7 +92,12 @@ export async function daemonStatus(opts) {
87
92
  console.log(JSON.stringify(s, null, 2));
88
93
  process.exit(s.running ? 0 : NOT_RUNNING);
89
94
  }
90
- if (s.running) {
95
+ if (s.busy) {
96
+ const up = ago(s.startedAt);
97
+ console.log(`${chalk.yellow("●")} kraftwerk daemon ${chalk.yellow("running, but slow to answer")} ${chalk.dim(`pid ${s.pid}, port ${s.port}${up ? `, up ${up}` : ""}${s.version ? `, ${s.version}` : ""}`)}`);
98
+ console.log(chalk.dim(" its process lives and holds its port — busy (agents at work, a loaded machine); ask again in a moment"));
99
+ }
100
+ else if (s.running) {
91
101
  const up = ago(s.startedAt);
92
102
  const behind = s.version && s.version !== s.installed ? chalk.yellow(` — ${s.installed} is installed: \`kraftwerk daemon restart\``) : "";
93
103
  console.log(`${chalk.green("●")} kraftwerk daemon ${chalk.green("running")} ${chalk.dim(`pid ${s.pid}${up ? `, up ${up}` : ""}${s.version ? `, ${s.version}` : ""}`)}${behind}`);
@@ -110,6 +120,7 @@ export async function daemonStatus(opts) {
110
120
  console.log(chalk.dim(` last trouble in the log: ${crash.trim().slice(0, 200)}`));
111
121
  console.log(chalk.dim(" `kraftwerk daemon start` starts it in the background"));
112
122
  }
123
+ console.log(chalk.dim(` identity: ${s.identity} · routines with runs_on: ${s.identity} fire here (\`kraftwerk whoami\`)`));
113
124
  console.log(chalk.dim(` login item: ${s.loginItem ? "starts at login" : "not installed (`kraftwerk daemon install` starts it at login)"}`));
114
125
  console.log(chalk.dim(` log: ${tildify(s.log)} · \`kraftwerk daemon logs -f\` follows it`));
115
126
  if (!s.running)
@@ -138,9 +149,10 @@ async function launch(port) {
138
149
  return "background";
139
150
  }
140
151
  export async function startDaemon(opts) {
141
- const running = await findDaemon();
142
- if (running) {
143
- console.log(`${chalk.green("✔")} the daemon already runs ${chalk.dim(`(pid ${running.pid}, http://localhost:${running.port})`)}`);
152
+ const running = await daemonState();
153
+ if (running.state !== "stopped") {
154
+ const busy = running.state === "busy" ? ", busy — slow to answer right now" : "";
155
+ console.log(`${chalk.green("✔")} the daemon already runs ${chalk.dim(`(pid ${running.pid}, http://localhost:${running.port}${busy})`)}`);
144
156
  return;
145
157
  }
146
158
  const how = await launch(opts.port);
@@ -157,9 +169,10 @@ export async function startDaemon(opts) {
157
169
  }
158
170
  /** Stop it: its supervisor first (else it would start the daemon again), then wait until it is gone. */
159
171
  async function stopRunning() {
160
- const [running, rec] = await Promise.all([findDaemon(), readDaemonFile()]);
161
- if (!running)
172
+ const [found, rec] = await Promise.all([daemonState(), readDaemonFile()]);
173
+ if (found.state === "stopped")
162
174
  return false;
175
+ const running = found;
163
176
  const target = rec?.pid === running.pid && alive(rec.supervisor) ? rec.supervisor : running.pid;
164
177
  process.kill(target, "SIGTERM");
165
178
  const gone = await until(async () => !alive(running.pid) && !alive(target) && (await findDaemon()) === null, 20_000);
@@ -182,9 +195,15 @@ export async function stopDaemon() {
182
195
  }
183
196
  /** Restart: the running daemon relaunches itself with the current install (its supervisor stays); none running — start one. */
184
197
  export async function restartDaemon(opts) {
185
- const running = await findDaemon();
186
- if (!running)
198
+ const found = await daemonState();
199
+ if (found.state === "stopped")
187
200
  return startDaemon(opts);
201
+ if (found.state === "busy") {
202
+ // It does not answer, so it cannot be asked to relaunch: stopping it is the only way (its agents are interrupted).
203
+ await stopRunning();
204
+ return startDaemon(opts);
205
+ }
206
+ const running = found;
188
207
  // The restart route lives in every workspace (the daemon's root answers machine routes only); any open one will do.
189
208
  const hub = await getJson(running.port, "/api/hub");
190
209
  const slug = hub?.workspaces?.[0]?.slug;
@@ -3,7 +3,7 @@ import { existsSync, promises as fs } from "node:fs";
3
3
  import os from "node:os";
4
4
  import path from "node:path";
5
5
  import chalk from "chalk";
6
- import { daemonLogPath, findDaemon } from "../core/daemon.js";
6
+ import { daemonLogPath, daemonState } from "../core/daemon.js";
7
7
  import { selfCommand } from "../core/self-command.js";
8
8
  import { tildify } from "../core/instances.js";
9
9
  /**
@@ -72,8 +72,8 @@ export async function installDaemon(opts) {
72
72
  await fs.writeFile(file, renderPlist([cmd, ...args], env, logPath()));
73
73
  // An older version of the item is replaced: unload it first (an error just means it was not loaded).
74
74
  spawnSync(launchctl(), ["bootout", `${domain()}/${LAUNCH_LABEL}`], { stdio: "ignore" });
75
- const running = await findDaemon();
76
- if (running) {
75
+ const running = await daemonState();
76
+ if (running.state !== "stopped") {
77
77
  // Loading now would start a second daemon, which refuses to run. launchd loads the file at the next login.
78
78
  console.log(`${chalk.green("✔")} login item installed: ${chalk.dim(tildify(file))}`);
79
79
  console.log(chalk.dim(` a daemon already runs (pid ${running.pid}); from the next login on, launchd starts it`));
@@ -1,5 +1,5 @@
1
1
  import chalk from "chalk";
2
- import { DAEMON_PORT, findDaemon, lastOpenRoots, setDaemonVersion, writeDaemonFile } from "../core/daemon.js";
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
5
  import { startPushWatcher } from "../server/push.js";
@@ -22,9 +22,10 @@ export async function runDaemon(opts) {
22
22
  process.env.KRAFTWERK_UI_HOST ||= "0.0.0.0";
23
23
  if (process.env.KRAFTWERK_UI_SUPERVISED !== "1")
24
24
  return supervise(["daemon", ...(opts.port ? ["--port", opts.port] : [])], "the daemon");
25
- const running = await findDaemon();
26
- if (running && running.pid !== process.pid) {
27
- console.error(chalk.red(`✖ a kraftwerk daemon already runs (pid ${running.pid}, port ${running.port})`));
25
+ // A busy one counts too: a second daemon would only crash on its port.
26
+ const running = await daemonState();
27
+ if (running.state !== "stopped" && running.pid !== process.pid) {
28
+ console.error(chalk.red(`✖ a kraftwerk daemon already runs (pid ${running.pid}, port ${running.port}${running.state === "busy" ? ", busy right now" : ""})`));
28
29
  process.exit(1);
29
30
  }
30
31
  const port = Number(opts.port ?? process.env.KRAFTWERK_DAEMON_PORT ?? DAEMON_PORT);
@@ -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,16 +2,19 @@ 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 { addWorkspaceId, cloudFor, ignoreEntryFor, isDir, reposRootFor, resolveWorkspace, workspaceSlug } from "../config.js";
6
6
  import { discoverWorkflows } from "../discover.js";
7
7
  import { missingEnv } from "../yaml.js";
8
8
  import { applyDotenv, DOTENV_FILE } from "../dotenv.js";
9
9
  import { legacyFindings, versionLt } from "./doctor-legacy.js";
10
10
  import { listDevices } from "../core/devices.js";
11
11
  import { slugClashes } from "../core/instances.js";
12
- import { findDaemon } from "../core/daemon.js";
12
+ import { daemonState } from "../core/daemon.js";
13
13
  import { daemonLoginItem } from "./daemon-login.js";
14
14
  import { readRelaySettings } from "../server/relay.js";
15
+ import { readIdentity, identityString } from "../core/identity.js";
16
+ import { adoptUnassigned, runnerFindings } from "../core/routines.js";
17
+ import { setDefaultWorkspace, Workspace } from "../core/workspace.js";
15
18
  const ICONS = {
16
19
  ok: chalk.green("✔"),
17
20
  warn: chalk.yellow("⚠"),
@@ -42,7 +45,7 @@ function cliVersion(command) {
42
45
  return undefined;
43
46
  return (r.stdout || r.stderr).trim().split("\n")[0];
44
47
  }
45
- export async function runDoctor(cwd) {
48
+ export async function runDoctor(cwd, opts = {}) {
46
49
  let failures = 0;
47
50
  // Runtime.
48
51
  const [major] = process.versions.node.split(".").map(Number);
@@ -100,6 +103,12 @@ export async function runDoctor(cwd) {
100
103
  report("ok", `${path.basename(project.configPath)} well-formed`, keys.length ? keys.map((k) => `${k}: ${show(cfg[k])}`).join(", ") : "empty — defaults apply");
101
104
  if (!cfg.name)
102
105
  report("info", "name not set in kraftwerk.yml", "inspector header falls back to the folder name");
106
+ if (cfg.id)
107
+ report("ok", `workspace id: ${cfg.id}`);
108
+ else if (opts.fix)
109
+ report("ok", `workspace id: ${await addWorkspaceId(project.configPath)}`, `added to ${path.basename(project.configPath)} — commit it`);
110
+ else
111
+ report("warn", "no workspace id in kraftwerk.yml", "the same workspace on every clone is known by it — `kraftwerk doctor --fix` adds one");
103
112
  for (const key of ["workflows", "knowledge", "agents", "output"]) {
104
113
  const value = cfg[key];
105
114
  if (!value)
@@ -143,6 +152,31 @@ export async function runDoctor(cwd) {
143
152
  else
144
153
  report("ok", label, "not inside a git repository");
145
154
  }
155
+ // Routine runners: a routine fires only on the machine its runs_on names.
156
+ setDefaultWorkspace(new Workspace({ root: project.root, outputDir: project.outputDir }));
157
+ const me = identityString(await readIdentity());
158
+ const runners = await runnerFindings().catch(() => ({ unassigned: [], retired: [] }));
159
+ const names = (rs) => rs.map((r) => `${r.agent}/${r.id}`).join(", ");
160
+ if (!runners.unassigned.length && !runners.retired.length) {
161
+ report("ok", `routines: runner identity ${me}`, "every enabled routine names its runner");
162
+ }
163
+ else if (opts.fix) {
164
+ const r = await adoptUnassigned({ retired: true });
165
+ report("ok", `${r.adopted.length} routine(s) now run here (${me})`, names(r.adopted));
166
+ if (r.commit?.ok)
167
+ report("ok", `committed ${r.files.join(", ")}`, "push so the other clones see who runs them");
168
+ else if (r.commit)
169
+ report("warn", `written, not committed: ${r.commit.error}`, `commit ${r.files.join(", ")} yourself`);
170
+ }
171
+ else {
172
+ if (runners.unassigned.length) {
173
+ 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}`);
174
+ }
175
+ for (const old of [...new Set(runners.retired.map((r) => r.from))]) {
176
+ const these = runners.retired.filter((r) => r.from === old);
177
+ report("warn", `${these.length} routine(s) name the retired identity ${old}`, `${names(these)} · \`kraftwerk doctor --fix\` moves them to ${me} and commits`);
178
+ }
179
+ }
146
180
  // Cloud manager: registration is best-effort at runtime, so doctor only says what would happen.
147
181
  const cloud = cloudFor(project);
148
182
  if (cloud) {
@@ -214,9 +248,10 @@ export async function runDoctor(cwd) {
214
248
  report("warn", "KRAFTWERK_UI_TRUST_NETWORK=1", "anyone who reaches the UI over the network uses it without pairing — unset it and pair devices instead (`kraftwerk devices pair`)");
215
249
  report("info", devices.length ? `${devices.length} paired device(s)` : "no paired devices", devices.length ? devices.map((d) => d.name).join(", ") : "a phone or another computer can use kraftwerk once paired: `kraftwerk ui --lan`, then `kraftwerk devices pair`");
216
250
  // The daemon: whether one runs, and whether it starts at login.
217
- const daemon = await findDaemon();
251
+ const state = await daemonState();
252
+ const daemon = state.state === "stopped" ? null : state;
218
253
  const loginItem = daemonLoginItem();
219
- report("info", daemon ? `kraftwerk daemon running on :${daemon.port}` : "no kraftwerk daemon running", loginItem
254
+ report("info", !daemon ? "no kraftwerk daemon running" : daemon.state === "busy" ? `kraftwerk daemon running on :${daemon.port}, but slow to answer (busy)` : `kraftwerk daemon running on :${daemon.port}`, loginItem
220
255
  ? daemon
221
256
  ? "starts at login · `kraftwerk daemon status` for more"
222
257
  : "starts at login, but not running now · `kraftwerk daemon start`, `kraftwerk daemon logs` says why it stopped"
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
@@ -21,6 +21,7 @@ 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";
24
25
  import { applyDotenv } from "../dotenv.js";
25
26
  import { resolveWorkspace } from "../config.js";
26
27
  import { registerRoutineCommands } from "./routines.js";
@@ -34,7 +35,8 @@ import { runUi } from "./ui.js";
34
35
  * kraftwerk run [workflow] [text] run one (prompts interactively if omitted)
35
36
  * kraftwerk runs [show <id>] inspect past runs from their traces
36
37
  * kraftwerk knowledge ... Knowledge: OKF bundles (list/get/put/verify/...)
37
- * kraftwerk routines ... per-agent scheduled prompts (list/add/remove/enable/run)
38
+ * kraftwerk routines ... per-agent scheduled prompts (list/add/remove/enable/run/adopt)
39
+ * kraftwerk whoami this machine's identity (handle@machine), the runner routines name
38
40
  * kraftwerk ui start the inspector web UI (localhost:1981)
39
41
  * kraftwerk workspaces ... known workspaces on this machine (list/start/stop/forget)
40
42
  * kraftwerk projects ... goal-scoped project folders (list/create/show/link/log/remove)
@@ -44,7 +46,7 @@ import { runUi } from "./ui.js";
44
46
  * kraftwerk trash ... what was deleted: list, restore, purge, empty
45
47
  * kraftwerk devices ... paired devices: list, pair one, revoke one
46
48
  * kraftwerk daemon the machine's kraftwerk: every open workspace at <slug>.localhost
47
- * kraftwerk doctor preflight: harness CLIs, docker, workflows, env
49
+ * kraftwerk doctor [--fix] preflight: harness CLIs, docker, workflows, env, routine runners
48
50
  * kraftwerk validate [paths...] validate without executing
49
51
  *
50
52
  * Workflows are auto-discovered under src/workflows/ (or workflows/), from
@@ -265,9 +267,10 @@ program
265
267
  });
266
268
  program
267
269
  .command("doctor")
268
- .description("Preflight: harness CLIs, docker, workflows, declared environment variables")
269
- .action(async () => {
270
- await runDoctor(process.cwd());
270
+ .description("Preflight: harness CLIs, docker, workflows, declared environment variables, routine runners, workspace id")
271
+ .option("--fix", "Apply the fixes doctor offers: run unassigned routines here, move routines off a retired identity (both committed), add a workspace id")
272
+ .action(async (opts) => {
273
+ await runDoctor(process.cwd(), { fix: !!opts.fix });
271
274
  });
272
275
  const runs = program
273
276
  .command("runs")
@@ -298,6 +301,7 @@ registerDevicesCommands(program);
298
301
  registerDaemonCommand(program);
299
302
  registerRemoteCommand(program);
300
303
  registerJournalCommands(program);
304
+ registerWhoamiCommand(program);
301
305
  program
302
306
  .command("ui")
303
307
  .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<{
@@ -3,13 +3,14 @@ import path from "node:path";
3
3
  import chalk from "chalk";
4
4
  import { resolveWorkspace } from "../config.js";
5
5
  import { setDefaultWorkspace, Workspace } from "../core/workspace.js";
6
- import { deleteRoutine, listRoutines, routineStatuses, saveRoutine, } from "../core/routines.js";
6
+ import { adoptUnassigned, deleteRoutine, listRoutines, routineStatuses, saveRoutine, } from "../core/routines.js";
7
7
  import { getAgent, listAgents } from "../core/agents.js";
8
8
  /**
9
9
  * `kraftwerk routines` — CLI surface over per-agent scheduled prompts
10
10
  * (agents/<slug>/routines.yml). Definition CRUD works directly on the
11
11
  * files; `run` fires through the running inspector server, because the
12
- * session (chat) and the scheduler live there.
12
+ * session (chat) and the scheduler live there. `adopt` makes this machine
13
+ * the runner (runs_on) of every unassigned routine and commits routines.yml.
13
14
  */
14
15
  /** Point the inspector context at this project so agent/routine paths resolve. */
15
16
  export async function initContext() {
@@ -37,11 +38,19 @@ const fmtTime = (iso) => {
37
38
  const p = (n) => String(n).padStart(2, "0");
38
39
  return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
39
40
  };
41
+ function runnerLabel(r) {
42
+ if (r.runner === "here")
43
+ return chalk.green("runs here");
44
+ if (r.runner === "unassigned")
45
+ return chalk.yellow("unassigned");
46
+ return r.retired ? chalk.yellow(`runs on ${r.runsOn} (retired)`) : chalk.dim(`runs on ${r.runsOn}`);
47
+ }
40
48
  function routineLine(r) {
41
49
  const state = r.enabled ? chalk.green("on ") : chalk.dim("off");
42
50
  const flags = r.lastError ? ` ${chalk.red(`error: ${r.lastError}`)}` : "";
43
51
  return (` ${chalk.cyan(r.id.padEnd(24))} ${state} ${r.schedule.padEnd(14)} ` +
44
52
  chalk.dim(`next ${fmtTime(r.nextRunAt).padEnd(17)} last ${fmtTime(r.lastRunAt)}`) +
53
+ ` ${runnerLabel(r)}` +
45
54
  flags);
46
55
  }
47
56
  export function registerRoutineCommands(program) {
@@ -86,6 +95,7 @@ export function registerRoutineCommands(program) {
86
95
  .option("--file <path>", "Read the prompt from a file")
87
96
  .option("--id <id>", "Routine id (default: derived from the name)")
88
97
  .option("--disabled", "Create it switched off")
98
+ .option("--runs-on <identity>", "The machine that fires it, handle@machine (default: this one for a new routine, unchanged for an update)")
89
99
  .action(async (agent, opts) => {
90
100
  await initContext();
91
101
  await requireAgent(agent);
@@ -103,12 +113,16 @@ export function registerRoutineCommands(program) {
103
113
  schedule: opts.schedule,
104
114
  prompt,
105
115
  enabled: !opts.disabled,
116
+ ...(opts.runsOn !== undefined ? { runsOn: opts.runsOn } : {}),
106
117
  });
107
118
  const verb = existing.has(routine.id) ? "updated" : "created";
108
119
  console.log(`${chalk.green("✔")} ${verb} ${chalk.cyan(`${agent}/${routine.id}`)} ` +
109
120
  chalk.dim(`(${routine.schedule}${routine.enabled ? "" : ", disabled"})`));
110
- if (routine.enabled) {
111
- console.log(chalk.dim(" fires from the inspector server — make sure `kraftwerk ui` is running."));
121
+ if (!routine.runsOn) {
122
+ console.log(chalk.yellow(" unassigned: it fires nowhere until a machine runs it (`kraftwerk routines adopt`)."));
123
+ }
124
+ else if (routine.enabled) {
125
+ console.log(chalk.dim(` fires from the inspector server on ${routine.runsOn} — make sure \`kraftwerk ui\` runs there.`));
112
126
  }
113
127
  }
114
128
  catch (err) {
@@ -148,6 +162,28 @@ export function registerRoutineCommands(program) {
148
162
  console.log(`${chalk.green("✔")} ${chalk.cyan(`${agent}/${id}`)} ${verb}d`);
149
163
  });
150
164
  }
165
+ routines
166
+ .command("adopt")
167
+ .description("Run these here: every enabled routine without runs_on gets this machine's identity; routines.yml is committed")
168
+ .option("--retired", "Also move routines that name a retired identity of this machine (see `kraftwerk whoami`)")
169
+ .option("--json", "Machine-readable output")
170
+ .action(async (opts) => {
171
+ await initContext();
172
+ const r = await adoptUnassigned({ retired: !!opts.retired });
173
+ if (opts.json)
174
+ return void console.log(JSON.stringify(r, null, 2));
175
+ if (!r.adopted.length) {
176
+ console.log(`${chalk.green("✔")} nothing to adopt — every enabled routine has a runner ${chalk.dim(`(this machine: ${r.runsOn})`)}`);
177
+ return;
178
+ }
179
+ for (const a of r.adopted) {
180
+ console.log(`${chalk.green("✔")} ${chalk.cyan(`${a.agent}/${a.id}`)} runs on ${r.runsOn}${a.from ? chalk.dim(` (was ${a.from})`) : ""}`);
181
+ }
182
+ if (r.commit?.ok)
183
+ console.log(chalk.dim(` committed ${r.files.join(", ")} — push so the other clones see it`));
184
+ else
185
+ console.log(chalk.yellow(` written, not committed: ${r.commit?.error ?? "unknown error"} — commit ${r.files.join(", ")} yourself`));
186
+ });
151
187
  routines
152
188
  .command("run")
153
189
  .description("Fire a routine now (opens a session via the running `kraftwerk ui` server)")
@@ -0,0 +1,9 @@
1
+ import type { Command } from "commander";
2
+ /**
3
+ * `kraftwerk whoami` — this machine's identity (~/.kraftwerk/identity.json):
4
+ * the handle, the machine name, and `handle@machine`, which routines name in
5
+ * `runs_on:` so only one clone of a workspace fires them. `--handle` /
6
+ * `--machine` rename; the old pair is kept in `previous`, and doctor offers
7
+ * to move routines that still name it.
8
+ */
9
+ export declare function registerWhoamiCommand(program: Command): void;
@@ -0,0 +1,48 @@
1
+ import chalk from "chalk";
2
+ import { identityPartError, identityString, readIdentity, setIdentity } from "../core/identity.js";
3
+ /**
4
+ * `kraftwerk whoami` — this machine's identity (~/.kraftwerk/identity.json):
5
+ * the handle, the machine name, and `handle@machine`, which routines name in
6
+ * `runs_on:` so only one clone of a workspace fires them. `--handle` /
7
+ * `--machine` rename; the old pair is kept in `previous`, and doctor offers
8
+ * to move routines that still name it.
9
+ */
10
+ export function registerWhoamiCommand(program) {
11
+ program
12
+ .command("whoami")
13
+ .description("This machine's identity (handle@machine) — the runner routines name in runs_on; --handle / --machine rename it")
14
+ .option("--handle <handle>", "Set your handle (lowercase letters, digits, . _ -)")
15
+ .option("--machine <machine>", "Set this machine's name (lowercase letters, digits, . _ -)")
16
+ .option("--json", "Machine-readable output")
17
+ .addHelpText("after", `
18
+ A routine fires only on the machine whose identity its runs_on names:
19
+ kraftwerk whoami print handle, machine and handle@machine
20
+ kraftwerk whoami --machine studio rename this machine (the old pair is kept as previous)
21
+ kraftwerk routines adopt make this machine the runner of every unassigned routine`)
22
+ .action(async (opts) => {
23
+ const before = identityString(await readIdentity());
24
+ for (const label of ["handle", "machine"]) {
25
+ const v = opts[label];
26
+ const err = v === undefined ? undefined : identityPartError(label, v);
27
+ if (err) {
28
+ console.error(chalk.red(err));
29
+ process.exit(2);
30
+ }
31
+ }
32
+ const changing = opts.handle !== undefined || opts.machine !== undefined;
33
+ const id = changing ? await setIdentity({ handle: opts.handle, machine: opts.machine }) : await readIdentity();
34
+ const now = identityString(id);
35
+ if (opts.json)
36
+ return void console.log(JSON.stringify({ handle: id.handle, machine: id.machine, identity: now, previous: id.previous }, null, 2));
37
+ if (changing && before !== now)
38
+ console.log(`${chalk.green("✔")} ${chalk.dim(before)} → ${chalk.cyan(now)}`);
39
+ console.log(`handle: ${id.handle}`);
40
+ console.log(`machine: ${id.machine}`);
41
+ console.log(`identity: ${chalk.cyan(now)}`);
42
+ if (id.previous.length)
43
+ console.log(chalk.dim(`previous: ${id.previous.join(", ")}`));
44
+ if (changing && before !== now) {
45
+ console.log(chalk.dim(` routines that name ${before} no longer fire here — \`kraftwerk doctor --fix\` moves them to ${now}`));
46
+ }
47
+ });
48
+ }
@@ -1,6 +1,7 @@
1
1
  /** Route name → [method, path pattern, and "raw" (answers a stream or a file) or "upload" (sends file bytes): both HTTP only]. */
2
2
  export declare const ROUTES: {
3
3
  readonly "agents.addJournal": readonly ["POST", "/api/agents/:slug/journal"];
4
+ readonly "agents.adoptRoutine": readonly ["POST", "/api/agents/:slug/routines/:id/adopt"];
4
5
  readonly "agents.archive": readonly ["POST", "/api/agents/:slug/archive"];
5
6
  readonly "agents.create": readonly ["POST", "/api/agents"];
6
7
  readonly "agents.delete": readonly ["DELETE", "/api/agents/:slug"];
@@ -70,6 +71,7 @@ export declare const ROUTES: {
70
71
  readonly "hub.close": readonly ["POST", "/api/hub/close"];
71
72
  readonly "hub.list": readonly ["GET", "/api/hub"];
72
73
  readonly "hub.open": readonly ["POST", "/api/hub/open"];
74
+ readonly "identity.get": readonly ["GET", "/api/identity"];
73
75
  readonly "knowledge.concept": readonly ["GET", "/api/knowledge/:bundle/concept"];
74
76
  readonly "knowledge.create": readonly ["POST", "/api/knowledge"];
75
77
  readonly "knowledge.delete": readonly ["DELETE", "/api/knowledge/:bundle"];
@@ -98,6 +100,7 @@ export declare const ROUTES: {
98
100
  readonly "repos.list": readonly ["GET", "/api/repos"];
99
101
  readonly "repos.remove": readonly ["DELETE", "/api/repos/:slug"];
100
102
  readonly "repos.update": readonly ["POST", "/api/repos/:slug/update"];
103
+ readonly "routines.adopt": readonly ["POST", "/api/routines/adopt"];
101
104
  readonly "routines.list": readonly ["GET", "/api/routines"];
102
105
  readonly "runs.decide": readonly ["POST", "/api/runs/:id/decision"];
103
106
  readonly "runs.delete": readonly ["DELETE", "/api/runs/:id"];
@@ -2,6 +2,7 @@
2
2
  /** Route name → [method, path pattern, and "raw" (answers a stream or a file) or "upload" (sends file bytes): both HTTP only]. */
3
3
  export const ROUTES = {
4
4
  "agents.addJournal": ["POST", "/api/agents/:slug/journal"],
5
+ "agents.adoptRoutine": ["POST", "/api/agents/:slug/routines/:id/adopt"],
5
6
  "agents.archive": ["POST", "/api/agents/:slug/archive"],
6
7
  "agents.create": ["POST", "/api/agents"],
7
8
  "agents.delete": ["DELETE", "/api/agents/:slug"],
@@ -71,6 +72,7 @@ export const ROUTES = {
71
72
  "hub.close": ["POST", "/api/hub/close"],
72
73
  "hub.list": ["GET", "/api/hub"],
73
74
  "hub.open": ["POST", "/api/hub/open"],
75
+ "identity.get": ["GET", "/api/identity"],
74
76
  "knowledge.concept": ["GET", "/api/knowledge/:bundle/concept"],
75
77
  "knowledge.create": ["POST", "/api/knowledge"],
76
78
  "knowledge.delete": ["DELETE", "/api/knowledge/:bundle"],
@@ -99,6 +101,7 @@ export const ROUTES = {
99
101
  "repos.list": ["GET", "/api/repos"],
100
102
  "repos.remove": ["DELETE", "/api/repos/:slug"],
101
103
  "repos.update": ["POST", "/api/repos/:slug/update"],
104
+ "routines.adopt": ["POST", "/api/routines/adopt"],
102
105
  "routines.list": ["GET", "/api/routines"],
103
106
  "runs.decide": ["POST", "/api/runs/:id/decision"],
104
107
  "runs.delete": ["DELETE", "/api/runs/:id"],
package/dist/config.d.ts CHANGED
@@ -9,6 +9,7 @@
9
9
  *
10
10
  * All fields are optional — a valid kraftwerk.yml may be empty:
11
11
  *
12
+ * id: ws_k3f9x2m7q1 # the workspace's identity across machines and clones (committed; `kraftwerk init` writes it)
12
13
  * name: my-project # display name (inspector header, "environment")
13
14
  * icon: "⚡" # emoji shown as inspector favicon
14
15
  * color: "#c2410c" # accent for this workspace in the switcher (default: derived from the root)
@@ -165,6 +166,12 @@ export interface SwitcherEntry {
165
166
  icon?: string;
166
167
  }
167
168
  export interface WorkspaceConfig {
169
+ /**
170
+ * The workspace's identity wherever it is cloned: `ws_` + 10 base36
171
+ * characters, committed with kraftwerk.yml. Unlike the slug (an address on
172
+ * one machine) it is the same on every clone. Nothing reads it yet.
173
+ */
174
+ id?: string;
168
175
  /** Display name of the project ("environment"), shown in the inspector header. */
169
176
  name?: string;
170
177
  /**
@@ -209,6 +216,8 @@ export interface ResolvedWorkspace {
209
216
  config: WorkspaceConfig;
210
217
  /** Absolute path of the config file, if one exists. */
211
218
  configPath?: string;
219
+ /** kraftwerk.yml `id`, when set. */
220
+ id?: string;
212
221
  /** Absolute workflows root, if one exists. */
213
222
  workflowsRoot?: string;
214
223
  /** Absolute run-artifact directory (may not exist yet). */
@@ -250,3 +259,14 @@ export declare function workspaceSlug(ws: {
250
259
  slug?: string;
251
260
  };
252
261
  }): string;
262
+ /** A workspace id: `ws_` and 10 lowercase base36 characters. */
263
+ export declare const WORKSPACE_ID_RE: RegExp;
264
+ /** A fresh workspace id for kraftwerk.yml. */
265
+ export declare function newWorkspaceId(): string;
266
+ /**
267
+ * Give an existing kraftwerk.yml an id, textually: one line inserted after
268
+ * the leading comments, the rest of the file byte for byte as it was (a
269
+ * re-serialised YAML document would lose the comments people keep there).
270
+ * Returns the id, or the one already there.
271
+ */
272
+ export declare function addWorkspaceId(configPath: string): Promise<string>;