@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
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
  }
@@ -29,10 +29,24 @@ export declare function lastOpenRoots(): Promise<string[]>;
29
29
  /** The daemon file as the last daemon left it; null without one. */
30
30
  export declare function readDaemonFile(): Promise<DaemonFile | null>;
31
31
  /** The running daemon (its file, and a hub that answers on its port), or null. */
32
- export declare function findDaemon(): Promise<{
32
+ export declare function findDaemon(timeoutMs?: number): Promise<{
33
33
  pid: number;
34
34
  port: number;
35
35
  } | null>;
36
+ export type DaemonState = {
37
+ state: "running" | "busy";
38
+ pid: number;
39
+ port: number;
40
+ } | {
41
+ state: "stopped";
42
+ };
43
+ /**
44
+ * Whether a daemon runs, telling a slow one from a stopped one: "busy" is a
45
+ * daemon whose process lives and whose port is taken but that did not answer
46
+ * in time. A busy daemon is never "stopped" — starting another would only
47
+ * crash on the taken port, and restarting it would interrupt its agents.
48
+ */
49
+ export declare function daemonState(timeoutMs?: number): Promise<DaemonState>;
36
50
  /** Ask the daemon to open or close a workspace (this machine's request: local trust). */
37
51
  export declare function daemonCall<T>(port: number, verb: "open" | "close", root: string): Promise<{
38
52
  ok: boolean;
@@ -1,4 +1,5 @@
1
1
  import { promises as fs } from "node:fs";
2
+ import net from "node:net";
2
3
  import os from "node:os";
3
4
  import path from "node:path";
4
5
  /**
@@ -47,18 +48,20 @@ export async function readDaemonFile() {
47
48
  return null;
48
49
  }
49
50
  }
51
+ /**
52
+ * How long a daemon may take to answer before it counts as not there. A
53
+ * daemon under load (agents at work, a busy machine) can take a second or
54
+ * two; 0.8 s once read such a one as stopped.
55
+ */
56
+ const ANSWER_MS = 3000;
50
57
  /** The running daemon (its file, and a hub that answers on its port), or null. */
51
- export async function findDaemon() {
52
- let rec;
53
- try {
54
- rec = JSON.parse(await fs.readFile(daemonFile(), "utf8"));
55
- }
56
- catch {
58
+ export async function findDaemon(timeoutMs = ANSWER_MS) {
59
+ const rec = await readDaemonFile();
60
+ if (!rec)
57
61
  return null;
58
- }
59
62
  try {
60
63
  // Every kraftwerk server answers /api/hub; only the daemon says it is one (a standalone `kraftwerk ui` may sit on that port now).
61
- const r = await fetch(`http://127.0.0.1:${rec.port}/api/hub`, { signal: AbortSignal.timeout(800) });
64
+ const r = await fetch(`http://127.0.0.1:${rec.port}/api/hub`, { signal: AbortSignal.timeout(timeoutMs) });
62
65
  const body = r.ok ? (await r.json()) : null;
63
66
  return body?.daemon === true ? { pid: rec.pid, port: rec.port } : null;
64
67
  }
@@ -66,6 +69,41 @@ export async function findDaemon() {
66
69
  return null;
67
70
  }
68
71
  }
72
+ const pidAlive = (pid) => {
73
+ try {
74
+ process.kill(pid, 0);
75
+ return true;
76
+ }
77
+ catch (err) {
78
+ // EPERM: it exists, it is just not ours to signal.
79
+ return err.code === "EPERM";
80
+ }
81
+ };
82
+ const portTaken = (port) => new Promise((resolve) => {
83
+ const sock = net.connect({ host: "127.0.0.1", port });
84
+ const done = (taken) => {
85
+ sock.destroy();
86
+ resolve(taken);
87
+ };
88
+ sock.setTimeout(1000, () => done(true)); // accepted nothing yet, refused nothing either: something holds it
89
+ sock.once("connect", () => done(true));
90
+ sock.once("error", () => done(false));
91
+ });
92
+ /**
93
+ * Whether a daemon runs, telling a slow one from a stopped one: "busy" is a
94
+ * daemon whose process lives and whose port is taken but that did not answer
95
+ * in time. A busy daemon is never "stopped" — starting another would only
96
+ * crash on the taken port, and restarting it would interrupt its agents.
97
+ */
98
+ export async function daemonState(timeoutMs = ANSWER_MS) {
99
+ const found = await findDaemon(timeoutMs);
100
+ if (found)
101
+ return { state: "running", ...found };
102
+ const rec = await readDaemonFile();
103
+ if (rec && pidAlive(rec.pid) && (await portTaken(rec.port)))
104
+ return { state: "busy", pid: rec.pid, port: rec.port };
105
+ return { state: "stopped" };
106
+ }
69
107
  /** Ask the daemon to open or close a workspace (this machine's request: local trust). */
70
108
  export async function daemonCall(port, verb, root) {
71
109
  const r = await fetch(`http://127.0.0.1:${port}/api/hub/${verb}`, {
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Who and where this kraftwerk is: ~/.kraftwerk/identity.json, one per
3
+ * machine (every workspace here shares it).
4
+ *
5
+ * { "handle": "lukas", "machine": "mbp", "previous": ["lukas@old-name"] }
6
+ *
7
+ * The identity string is `handle@machine`. A workspace is git-shared, its
8
+ * run state is not, so a routine names the one identity that fires it
9
+ * (`runs_on:` in routines.yml) — without that every clone would fire it.
10
+ *
11
+ * Defaults come from the OS (user name, host name without its domain) and
12
+ * are written the first time they are read: macOS changes the host name
13
+ * with the network, and an identity that drifts with it would silently stop
14
+ * every routine assigned to this machine. Renaming appends the retired pair
15
+ * to `previous`, so routines that still name it can be found and healed.
16
+ */
17
+ export interface Identity {
18
+ handle: string;
19
+ machine: string;
20
+ /** Retired `handle@machine` pairs, oldest first. */
21
+ previous: string[];
22
+ }
23
+ /** One half of an identity: lowercase, no "@". */
24
+ export declare const IDENTITY_PART_RE: RegExp;
25
+ /** The OS user name. */
26
+ export declare function defaultHandle(): string;
27
+ /** The host name, lowercased, without `.local` or a domain; anything outside [a-z0-9-] becomes "-". */
28
+ export declare function defaultMachine(hostname?: string): string;
29
+ /** Why a handle or machine name is refused, or undefined when it is fine. */
30
+ export declare function identityPartError(label: "handle" | "machine", value: string): string | undefined;
31
+ /** `handle@machine` with both halves well-formed: what `runs_on:` must look like. */
32
+ export declare function isIdentityString(value: string): boolean;
33
+ export declare const identityString: (id: Pick<Identity, "handle" | "machine">) => string;
34
+ /**
35
+ * This machine's identity. A missing file gets the OS defaults, written so
36
+ * they stay put; a damaged field falls back to its default without being
37
+ * written over (a person may be halfway through editing the file).
38
+ */
39
+ export declare function readIdentity(): Promise<Identity>;
40
+ /** This machine's `handle@machine`. */
41
+ export declare function localIdentity(): Promise<string>;
42
+ /** Change the handle and/or machine name; the pair it replaces goes to `previous`. */
43
+ export declare function setIdentity(change: {
44
+ handle?: string;
45
+ machine?: string;
46
+ }): Promise<Identity>;
@@ -0,0 +1,106 @@
1
+ import { randomBytes } from "node:crypto";
2
+ import { promises as fs } from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+ /** One half of an identity: lowercase, no "@". */
6
+ export const IDENTITY_PART_RE = /^[a-z0-9._-]+$/;
7
+ /** How many retired pairs are kept. */
8
+ const MAX_PREVIOUS = 10;
9
+ // Resolved on every use, not at import: a test swaps HOME after some module already imported this one.
10
+ const identityFile = () => path.join(os.homedir(), ".kraftwerk", "identity.json");
11
+ /** A free-form name as an identity half: lowercase, anything outside [a-z0-9._-] becomes "-". */
12
+ function sanitize(raw, fallback) {
13
+ const s = raw
14
+ .toLowerCase()
15
+ .replace(/[^a-z0-9._-]+/g, "-")
16
+ .replace(/-{2,}/g, "-")
17
+ .replace(/^[-.]+|[-.]+$/g, "");
18
+ return s || fallback;
19
+ }
20
+ /** The OS user name. */
21
+ export function defaultHandle() {
22
+ let name = "";
23
+ try {
24
+ name = os.userInfo().username;
25
+ }
26
+ catch {
27
+ name = process.env.USER ?? process.env.USERNAME ?? "";
28
+ }
29
+ return sanitize(name, "user");
30
+ }
31
+ /** The host name, lowercased, without `.local` or a domain; anything outside [a-z0-9-] becomes "-". */
32
+ export function defaultMachine(hostname = os.hostname()) {
33
+ const host = hostname.toLowerCase().split(".")[0] ?? "";
34
+ const s = host.replace(/[^a-z0-9-]+/g, "-").replace(/-{2,}/g, "-").replace(/^-+|-+$/g, "");
35
+ return s || "machine";
36
+ }
37
+ /** Why a handle or machine name is refused, or undefined when it is fine. */
38
+ export function identityPartError(label, value) {
39
+ if (!value)
40
+ return `${label} must not be empty`;
41
+ if (value.includes("@"))
42
+ return `${label} must not contain "@" (the identity is handle@machine)`;
43
+ if (!IDENTITY_PART_RE.test(value))
44
+ return `${label} must be lowercase letters, digits, ".", "_" or "-"`;
45
+ return undefined;
46
+ }
47
+ /** `handle@machine` with both halves well-formed: what `runs_on:` must look like. */
48
+ export function isIdentityString(value) {
49
+ const parts = value.split("@");
50
+ return parts.length === 2 && IDENTITY_PART_RE.test(parts[0]) && IDENTITY_PART_RE.test(parts[1]);
51
+ }
52
+ export const identityString = (id) => `${id.handle}@${id.machine}`;
53
+ async function write(id) {
54
+ const file = identityFile();
55
+ await fs.mkdir(path.dirname(file), { recursive: true });
56
+ // A temp name per write: two writers in flight must not rename each other's file.
57
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
58
+ await fs.writeFile(tmp, JSON.stringify(id, null, 2) + "\n");
59
+ await fs.rename(tmp, file);
60
+ }
61
+ /**
62
+ * This machine's identity. A missing file gets the OS defaults, written so
63
+ * they stay put; a damaged field falls back to its default without being
64
+ * written over (a person may be halfway through editing the file).
65
+ */
66
+ export async function readIdentity() {
67
+ let raw = null;
68
+ try {
69
+ raw = JSON.parse(await fs.readFile(identityFile(), "utf8"));
70
+ }
71
+ catch (err) {
72
+ if (err.code !== "ENOENT")
73
+ raw = {};
74
+ }
75
+ const handle = typeof raw?.handle === "string" && !identityPartError("handle", raw.handle) ? raw.handle : defaultHandle();
76
+ const machine = typeof raw?.machine === "string" && !identityPartError("machine", raw.machine) ? raw.machine : defaultMachine();
77
+ const previous = Array.isArray(raw?.previous) ? raw.previous.filter((p) => typeof p === "string" && isIdentityString(p)) : [];
78
+ const id = { handle, machine, previous };
79
+ if (raw === null)
80
+ await write(id).catch(() => { });
81
+ return id;
82
+ }
83
+ /** This machine's `handle@machine`. */
84
+ export async function localIdentity() {
85
+ return identityString(await readIdentity());
86
+ }
87
+ /** Change the handle and/or machine name; the pair it replaces goes to `previous`. */
88
+ export async function setIdentity(change) {
89
+ for (const label of ["handle", "machine"]) {
90
+ const v = change[label];
91
+ if (v === undefined)
92
+ continue;
93
+ const err = identityPartError(label, v);
94
+ if (err)
95
+ throw new Error(err);
96
+ }
97
+ const cur = await readIdentity();
98
+ const next = { handle: change.handle ?? cur.handle, machine: change.machine ?? cur.machine, previous: cur.previous };
99
+ const before = identityString(cur);
100
+ const after = identityString(next);
101
+ if (before !== after) {
102
+ next.previous = [...cur.previous.filter((p) => p !== before && p !== after), before].slice(-MAX_PREVIOUS);
103
+ }
104
+ await write(next);
105
+ return next;
106
+ }
@@ -10,6 +10,15 @@
10
10
  * session (chat) for the agent and posts the prompt — the run shows up in
11
11
  * the agent's sessions sidebar like any conversation. Schedules use
12
12
  * standard 5-field cron in the server's local time.
13
+ *
14
+ * The definitions travel with the workspace's git, the state does not, so a
15
+ * workspace cloned on three machines would fire every routine three times.
16
+ * Each routine therefore names its runner, `runs_on: <handle>@<machine>`
17
+ * (see identity.ts), and the scheduler is strict: a routine fires only where
18
+ * runs_on is exactly this machine's identity. No runs_on, no runner — an
19
+ * unassigned routine fires nowhere until someone adopts it ("run these
20
+ * here"), which writes runs_on and commits routines.yml so the other clones
21
+ * learn who runs it. `private:` is stored and round-tripped; nothing reads it yet.
13
22
  */
14
23
  export interface Routine {
15
24
  id: string;
@@ -18,8 +27,21 @@ export interface Routine {
18
27
  schedule: string;
19
28
  prompt: string;
20
29
  enabled: boolean;
30
+ /** The one identity (`handle@machine`) that fires it. Absent: unassigned, fires nowhere. */
31
+ runsOn?: string;
32
+ /** Stored as given; no behaviour yet. */
33
+ private?: boolean;
21
34
  }
35
+ /**
36
+ * Who fires a routine, seen from this machine: here, on another identity,
37
+ * or nobody (no runs_on).
38
+ */
39
+ export type RoutineRunner = "here" | "elsewhere" | "unassigned";
22
40
  export interface RoutineStatus extends Routine {
41
+ runner: RoutineRunner;
42
+ /** runs_on names an identity this machine had before (identity.json `previous`): a rename left it behind. */
43
+ retired?: boolean;
44
+ /** Only for an enabled routine that fires here. */
23
45
  nextRunAt?: string;
24
46
  lastRunAt?: string;
25
47
  lastChatId?: string;
@@ -46,6 +68,9 @@ export interface SaveRoutineInput {
46
68
  schedule: string;
47
69
  prompt: string;
48
70
  enabled?: boolean;
71
+ /** Absent: kept as it is (a new routine gets this machine's identity). null or "": unassigned. */
72
+ runsOn?: string | null;
73
+ private?: boolean;
49
74
  }
50
75
  export declare function saveRoutine(slug: string, input: SaveRoutineInput): Promise<Routine>;
51
76
  export declare function deleteRoutine(slug: string, id: string): Promise<void>;
@@ -70,10 +95,66 @@ export declare function allRoutines(workflows: {
70
95
  };
71
96
  workflows: string[];
72
97
  })[]>;
98
+ /** Whether a routine fires on the machine whose identity is `me`: enabled and runs_on exactly that. */
99
+ export declare const firesHere: (r: Routine, me: string) => boolean;
73
100
  export declare function routineStatuses(slug: string): Promise<RoutineStatus[]>;
74
101
  export declare function runRoutineNow(slug: string, id: string): Promise<{
75
102
  chatId: string;
76
103
  }>;
104
+ /**
105
+ * The routines due at `now` that fire on this machine: enabled, runs_on
106
+ * exactly this identity, cron matching. Everything else — unassigned,
107
+ * another machine's, a retired identity's — is never due here.
108
+ */
109
+ export declare function dueRoutines(now: Date, me?: string): Promise<{
110
+ slug: string;
111
+ routine: Routine;
112
+ }[]>;
113
+ export interface RunnerRef {
114
+ agent: string;
115
+ id: string;
116
+ name: string;
117
+ /** What runs_on said before (absent: unassigned). */
118
+ from?: string;
119
+ }
120
+ export interface RunnerFindings {
121
+ /** Enabled routines without runs_on: they fire nowhere. */
122
+ unassigned: RunnerRef[];
123
+ /** Routines whose runs_on is one of this machine's retired identities. */
124
+ retired: RunnerRef[];
125
+ /** Newest change to a routines.yml involved, ISO (the attention item's "since"). */
126
+ since?: string;
127
+ }
128
+ /** Every routine in the workspace that has no runner, or names a retired identity of this machine. */
129
+ export declare function runnerFindings(): Promise<RunnerFindings>;
130
+ export interface AdoptResult {
131
+ /** This machine's identity, now the runner of every routine listed. */
132
+ runsOn: string;
133
+ adopted: RunnerRef[];
134
+ /** routines.yml files written, relative to the workspace root. */
135
+ files: string[];
136
+ /** The git commit of those files; null when nothing changed. Not ok: written, but not committed (why in `error`). */
137
+ commit: {
138
+ ok: boolean;
139
+ error?: string;
140
+ committed?: number;
141
+ } | null;
142
+ }
143
+ /**
144
+ * "Run these here": make this machine the runner of the routines `pick`
145
+ * selects, write routines.yml and commit just the files it touched — the
146
+ * commit is what tells the other clones who runs them. A workspace without
147
+ * git sync, or a commit that fails, keeps the written files and says why.
148
+ */
149
+ export declare function adoptRoutines(pick: (agent: string, r: Routine, previous: string[]) => boolean, message?: string): Promise<AdoptResult>;
150
+ /** Adopt one routine (whatever its runs_on said). */
151
+ export declare function adoptRoutine(slug: string, id: string): Promise<AdoptResult & {
152
+ routine: RoutineStatus;
153
+ }>;
154
+ /** Adopt every enabled routine without runs_on — and, with `retired`, every one naming a retired identity of this machine. */
155
+ export declare function adoptUnassigned(opts?: {
156
+ retired?: boolean;
157
+ }): Promise<AdoptResult>;
77
158
  /** Start the current workspace's scheduler; safe to call more than once. Ticks run in that workspace. */
78
159
  export declare function startRoutineScheduler(): void;
79
160
  /** Stop the current workspace's scheduler (the workspace closes): no routine of it fires any more. */