@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
@@ -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"];
@@ -44,6 +45,10 @@ export declare const ROUTES: {
44
45
  readonly "chats.setVibeable": readonly ["POST", "/api/chats/:id/vibeable"];
45
46
  readonly "chats.steer": readonly ["POST", "/api/chats/:id/steer"];
46
47
  readonly "chats.stopTask": readonly ["POST", "/api/chats/:id/task-stop"];
48
+ readonly "cloud.set": readonly ["POST", "/api/cloud"];
49
+ readonly "cloud.status": readonly ["GET", "/api/cloud"];
50
+ readonly "cloud.sync": readonly ["POST", "/api/cloud/sync"];
51
+ readonly "cloud.unlink": readonly ["POST", "/api/cloud/unlink"];
47
52
  readonly "devices.code": readonly ["POST", "/api/devices/code"];
48
53
  readonly "devices.dropPush": readonly ["DELETE", "/api/devices/push"];
49
54
  readonly "devices.list": readonly ["GET", "/api/devices"];
@@ -70,6 +75,7 @@ export declare const ROUTES: {
70
75
  readonly "hub.close": readonly ["POST", "/api/hub/close"];
71
76
  readonly "hub.list": readonly ["GET", "/api/hub"];
72
77
  readonly "hub.open": readonly ["POST", "/api/hub/open"];
78
+ readonly "identity.get": readonly ["GET", "/api/identity"];
73
79
  readonly "knowledge.concept": readonly ["GET", "/api/knowledge/:bundle/concept"];
74
80
  readonly "knowledge.create": readonly ["POST", "/api/knowledge"];
75
81
  readonly "knowledge.delete": readonly ["DELETE", "/api/knowledge/:bundle"];
@@ -98,6 +104,7 @@ export declare const ROUTES: {
98
104
  readonly "repos.list": readonly ["GET", "/api/repos"];
99
105
  readonly "repos.remove": readonly ["DELETE", "/api/repos/:slug"];
100
106
  readonly "repos.update": readonly ["POST", "/api/repos/:slug/update"];
107
+ readonly "routines.adopt": readonly ["POST", "/api/routines/adopt"];
101
108
  readonly "routines.list": readonly ["GET", "/api/routines"];
102
109
  readonly "runs.decide": readonly ["POST", "/api/runs/:id/decision"];
103
110
  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"],
@@ -45,6 +46,10 @@ export const ROUTES = {
45
46
  "chats.setVibeable": ["POST", "/api/chats/:id/vibeable"],
46
47
  "chats.steer": ["POST", "/api/chats/:id/steer"],
47
48
  "chats.stopTask": ["POST", "/api/chats/:id/task-stop"],
49
+ "cloud.set": ["POST", "/api/cloud"],
50
+ "cloud.status": ["GET", "/api/cloud"],
51
+ "cloud.sync": ["POST", "/api/cloud/sync"],
52
+ "cloud.unlink": ["POST", "/api/cloud/unlink"],
48
53
  "devices.code": ["POST", "/api/devices/code"],
49
54
  "devices.dropPush": ["DELETE", "/api/devices/push"],
50
55
  "devices.list": ["GET", "/api/devices"],
@@ -71,6 +76,7 @@ export const ROUTES = {
71
76
  "hub.close": ["POST", "/api/hub/close"],
72
77
  "hub.list": ["GET", "/api/hub"],
73
78
  "hub.open": ["POST", "/api/hub/open"],
79
+ "identity.get": ["GET", "/api/identity"],
74
80
  "knowledge.concept": ["GET", "/api/knowledge/:bundle/concept"],
75
81
  "knowledge.create": ["POST", "/api/knowledge"],
76
82
  "knowledge.delete": ["DELETE", "/api/knowledge/:bundle"],
@@ -99,6 +105,7 @@ export const ROUTES = {
99
105
  "repos.list": ["GET", "/api/repos"],
100
106
  "repos.remove": ["DELETE", "/api/repos/:slug"],
101
107
  "repos.update": ["POST", "/api/repos/:slug/update"],
108
+ "routines.adopt": ["POST", "/api/routines/adopt"],
102
109
  "routines.list": ["GET", "/api/routines"],
103
110
  "runs.decide": ["POST", "/api/runs/:id/decision"],
104
111
  "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)
@@ -33,11 +34,10 @@
33
34
  * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
34
35
  * projects: # goal-scoped folders (brief, systems of record, links) that chats work in (absent = off, bare key = on)
35
36
  * root: kraftwerk-data/projects # one folder per project, part of the workspace. Default: kraftwerk-data/projects
36
- * cloud: # kraftwerk cloud manager — ON BY DEFAULT, `enabled: false` opts out
37
- * url: https://srv.kraftwerk-cloud.netnode.cloud # where the cloud answers (default). KRAFTWERK_CLOUD_URL env wins; "off" disables
38
- * interval: 60 # seconds between heartbeats. Default: 60
39
- * # No key here: the cloud hands the instance its identity and a claim code (Settings → Cloud in the UI);
40
- * # a server that should register under an account unattended puts KRAFTWERK_CLOUD_TOKEN into .env.
37
+ * cloud: # the machine's kraftwerk cloud record lists this workspace — `enabled: false` leaves it out
38
+ * enabled: false # a per-workspace veto; the machine's switch is `kraftwerk cloud on|off` (~/.kraftwerk/cloud.json)
39
+ * # No key here: the daemon registers the MACHINE (its relay id) and `kraftwerk login` claims it.
40
+ * # `url` and `interval` are deprecated: the cloud's origin is the machine's (cloud.json / KRAFTWERK_CLOUD_URL).
41
41
  */
42
42
  /** Stable, versionless URL of the workflow JSON schema (editor validation). */
43
43
  export declare const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
@@ -122,35 +122,23 @@ export declare const PROJECTS_DEFAULT_ROOT = "kraftwerk-data/projects";
122
122
  /** Absolute projects root when the feature is on, undefined otherwise. */
123
123
  export declare function projectsRootFor(project: ResolvedWorkspace): string | undefined;
124
124
  /**
125
- * Cloud manager: the kraftwerk cloud is ~/.kraftwerk made reachable over
126
- * the internet — a registry of instances across machines. Every inspector
127
- * registers on start and heartbeats while it runs, so the cloud's global
128
- * view shows this workspace, where it runs, and whether it is live; the
129
- * user claims it into their account with the code the UI shows. On by
130
- * default: an absent block is the same as a bare one, `enabled: false`
131
- * opts out, and so does KRAFTWERK_CLOUD_URL=off in the environment (what
132
- * the tests and CI set). No credential lives in kraftwerk.yml — the
133
- * identity comes from the cloud, and an account token for unattended
134
- * servers is KRAFTWERK_CLOUD_TOKEN in .env.
125
+ * kraftwerk.yml `cloud:` — since the cloud knows machines (core/cloud.ts,
126
+ * server/cloud.ts), this block only vetoes: `enabled: false` leaves the
127
+ * workspace out of the machine's reports. The machine's switch and the
128
+ * cloud's origin are ~/.kraftwerk/cloud.json (`kraftwerk cloud on|off`) and
129
+ * KRAFTWERK_CLOUD_URL. `url` is still honored for one release (with a
130
+ * warning) when the machine names none; `interval` is ignored — the cloud
131
+ * sets the cadence.
135
132
  */
136
133
  export interface CloudConfig {
137
- /** false turns the feature off. Default: true. */
134
+ /** false leaves this workspace out of the machine's cloud record. Default: true. */
138
135
  enabled?: boolean;
139
- /** Origin of the cloud manager. Default: https://srv.kraftwerk-cloud.netnode.cloud */
136
+ /** @deprecated The machine's (cloud.json / KRAFTWERK_CLOUD_URL); used only when the machine names none. */
140
137
  url?: string;
141
- /** Seconds between heartbeats. Default: 60 (the cloud may raise it). */
138
+ /** @deprecated Ignored: the cloud sets the heartbeat interval. */
142
139
  interval?: number;
143
140
  }
144
141
  export declare const CLOUD_DEFAULT_URL = "https://srv.kraftwerk-cloud.netnode.cloud";
145
- export declare const CLOUD_DEFAULT_INTERVAL = 60;
146
- export interface CloudSettings {
147
- url: string;
148
- interval: number;
149
- /** KRAFTWERK_CLOUD_TOKEN from the environment (.env), when set. */
150
- token?: string;
151
- }
152
- /** The effective cloud settings when the feature is on; undefined when kraftwerk.yml or the environment turned it off. */
153
- export declare function cloudFor(project: ResolvedWorkspace): CloudSettings | undefined;
154
142
  /** A directory as a .gitignore entry: relative, forward slashes, no trailing slash; undefined outside the root. */
155
143
  export declare function ignoreEntryFor(projectRoot: string, dir: string): string | undefined;
156
144
  /** True when .gitignore text already covers the entry (with or without a leading or trailing slash). */
@@ -165,6 +153,12 @@ export interface SwitcherEntry {
165
153
  icon?: string;
166
154
  }
167
155
  export interface WorkspaceConfig {
156
+ /**
157
+ * The workspace's identity wherever it is cloned: `ws_` + 10 base36
158
+ * characters, committed with kraftwerk.yml. Unlike the slug (an address on
159
+ * one machine) it is the same on every clone. Nothing reads it yet.
160
+ */
161
+ id?: string;
168
162
  /** Display name of the project ("environment"), shown in the inspector header. */
169
163
  name?: string;
170
164
  /**
@@ -199,7 +193,7 @@ export interface WorkspaceConfig {
199
193
  vibeables?: VibeablesConfig;
200
194
  /** Projects: goal-scoped folders the chats work in. Absent = off. */
201
195
  projects?: ProjectsConfig;
202
- /** kraftwerk cloud manager this instance registers with. Absent = on with defaults; `enabled: false` opts out. */
196
+ /** The machine's cloud record lists this workspace. Absent = listed; `enabled: false` leaves it out. */
203
197
  cloud?: CloudConfig;
204
198
  }
205
199
  export interface ResolvedWorkspace {
@@ -209,6 +203,8 @@ export interface ResolvedWorkspace {
209
203
  config: WorkspaceConfig;
210
204
  /** Absolute path of the config file, if one exists. */
211
205
  configPath?: string;
206
+ /** kraftwerk.yml `id`, when set. */
207
+ id?: string;
212
208
  /** Absolute workflows root, if one exists. */
213
209
  workflowsRoot?: string;
214
210
  /** Absolute run-artifact directory (may not exist yet). */
@@ -250,3 +246,14 @@ export declare function workspaceSlug(ws: {
250
246
  slug?: string;
251
247
  };
252
248
  }): string;
249
+ /** A workspace id: `ws_` and 10 lowercase base36 characters. */
250
+ export declare const WORKSPACE_ID_RE: RegExp;
251
+ /** A fresh workspace id for kraftwerk.yml. */
252
+ export declare function newWorkspaceId(): string;
253
+ /**
254
+ * Give an existing kraftwerk.yml an id, textually: one line inserted after
255
+ * the leading comments, the rest of the file byte for byte as it was (a
256
+ * re-serialised YAML document would lose the comments people keep there).
257
+ * Returns the id, or the one already there.
258
+ */
259
+ 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)
@@ -37,11 +39,10 @@ import { parse } from "yaml";
37
39
  * root: apps # one folder per app, part of the workspace. Default: kraftwerk-data/vibeables
38
40
  * projects: # goal-scoped folders (brief, systems of record, links) that chats work in (absent = off, bare key = on)
39
41
  * root: kraftwerk-data/projects # one folder per project, part of the workspace. Default: kraftwerk-data/projects
40
- * cloud: # kraftwerk cloud manager — ON BY DEFAULT, `enabled: false` opts out
41
- * url: https://srv.kraftwerk-cloud.netnode.cloud # where the cloud answers (default). KRAFTWERK_CLOUD_URL env wins; "off" disables
42
- * interval: 60 # seconds between heartbeats. Default: 60
43
- * # No key here: the cloud hands the instance its identity and a claim code (Settings → Cloud in the UI);
44
- * # a server that should register under an account unattended puts KRAFTWERK_CLOUD_TOKEN into .env.
42
+ * cloud: # the machine's kraftwerk cloud record lists this workspace — `enabled: false` leaves it out
43
+ * enabled: false # a per-workspace veto; the machine's switch is `kraftwerk cloud on|off` (~/.kraftwerk/cloud.json)
44
+ * # No key here: the daemon registers the MACHINE (its relay id) and `kraftwerk login` claims it.
45
+ * # `url` and `interval` are deprecated: the cloud's origin is the machine's (cloud.json / KRAFTWERK_CLOUD_URL).
45
46
  */
46
47
  /** Stable, versionless URL of the workflow JSON schema (editor validation). */
47
48
  export const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
@@ -96,19 +97,6 @@ export function projectsRootFor(project) {
96
97
  return path.resolve(project.root, p.root ?? PROJECTS_DEFAULT_ROOT);
97
98
  }
98
99
  export const CLOUD_DEFAULT_URL = "https://srv.kraftwerk-cloud.netnode.cloud";
99
- export const CLOUD_DEFAULT_INTERVAL = 60;
100
- /** The effective cloud settings when the feature is on; undefined when kraftwerk.yml or the environment turned it off. */
101
- export function cloudFor(project) {
102
- const c = project.config.cloud ?? {};
103
- const envUrl = process.env.KRAFTWERK_CLOUD_URL?.trim();
104
- if (c.enabled === false || envUrl?.toLowerCase() === "off")
105
- return undefined;
106
- return {
107
- url: (envUrl || c.url || CLOUD_DEFAULT_URL).replace(/\/+$/, ""),
108
- interval: c.interval ?? CLOUD_DEFAULT_INTERVAL,
109
- token: process.env.KRAFTWERK_CLOUD_TOKEN?.trim() || undefined,
110
- };
111
- }
112
100
  /** A directory as a .gitignore entry: relative, forward slashes, no trailing slash; undefined outside the root. */
113
101
  export function ignoreEntryFor(projectRoot, dir) {
114
102
  const rel = path.relative(projectRoot, path.resolve(projectRoot, dir)).split(path.sep).join("/");
@@ -174,7 +162,7 @@ export async function resolveWorkspace(cwd) {
174
162
  const { config, legacyKeys } = loaded;
175
163
  const workflowsRoot = await findWorkflowsDir(dir, config.workflows);
176
164
  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 } : {}) };
165
+ return { root: dir, config, configPath, ...(config.id ? { id: config.id } : {}), workflowsRoot, outputDir: path.resolve(dir, config.output ?? "output"), ...(legacyKeys.length ? { legacyKeys } : {}) };
178
166
  }
179
167
  return bare;
180
168
  }
@@ -213,7 +201,37 @@ export function slugFromFolder(folder) {
213
201
  export function workspaceSlug(ws) {
214
202
  return ws.config.slug ?? slugFromFolder(path.basename(ws.root));
215
203
  }
216
- const KNOWN_KEYS = ["name", "slug", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "cloud"];
204
+ /** A workspace id: `ws_` and 10 lowercase base36 characters. */
205
+ export const WORKSPACE_ID_RE = /^ws_[a-z0-9]{10}$/;
206
+ /** A fresh workspace id for kraftwerk.yml. */
207
+ export function newWorkspaceId() {
208
+ const alphabet = "0123456789abcdefghijklmnopqrstuvwxyz";
209
+ let id = "ws_";
210
+ for (let i = 0; i < 10; i++)
211
+ id += alphabet[randomInt(alphabet.length)];
212
+ return id;
213
+ }
214
+ /**
215
+ * Give an existing kraftwerk.yml an id, textually: one line inserted after
216
+ * the leading comments, the rest of the file byte for byte as it was (a
217
+ * re-serialised YAML document would lose the comments people keep there).
218
+ * Returns the id, or the one already there.
219
+ */
220
+ export async function addWorkspaceId(configPath) {
221
+ const text = await readFile(configPath, "utf8");
222
+ const existing = /^id:\s*["']?(ws_[a-z0-9]{10})["']?\s*(#.*)?$/m.exec(text);
223
+ if (existing)
224
+ return existing[1];
225
+ const id = newWorkspaceId();
226
+ const line = `id: ${id} # this workspace's identity on every machine and clone — commit it, keep it`;
227
+ const lines = text.split("\n");
228
+ const first = lines.findIndex((l) => !/^\s*(#|$)/.test(l));
229
+ // Nothing but comments (or nothing at all): the id goes at the end.
230
+ const out = first === -1 ? `${text.replace(/\n*$/, text.trim() ? "\n" : "")}${line}\n` : [...lines.slice(0, first), line, ...lines.slice(first)].join("\n");
231
+ await writeFile(configPath, out);
232
+ return id;
233
+ }
234
+ const KNOWN_KEYS = ["id", "name", "slug", "icon", "color", "port", "workflows", "output", "knowledge", "agents", "skills", "switcher", "git", "repos", "vibeables", "projects", "cloud"];
217
235
  /**
218
236
  * Keys an older kraftwerk understood and this one ignores, so the files
219
237
  * that still carry them keep loading: `public:` and `tunnel:` (the
@@ -243,6 +261,9 @@ async function loadConfig(configPath) {
243
261
  if (!known.includes(key)) {
244
262
  throw new Error(`${path.basename(configPath)}: unknown key "${key}" (allowed: ${known.join(", ")})`);
245
263
  }
264
+ if (key === "id" && (typeof config[key] !== "string" || !WORKSPACE_ID_RE.test(config[key]))) {
265
+ 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`);
266
+ }
246
267
  if (key === "slug" && (typeof config[key] !== "string" || !SLUG_RE.test(config[key]))) {
247
268
  throw new Error(`${path.basename(configPath)}: slug must be lowercase letters, digits and dashes, starting with a letter or digit (at most 63)`);
248
269
  }
@@ -302,7 +323,7 @@ function validateCloud(configPath, value) {
302
323
  const c = value;
303
324
  for (const key of Object.keys(c)) {
304
325
  if (key === "token") {
305
- throw new Error(`${file}: cloud.token does not belong in kraftwerk.yml — put KRAFTWERK_CLOUD_TOKEN into .env (and only for unattended servers; a workspace with a UI is claimed with its code)`);
326
+ throw new Error(`${file}: cloud.token does not belong in kraftwerk.yml — the cloud knows the machine, and \`kraftwerk login\` claims it`);
306
327
  }
307
328
  if (!["enabled", "url", "interval"].includes(key)) {
308
329
  throw new Error(`${file}: cloud.${key} is unknown (allowed: enabled, url, interval)`);
@@ -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
  }