@netnodeag/kraftwerk 0.75.1 → 0.76.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/cli/daemon-control.js +4 -2
- package/dist/cli/doctor.d.ts +3 -1
- package/dist/cli/doctor.js +36 -2
- 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/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
|
@@ -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. */
|
package/dist/core/routines.js
CHANGED
|
@@ -1,11 +1,13 @@
|
|
|
1
|
-
import { promises as fs } from "node:fs";
|
|
1
|
+
import { promises as fs, realpathSync } from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { parse, stringify } from "yaml";
|
|
4
|
-
import { getOutputDir } from "./context.js";
|
|
4
|
+
import { getOutputDir, workspaceRoot } from "./context.js";
|
|
5
5
|
import { perWorkspace } from "./workspace.js";
|
|
6
6
|
import { getAgent, listAgents, safeAgentSlug, agentsRoot } from "./agents.js";
|
|
7
7
|
import { chatAwaitingApproval, createChat, postMessage } from "./chat/sessions.js";
|
|
8
8
|
import { pushNotification } from "./notifications.js";
|
|
9
|
+
import { isIdentityString, localIdentity, readIdentity, identityString } from "./identity.js";
|
|
10
|
+
import { gitCommit, repoRootFor } from "./git.js";
|
|
9
11
|
/* ---------- cron ---------- */
|
|
10
12
|
const ALIASES = {
|
|
11
13
|
"@hourly": "0 * * * *",
|
|
@@ -100,8 +102,26 @@ function normalizeRoutine(raw) {
|
|
|
100
102
|
schedule: String(raw.schedule ?? ""),
|
|
101
103
|
prompt: String(raw.prompt ?? ""),
|
|
102
104
|
enabled: raw.enabled !== false,
|
|
105
|
+
...(typeof raw.runs_on === "string" && raw.runs_on.trim() ? { runsOn: raw.runs_on.trim() } : {}),
|
|
106
|
+
...(typeof raw.private === "boolean" ? { private: raw.private } : {}),
|
|
103
107
|
};
|
|
104
108
|
}
|
|
109
|
+
/** The file's own spelling: snake_case `runs_on`, optional keys only when set. */
|
|
110
|
+
function serializeRoutine(r) {
|
|
111
|
+
return {
|
|
112
|
+
id: r.id,
|
|
113
|
+
name: r.name,
|
|
114
|
+
schedule: r.schedule,
|
|
115
|
+
prompt: r.prompt,
|
|
116
|
+
enabled: r.enabled,
|
|
117
|
+
...(r.runsOn ? { runs_on: r.runsOn } : {}),
|
|
118
|
+
...(r.private !== undefined ? { private: r.private } : {}),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
/** Why a runs_on value is refused, or undefined. */
|
|
122
|
+
function runsOnError(value) {
|
|
123
|
+
return isIdentityString(value) ? undefined : `runs_on must be <handle>@<machine> (lowercase letters, digits, ".", "_", "-"), got "${value}"`;
|
|
124
|
+
}
|
|
105
125
|
export async function listRoutines(slug) {
|
|
106
126
|
let raw;
|
|
107
127
|
try {
|
|
@@ -117,7 +137,7 @@ export async function listRoutines(slug) {
|
|
|
117
137
|
.filter(Boolean);
|
|
118
138
|
}
|
|
119
139
|
async function writeRoutines(slug, routines) {
|
|
120
|
-
await fs.writeFile(await routinesFile(slug), stringify(routines));
|
|
140
|
+
await fs.writeFile(await routinesFile(slug), stringify(routines.map(serializeRoutine)));
|
|
121
141
|
}
|
|
122
142
|
export async function saveRoutine(slug, input) {
|
|
123
143
|
const name = input.name?.trim();
|
|
@@ -134,15 +154,26 @@ export async function saveRoutine(slug, input) {
|
|
|
134
154
|
.slice(0, 48);
|
|
135
155
|
if (!id)
|
|
136
156
|
throw new Error("name must contain at least one letter or digit");
|
|
157
|
+
const given = typeof input.runsOn === "string" ? input.runsOn.trim() : input.runsOn;
|
|
158
|
+
if (given) {
|
|
159
|
+
const err = runsOnError(given);
|
|
160
|
+
if (err)
|
|
161
|
+
throw new Error(err);
|
|
162
|
+
}
|
|
163
|
+
const routines = await listRoutines(slug);
|
|
164
|
+
const i = routines.findIndex((r) => r.id === id);
|
|
165
|
+
const existing = i >= 0 ? routines[i] : undefined;
|
|
166
|
+
const runsOn = given === undefined ? (existing ? existing.runsOn : await localIdentity()) : given || undefined;
|
|
167
|
+
const priv = input.private ?? existing?.private;
|
|
137
168
|
const routine = {
|
|
138
169
|
id,
|
|
139
170
|
name,
|
|
140
171
|
schedule: input.schedule.trim(),
|
|
141
172
|
prompt: input.prompt,
|
|
142
173
|
enabled: input.enabled !== false,
|
|
174
|
+
...(runsOn ? { runsOn } : {}),
|
|
175
|
+
...(priv !== undefined ? { private: priv } : {}),
|
|
143
176
|
};
|
|
144
|
-
const routines = await listRoutines(slug);
|
|
145
|
-
const i = routines.findIndex((r) => r.id === id);
|
|
146
177
|
if (i >= 0)
|
|
147
178
|
routines[i] = routine;
|
|
148
179
|
else
|
|
@@ -191,13 +222,19 @@ export async function allRoutines(workflows) {
|
|
|
191
222
|
}))));
|
|
192
223
|
return per.flat();
|
|
193
224
|
}
|
|
225
|
+
/** Whether a routine fires on the machine whose identity is `me`: enabled and runs_on exactly that. */
|
|
226
|
+
export const firesHere = (r, me) => r.enabled && r.runsOn === me;
|
|
194
227
|
export async function routineStatuses(slug) {
|
|
195
|
-
const [routines, state] = await Promise.all([listRoutines(slug), readState()]);
|
|
228
|
+
const [routines, state, id] = await Promise.all([listRoutines(slug), readState(), readIdentity()]);
|
|
229
|
+
const me = identityString(id);
|
|
196
230
|
return routines.map((r) => {
|
|
197
231
|
const s = state[`${slug}/${r.id}`] ?? {};
|
|
232
|
+
const runner = !r.runsOn ? "unassigned" : r.runsOn === me ? "here" : "elsewhere";
|
|
198
233
|
return {
|
|
199
234
|
...r,
|
|
200
|
-
|
|
235
|
+
runner,
|
|
236
|
+
...(r.runsOn && id.previous.includes(r.runsOn) ? { retired: true } : {}),
|
|
237
|
+
...(firesHere(r, me) ? { nextRunAt: nextRun(r.schedule) } : {}),
|
|
201
238
|
...(s.lastRunAt ? { lastRunAt: s.lastRunAt } : {}),
|
|
202
239
|
...(s.lastChatId ? { lastChatId: s.lastChatId } : {}),
|
|
203
240
|
...(s.lastChatId && chatAwaitingApproval(s.lastChatId) ? { awaitingApproval: true } : {}),
|
|
@@ -254,48 +291,152 @@ export async function runRoutineNow(slug, id) {
|
|
|
254
291
|
/* ---------- scheduler ---------- */
|
|
255
292
|
/** The scheduler's timer, one per workspace. */
|
|
256
293
|
const scheduler = perWorkspace(() => ({ timer: null }));
|
|
294
|
+
/**
|
|
295
|
+
* The routines due at `now` that fire on this machine: enabled, runs_on
|
|
296
|
+
* exactly this identity, cron matching. Everything else — unassigned,
|
|
297
|
+
* another machine's, a retired identity's — is never due here.
|
|
298
|
+
*/
|
|
299
|
+
export async function dueRoutines(now, me) {
|
|
300
|
+
const identity = me ?? (await localIdentity());
|
|
301
|
+
const out = [];
|
|
302
|
+
for (const m of await listAgents()) {
|
|
303
|
+
for (const r of await listRoutines(m.slug).catch(() => [])) {
|
|
304
|
+
if (!firesHere(r, identity))
|
|
305
|
+
continue;
|
|
306
|
+
try {
|
|
307
|
+
if (cronMatches(parseCron(r.schedule), now))
|
|
308
|
+
out.push({ slug: m.slug, routine: r });
|
|
309
|
+
}
|
|
310
|
+
catch {
|
|
311
|
+
// invalid schedule — surfaced by validation on save, skip here
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
return out;
|
|
316
|
+
}
|
|
257
317
|
async function tick() {
|
|
258
318
|
const now = new Date();
|
|
259
319
|
const p = (n) => String(n).padStart(2, "0");
|
|
260
320
|
const minuteKey = `${now.getFullYear()}-${p(now.getMonth() + 1)}-${p(now.getDate())}T${p(now.getHours())}:${p(now.getMinutes())}`;
|
|
261
|
-
let
|
|
321
|
+
let due;
|
|
262
322
|
try {
|
|
263
|
-
|
|
323
|
+
due = await dueRoutines(now);
|
|
264
324
|
}
|
|
265
325
|
catch {
|
|
266
326
|
return;
|
|
267
327
|
}
|
|
328
|
+
if (!due.length)
|
|
329
|
+
return;
|
|
268
330
|
const state = await readState();
|
|
269
331
|
let dirty = false;
|
|
270
|
-
for (const
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
332
|
+
for (const { slug, routine: r } of due) {
|
|
333
|
+
const key = `${slug}/${r.id}`;
|
|
334
|
+
if (state[key]?.lastMinute === minuteKey)
|
|
335
|
+
continue;
|
|
336
|
+
state[key] = { ...state[key], lastMinute: minuteKey };
|
|
337
|
+
dirty = true;
|
|
338
|
+
try {
|
|
339
|
+
const chatId = await fireRoutine(slug, r);
|
|
340
|
+
state[key] = { ...state[key], lastRunAt: now.toISOString(), lastChatId: chatId };
|
|
341
|
+
delete state[key].lastError;
|
|
342
|
+
}
|
|
343
|
+
catch (err) {
|
|
344
|
+
state[key] = { ...state[key], lastError: err.message };
|
|
345
|
+
void notifyRoutineFailed(slug, r, err.message);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
if (dirty)
|
|
349
|
+
await writeState(state);
|
|
350
|
+
}
|
|
351
|
+
/** Every routine in the workspace that has no runner, or names a retired identity of this machine. */
|
|
352
|
+
export async function runnerFindings() {
|
|
353
|
+
const id = await readIdentity();
|
|
354
|
+
const out = { unassigned: [], retired: [] };
|
|
355
|
+
let since = 0;
|
|
356
|
+
// Archived agents included: the scheduler fires their routines too.
|
|
357
|
+
for (const a of await listAgents()) {
|
|
358
|
+
const routines = await listRoutines(a.slug).catch(() => []);
|
|
359
|
+
let hit = false;
|
|
360
|
+
for (const r of routines) {
|
|
361
|
+
if (!r.runsOn && r.enabled) {
|
|
362
|
+
out.unassigned.push({ agent: a.slug, id: r.id, name: r.name });
|
|
363
|
+
hit = true;
|
|
277
364
|
}
|
|
278
|
-
|
|
279
|
-
|
|
365
|
+
else if (r.runsOn && id.previous.includes(r.runsOn)) {
|
|
366
|
+
out.retired.push({ agent: a.slug, id: r.id, name: r.name, from: r.runsOn });
|
|
367
|
+
hit = true;
|
|
280
368
|
}
|
|
281
|
-
|
|
282
|
-
|
|
369
|
+
}
|
|
370
|
+
if (hit) {
|
|
371
|
+
const st = await fs.stat(await routinesFile(a.slug)).catch(() => null);
|
|
372
|
+
if (st && st.mtimeMs > since)
|
|
373
|
+
since = st.mtimeMs;
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
if (since)
|
|
377
|
+
out.since = new Date(since).toISOString();
|
|
378
|
+
return out;
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* "Run these here": make this machine the runner of the routines `pick`
|
|
382
|
+
* selects, write routines.yml and commit just the files it touched — the
|
|
383
|
+
* commit is what tells the other clones who runs them. A workspace without
|
|
384
|
+
* git sync, or a commit that fails, keeps the written files and says why.
|
|
385
|
+
*/
|
|
386
|
+
export async function adoptRoutines(pick, message) {
|
|
387
|
+
const id = await readIdentity();
|
|
388
|
+
const me = identityString(id);
|
|
389
|
+
const adopted = [];
|
|
390
|
+
const written = [];
|
|
391
|
+
for (const a of await listAgents()) {
|
|
392
|
+
const routines = await listRoutines(a.slug).catch(() => []);
|
|
393
|
+
let changed = false;
|
|
394
|
+
for (const r of routines) {
|
|
395
|
+
if (r.runsOn === me || !pick(a.slug, r, id.previous))
|
|
283
396
|
continue;
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
catch (err) {
|
|
292
|
-
state[key] = { ...state[key], lastError: err.message };
|
|
293
|
-
void notifyRoutineFailed(m.slug, r, err.message);
|
|
294
|
-
}
|
|
397
|
+
adopted.push({ agent: a.slug, id: r.id, name: r.name, ...(r.runsOn ? { from: r.runsOn } : {}) });
|
|
398
|
+
r.runsOn = me;
|
|
399
|
+
changed = true;
|
|
400
|
+
}
|
|
401
|
+
if (changed) {
|
|
402
|
+
await writeRoutines(a.slug, routines);
|
|
403
|
+
written.push(await routinesFile(a.slug));
|
|
295
404
|
}
|
|
296
405
|
}
|
|
297
|
-
|
|
298
|
-
|
|
406
|
+
const root = workspaceRoot();
|
|
407
|
+
const files = written.map((f) => path.relative(root, f).split(path.sep).join("/"));
|
|
408
|
+
if (!written.length)
|
|
409
|
+
return { runsOn: me, adopted, files, commit: null };
|
|
410
|
+
return { runsOn: me, adopted, files, commit: await commitFiles(written, message ?? `Routines run on ${me}: ${adopted.map((x) => `${x.agent}/${x.id}`).join(", ")}`) };
|
|
411
|
+
}
|
|
412
|
+
async function commitFiles(abs, message) {
|
|
413
|
+
const repoRoot = await repoRootFor(path.dirname(abs[0]));
|
|
414
|
+
if (!repoRoot)
|
|
415
|
+
return { ok: false, error: "the workspace is not a git repository" };
|
|
416
|
+
// rev-parse answers with symlinks resolved (/var → /private/var on macOS); the files must be spelled the same way.
|
|
417
|
+
const real = (p) => {
|
|
418
|
+
try {
|
|
419
|
+
return realpathSync.native(p);
|
|
420
|
+
}
|
|
421
|
+
catch {
|
|
422
|
+
return path.resolve(p);
|
|
423
|
+
}
|
|
424
|
+
};
|
|
425
|
+
const rel = abs.map((f) => path.relative(repoRoot, real(f)).split(path.sep).join("/"));
|
|
426
|
+
return gitCommit(rel, message);
|
|
427
|
+
}
|
|
428
|
+
/** Adopt one routine (whatever its runs_on said). */
|
|
429
|
+
export async function adoptRoutine(slug, id) {
|
|
430
|
+
const safe = safeAgentSlug(slug);
|
|
431
|
+
if (!(await listRoutines(safe)).some((r) => r.id === id))
|
|
432
|
+
throw new Error("routine not found");
|
|
433
|
+
const result = await adoptRoutines((agent, r) => agent === safe && r.id === id);
|
|
434
|
+
const routine = (await routineStatuses(safe)).find((r) => r.id === id);
|
|
435
|
+
return { ...result, routine };
|
|
436
|
+
}
|
|
437
|
+
/** Adopt every enabled routine without runs_on — and, with `retired`, every one naming a retired identity of this machine. */
|
|
438
|
+
export function adoptUnassigned(opts = {}) {
|
|
439
|
+
return adoptRoutines((_agent, r, previous) => (!r.runsOn && r.enabled) || (!!opts.retired && !!r.runsOn && previous.includes(r.runsOn)));
|
|
299
440
|
}
|
|
300
441
|
/** Start the current workspace's scheduler; safe to call more than once. Ticks run in that workspace. */
|
|
301
442
|
export function startRoutineScheduler() {
|
|
@@ -5,7 +5,9 @@ export declare const agentRoutes: (import("./router.js").Route<"agents.addJourna
|
|
|
5
5
|
}, "/api/agents/:slug/journal", z.ZodObject<{
|
|
6
6
|
entry: z.ZodDefault<z.ZodString>;
|
|
7
7
|
kind: z.ZodOptional<z.ZodString>;
|
|
8
|
-
}, z.core.$strip>> | import("./router.js").Route<"agents.
|
|
8
|
+
}, z.core.$strip>> | import("./router.js").Route<"agents.adoptRoutine", import("../../core/routines.js").AdoptResult & {
|
|
9
|
+
routine: import("../../core/routines.js").RoutineStatus;
|
|
10
|
+
}, "/api/agents/:slug/routines/:id/adopt", undefined> | import("./router.js").Route<"agents.archive", import("../../core/agents.js").AgentDetail, "/api/agents/:slug/archive", z.ZodObject<{
|
|
9
11
|
archived: z.ZodDefault<z.ZodBoolean>;
|
|
10
12
|
}, z.core.$strip>> | import("./router.js").Route<"agents.create", import("../../core/agents.js").AgentDetail, "/api/agents", z.ZodObject<{
|
|
11
13
|
name: z.ZodOptional<z.ZodString>;
|
|
@@ -56,6 +58,8 @@ export declare const agentRoutes: (import("./router.js").Route<"agents.addJourna
|
|
|
56
58
|
schedule: z.ZodOptional<z.ZodString>;
|
|
57
59
|
prompt: z.ZodOptional<z.ZodString>;
|
|
58
60
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
61
|
+
runsOn: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
62
|
+
private: z.ZodOptional<z.ZodBoolean>;
|
|
59
63
|
}, z.core.$strip>> | import("./router.js").Route<"agents.saveSkill", import("../../core/skills.js").SkillInfo & {
|
|
60
64
|
content: string;
|
|
61
65
|
}, "/api/agents/:slug/skills", z.ZodObject<{
|
|
@@ -2,7 +2,7 @@ import * as z from "zod";
|
|
|
2
2
|
import { agentsRoot, deleteAgent, getAgent, listAgents, saveAgent, setAgentArchived } from "../../core/agents.js";
|
|
3
3
|
import { appendJournal, readJournal } from "../../core/journal.js";
|
|
4
4
|
import { UI_ACTOR } from "../../core/knowledge.js";
|
|
5
|
-
import { deleteRoutine, routineStatuses, runRoutineNow, saveRoutine } from "../../core/routines.js";
|
|
5
|
+
import { adoptRoutine, deleteRoutine, listRoutines, routineStatuses, runRoutineNow, saveRoutine } from "../../core/routines.js";
|
|
6
6
|
import { deleteAgentSkill, listAgentSkills, readSkill, saveAgentSkill } from "../../core/skills.js";
|
|
7
7
|
import { fail, route } from "./router.js";
|
|
8
8
|
/** A member's editable fields. Loose: saveAgent checks values (name, harness, effort) and keeps what it knows. */
|
|
@@ -21,13 +21,19 @@ const AgentFields = z.looseObject({
|
|
|
21
21
|
skills: z.array(z.string()).optional(),
|
|
22
22
|
system: z.string().optional(),
|
|
23
23
|
});
|
|
24
|
-
/**
|
|
24
|
+
/**
|
|
25
|
+
* A routine: every field saveRoutine stores (it builds the routine from these alone), so a strict object loses nothing.
|
|
26
|
+
* saveRoutine checks name, prompt, the cron schedule and runsOn. runsOn absent keeps the stored runner (a new routine:
|
|
27
|
+
* this machine); null or "" unassigns it.
|
|
28
|
+
*/
|
|
25
29
|
const RoutineFields = z.object({
|
|
26
30
|
id: z.string().optional(),
|
|
27
31
|
name: z.string().optional(),
|
|
28
32
|
schedule: z.string().optional(),
|
|
29
33
|
prompt: z.string().optional(),
|
|
30
34
|
enabled: z.boolean().optional(),
|
|
35
|
+
runsOn: z.string().nullable().optional(),
|
|
36
|
+
private: z.boolean().optional(),
|
|
31
37
|
});
|
|
32
38
|
/** Agents (team members): definition, own skills, journal, routines. */
|
|
33
39
|
export const agentRoutes = [
|
|
@@ -92,5 +98,16 @@ export const agentRoutes = [
|
|
|
92
98
|
await deleteRoutine(c.params.slug, c.params.id);
|
|
93
99
|
return { ok: true };
|
|
94
100
|
}),
|
|
101
|
+
route({
|
|
102
|
+
name: "agents.adoptRoutine",
|
|
103
|
+
method: "POST",
|
|
104
|
+
path: "/api/agents/:slug/routines/:id/adopt",
|
|
105
|
+
summary: "run this routine here: runs_on = this machine's identity, routines.yml committed",
|
|
106
|
+
errors: 400,
|
|
107
|
+
}, async (c) => {
|
|
108
|
+
if (!(await listRoutines(c.params.slug)).some((r) => r.id === c.params.id))
|
|
109
|
+
fail(404, "routine not found");
|
|
110
|
+
return adoptRoutine(c.params.slug, c.params.id);
|
|
111
|
+
}),
|
|
95
112
|
route({ name: "agents.runRoutine", method: "POST", path: "/api/agents/:slug/routines/:id/run", summary: "run a routine now", errors: 400 }, async (c) => runRoutineNow(c.params.slug, c.params.id)),
|
|
96
113
|
];
|
|
@@ -27,7 +27,9 @@ export declare const routes: (Route<"agents.addJournal", {
|
|
|
27
27
|
}, "/api/agents/:slug/journal", z.ZodObject<{
|
|
28
28
|
entry: z.ZodDefault<z.ZodString>;
|
|
29
29
|
kind: z.ZodOptional<z.ZodString>;
|
|
30
|
-
}, z.core.$strip>> | Route<"agents.
|
|
30
|
+
}, z.core.$strip>> | Route<"agents.adoptRoutine", import("../../core/routines.js").AdoptResult & {
|
|
31
|
+
routine: import("../../core/routines.js").RoutineStatus;
|
|
32
|
+
}, "/api/agents/:slug/routines/:id/adopt", undefined> | Route<"agents.archive", import("../../core/agents.js").AgentDetail, "/api/agents/:slug/archive", z.ZodObject<{
|
|
31
33
|
archived: z.ZodDefault<z.ZodBoolean>;
|
|
32
34
|
}, z.core.$strip>> | Route<"agents.create", import("../../core/agents.js").AgentDetail, "/api/agents", z.ZodObject<{
|
|
33
35
|
name: z.ZodOptional<z.ZodString>;
|
|
@@ -78,6 +80,8 @@ export declare const routes: (Route<"agents.addJournal", {
|
|
|
78
80
|
schedule: z.ZodOptional<z.ZodString>;
|
|
79
81
|
prompt: z.ZodOptional<z.ZodString>;
|
|
80
82
|
enabled: z.ZodOptional<z.ZodBoolean>;
|
|
83
|
+
runsOn: z.ZodOptional<z.ZodNullable<z.ZodString>>;
|
|
84
|
+
private: z.ZodOptional<z.ZodBoolean>;
|
|
81
85
|
}, z.core.$strip>> | Route<"agents.saveSkill", import("../../core/skills.js").SkillInfo & {
|
|
82
86
|
content: string;
|
|
83
87
|
}, "/api/agents/:slug/skills", z.ZodObject<{
|
|
@@ -399,7 +403,12 @@ export declare const routes: (Route<"agents.addJournal", {
|
|
|
399
403
|
aliasPort?: number | undefined;
|
|
400
404
|
}, "/api/hub/open", z.ZodObject<{
|
|
401
405
|
root: z.ZodString;
|
|
402
|
-
}, z.core.$strip>> | Route<"
|
|
406
|
+
}, z.core.$strip>> | Route<"identity.get", {
|
|
407
|
+
handle: string;
|
|
408
|
+
machine: string;
|
|
409
|
+
identity: string;
|
|
410
|
+
previous: string[];
|
|
411
|
+
}, "/api/identity", undefined> | Route<"knowledge.concept", import("../../okf.js").ConceptDetail, "/api/knowledge/:bundle/concept", undefined> | Route<"knowledge.create", import("../../okf.js").BundleInfo, "/api/knowledge", z.ZodObject<{
|
|
403
412
|
name: z.ZodDefault<z.ZodString>;
|
|
404
413
|
}, z.core.$strip>> | Route<"knowledge.delete", {
|
|
405
414
|
ok: boolean;
|
|
@@ -497,7 +506,9 @@ export declare const routes: (Route<"agents.addJournal", {
|
|
|
497
506
|
error?: string;
|
|
498
507
|
off?: boolean;
|
|
499
508
|
repo?: import("../../core/repos.js").RepoInfo;
|
|
500
|
-
}>, "/api/repos/:slug/update", undefined> | Route<"routines.
|
|
509
|
+
}>, "/api/repos/:slug/update", undefined> | Route<"routines.adopt", import("../../core/routines.js").AdoptResult, "/api/routines/adopt", z.ZodObject<{
|
|
510
|
+
retired: z.ZodDefault<z.ZodBoolean>;
|
|
511
|
+
}, z.core.$strip>> | Route<"routines.list", {
|
|
501
512
|
routines: (import("../../core/routines.js").RoutineStatus & {
|
|
502
513
|
agent: {
|
|
503
514
|
slug: string;
|