@netnodeag/kraftwerk 0.75.1 → 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 (45) hide show
  1. package/dist/cli/daemon-control.js +4 -2
  2. package/dist/cli/doctor.d.ts +3 -1
  3. package/dist/cli/doctor.js +36 -2
  4. package/dist/cli/init.js +5 -2
  5. package/dist/cli/kraftwerk.js +9 -5
  6. package/dist/cli/routines.d.ts +2 -1
  7. package/dist/cli/routines.js +40 -4
  8. package/dist/cli/whoami.d.ts +9 -0
  9. package/dist/cli/whoami.js +48 -0
  10. package/dist/client/routes.d.ts +3 -0
  11. package/dist/client/routes.js +3 -0
  12. package/dist/config.d.ts +20 -0
  13. package/dist/config.js +38 -3
  14. package/dist/core/attention.d.ts +8 -1
  15. package/dist/core/attention.js +38 -2
  16. package/dist/core/identity.d.ts +46 -0
  17. package/dist/core/identity.js +106 -0
  18. package/dist/core/routines.d.ts +81 -0
  19. package/dist/core/routines.js +174 -33
  20. package/dist/server/api/agents.d.ts +5 -1
  21. package/dist/server/api/agents.js +19 -2
  22. package/dist/server/api/index.d.ts +14 -3
  23. package/dist/server/api/runs.d.ts +3 -1
  24. package/dist/server/api/runs.js +9 -1
  25. package/dist/server/api/workspace.d.ts +6 -1
  26. package/dist/server/api/workspace.js +5 -0
  27. package/dist/server/push.js +1 -1
  28. package/package.json +1 -1
  29. package/schema/kraftwerk.schema.json +5 -0
  30. package/web/dist/assets/{dist-YNCNGGgK.js → dist-BQpWYkxm.js} +1 -1
  31. package/web/dist/assets/{dist-DaGnyH_l.js → dist-BTVP-6On.js} +1 -1
  32. package/web/dist/assets/dist-BURf1l-A.js +1 -0
  33. package/web/dist/assets/{dist-CLZjnhZZ.js → dist-BYkcY-Vl.js} +1 -1
  34. package/web/dist/assets/{dist-CzM6tn0f.js → dist-BwIca_Uj.js} +1 -1
  35. package/web/dist/assets/{dist-Y9aHncjU.js → dist-DFXmaDc5.js} +1 -1
  36. package/web/dist/assets/{dist-CezfArgg.js → dist-T1sNLC62.js} +1 -1
  37. package/web/dist/assets/{dist-CYicby0c.js → dist-fgB7OEJy.js} +1 -1
  38. package/web/dist/assets/{dist-QqFmuPda.js → dist-flnLXEzU.js} +1 -1
  39. package/web/dist/assets/{dist-BI4umsk6.js → dist-l7foWlFx.js} +1 -1
  40. package/web/dist/assets/{dist-CODxhtAK.js → dist-vuciRCSz.js} +1 -1
  41. package/web/dist/assets/{editor-DEb8z5-Q.js → editor-C6Ok8NPo.js} +3 -3
  42. package/web/dist/assets/index-z4tpCCzk.js +231 -0
  43. package/web/dist/index.html +1 -1
  44. package/web/dist/assets/dist-DtktO5hc.js +0 -1
  45. package/web/dist/assets/index-kmyK4tFm.js +0 -231
