@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.
- package/dist/cli/daemon-control.js +32 -13
- package/dist/cli/daemon-login.js +3 -3
- package/dist/cli/daemon.js +5 -4
- package/dist/cli/doctor.d.ts +3 -1
- package/dist/cli/doctor.js +40 -5
- package/dist/cli/init.js +5 -2
- package/dist/cli/kraftwerk.js +9 -5
- package/dist/cli/routines.d.ts +2 -1
- package/dist/cli/routines.js +40 -4
- package/dist/cli/whoami.d.ts +9 -0
- package/dist/cli/whoami.js +48 -0
- package/dist/client/routes.d.ts +3 -0
- package/dist/client/routes.js +3 -0
- package/dist/config.d.ts +20 -0
- package/dist/config.js +38 -3
- package/dist/core/attention.d.ts +8 -1
- package/dist/core/attention.js +38 -2
- package/dist/core/daemon.d.ts +15 -1
- package/dist/core/daemon.js +46 -8
- package/dist/core/identity.d.ts +46 -0
- package/dist/core/identity.js +106 -0
- package/dist/core/routines.d.ts +81 -0
- package/dist/core/routines.js +174 -33
- package/dist/server/api/agents.d.ts +5 -1
- package/dist/server/api/agents.js +19 -2
- package/dist/server/api/index.d.ts +14 -3
- package/dist/server/api/runs.d.ts +3 -1
- package/dist/server/api/runs.js +9 -1
- package/dist/server/api/workspace.d.ts +6 -1
- package/dist/server/api/workspace.js +5 -0
- package/dist/server/push.js +1 -1
- package/package.json +1 -1
- package/schema/kraftwerk.schema.json +5 -0
- package/web/dist/assets/{dist-YNCNGGgK.js → dist-BQpWYkxm.js} +1 -1
- package/web/dist/assets/{dist-DaGnyH_l.js → dist-BTVP-6On.js} +1 -1
- package/web/dist/assets/dist-BURf1l-A.js +1 -0
- package/web/dist/assets/{dist-CLZjnhZZ.js → dist-BYkcY-Vl.js} +1 -1
- package/web/dist/assets/{dist-CzM6tn0f.js → dist-BwIca_Uj.js} +1 -1
- package/web/dist/assets/{dist-Y9aHncjU.js → dist-DFXmaDc5.js} +1 -1
- package/web/dist/assets/{dist-CezfArgg.js → dist-T1sNLC62.js} +1 -1
- package/web/dist/assets/{dist-CYicby0c.js → dist-fgB7OEJy.js} +1 -1
- package/web/dist/assets/{dist-QqFmuPda.js → dist-flnLXEzU.js} +1 -1
- package/web/dist/assets/{dist-BI4umsk6.js → dist-l7foWlFx.js} +1 -1
- package/web/dist/assets/{dist-CODxhtAK.js → dist-vuciRCSz.js} +1 -1
- package/web/dist/assets/{editor-DEb8z5-Q.js → editor-C6Ok8NPo.js} +3 -3
- package/web/dist/assets/index-z4tpCCzk.js +231 -0
- package/web/dist/index.html +1 -1
- package/web/dist/assets/dist-DtktO5hc.js +0 -1
- package/web/dist/assets/index-kmyK4tFm.js +0 -231
package/dist/config.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
import {
|
|
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
|
-
|
|
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
|
}
|
package/dist/core/attention.d.ts
CHANGED
|
@@ -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
|
-
|
|
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[];
|
package/dist/core/attention.js
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/dist/core/daemon.d.ts
CHANGED
|
@@ -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;
|
package/dist/core/daemon.js
CHANGED
|
@@ -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
|
-
|
|
53
|
-
|
|
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(
|
|
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
|
+
}
|
package/dist/core/routines.d.ts
CHANGED
|
@@ -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. */
|