@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.
Files changed (41) hide show
  1. package/BACKLOG.md +34 -80
  2. package/CHANGELOG.md +29 -0
  3. package/README.md +7 -1
  4. package/dist/index.js +11 -0
  5. package/dist/lib/actor-rooms.d.ts +1 -0
  6. package/dist/lib/actor-rooms.js +33 -1
  7. package/dist/lib/async-runs.d.ts +35 -1
  8. package/dist/lib/async-runs.js +318 -36
  9. package/dist/lib/command-templates.js +8 -1
  10. package/dist/lib/observability.d.ts +15 -0
  11. package/dist/lib/observability.js +103 -18
  12. package/dist/lib/recipe-discovery.js +13 -5
  13. package/dist/lib/recipe-references.js +137 -11
  14. package/dist/lib/runtime-notifier.d.ts +48 -0
  15. package/dist/lib/runtime-notifier.js +137 -0
  16. package/dist/lib/tools.js +18 -9
  17. package/docs/README.md +1 -1
  18. package/docs/actor-messages.md +8 -3
  19. package/docs/async-runs.md +7 -5
  20. package/docs/recipe-library.md +25 -8
  21. package/docs/template-recipes.md +37 -7
  22. package/docs/tool-registry.md +4 -3
  23. package/index.ts +14 -0
  24. package/lib/actor-rooms.ts +46 -1
  25. package/lib/async-runs.ts +433 -49
  26. package/lib/command-templates.ts +8 -1
  27. package/lib/observability.ts +133 -20
  28. package/lib/recipe-discovery.ts +21 -6
  29. package/lib/recipe-references.ts +141 -17
  30. package/lib/runtime-notifier.ts +207 -0
  31. package/lib/tools.ts +36 -13
  32. package/package.json +2 -3
  33. package/recipes/music-player.json +1 -1
  34. package/recipes/pipeline-room-swarm.json +1 -1
  35. package/scripts/coordinator.mjs +276 -135
  36. package/scripts/locker.mjs +87 -28
  37. package/scripts/music-player.mjs +401 -94
  38. package/scripts/validate-recipe.mjs +2 -2
  39. package/skills/actors/SKILL.md +10 -9
  40. package/skills/swarm/SKILL.md +1 -1
  41. package/index.js +0 -19
@@ -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 = readdirSync(stateRoot, { withFileTypes: true })
182
- .filter((entry) => entry.isDirectory())
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 = readdirSync(stateRoot, { withFileTypes: true })
268
- .filter((entry) => entry.isDirectory())
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
- .filter((run) => {
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
- return (
381
- run.status === "running" &&
382
- run.retireWhen === "children_terminal" &&
383
- run.stateDir &&
384
- activeSubagents + descendantSubagents <= 0
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
- .map((run) => ({
388
- activeSubagents: Math.max(0, Math.floor(run.activeSubagents ?? 0)),
389
- descendantSubagents: Math.max(0, Math.floor(run.descendantSubagents ?? 0)),
390
- run: run.run,
391
- stateDir: run.stateDir!,
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,
@@ -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
- typeof config.template === "string" ? config.template : { template: config.template },
105
- ).map((warning) => `Recipe ${file}: ${warning}`);
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) => a.priority - b.priority || a.path.localeCompare(b.path),
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)) shadow.shadowed = true;
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`,
@@ -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") ? expanded : `${expanded}.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 recipeNameFile(value: string): string {
115
+ function recipeNameFiles(value: string): string[] {
114
116
  const trimmed = value.trim();
115
- return trimmed.endsWith(".json") ? trimmed : `${trimmed}.json`;
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 = [...new Set(roots.map((root) => resolve(root, file)))];
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")) return resolveRecipePath(trimmed, recipeRoot);
138
- const path = resolveRecipePath(trimmed, recipeRoot);
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 = JSON.parse(readFileSync(path, "utf8")) as Record<
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 raw = JSON.parse(readFileSync(path, "utf8")) as Record<
247
- string,
248
- unknown
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, ".json");
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
+ }