@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
@@ -0,0 +1,115 @@
1
+ /**
2
+ * The kraftwerk cloud, machine side: the settings and the wire contract the
3
+ * daemon's machine client (server/cloud.ts) and the CLI (`kraftwerk cloud`,
4
+ * `login`, `logout`) share.
5
+ *
6
+ * The daemon registers this MACHINE once; the workspaces it serves ride
7
+ * along as children of that record. The machine's id is its relay id (the
8
+ * hash of the X25519 key in ~/.kraftwerk/relay.json) and the proof is the
9
+ * relay secret — the key cannot sign. Nothing here opens the relay link.
10
+ *
11
+ * ~/.kraftwerk/cloud.json — the machine's switch and the cloud's origin:
12
+ *
13
+ * { "enabled": false, "url": "https://cloud.example.com" }
14
+ *
15
+ * `enabled` absent = on. The origin: KRAFTWERK_CLOUD_URL (env) wins, then
16
+ * cloud.json `url`, then a kraftwerk.yml `cloud.url` of an open workspace
17
+ * (deprecated, one release), then the default. KRAFTWERK_CLOUD_URL=off
18
+ * turns the cloud off for the process (what tests and CI set). kraftwerk.yml
19
+ * `cloud.enabled: false` is a per-workspace veto: that workspace is left out
20
+ * of the machine's reports.
21
+ *
22
+ * The contract (all POST, JSON):
23
+ *
24
+ * /api/machines/register {id, key, secret, name, handle, hostname?, platform?, version, interval?, workspaces}
25
+ * → {ok, interval, claimCode|null, account|null} (403: wrong secret)
26
+ * /api/machines/heartbeat {id, secret, version?, workspaces?} → {ok, interval, account, claimCode}
27
+ * /api/machines/goodbye {id, secret}
28
+ * /api/machines/unlink {id, secret} → {ok, claimCode}
29
+ *
30
+ * workspaces: [{id?: "ws_…", name, slug, root?}] — `root` only once the
31
+ * machine is claimed (account set): an unclaimed machine tells the cloud no
32
+ * paths.
33
+ */
34
+ export interface CloudFile {
35
+ enabled?: boolean;
36
+ url?: string;
37
+ }
38
+ /** Where the effective origin came from. */
39
+ export type CloudUrlSource = "env" | "cloud.json" | "kraftwerk.yml" | "default";
40
+ export interface MachineCloud {
41
+ enabled: boolean;
42
+ url: string;
43
+ source: CloudUrlSource;
44
+ /** Why it is off, when it is. */
45
+ reason?: string;
46
+ }
47
+ export interface MachineWorkspace {
48
+ id?: string;
49
+ name: string;
50
+ slug: string;
51
+ root?: string;
52
+ }
53
+ export interface Credentials {
54
+ id: string;
55
+ key: string;
56
+ secret: string;
57
+ }
58
+ export interface RegisterResponse {
59
+ ok: boolean;
60
+ interval: number;
61
+ claimCode: string | null;
62
+ account: string | null;
63
+ }
64
+ export interface HeartbeatResponse {
65
+ ok: boolean;
66
+ interval: number;
67
+ account: string | null;
68
+ claimCode: string | null;
69
+ }
70
+ export interface UnlinkResponse {
71
+ ok: boolean;
72
+ claimCode: string | null;
73
+ }
74
+ export declare const cloudFilePath: () => string;
75
+ /** The retired per-workspace identities (before machines): deleted at the daemon's start. */
76
+ export declare const retiredCloudDir: () => string;
77
+ export declare function readCloudFile(): Promise<CloudFile>;
78
+ /** Merge a change into cloud.json (a `url: undefined` leaves the URL as it is). */
79
+ export declare function writeCloudFile(change: CloudFile): Promise<CloudFile>;
80
+ /** Whether the environment turned the cloud off for this process. */
81
+ export declare const envCloudOff: () => boolean;
82
+ /**
83
+ * The machine's effective cloud. `ymlUrls`: `cloud.url` of open workspaces
84
+ * (deprecated) — used only when neither the environment nor cloud.json names
85
+ * an origin.
86
+ */
87
+ export declare function machineCloud(ymlUrls?: string[]): Promise<MachineCloud>;
88
+ export declare const claimUrlFor: (url: string, code: string | null | undefined) => string | null;
89
+ export declare const GOODBYE_TIMEOUT_MS = 800;
90
+ /** A refused or failed call; `status` is the HTTP status (0: no answer). */
91
+ export declare class CloudError extends Error {
92
+ readonly status: number;
93
+ constructor(message: string, status: number);
94
+ }
95
+ /** The cloud no longer knows this machine (its data was reset): registering again fixes it. */
96
+ export declare const unknownMachine: (err: unknown) => boolean;
97
+ export declare function cloudPost<T>(base: string, route: string, body: unknown, opts?: {
98
+ version?: string;
99
+ timeout?: number;
100
+ }): Promise<T>;
101
+ /** What register says about the machine besides its credentials. */
102
+ export interface MachineHello {
103
+ name: string;
104
+ handle: string;
105
+ hostname: string;
106
+ platform: string;
107
+ version: string;
108
+ }
109
+ export declare const machineRegister: (base: string, creds: Credentials, hello: MachineHello, workspaces: MachineWorkspace[]) => Promise<RegisterResponse>;
110
+ export declare const machineHeartbeat: (base: string, creds: Credentials, extra: {
111
+ version?: string;
112
+ workspaces?: MachineWorkspace[];
113
+ }) => Promise<HeartbeatResponse>;
114
+ export declare const machineGoodbye: (base: string, creds: Credentials, version?: string) => Promise<unknown>;
115
+ export declare const machineUnlink: (base: string, creds: Credentials, version?: string) => Promise<UnlinkResponse>;
@@ -0,0 +1,94 @@
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
+ import { CLOUD_DEFAULT_URL } from "../config.js";
6
+ // Resolved on every use, not at import: a test swaps HOME after some module already imported this one.
7
+ const kwHome = () => path.join(os.homedir(), ".kraftwerk");
8
+ export const cloudFilePath = () => path.join(kwHome(), "cloud.json");
9
+ /** The retired per-workspace identities (before machines): deleted at the daemon's start. */
10
+ export const retiredCloudDir = () => path.join(kwHome(), "cloud");
11
+ export async function readCloudFile() {
12
+ try {
13
+ const raw = JSON.parse(await fs.readFile(cloudFilePath(), "utf8"));
14
+ return {
15
+ ...(typeof raw.enabled === "boolean" ? { enabled: raw.enabled } : {}),
16
+ ...(typeof raw.url === "string" && raw.url ? { url: raw.url.replace(/\/+$/, "") } : {}),
17
+ };
18
+ }
19
+ catch {
20
+ return {};
21
+ }
22
+ }
23
+ /** Merge a change into cloud.json (a `url: undefined` leaves the URL as it is). */
24
+ export async function writeCloudFile(change) {
25
+ const cur = await readCloudFile();
26
+ const next = { ...cur, ...(change.enabled !== undefined ? { enabled: change.enabled } : {}), ...(change.url ? { url: change.url.replace(/\/+$/, "") } : {}) };
27
+ const file = cloudFilePath();
28
+ await fs.mkdir(path.dirname(file), { recursive: true });
29
+ const tmp = `${file}.${process.pid}.${randomBytes(4).toString("hex")}.tmp`;
30
+ await fs.writeFile(tmp, JSON.stringify(next, null, 2) + "\n");
31
+ await fs.rename(tmp, file);
32
+ return next;
33
+ }
34
+ /** Whether the environment turned the cloud off for this process. */
35
+ export const envCloudOff = () => process.env.KRAFTWERK_CLOUD_URL?.trim().toLowerCase() === "off";
36
+ /**
37
+ * The machine's effective cloud. `ymlUrls`: `cloud.url` of open workspaces
38
+ * (deprecated) — used only when neither the environment nor cloud.json names
39
+ * an origin.
40
+ */
41
+ export async function machineCloud(ymlUrls = []) {
42
+ const file = await readCloudFile();
43
+ const env = process.env.KRAFTWERK_CLOUD_URL?.trim();
44
+ const [url, source] = env && !envCloudOff() ? [env, "env"] : file.url ? [file.url, "cloud.json"] : ymlUrls[0] ? [ymlUrls[0], "kraftwerk.yml"] : [CLOUD_DEFAULT_URL, "default"];
45
+ const base = { url: url.replace(/\/+$/, ""), source };
46
+ if (envCloudOff())
47
+ return { ...base, enabled: false, reason: "KRAFTWERK_CLOUD_URL=off" };
48
+ if (file.enabled === false)
49
+ return { ...base, enabled: false, reason: "turned off for this machine (`kraftwerk cloud on` turns it on)" };
50
+ return { ...base, enabled: true };
51
+ }
52
+ export const claimUrlFor = (url, code) => (code ? `${url}/claim/${encodeURIComponent(code)}` : null);
53
+ const REQUEST_TIMEOUT_MS = 8_000;
54
+ export const GOODBYE_TIMEOUT_MS = 800;
55
+ /** A refused or failed call; `status` is the HTTP status (0: no answer). */
56
+ export class CloudError extends Error {
57
+ status;
58
+ constructor(message, status) {
59
+ super(message);
60
+ this.status = status;
61
+ }
62
+ }
63
+ /** The cloud no longer knows this machine (its data was reset): registering again fixes it. */
64
+ export const unknownMachine = (err) => err instanceof CloudError && (err.status === 404 || /unknown machine|not registered/i.test(err.message));
65
+ export async function cloudPost(base, route, body, opts = {}) {
66
+ const url = `${base}${route}`;
67
+ let r;
68
+ try {
69
+ r = await fetch(url, {
70
+ method: "POST",
71
+ headers: { "content-type": "application/json", "user-agent": `kraftwerk/${opts.version ?? "dev"}` },
72
+ body: JSON.stringify(body),
73
+ signal: AbortSignal.timeout(opts.timeout ?? REQUEST_TIMEOUT_MS),
74
+ });
75
+ }
76
+ catch (err) {
77
+ throw new CloudError(err.message, 0);
78
+ }
79
+ const text = await r.text();
80
+ let data;
81
+ try {
82
+ data = text ? JSON.parse(text) : {};
83
+ }
84
+ catch {
85
+ throw new CloudError(`${r.status} from ${url}: not JSON`, r.status);
86
+ }
87
+ if (!r.ok)
88
+ throw new CloudError(data.error ?? `${r.status} from ${url}`, r.status);
89
+ return data;
90
+ }
91
+ export const machineRegister = (base, creds, hello, workspaces) => cloudPost(base, "/api/machines/register", { ...creds, ...hello, workspaces }, { version: hello.version });
92
+ export const machineHeartbeat = (base, creds, extra) => cloudPost(base, "/api/machines/heartbeat", { id: creds.id, secret: creds.secret, ...extra }, { version: extra.version });
93
+ export const machineGoodbye = (base, creds, version) => cloudPost(base, "/api/machines/goodbye", { id: creds.id, secret: creds.secret }, { version, timeout: GOODBYE_TIMEOUT_MS });
94
+ export const machineUnlink = (base, creds, version) => cloudPost(base, "/api/machines/unlink", { id: creds.id, secret: creds.secret }, { version });
@@ -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. */