@llblab/pi-actors 0.20.2 → 0.22.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/BACKLOG.md +34 -80
- package/CHANGELOG.md +29 -0
- package/README.md +7 -1
- package/dist/index.js +11 -0
- package/dist/lib/actor-rooms.d.ts +1 -0
- package/dist/lib/actor-rooms.js +33 -1
- package/dist/lib/async-runs.d.ts +35 -1
- package/dist/lib/async-runs.js +318 -36
- package/dist/lib/command-templates.js +8 -1
- package/dist/lib/observability.d.ts +15 -0
- package/dist/lib/observability.js +103 -18
- package/dist/lib/recipe-discovery.js +13 -5
- package/dist/lib/recipe-references.js +137 -11
- package/dist/lib/runtime-notifier.d.ts +48 -0
- package/dist/lib/runtime-notifier.js +137 -0
- package/dist/lib/tools.js +18 -9
- package/docs/README.md +1 -1
- package/docs/actor-messages.md +8 -3
- package/docs/async-runs.md +7 -5
- package/docs/recipe-library.md +25 -8
- package/docs/template-recipes.md +37 -7
- package/docs/tool-registry.md +4 -3
- package/index.ts +14 -0
- package/lib/actor-rooms.ts +46 -1
- package/lib/async-runs.ts +433 -49
- package/lib/command-templates.ts +8 -1
- package/lib/observability.ts +133 -20
- package/lib/recipe-discovery.ts +21 -6
- package/lib/recipe-references.ts +141 -17
- package/lib/runtime-notifier.ts +207 -0
- package/lib/tools.ts +36 -13
- package/package.json +2 -3
- package/recipes/music-player.json +1 -1
- package/recipes/pipeline-room-swarm.json +1 -1
- package/scripts/coordinator.mjs +276 -135
- package/scripts/locker.mjs +87 -28
- package/scripts/music-player.mjs +401 -94
- package/scripts/validate-recipe.mjs +2 -2
- package/skills/actors/SKILL.md +10 -9
- package/skills/swarm/SKILL.md +1 -1
- package/index.js +0 -19
package/lib/observability.ts
CHANGED
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
8
|
-
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
8
|
+
import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
|
|
9
9
|
|
|
10
10
|
import * as AsyncRuns from "./async-runs.ts";
|
|
11
11
|
import * as Paths from "./paths.ts";
|
|
@@ -52,9 +52,25 @@ export interface RunSummary {
|
|
|
52
52
|
|
|
53
53
|
export interface RunRetirementCandidate {
|
|
54
54
|
activeSubagents: number;
|
|
55
|
+
childRuns: number;
|
|
55
56
|
descendantSubagents: number;
|
|
56
57
|
run: string;
|
|
57
58
|
stateDir: string;
|
|
59
|
+
terminalChildRuns: number;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export interface RunRetirementExecution {
|
|
63
|
+
action: "stop" | "cancel" | "skip" | "failed";
|
|
64
|
+
error?: string;
|
|
65
|
+
run: string;
|
|
66
|
+
stateDir: string;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export interface RunRetirementExecutorOptions {
|
|
70
|
+
attempted?: Set<string>;
|
|
71
|
+
cancelRun: (candidate: RunRetirementCandidate) => Record<string, unknown>;
|
|
72
|
+
notify?: (message: string, level: "info" | "warning" | "error") => void;
|
|
73
|
+
sendStop: (candidate: RunRetirementCandidate) => Promise<unknown>;
|
|
58
74
|
}
|
|
59
75
|
|
|
60
76
|
export interface RunTransition {
|
|
@@ -93,6 +109,7 @@ const TERMINAL = new Set<RunObservedStatus>([
|
|
|
93
109
|
"killed",
|
|
94
110
|
]);
|
|
95
111
|
const PROC_DESCENDANT_SCAN_TTL_MS = 1000;
|
|
112
|
+
const RUN_STATE_DISCOVERY_MAX_DEPTH = 8;
|
|
96
113
|
|
|
97
114
|
const procDescendantScanCache = new Map<
|
|
98
115
|
string,
|
|
@@ -120,6 +137,30 @@ function getUpdatedAt(status: Record<string, unknown>): string | undefined {
|
|
|
120
137
|
: undefined;
|
|
121
138
|
}
|
|
122
139
|
|
|
140
|
+
function listRunStateDirs(
|
|
141
|
+
stateRoot: string,
|
|
142
|
+
depth = 0,
|
|
143
|
+
seen = new Set<string>(),
|
|
144
|
+
): string[] {
|
|
145
|
+
if (!existsSync(stateRoot) || seen.has(stateRoot)) return [];
|
|
146
|
+
seen.add(stateRoot);
|
|
147
|
+
let entries: import("node:fs").Dirent[];
|
|
148
|
+
try {
|
|
149
|
+
entries = readdirSync(stateRoot, { withFileTypes: true });
|
|
150
|
+
} catch {
|
|
151
|
+
return [];
|
|
152
|
+
}
|
|
153
|
+
const result: string[] = [];
|
|
154
|
+
for (const entry of entries) {
|
|
155
|
+
if (!entry.isDirectory()) continue;
|
|
156
|
+
const child = join(stateRoot, entry.name);
|
|
157
|
+
if (existsSync(join(child, "run.json"))) result.push(child);
|
|
158
|
+
if (depth + 1 < RUN_STATE_DISCOVERY_MAX_DEPTH)
|
|
159
|
+
result.push(...listRunStateDirs(child, depth + 1, seen));
|
|
160
|
+
}
|
|
161
|
+
return result;
|
|
162
|
+
}
|
|
163
|
+
|
|
123
164
|
function observeRun(stateDir: string): RunObservation | undefined {
|
|
124
165
|
try {
|
|
125
166
|
const status = AsyncRuns.getRunStatus(stateDir);
|
|
@@ -178,9 +219,8 @@ export function summarizeRuns(
|
|
|
178
219
|
total: 0,
|
|
179
220
|
};
|
|
180
221
|
}
|
|
181
|
-
const runs =
|
|
182
|
-
.
|
|
183
|
-
.map((entry) => observeRun(join(stateRoot, entry.name)))
|
|
222
|
+
const runs = listRunStateDirs(stateRoot)
|
|
223
|
+
.map((stateDir) => observeRun(stateDir))
|
|
184
224
|
.filter((run): run is RunObservation => Boolean(run))
|
|
185
225
|
.filter((run) => ownerId === undefined || run.ownerId === ownerId)
|
|
186
226
|
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
@@ -242,7 +282,7 @@ function getRunningRunPidMap(stateRoot: string, ownerId?: string): Map<string, s
|
|
|
242
282
|
const pids = new Map<string, string>();
|
|
243
283
|
for (const run of summarizeRunsWithoutSubagents(stateRoot, ownerId).runs) {
|
|
244
284
|
if (run.status !== "running") continue;
|
|
245
|
-
const status = AsyncRuns.getRunStatus(join(stateRoot, run.run));
|
|
285
|
+
const status = AsyncRuns.getRunStatus(run.stateDir ?? join(stateRoot, run.run));
|
|
246
286
|
const pid = Number(status.pid || 0);
|
|
247
287
|
if (pid > 0) pids.set(String(pid), run.run);
|
|
248
288
|
}
|
|
@@ -264,9 +304,8 @@ function summarizeRunsWithoutSubagents(
|
|
|
264
304
|
runs: [],
|
|
265
305
|
total: 0,
|
|
266
306
|
};
|
|
267
|
-
const runs =
|
|
268
|
-
.
|
|
269
|
-
.map((entry) => observeRun(join(stateRoot, entry.name)))
|
|
307
|
+
const runs = listRunStateDirs(stateRoot)
|
|
308
|
+
.map((stateDir) => observeRun(stateDir))
|
|
270
309
|
.filter((run): run is RunObservation => Boolean(run))
|
|
271
310
|
.filter((run) => ownerId === undefined || run.ownerId === ownerId)
|
|
272
311
|
.sort((a, b) => (b.updatedAt ?? "").localeCompare(a.updatedAt ?? ""));
|
|
@@ -370,28 +409,102 @@ export function renderRunStatus(
|
|
|
370
409
|
return renderSubagentStatus(summary.runningSubagents, frame);
|
|
371
410
|
}
|
|
372
411
|
|
|
412
|
+
function isNestedStateDir(parent: string, child: string): boolean {
|
|
413
|
+
const path = relative(parent, child);
|
|
414
|
+
return Boolean(path) && !path.startsWith("..") && !isAbsolute(path);
|
|
415
|
+
}
|
|
416
|
+
|
|
373
417
|
export function findRunRetirementCandidates(
|
|
374
418
|
summary: RunSummary,
|
|
375
419
|
): RunRetirementCandidate[] {
|
|
376
420
|
return summary.runs
|
|
377
|
-
.
|
|
421
|
+
.map((run) => {
|
|
378
422
|
const activeSubagents = Math.max(0, Math.floor(run.activeSubagents ?? 0));
|
|
379
423
|
const descendantSubagents = Math.max(0, Math.floor(run.descendantSubagents ?? 0));
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
424
|
+
const childRuns = run.stateDir
|
|
425
|
+
? summary.runs.filter(
|
|
426
|
+
(child) =>
|
|
427
|
+
child.stateDir !== undefined &&
|
|
428
|
+
child.stateDir !== run.stateDir &&
|
|
429
|
+
isNestedStateDir(run.stateDir!, child.stateDir),
|
|
430
|
+
)
|
|
431
|
+
: [];
|
|
432
|
+
const runningChildRuns = childRuns.filter((child) => child.status === "running").length;
|
|
433
|
+
return {
|
|
434
|
+
activeSubagents,
|
|
435
|
+
childRuns: childRuns.length,
|
|
436
|
+
descendantSubagents,
|
|
437
|
+
ready:
|
|
438
|
+
run.status === "running" &&
|
|
439
|
+
run.retireWhen === "children_terminal" &&
|
|
440
|
+
run.stateDir !== undefined &&
|
|
441
|
+
!run.terminalHandled &&
|
|
442
|
+
activeSubagents + descendantSubagents + runningChildRuns <= 0,
|
|
443
|
+
run,
|
|
444
|
+
terminalChildRuns: childRuns.filter((child) => TERMINAL.has(child.status)).length,
|
|
445
|
+
};
|
|
386
446
|
})
|
|
387
|
-
.
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
447
|
+
.filter((item) => item.ready)
|
|
448
|
+
.map((item) => ({
|
|
449
|
+
activeSubagents: item.activeSubagents,
|
|
450
|
+
childRuns: item.childRuns,
|
|
451
|
+
descendantSubagents: item.descendantSubagents,
|
|
452
|
+
run: item.run.run,
|
|
453
|
+
stateDir: item.run.stateDir!,
|
|
454
|
+
terminalChildRuns: item.terminalChildRuns,
|
|
392
455
|
}));
|
|
393
456
|
}
|
|
394
457
|
|
|
458
|
+
export async function executeRunRetirements(
|
|
459
|
+
summary: RunSummary,
|
|
460
|
+
options: RunRetirementExecutorOptions,
|
|
461
|
+
): Promise<RunRetirementExecution[]> {
|
|
462
|
+
const results: RunRetirementExecution[] = [];
|
|
463
|
+
for (const candidate of findRunRetirementCandidates(summary)) {
|
|
464
|
+
if (options.attempted?.has(candidate.stateDir)) {
|
|
465
|
+
results.push({ action: "skip", run: candidate.run, stateDir: candidate.stateDir });
|
|
466
|
+
continue;
|
|
467
|
+
}
|
|
468
|
+
options.attempted?.add(candidate.stateDir);
|
|
469
|
+
try {
|
|
470
|
+
await options.sendStop(candidate);
|
|
471
|
+
options.notify?.(
|
|
472
|
+
`Retiring actor ${candidate.run} after child runs reached terminal state`,
|
|
473
|
+
"info",
|
|
474
|
+
);
|
|
475
|
+
results.push({ action: "stop", run: candidate.run, stateDir: candidate.stateDir });
|
|
476
|
+
continue;
|
|
477
|
+
} catch (error) {
|
|
478
|
+
try {
|
|
479
|
+
const cancelResult = options.cancelRun(candidate);
|
|
480
|
+
const cancelled = Boolean((cancelResult as { cancelled?: unknown }).cancelled);
|
|
481
|
+
options.notify?.(
|
|
482
|
+
cancelled
|
|
483
|
+
? `Retiring actor ${candidate.run} by cancellation after graceful stop failed`
|
|
484
|
+
: `Actor retirement skipped for ${candidate.run}: ${error instanceof Error ? error.message : String(error)}`,
|
|
485
|
+
cancelled ? "warning" : "error",
|
|
486
|
+
);
|
|
487
|
+
results.push({
|
|
488
|
+
action: cancelled ? "cancel" : "skip",
|
|
489
|
+
...(cancelled ? {} : { error: error instanceof Error ? error.message : String(error) }),
|
|
490
|
+
run: candidate.run,
|
|
491
|
+
stateDir: candidate.stateDir,
|
|
492
|
+
});
|
|
493
|
+
} catch (cancelError) {
|
|
494
|
+
const message = cancelError instanceof Error ? cancelError.message : String(cancelError);
|
|
495
|
+
options.notify?.(`Actor retirement failed for ${candidate.run}: ${message}`, "error");
|
|
496
|
+
results.push({
|
|
497
|
+
action: "failed",
|
|
498
|
+
error: message,
|
|
499
|
+
run: candidate.run,
|
|
500
|
+
stateDir: candidate.stateDir,
|
|
501
|
+
});
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
return results;
|
|
506
|
+
}
|
|
507
|
+
|
|
395
508
|
export function detectRunTransitions(
|
|
396
509
|
previous: Map<string, RunObservedStatus>,
|
|
397
510
|
summary: RunSummary,
|
package/lib/recipe-discovery.ts
CHANGED
|
@@ -88,11 +88,11 @@ function listRecipeFiles(root: string): string[] {
|
|
|
88
88
|
.filter(
|
|
89
89
|
(entry) =>
|
|
90
90
|
entry.isFile() &&
|
|
91
|
-
entry.name.endsWith(".json") &&
|
|
91
|
+
(entry.name.endsWith(".json") || entry.name.endsWith(".md")) &&
|
|
92
92
|
entry.name !== "legacy-tool-registry-migration-report.json",
|
|
93
93
|
)
|
|
94
94
|
.map((entry) => join(root, entry.name))
|
|
95
|
-
.sort();
|
|
95
|
+
.sort((a, b) => a.replace(/\.md$/, ".json").localeCompare(b.replace(/\.md$/, ".json")) || (a.endsWith(".json") ? -1 : 1));
|
|
96
96
|
}
|
|
97
97
|
|
|
98
98
|
function getRecipeConfigDiagnostics(
|
|
@@ -100,9 +100,15 @@ function getRecipeConfigDiagnostics(
|
|
|
100
100
|
config: TemplateRecipeConfig | undefined,
|
|
101
101
|
): string[] {
|
|
102
102
|
if (!config) return [`Invalid recipe: ${file}`];
|
|
103
|
+
const commandTemplateConfig =
|
|
104
|
+
typeof config.template === "object" && config.template !== null
|
|
105
|
+
? config.template
|
|
106
|
+
: config;
|
|
103
107
|
return CommandTemplates.getCommandTemplateWarnings(
|
|
104
|
-
|
|
105
|
-
).map(
|
|
108
|
+
commandTemplateConfig as CommandTemplates.CommandTemplateConfig,
|
|
109
|
+
).map(
|
|
110
|
+
(warning) => `Recipe ${file}: ${warning}`,
|
|
111
|
+
);
|
|
106
112
|
}
|
|
107
113
|
|
|
108
114
|
function readDiscoveredRecipe(
|
|
@@ -218,13 +224,22 @@ export function discoverRecipeSources(
|
|
|
218
224
|
const diagnostics: string[] = getRecipeRootDiagnostics(sources);
|
|
219
225
|
for (const [id, bucket] of byId) {
|
|
220
226
|
bucket.sort(
|
|
221
|
-
(a, b) =>
|
|
227
|
+
(a, b) =>
|
|
228
|
+
a.priority - b.priority ||
|
|
229
|
+
a.path.replace(/\.md$/, ".json").localeCompare(b.path.replace(/\.md$/, ".json")) ||
|
|
230
|
+
(a.path.endsWith(".json") ? -1 : 1),
|
|
222
231
|
);
|
|
223
232
|
const winner = bucket[0];
|
|
224
233
|
winner.active = true;
|
|
225
234
|
winner.shadows = bucket.slice(1).map((entry) => entry.path);
|
|
226
235
|
active.set(id, winner);
|
|
227
|
-
for (const shadow of bucket.slice(1))
|
|
236
|
+
for (const shadow of bucket.slice(1)) {
|
|
237
|
+
shadow.shadowed = true;
|
|
238
|
+
if (winner.path.endsWith(".json") && shadow.path.endsWith(".md"))
|
|
239
|
+
shadow.diagnostics.push(
|
|
240
|
+
`Markdown recipe ${shadow.path} is shadowed by JSON recipe ${winner.path}`,
|
|
241
|
+
);
|
|
242
|
+
}
|
|
228
243
|
if (winner.invalid)
|
|
229
244
|
diagnostics.push(
|
|
230
245
|
`Recipe ${id} is invalid and blocks lower-priority recipes`,
|
package/lib/recipe-references.ts
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
8
8
|
import { homedir } from "node:os";
|
|
9
|
-
import { basename, dirname, resolve } from "node:path";
|
|
9
|
+
import { basename, dirname, extname, resolve } from "node:path";
|
|
10
10
|
|
|
11
11
|
import type {
|
|
12
12
|
CommandTemplateConfig,
|
|
@@ -101,7 +101,9 @@ export function resolveRecipePath(
|
|
|
101
101
|
if (expanded.includes("/")) return resolve(expanded);
|
|
102
102
|
return resolve(
|
|
103
103
|
recipeRoot,
|
|
104
|
-
expanded.endsWith(".json")
|
|
104
|
+
expanded.endsWith(".json") || expanded.endsWith(".md")
|
|
105
|
+
? expanded
|
|
106
|
+
: `${expanded}.json`,
|
|
105
107
|
);
|
|
106
108
|
}
|
|
107
109
|
|
|
@@ -110,20 +112,26 @@ function isBareRecipeName(value: string): boolean {
|
|
|
110
112
|
return Boolean(trimmed) && !trimmed.includes("/") && !trimmed.startsWith("~") && !trimmed.includes("{");
|
|
111
113
|
}
|
|
112
114
|
|
|
113
|
-
function
|
|
115
|
+
function recipeNameFiles(value: string): string[] {
|
|
114
116
|
const trimmed = value.trim();
|
|
115
|
-
|
|
117
|
+
if (trimmed.endsWith(".json") || trimmed.endsWith(".md")) return [trimmed];
|
|
118
|
+
return [`${trimmed}.json`, `${trimmed}.md`];
|
|
116
119
|
}
|
|
117
120
|
|
|
118
121
|
function resolveRecipeImportPath(value: string, currentRecipeRoot: string): string {
|
|
119
122
|
if (!isBareRecipeName(value)) return resolveRecipePath(value, currentRecipeRoot);
|
|
120
|
-
const file = recipeNameFile(value);
|
|
121
123
|
const roots = [
|
|
122
124
|
Paths.getRecipeRoot(),
|
|
123
125
|
currentRecipeRoot,
|
|
124
126
|
Paths.getPackagedRecipeRoot(),
|
|
125
127
|
];
|
|
126
|
-
const candidates = [
|
|
128
|
+
const candidates = [
|
|
129
|
+
...new Set(
|
|
130
|
+
roots.flatMap((root) =>
|
|
131
|
+
recipeNameFiles(value).map((file) => resolve(root, file)),
|
|
132
|
+
),
|
|
133
|
+
),
|
|
134
|
+
];
|
|
127
135
|
return candidates.find((candidate) => existsSync(candidate)) ?? candidates[0];
|
|
128
136
|
}
|
|
129
137
|
|
|
@@ -134,14 +142,14 @@ export function getRecipePath(
|
|
|
134
142
|
if (typeof value !== "string") return undefined;
|
|
135
143
|
const trimmed = value.trim();
|
|
136
144
|
if (!trimmed || hasWhitespace(trimmed)) return undefined;
|
|
137
|
-
if (trimmed.endsWith(".json")
|
|
138
|
-
|
|
145
|
+
if (trimmed.endsWith(".json") || trimmed.endsWith(".md"))
|
|
146
|
+
return resolveRecipePath(trimmed, recipeRoot);
|
|
147
|
+
const jsonPath = resolveRecipePath(trimmed, recipeRoot);
|
|
148
|
+
const mdPath = resolveRecipePath(`${trimmed}.md`, recipeRoot);
|
|
149
|
+
const path = existsSync(jsonPath) ? jsonPath : mdPath;
|
|
139
150
|
if (!existsSync(path)) return undefined;
|
|
140
151
|
try {
|
|
141
|
-
const raw =
|
|
142
|
-
string,
|
|
143
|
-
unknown
|
|
144
|
-
>;
|
|
152
|
+
const raw = readRawRecipeConfig(path);
|
|
145
153
|
return raw && typeof raw === "object" && Object.hasOwn(raw, "template")
|
|
146
154
|
? path
|
|
147
155
|
: undefined;
|
|
@@ -232,6 +240,123 @@ function getRecipeCommandTemplate(
|
|
|
232
240
|
return normalizeRecipeTemplate({ ...envelope, template });
|
|
233
241
|
}
|
|
234
242
|
|
|
243
|
+
function parseMarkdownScalar(value: string): unknown {
|
|
244
|
+
const trimmed = value.trim();
|
|
245
|
+
if (!trimmed) return "";
|
|
246
|
+
if (
|
|
247
|
+
(trimmed.startsWith("{") && trimmed.endsWith("}")) ||
|
|
248
|
+
(trimmed.startsWith("[") && trimmed.endsWith("]"))
|
|
249
|
+
) {
|
|
250
|
+
try {
|
|
251
|
+
return JSON.parse(trimmed);
|
|
252
|
+
} catch {
|
|
253
|
+
return trimmed;
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
const quoted = trimmed.match(/^(?:"([^"]*)"|'([^']*)')$/);
|
|
257
|
+
if (quoted) return quoted[1] ?? quoted[2] ?? "";
|
|
258
|
+
if (trimmed === "true") return true;
|
|
259
|
+
if (trimmed === "false") return false;
|
|
260
|
+
if (trimmed === "null") return null;
|
|
261
|
+
if (/^-?\d+(?:\.\d+)?$/.test(trimmed)) return Number(trimmed);
|
|
262
|
+
return trimmed;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
function parseMarkdownFrontmatterObject(
|
|
266
|
+
lines: string[],
|
|
267
|
+
): Record<string, unknown> | unknown[] {
|
|
268
|
+
if (lines.every((line) => /^\s*-\s+/.test(line))) {
|
|
269
|
+
return lines.map((line) => parseMarkdownScalar(line.replace(/^\s*-\s+/, "")));
|
|
270
|
+
}
|
|
271
|
+
const result: Record<string, unknown> = {};
|
|
272
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
273
|
+
const match = lines[index].match(/^\s{2}([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/);
|
|
274
|
+
if (!match) continue;
|
|
275
|
+
if (match[2]) {
|
|
276
|
+
result[match[1]] = parseMarkdownScalar(match[2]);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const nested: string[] = [];
|
|
280
|
+
while (index + 1 < lines.length && /^\s{4}/.test(lines[index + 1])) {
|
|
281
|
+
index += 1;
|
|
282
|
+
nested.push(lines[index].slice(2));
|
|
283
|
+
}
|
|
284
|
+
result[match[1]] = parseMarkdownFrontmatterObject(nested);
|
|
285
|
+
}
|
|
286
|
+
return result;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function parseMarkdownFrontmatter(value: string): Record<string, unknown> {
|
|
290
|
+
const result: Record<string, unknown> = {};
|
|
291
|
+
const lines = value.split(/\r?\n/);
|
|
292
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
293
|
+
const line = lines[index];
|
|
294
|
+
if (!line.trim() || line.trim().startsWith("#")) continue;
|
|
295
|
+
const match = line.match(/^([A-Za-z_][A-Za-z0-9_.-]*):\s*(.*)$/);
|
|
296
|
+
if (!match) continue;
|
|
297
|
+
if (match[2]) {
|
|
298
|
+
result[match[1]] = parseMarkdownScalar(match[2]);
|
|
299
|
+
continue;
|
|
300
|
+
}
|
|
301
|
+
const nested: string[] = [];
|
|
302
|
+
while (index + 1 < lines.length && /^\s+/.test(lines[index + 1])) {
|
|
303
|
+
index += 1;
|
|
304
|
+
nested.push(lines[index]);
|
|
305
|
+
}
|
|
306
|
+
result[match[1]] = parseMarkdownFrontmatterObject(nested);
|
|
307
|
+
}
|
|
308
|
+
return result;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function findMarkdownRecipeFence(
|
|
312
|
+
body: string,
|
|
313
|
+
): { info: string; body: string } | undefined {
|
|
314
|
+
const pattern = /```([^\n`]*)\n([\s\S]*?)```/g;
|
|
315
|
+
for (const match of body.matchAll(pattern)) {
|
|
316
|
+
const info = match[1].trim().toLowerCase();
|
|
317
|
+
if (
|
|
318
|
+
info.includes("recipe") ||
|
|
319
|
+
info.includes("template") ||
|
|
320
|
+
info.includes("command") ||
|
|
321
|
+
info.includes("json")
|
|
322
|
+
) {
|
|
323
|
+
return { info, body: match[2].trim() };
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
return undefined;
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
function parseMarkdownRecipeConfig(
|
|
330
|
+
content: string,
|
|
331
|
+
): Record<string, unknown> | undefined {
|
|
332
|
+
const lines = content.split(/\r?\n/);
|
|
333
|
+
if (lines[0]?.trim() !== "---") return undefined;
|
|
334
|
+
const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
335
|
+
if (end === -1) return undefined;
|
|
336
|
+
const frontmatter = parseMarkdownFrontmatter(lines.slice(1, end).join("\n"));
|
|
337
|
+
const fence = findMarkdownRecipeFence(lines.slice(end + 1).join("\n"));
|
|
338
|
+
if (!fence) return Object.hasOwn(frontmatter, "template") ? frontmatter : undefined;
|
|
339
|
+
const text = fence.body.trim();
|
|
340
|
+
if (!text) return undefined;
|
|
341
|
+
if (
|
|
342
|
+
fence.info.includes("json") ||
|
|
343
|
+
fence.info.includes("recipe") ||
|
|
344
|
+
text.startsWith("{") ||
|
|
345
|
+
text.startsWith("[") ||
|
|
346
|
+
text.startsWith('"')
|
|
347
|
+
) {
|
|
348
|
+
try {
|
|
349
|
+
const parsed = JSON.parse(text) as unknown;
|
|
350
|
+
if (isRecord(parsed) && Object.hasOwn(parsed, "template"))
|
|
351
|
+
return { ...frontmatter, ...parsed };
|
|
352
|
+
return { ...frontmatter, template: parsed };
|
|
353
|
+
} catch {
|
|
354
|
+
if (fence.info.includes("json") || fence.info.includes("recipe")) return undefined;
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
return { ...frontmatter, template: text };
|
|
358
|
+
}
|
|
359
|
+
|
|
235
360
|
export function readRawRecipeConfig(
|
|
236
361
|
path: string,
|
|
237
362
|
): Record<string, unknown> | undefined {
|
|
@@ -243,10 +368,9 @@ export function readRawRecipeConfig(
|
|
|
243
368
|
);
|
|
244
369
|
}
|
|
245
370
|
try {
|
|
246
|
-
const
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
>;
|
|
371
|
+
const content = readFileSync(path, "utf8");
|
|
372
|
+
if (path.endsWith(".md")) return parseMarkdownRecipeConfig(content);
|
|
373
|
+
const raw = JSON.parse(content) as Record<string, unknown>;
|
|
250
374
|
return raw && typeof raw === "object" ? raw : undefined;
|
|
251
375
|
} catch {
|
|
252
376
|
return undefined;
|
|
@@ -254,7 +378,7 @@ export function readRawRecipeConfig(
|
|
|
254
378
|
}
|
|
255
379
|
|
|
256
380
|
export function getRecipeIdFromPath(file: string): string {
|
|
257
|
-
return basename(file,
|
|
381
|
+
return basename(file, extname(file));
|
|
258
382
|
}
|
|
259
383
|
|
|
260
384
|
function readRecipeConfig(value: unknown): TemplateRecipeConfig | undefined {
|
|
@@ -0,0 +1,207 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Runtime wake notifications for actor state.
|
|
3
|
+
* Zones: advisory wake layer, file-backed runtime state, cross-platform notification boundary
|
|
4
|
+
* Owns best-effort live wake signals while durable mailbox/state files remain canonical.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { randomUUID } from "node:crypto";
|
|
8
|
+
import {
|
|
9
|
+
appendFileSync,
|
|
10
|
+
existsSync,
|
|
11
|
+
mkdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
statSync,
|
|
14
|
+
watch,
|
|
15
|
+
type FSWatcher,
|
|
16
|
+
} from "node:fs";
|
|
17
|
+
import { basename, dirname, join } from "node:path";
|
|
18
|
+
|
|
19
|
+
export interface RuntimeWakeEvent {
|
|
20
|
+
actor: string;
|
|
21
|
+
id: string;
|
|
22
|
+
metadata?: Record<string, unknown>;
|
|
23
|
+
reason: string;
|
|
24
|
+
state_dir: string;
|
|
25
|
+
ts: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface RuntimeNotifierSubscription {
|
|
29
|
+
close(): void;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type RuntimeReconcileReason = "initial" | "poll" | "wake";
|
|
33
|
+
|
|
34
|
+
export interface RuntimeReconcileEvent {
|
|
35
|
+
actor: string;
|
|
36
|
+
reason: RuntimeReconcileReason;
|
|
37
|
+
state_dir: string;
|
|
38
|
+
ts: string;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export interface RuntimeNotifierSubscribeOptions {
|
|
42
|
+
onReconcile?: (event: RuntimeReconcileEvent) => void;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface FileRuntimeNotifierOptions {
|
|
46
|
+
pollIntervalMs?: number;
|
|
47
|
+
replay?: boolean;
|
|
48
|
+
watch?: boolean;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface RuntimeNotifier {
|
|
52
|
+
notify(event: { actor: string; metadata?: Record<string, unknown>; reason: string }): RuntimeWakeEvent;
|
|
53
|
+
subscribe(
|
|
54
|
+
actor: string,
|
|
55
|
+
onWake: (event: RuntimeWakeEvent) => void,
|
|
56
|
+
options?: RuntimeNotifierSubscribeOptions,
|
|
57
|
+
): RuntimeNotifierSubscription;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const DEFAULT_POLL_INTERVAL_MS = 1000;
|
|
61
|
+
|
|
62
|
+
export function runtimeWakeFile(stateDir: string): string {
|
|
63
|
+
return join(stateDir, "wake.jsonl");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normalizeWakeEvent(
|
|
67
|
+
stateDir: string,
|
|
68
|
+
event: { actor: string; metadata?: Record<string, unknown>; reason: string },
|
|
69
|
+
): RuntimeWakeEvent {
|
|
70
|
+
const actor = event.actor.trim();
|
|
71
|
+
const reason = event.reason.trim();
|
|
72
|
+
if (!actor) throw new Error("Runtime wake event requires actor.");
|
|
73
|
+
if (!reason) throw new Error("Runtime wake event requires reason.");
|
|
74
|
+
return {
|
|
75
|
+
actor,
|
|
76
|
+
id: randomUUID(),
|
|
77
|
+
...(event.metadata ? { metadata: event.metadata } : {}),
|
|
78
|
+
reason,
|
|
79
|
+
state_dir: stateDir,
|
|
80
|
+
ts: new Date().toISOString(),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
export function notifyRuntimeWake(
|
|
85
|
+
stateDir: string,
|
|
86
|
+
event: { actor: string; metadata?: Record<string, unknown>; reason: string },
|
|
87
|
+
): RuntimeWakeEvent {
|
|
88
|
+
const normalized = normalizeWakeEvent(stateDir, event);
|
|
89
|
+
const file = runtimeWakeFile(stateDir);
|
|
90
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
91
|
+
appendFileSync(file, `${JSON.stringify(normalized)}\n`, "utf8");
|
|
92
|
+
return normalized;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function parseRuntimeWakeEventLine(
|
|
96
|
+
line: string,
|
|
97
|
+
): RuntimeWakeEvent | undefined {
|
|
98
|
+
try {
|
|
99
|
+
const record = JSON.parse(line) as Record<string, unknown>;
|
|
100
|
+
if (
|
|
101
|
+
typeof record.actor !== "string" ||
|
|
102
|
+
typeof record.id !== "string" ||
|
|
103
|
+
typeof record.reason !== "string" ||
|
|
104
|
+
typeof record.state_dir !== "string" ||
|
|
105
|
+
typeof record.ts !== "string"
|
|
106
|
+
) {
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
return {
|
|
110
|
+
actor: record.actor,
|
|
111
|
+
id: record.id,
|
|
112
|
+
...(record.metadata &&
|
|
113
|
+
typeof record.metadata === "object" &&
|
|
114
|
+
!Array.isArray(record.metadata)
|
|
115
|
+
? { metadata: record.metadata as Record<string, unknown> }
|
|
116
|
+
: {}),
|
|
117
|
+
reason: record.reason,
|
|
118
|
+
state_dir: record.state_dir,
|
|
119
|
+
ts: record.ts,
|
|
120
|
+
};
|
|
121
|
+
} catch {
|
|
122
|
+
return undefined;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function readRuntimeWakeEvents(stateDir: string): RuntimeWakeEvent[] {
|
|
127
|
+
const file = runtimeWakeFile(stateDir);
|
|
128
|
+
if (!existsSync(file)) return [];
|
|
129
|
+
return readFileSync(file, "utf8")
|
|
130
|
+
.split("\n")
|
|
131
|
+
.filter((line) => line.trim())
|
|
132
|
+
.map(parseRuntimeWakeEventLine)
|
|
133
|
+
.filter((event): event is RuntimeWakeEvent => Boolean(event));
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
export function createFileRuntimeNotifier(
|
|
137
|
+
stateDir: string,
|
|
138
|
+
options: FileRuntimeNotifierOptions = {},
|
|
139
|
+
): RuntimeNotifier {
|
|
140
|
+
const file = runtimeWakeFile(stateDir);
|
|
141
|
+
const pollIntervalMs = Math.max(
|
|
142
|
+
25,
|
|
143
|
+
Number(options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS),
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
notify: (event) => notifyRuntimeWake(stateDir, event),
|
|
148
|
+
subscribe: (actor, onWake, subscribeOptions = {}) => {
|
|
149
|
+
mkdirSync(dirname(file), { recursive: true });
|
|
150
|
+
let position =
|
|
151
|
+
options.replay || !existsSync(file) ? 0 : statSync(file).size;
|
|
152
|
+
let closed = false;
|
|
153
|
+
const reconcile = (reason: RuntimeReconcileReason): void => {
|
|
154
|
+
if (closed) return;
|
|
155
|
+
subscribeOptions.onReconcile?.({
|
|
156
|
+
actor,
|
|
157
|
+
reason,
|
|
158
|
+
state_dir: stateDir,
|
|
159
|
+
ts: new Date().toISOString(),
|
|
160
|
+
});
|
|
161
|
+
};
|
|
162
|
+
const drain = (): void => {
|
|
163
|
+
if (closed || !existsSync(file)) return;
|
|
164
|
+
const buffer = readFileSync(file);
|
|
165
|
+
if (position > buffer.length) position = 0;
|
|
166
|
+
const chunk = buffer.subarray(position).toString("utf8");
|
|
167
|
+
position = buffer.length;
|
|
168
|
+
for (const line of chunk.split("\n")) {
|
|
169
|
+
if (!line.trim()) continue;
|
|
170
|
+
const event = parseRuntimeWakeEventLine(line);
|
|
171
|
+
if (event && event.actor === actor) {
|
|
172
|
+
onWake(event);
|
|
173
|
+
reconcile("wake");
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
let watcher: FSWatcher | undefined;
|
|
179
|
+
if (options.watch !== false) {
|
|
180
|
+
try {
|
|
181
|
+
watcher = watch(
|
|
182
|
+
dirname(file),
|
|
183
|
+
{ persistent: false },
|
|
184
|
+
(_eventType, changedFile) => {
|
|
185
|
+
if (!changedFile || String(changedFile) === basename(file)) drain();
|
|
186
|
+
},
|
|
187
|
+
);
|
|
188
|
+
} catch {
|
|
189
|
+
// fs.watch availability varies by platform/filesystem; polling below is the fallback.
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
reconcile("initial");
|
|
193
|
+
const timer = setInterval(() => {
|
|
194
|
+
drain();
|
|
195
|
+
reconcile("poll");
|
|
196
|
+
}, pollIntervalMs);
|
|
197
|
+
timer.unref?.();
|
|
198
|
+
return {
|
|
199
|
+
close: () => {
|
|
200
|
+
closed = true;
|
|
201
|
+
clearInterval(timer);
|
|
202
|
+
watcher?.close();
|
|
203
|
+
},
|
|
204
|
+
};
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
}
|