@@ -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)
@@ -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,7 @@ 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";
@@ -12,6 +12,9 @@ import { slugClashes } from "../core/instances.js";
12
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) {
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>;
package/dist/config.js CHANGED
@@ -1,4 +1,5 @@
1
- import { readFile, stat } from "node:fs/promises";
1
+ import { randomInt } from "node:crypto";
2
+ import { readFile, stat, writeFile } from "node:fs/promises";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  import { parse } from "yaml";
@@ -13,6 +14,7 @@ import { parse } from "yaml";
13
14
  *
14
15
  * All fields are optional — a valid kraftwerk.yml may be empty:
15
16
  *
17
+ * id: ws_k3f9x2m7q1 # the workspace's identity across machines and clones (committed; `kraftwerk init` writes it)
16
18
  * name: my-project # display name (inspector header, "environment")
17
19
  * icon: "⚡" # emoji shown as inspector favicon
18
20
  * color: "#c2410c" # accent for this workspace in the switcher (default: derived from the root)
@@ -174,7 +176,7 @@ export async function resolveWorkspace(cwd) {
174
176
  const { config, legacyKeys } = loaded;
175
177
  const workflowsRoot = await findWorkflowsDir(dir, config.workflows);
176
178
  if (!bare || (workflowsRoot && path.resolve(workflowsRoot) === bare.workflowsRoot)) {
177
- return { root: dir, config, configPath, workflowsRoot, outputDir: path.resolve(dir, config.output ?? "output"), ...(legacyKeys.length ? { legacyKeys } : {}) };
179
+ return { root: dir, config, configPath, ...(config.id ? { id: config.id } : {}), workflowsRoot, outputDir: path.resolve(dir, config.output ?? "output"), ...(legacyKeys.length ? { legacyKeys } : {}) };
178
180
  }
179
181
  return bare;
180
182
  }
@@ -213,7 +215,37 @@ export function slugFromFolder(folder) {
213
215
  export function workspaceSlug(ws) {
214
216
  return ws.config.slug ?? slugFromFolder(path.basename(ws.root));
215
217
  }
216
- const KNOWN_KEYS = ["name", "slug", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "cloud"];
218
+ /** A workspace id: `ws_` and 10 lowercase base36 characters. */
219
+ export const WORKSPACE_ID_RE = /^ws_[a-z0-9]{10}$/;
220
+ /** A fresh workspace id for kraftwerk.yml. */
221
+ export function newWorkspaceId() {
222
+ const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
223
+ let id = "ws_";
224
+ for (let i = 0; i < 10; i++)
225
+ id += alphabet[randomInt(alphabet.length)];
226
+ return id;
227
+ }
228
+ /**
229
+ * Give an existing kraftwerk.yml an id, textually: one line inserted after
230
+ * the leading comments, the rest of the file byte for byte as it was (a
231
+ * re-serialised YAML document would lose the comments people keep there).
232
+ * Returns the id, or the one already there.
233
+ */
234
+ export async function addWorkspaceId(configPath) {
235
+ const text = await readFile(configPath, "utf8");
236
+ const existing = /^id:\s*["']?(ws_[a-z0-9]{10})["']?\s*(#.*)?$/m.exec(text);
237
+ if (existing)
238
+ return existing[1];
239
+ const id = newWorkspaceId();
240
+ const line = `id: ${id} # this workspace's identity on every machine and clone — commit it, keep it`;
241
+ const lines = text.split("\n");
242
+ const first = lines.findIndex((l) => !/^\s*(#|$)/.test(l));
243
+ // Nothing but comments (or nothing at all): the id goes at the end.
244
+ const out = first === -1 ? `${text.replace(/\n*$/, text.trim() ? "\n" : "")}${line}\n` : [...lines.slice(0, first), line, ...lines.slice(first)].join("\n");
245
+ await writeFile(configPath, out);
246
+ return id;
247
+ }
248
+ const KNOWN_KEYS = ["id", "name", "slug", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "cloud"];
217
249
  /**
218
250
  * Keys an older kraftwerk understood and this one ignores, so the files
219
251
  * that still carry them keep loading: `public:` and `tunnel:` (the
@@ -243,6 +275,9 @@ async function loadConfig(configPath) {
243
275
  if (!known.includes(key)) {
244
276
  throw new Error(`${path.basename(configPath)}: unknown key "${key}" (allowed: ${known.join(", ")})`);
245
277
  }
278
+ if (key === "id" && (typeof config[key] !== "string" || !WORKSPACE_ID_RE.test(config[key]))) {
279
+ throw new Error(`${path.basename(configPath)}: id must be "ws_" followed by 10 lowercase letters or digits (like ws_k3f9x2m7q1), got ${JSON.stringify(config[key])} — delete the line and run \`kraftwerk doctor --fix\` for a fresh one`);
280
+ }
246
281
  if (key === "slug" && (typeof config[key] !== "string" || !SLUG_RE.test(config[key]))) {
247
282
  throw new Error(`${path.basename(configPath)}: slug must be lowercase letters, digits and dashes, starting with a letter or digit (at most 63)`);
248
283
  }
@@ -1,9 +1,13 @@
1
+ import { type RunnerRef } from "./routines.js";
1
2
  /**
2
3
  * What needs you: every place where work is stuck on a human, with whose it
3
4
  * is. Two sources: requests waiting in a live chat (an approval, a question
4
5
  * — the agent cannot go on until someone answers) and failures nobody has
5
6
  * looked at yet (a routine or a workflow run that ended badly; unread
6
7
  * failure notifications). Oldest first: what waits longest comes first.
8
+ * A third, about setup rather than one piece of work: routines nobody runs
9
+ * (enabled, no runs_on) and routines naming an identity this machine has
10
+ * retired — both fire nowhere until someone assigns them a runner.
7
11
  *
8
12
  * The inspector builds everything urgent from this one list: the counts on
9
13
  * the rail, the "needs you" part of the bell, the tab title and "next",
@@ -29,7 +33,8 @@ export interface AttentionOwner {
29
33
  export interface AttentionItem {
30
34
  /** The request id (live requests) or the notification id (failures). */
31
35
  id: string;
32
- kind: "approval" | "question" | "failed";
36
+ /** runner: routines without a runner, or naming a retired identity of this machine (see routines.ts). */
37
+ kind: "approval" | "question" | "failed" | "runner";
33
38
  /** What is asked, or what went wrong. */
34
39
  title: string;
35
40
  /** The session it sits in. */
@@ -41,6 +46,8 @@ export interface AttentionItem {
41
46
  owner: AttentionOwner;
42
47
  /** Failures: the notification to mark read once the item was opened. */
43
48
  notificationId?: string;
49
+ /** runner items: the routines concerned; `from` is the retired identity they name. */
50
+ routines?: RunnerRef[];
44
51
  }
45
52
  export interface AttentionView {
46
53
  items: AttentionItem[];
@@ -2,11 +2,13 @@ import { listAgents } from "./agents.js";
2
2
  import { waitingRequests } from "./chat/sessions.js";
3
3
  import { chatHref, listNotifications } from "./notifications.js";
4
4
  import { listProjects } from "./projects.js";
5
+ import { runnerFindings } from "./routines.js";
5
6
  export async function listAttention() {
6
- const [agents, projects, notes] = await Promise.all([
7
+ const [agents, projects, notes, runners] = await Promise.all([
7
8
  listAgents().catch(() => []),
8
9
  listProjects().catch(() => ({ projects: [] })),
9
10
  listNotifications().catch(() => ({ items: [] })),
11
+ runnerFindings().catch(() => ({ unassigned: [], retired: [] })),
10
12
  ]);
11
13
  const agentOf = (slug) => {
12
14
  const a = agents.find((x) => x.slug === slug);
@@ -65,6 +67,40 @@ export async function listAttention() {
65
67
  notificationId: n.id,
66
68
  };
67
69
  });
68
- const items = [...waiting, ...failed].sort((a, b) => a.since.localeCompare(b.since));
70
+ // One item per problem, not per routine: the fix ("run these here") is one action for all of them.
71
+ const since = runners.since ?? new Date(0).toISOString();
72
+ // The fix lives on an agent profile's routines panel (/agents/<slug>/info — /agents/<slug> redirects
73
+ // to a chat). Routines of one agent: that profile and its rail entry; of several: the first agent's
74
+ // profile, whose banner adopts across the whole workspace.
75
+ const placed = (these) => {
76
+ const slugs = [...new Set(these.map((r) => r.agent))];
77
+ return slugs.length === 1
78
+ ? { href: `/agents/${slugs[0]}/info`, owner: { agent: agentOf(slugs[0]) } }
79
+ : { href: `/agents/${slugs[0]}/info`, owner: {} };
80
+ };
81
+ const runner = [];
82
+ const n = runners.unassigned.length;
83
+ if (n) {
84
+ runner.push({
85
+ id: "runner:unassigned",
86
+ kind: "runner",
87
+ title: `${n} routine${n === 1 ? " has" : "s have"} no runner — ${n === 1 ? "it fires" : "they fire"} nowhere until a machine runs ${n === 1 ? "it" : "them"}`,
88
+ since,
89
+ ...placed(runners.unassigned),
90
+ routines: runners.unassigned,
91
+ });
92
+ }
93
+ for (const old of [...new Set(runners.retired.map((r) => r.from))]) {
94
+ const these = runners.retired.filter((r) => r.from === old);
95
+ runner.push({
96
+ id: `runner:retired:${old}`,
97
+ kind: "runner",
98
+ title: `${these.length} routine${these.length === 1 ? " names" : "s name"} a retired identity ${old} — reassign`,
99
+ since,
100
+ ...placed(these),
101
+ routines: these,
102
+ });
103
+ }
104
+ const items = [...waiting, ...failed, ...runner].sort((a, b) => a.since.localeCompare(b.since));
69
105
  return { items };
70
106
  }