pi-extensible-workflows 2.0.0 → 3.0.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.
@@ -5,20 +5,13 @@ import { access, link, mkdir, open, readFile, readdir, rename, rm, stat, writeFi
5
5
  import { basename, dirname, join, relative, resolve, sep } from "node:path";
6
6
  import { homedir } from "node:os";
7
7
  import { promisify } from "node:util";
8
- import type { BudgetApprovalRequest, JsonValue, LaunchSnapshot, RunRecord, WorkflowRunEvent } from "./index.js";
8
+ import type { BudgetApprovalRequest, JsonValue, LaunchSnapshot, RunRecord, WorkflowRunEvent } from "./types.js";
9
9
  import type { OwnershipRecord } from "./agent-execution.js";
10
- import { loadLaunchSnapshot, WorkflowError } from "./index.js";
10
+ import { WorkflowError } from "./types.js";
11
+ import { loadLaunchSnapshot } from "./utils.js";
11
12
 
12
13
  export interface NativeSessionReference { sessionId: string; sessionFile: string }
13
14
  export interface EffectiveSystemPrompt { sessionId: string; attempt: number; turn: number; sha256: string; prompt: string }
14
- export interface ConversationHead { turn: number; sessionId: string; sessionFile: string; leafId: string; systemPrompt: string; systemPromptSha256: string; toolDefinitionsSha256: string }
15
- export interface PersistedConversation { id: string; policy: JsonValue; head: ConversationHead }
16
- type ConversationArtifact = { version: 1; conversations: Record<string, PersistedConversation> };
17
- function isConversationArtifact(value: unknown): value is ConversationArtifact {
18
- if (!value || typeof value !== "object" || Array.isArray(value)) return false;
19
- const artifact = value as { version?: unknown; conversations?: unknown };
20
- return artifact.version === 1 && Boolean(artifact.conversations) && typeof artifact.conversations === "object" && !Array.isArray(artifact.conversations);
21
- }
22
15
  export interface PersistedRun extends RunRecord { nativeSessions: readonly NativeSessionReference[] }
23
16
  export interface CompletedOperation { path: string; value: JsonValue }
24
17
  export interface AwaitingCheckpoint { path: string; name: string; prompt: string; context: JsonValue }
@@ -197,7 +190,6 @@ export class RunStore {
197
190
  private launchSnapshotWrite: Promise<void> = Promise.resolve();
198
191
  // ponytail: the session lease prevents concurrent RunStore writers for one run.
199
192
  private systemPromptWrite: Promise<void> = Promise.resolve();
200
- private conversationWrite: Promise<void> = Promise.resolve();
201
193
  constructor(readonly cwd: string, readonly sessionId: string, readonly runId: string, readonly home = homedir()) {
202
194
  this.cwd = resolve(cwd);
203
195
  this.directory = join(runsDirectory(this.cwd, sessionId, home), safePart(runId));
@@ -217,7 +209,6 @@ export class RunStore {
217
209
  await atomicJson(join(temporary, "borrowed-worktrees.json"), []);
218
210
  await atomicJson(join(temporary, "state.json"), run);
219
211
  await atomicJson(join(temporary, "system-prompts.json"), { version: 1, entries: [] });
220
- await atomicJson(join(temporary, "conversations.json"), { version: 1, conversations: {} });
221
212
  await rename(temporary, this.directory);
222
213
  } catch (error) {
223
214
  await rm(temporary, { recursive: true, force: true });
@@ -296,27 +287,6 @@ export class RunStore {
296
287
  return (await json<{ version: 1; entries: EffectiveSystemPrompt[] }>(this.systemPromptPath()).catch((error: unknown) => { if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1 as const, entries: [] }; throw error; })).entries;
297
288
  }
298
289
 
299
- conversationPath(): string { return join(this.directory, "conversations.json"); }
300
- async conversation(id: string): Promise<PersistedConversation | undefined> {
301
- await this.conversationWrite;
302
- let artifact: ConversationArtifact;
303
- try { const raw = await json<unknown>(this.conversationPath()); if (!isConversationArtifact(raw)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation state is corrupt"); artifact = raw; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; if (error instanceof WorkflowError) throw error; throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
304
- return artifact.conversations[id];
305
- }
306
- async saveConversation(conversation: PersistedConversation): Promise<void> {
307
- const write = this.conversationWrite.then(async () => {
308
- const path = this.conversationPath();
309
- let artifact: ConversationArtifact;
310
- try { const raw = await json<unknown>(path); if (!isConversationArtifact(raw)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Conversation state is corrupt"); artifact = raw; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") artifact = { version: 1, conversations: {} }; else if (error instanceof WorkflowError) throw error; else throw new WorkflowError("RESUME_INCOMPATIBLE", `Cannot load conversation state: ${error instanceof Error ? error.message : String(error)}`); }
311
- const previous = artifact.conversations[conversation.id];
312
- if (previous && previous.head.turn + 1 !== conversation.head.turn) throw new WorkflowError("RESUME_INCOMPATIBLE", `Conversation head is not the previous turn: ${conversation.id}`);
313
- if (!previous && conversation.head.turn !== 1) throw new WorkflowError("RESUME_INCOMPATIBLE", `Conversation must start at turn one: ${conversation.id}`);
314
- artifact.conversations[conversation.id] = structuredClone(conversation);
315
- await atomicJson(path, artifact);
316
- });
317
- this.conversationWrite = write.catch(() => undefined);
318
- await write;
319
- }
320
290
  private async updateJournal<T>(update: (journal: Journal) => T | Promise<T>): Promise<T> {
321
291
  let result!: T;
322
292
  const write = this.journalWrite.then(async () => {
@@ -339,11 +309,33 @@ export class RunStore {
339
309
  }
340
310
 
341
311
  async replay(path: string): Promise<CompletedOperation | undefined> {
312
+ const operations = await this.replayableOperations();
313
+ return operations.find((operation) => operation.path === path);
314
+ }
315
+
316
+ async replayableOperations(): Promise<readonly CompletedOperation[]> {
317
+ return this.replayableOperationsFrom(new Set());
318
+ }
319
+
320
+ private async replayableOperationsFrom(seen: Set<string>): Promise<readonly CompletedOperation[]> {
321
+ if (seen.has(this.runId)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance contains a cycle");
322
+ const nextSeen = new Set(seen);
323
+ nextSeen.add(this.runId);
342
324
  await this.journalWrite;
343
- return (await json<Journal>(join(this.directory, "journal.json"))).completed[path];
325
+ const loaded = await this.load();
326
+ const operations = new Map<string, CompletedOperation>();
327
+ if (loaded.run.retry?.sourceRunId) {
328
+ const source = await this.sourceRun(loaded.run.retry.sourceRunId);
329
+ for (const operation of await source.replayableOperationsFrom(nextSeen)) operations.set(operation.path, operation);
330
+ }
331
+ const journal = await json<Journal>(join(this.directory, "journal.json"));
332
+ for (const operation of Object.values(journal.completed)) operations.set(operation.path, operation);
333
+ return [...operations.values()].map((operation) => structuredClone(operation));
344
334
  }
345
335
 
346
336
  async awaitCheckpoint(checkpoint: AwaitingCheckpoint): Promise<boolean | undefined> {
337
+ const replayed = await this.replay(checkpoint.path);
338
+ if (replayed) return replayed.value as boolean;
347
339
  return this.updateJournal((journal) => {
348
340
  const completed = journal.completed[checkpoint.path];
349
341
  if (completed) return completed.value as boolean;
@@ -458,6 +450,26 @@ export class RunStore {
458
450
  }
459
451
 
460
452
  async validateParentRun(parentRunId: string): Promise<void> { await this.sourceRun(parentRunId); }
453
+ async validateRetrySource(): Promise<void> {
454
+ const validate = async (current: RunStore, seen: Set<string>): Promise<void> => {
455
+ if (seen.has(current.runId)) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance contains a cycle");
456
+ const nextSeen = new Set(seen);
457
+ nextSeen.add(current.runId);
458
+ const loaded = await current.load();
459
+ const retry = loaded.run.retry;
460
+ if (!retry) return;
461
+ if (typeof retry.sourceRunId !== "string" || !retry.sourceRunId || retry.sourceRunId === current.runId || typeof retry.lineageRootRunId !== "string" || !retry.lineageRootRunId || !Array.isArray(retry.completedPaths) || retry.completedPaths.some((path) => typeof path !== "string") || !Array.isArray(retry.incompletePaths) || retry.incompletePaths.some((path) => typeof path !== "string") || !Array.isArray(retry.namedWorktrees) || retry.namedWorktrees.some((name) => typeof name !== "string")) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry provenance is incomplete");
462
+ const source = await current.sourceRun(retry.sourceRunId);
463
+ const sourceRun = (await source.load()).run;
464
+ if (loaded.run.parentRunId !== retry.sourceRunId) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry parent run does not match its source run");
465
+ if (sourceRun.state !== "failed") throw new WorkflowError("RESUME_INCOMPATIBLE", `Retry source run ${retry.sourceRunId} is not failed`);
466
+ const expectedLineageRoot = sourceRun.retry?.lineageRootRunId ?? sourceRun.id;
467
+ if (retry.lineageRootRunId !== expectedLineageRoot) throw new WorkflowError("RESUME_INCOMPATIBLE", "Retry lineage root does not match its source run");
468
+ await validate(source, nextSeen);
469
+ };
470
+ try { await validate(this, new Set()); }
471
+ catch (error) { throw error instanceof WorkflowError && error.code === "RESUME_INCOMPATIBLE" ? error : new WorkflowError("RESUME_INCOMPATIBLE", error instanceof Error ? error.message : String(error)); }
472
+ }
461
473
 
462
474
  private async ownedWorktree(owner: string, cwd?: string): Promise<WorktreeReference> {
463
475
  const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
@@ -497,7 +509,12 @@ export class RunStore {
497
509
  }
498
510
  const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
499
511
  const matches = records.filter((candidate) => candidate && typeof candidate === "object" && (candidate as Partial<WorktreeReference>).owner === owner);
500
- if (matches.length === 0) return undefined;
512
+ if (matches.length === 0) {
513
+ const loaded = await this.load();
514
+ if (loaded.run.parentRunId === undefined) return undefined;
515
+ const parent = await this.sourceRun(loaded.run.parentRunId);
516
+ return parent.findNamedWorktree(name, nextSeen);
517
+ }
501
518
  try {
502
519
  const reference = await this.ownedWorktree(owner);
503
520
  return { reference, sourceRunId: this.runId, owner };
@@ -521,6 +538,17 @@ export class RunStore {
521
538
  throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
522
539
  }
523
540
  }
541
+ async validateNamedWorktrees(): Promise<void> {
542
+ try {
543
+ const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
544
+ for (const record of records) {
545
+ const owner = record && typeof record === "object" && typeof (record as Partial<WorktreeReference>).owner === "string" ? (record as Partial<WorktreeReference>).owner : undefined;
546
+ if (owner && this.worktreeName(owner)) await this.validateWorktree(owner);
547
+ }
548
+ } catch (error) {
549
+ throw error instanceof WorkflowError && error.code === "WORKTREE_FAILED" ? error : new WorkflowError("WORKTREE_FAILED", error instanceof Error ? error.message : String(error));
550
+ }
551
+ }
524
552
 
525
553
  async ownsWorktree(owner: string): Promise<boolean> {
526
554
  const records = await json<unknown[]>(join(this.directory, "worktrees.json"));
@@ -579,6 +607,7 @@ export class RunStore {
579
607
  return resolved.reference;
580
608
  }
581
609
  }
610
+ if (name && Array.isArray(loaded.run.retry?.namedWorktrees) && loaded.run.retry.namedWorktrees.includes(name)) throw new WorkflowError("WORKTREE_FAILED", `Missing inherited named worktree ${name}`);
582
611
  const existing = records.find((record) => record.owner === owner);
583
612
  if (existing) return this.validateWorktree(owner);
584
613
  const { path, branch } = this.expectedWorktree(owner);
@@ -673,7 +702,29 @@ export class RunStore {
673
702
  const borrowed = await Promise.all(bindings.map(async (binding) => (await this.resolveBorrowedWorktree(binding, new Set([this.runId]))).reference));
674
703
  return [...owned.filter((record): record is WorktreeReference => record !== undefined), ...borrowed];
675
704
  }
676
-
705
+ async validNamedWorktrees(): Promise<readonly string[]> {
706
+ const load = async (path: string): Promise<unknown> => {
707
+ try { return await json<unknown>(path); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return []; throw error; }
708
+ };
709
+ const names = new Set<string>();
710
+ const rawRecords = await load(join(this.directory, "worktrees.json"));
711
+ let bindings: readonly BorrowedWorktreeBinding[];
712
+ try { bindings = await this.borrowedWorktreeRecords(); }
713
+ catch (error) { if (error instanceof WorkflowError && error.code === "WORKTREE_FAILED") return []; throw error; }
714
+ const boundOwners = new Set(bindings.map((binding) => binding.owner));
715
+ for (const record of Array.isArray(rawRecords) ? rawRecords : []) {
716
+ if (!record || typeof record !== "object") continue;
717
+ const owner = (record as Partial<WorktreeReference>).owner;
718
+ if (typeof owner !== "string") continue;
719
+ const name = this.worktreeName(owner);
720
+ if (!name || owner !== this.namedWorktreeOwner(name) || boundOwners.has(owner)) continue;
721
+ try { await this.ownedWorktree(owner); names.add(name); } catch { /* Do not advertise stale or invalid records. */ }
722
+ }
723
+ for (const binding of bindings) {
724
+ try { await this.resolveBorrowedWorktree(binding, new Set([this.runId])); names.add(binding.name); } catch { /* Do not advertise stale inherited records. */ }
725
+ }
726
+ return [...names];
727
+ }
677
728
  async changedWorktrees(): Promise<readonly WorktreeReference[]> {
678
729
  const changed: WorktreeReference[] = [];
679
730
  for (const valid of await this.worktrees()) {
@@ -0,0 +1,157 @@
1
+ import { Value } from "typebox/value";
2
+ import type { JsonValue, RegisteredAgentSetupHook, WorkflowCatalog, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogVariable, WorkflowExtension, WorkflowFunction, WorkflowFunctionContext, WorkflowJournal, WorkflowVariable } from "./types.js";
3
+ import { deepFreeze, fail, jsonValue, object } from "./utils.js";
4
+ import { loadSettings, validateSchema } from "./validation.js";
5
+
6
+ const RESERVED_GLOBALS = new Set(["agent", "shell", "prompt", "checkpoint", "parallel", "pipeline", "phase", "withWorktree", "log", "args", "Promise", "JSON", "Math", "Date", "eval", "Function", "WebAssembly", "process", "require", "module", "exports", "console", "fetch", "XMLHttpRequest", "WebSocket", "performance", "crypto", "setTimeout", "setInterval", "setImmediate", "queueMicrotask", "Intl", "SharedArrayBuffer", "Atomics", "globalThis", "global", "undefined", "NaN", "Infinity", "extensions", "workflow_catalog"]);
7
+ const IDENTIFIER = /^[A-Za-z_$][\w$]*$/;
8
+ const SEMVER = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?(?:\+[0-9A-Za-z-]+(?:\.[0-9A-Za-z-]+)*)?$/;
9
+
10
+ export class WorkflowRegistry {
11
+ readonly #extensions = new Set<Readonly<WorkflowExtension>>();
12
+ readonly #globals = new Map<string, string>();
13
+ readonly #hooks = new Map<string, RegisteredAgentSetupHook>();
14
+ #frozen = false;
15
+
16
+ get frozen(): boolean { return this.#frozen; }
17
+ freeze(): void { this.#frozen = true; }
18
+
19
+ register(extension: WorkflowExtension): void {
20
+ if (this.#frozen) fail("REGISTRY_FROZEN", "Workflow extension registration is closed after session_start");
21
+ if (object(extension) && Object.prototype.hasOwnProperty.call(extension, "workflows")) fail("INVALID_METADATA", "Separate registered workflow definitions were removed; register a function with input and output schemas instead");
22
+ if (!object(extension) || Object.keys(extension).some((key) => !["version", "headline", "description", "functions", "variables", "agentSetupHooks"].includes(key)) || typeof extension.version !== "string" || !SEMVER.test(extension.version) || typeof extension.headline !== "string" || !extension.headline.trim() || typeof extension.description !== "string" || !extension.description.trim()) fail("INVALID_METADATA", "Workflow extensions require a semantic version, headline, and description");
23
+ const functions = extension.functions ?? {};
24
+ const variables = extension.variables ?? {};
25
+ const agentSetupHooks = extension.agentSetupHooks ?? {};
26
+ if (!object(functions) || !object(variables) || !object(agentSetupHooks) || (Object.keys(functions).length === 0 && Object.keys(variables).length === 0 && Object.keys(agentSetupHooks).length === 0)) fail("INVALID_METADATA", "Workflow extensions require functions, variables, or agent setup hooks");
27
+ const names = [...Object.keys(functions), ...Object.keys(variables)];
28
+ if (new Set(names).size !== names.length) fail("GLOBAL_COLLISION", "Global name collision inside extension");
29
+ for (const name of names) {
30
+ if (!IDENTIFIER.test(name) || name.startsWith("__pi_extensible_workflows_")) fail("INVALID_METADATA", `Invalid global name: ${name}`);
31
+ if (RESERVED_GLOBALS.has(name)) fail("GLOBAL_COLLISION", `Global name is reserved: ${name}`);
32
+ if (this.#globals.has(name)) fail("GLOBAL_COLLISION", `Global name is already registered: ${name}`);
33
+ }
34
+ for (const [name, fn] of Object.entries(functions)) {
35
+ if (!object(fn) || Object.keys(fn).some((key) => !["description", "input", "output", "run"].includes(key)) || typeof fn.description !== "string" || !fn.description.trim() || typeof fn.run !== "function") fail("INVALID_METADATA", `Invalid workflow function: ${name}`);
36
+ validateSchema(fn.input, `${name} input`);
37
+ validateSchema(fn.output, `${name} output`);
38
+ if (fn.input.type !== "object") fail("INVALID_SCHEMA", `${name} input must describe one object`);
39
+ }
40
+ for (const [name, variable] of Object.entries(variables)) {
41
+ if (!object(variable) || Object.keys(variable).some((key) => !["description", "schema", "resolve"].includes(key)) || typeof variable.description !== "string" || !variable.description.trim() || typeof variable.resolve !== "function") fail("INVALID_METADATA", `Invalid workflow variable: ${name}`);
42
+ validateSchema(variable.schema, `${name} schema`);
43
+ }
44
+ for (const [name, hook] of Object.entries(agentSetupHooks)) {
45
+ if (!IDENTIFIER.test(name) || !object(hook) || Object.keys(hook).some((key) => !["priority", "setup"].includes(key)) || typeof hook.setup !== "function" || hook.priority !== undefined && (typeof hook.priority !== "number" || !Number.isFinite(hook.priority))) fail("INVALID_METADATA", `Invalid agent setup hook: ${name}`);
46
+ if (this.#hooks.has(name)) fail("DUPLICATE_NAME", `Agent setup hook already registered: ${name}`);
47
+ }
48
+ const stored = deepFreeze({ ...extension, functions, variables, agentSetupHooks });
49
+ this.#extensions.add(stored);
50
+ for (const name of names) this.#globals.set(name, name);
51
+ for (const [name, hook] of Object.entries(agentSetupHooks)) this.#hooks.set(name, { name, priority: hook.priority ?? 10, setup: hook.setup });
52
+ }
53
+
54
+ function(name: string): WorkflowFunction {
55
+ if (!IDENTIFIER.test(name)) fail("MISSING_WORKFLOW", `Registered functions require an unqualified name: ${name}`);
56
+ const fn = [...this.#extensions].find((extension) => extension.functions?.[name])?.functions?.[name];
57
+ if (!fn) fail("MISSING_WORKFLOW", `Registered function is unavailable: ${name}; the separate registered-workflow format was removed`);
58
+ return fn;
59
+ }
60
+
61
+ functions(): Readonly<Record<string, WorkflowFunction>> {
62
+ return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.entries(extension.functions ?? {}))));
63
+ }
64
+
65
+ catalog(): WorkflowCatalog {
66
+ const functions: WorkflowCatalogFunction[] = [];
67
+ const variables: WorkflowCatalogVariable[] = [];
68
+ for (const extension of this.#extensions) {
69
+ for (const [name, fn] of Object.entries(extension.functions ?? {})) functions.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: fn.description, input: structuredClone(fn.input), output: structuredClone(fn.output) });
70
+ for (const [name, variable] of Object.entries(extension.variables ?? {})) variables.push({ name, version: extension.version, headline: extension.headline, extensionDescription: extension.description, description: variable.description, schema: structuredClone(variable.schema) });
71
+ }
72
+ let aliases: Readonly<Record<string, string>> | undefined;
73
+ try { aliases = loadSettings().modelAliases; } catch { aliases = undefined; }
74
+ const sort = (left: { name: string }, right: { name: string }) => left.name.localeCompare(right.name);
75
+ return deepFreeze({ functions: functions.sort(sort), variables: variables.sort(sort), ...(aliases ? { modelAliases: structuredClone(aliases) } : {}) });
76
+ }
77
+
78
+ catalogIndex(): WorkflowCatalogIndex {
79
+ const catalog = this.catalog();
80
+ return deepFreeze({
81
+ functions: catalog.functions.map(({ name, description, input }) => ({ name, description, input: structuredClone(input) })),
82
+ variables: catalog.variables.map(({ name, description, schema }) => ({ name, description, schema: structuredClone(schema) })),
83
+ ...(catalog.modelAliases ? { modelAliases: structuredClone(catalog.modelAliases) } : {}),
84
+ });
85
+ }
86
+
87
+ catalogDetail(name: string): WorkflowCatalogFunction | WorkflowCatalogVariable | WorkflowCatalogError {
88
+ const catalog = this.catalog();
89
+ const entry = catalog.functions.find((candidate) => candidate.name === name) ?? catalog.variables.find((candidate) => candidate.name === name);
90
+ if (entry) return entry;
91
+ return deepFreeze({ error: { code: "NOT_FOUND", name, message: `No registered workflow function or variable is available: ${name}` } });
92
+ }
93
+
94
+ globals(): Readonly<Record<string, { name: string }>> {
95
+ return Object.freeze(Object.fromEntries([...this.#extensions].flatMap((extension) => Object.keys(extension.functions ?? {}).map((name) => [name, { name }]))));
96
+ }
97
+
98
+ async invokeFunction(name: string, input: unknown, context: Readonly<WorkflowFunctionContext>, path: string, journal: WorkflowJournal): Promise<JsonValue> {
99
+ const fn = this.function(name);
100
+ if (!object(input) || !jsonValue(input) || !Value.Check(fn.input, input)) fail("RESULT_INVALID", `Invalid input for ${name}`);
101
+ const replayed = journal.get(path);
102
+ if (replayed !== undefined) {
103
+ if (!jsonValue(replayed) || !Value.Check(fn.output, replayed)) fail("RESULT_INVALID", `Invalid replay for ${name}`);
104
+ return structuredClone(replayed);
105
+ }
106
+ const result: unknown = await fn.run(deepFreeze(structuredClone(input)), Object.freeze({ run: context.run, invoke: context.invoke, agent: context.agent, shell: context.shell, prompt: context.prompt, parallel: context.parallel, pipeline: context.pipeline, withWorktree: context.withWorktree, checkpoint: context.checkpoint, phase: context.phase, log: context.log }));
107
+ if (!jsonValue(result) || !Value.Check(fn.output, result)) fail("RESULT_INVALID", `Invalid output from ${name}`);
108
+ const stored = structuredClone(result);
109
+ journal.put(path, stored);
110
+ return structuredClone(stored);
111
+ }
112
+
113
+ variables(): readonly { name: string; variable: WorkflowVariable }[] {
114
+ return [...this.#extensions].flatMap((extension) => Object.entries(extension.variables ?? {}).map(([name, variable]) => ({ name, variable })));
115
+ }
116
+ agentSetupHooks(): readonly RegisteredAgentSetupHook[] {
117
+ return [...this.#hooks.values()].sort((left, right) => left.priority - right.priority || (left.name < right.name ? -1 : left.name > right.name ? 1 : 0));
118
+ }
119
+ }
120
+ export type WorkflowRegistryApi = Pick<WorkflowRegistry, "frozen" | "freeze" | "register" | "function" | "functions" | "catalog" | "catalogIndex" | "catalogDetail" | "globals" | "invokeFunction" | "variables" | "agentSetupHooks">;
121
+ interface WorkflowRegistryHost { api: WorkflowRegistryApi }
122
+ const WORKFLOW_REGISTRY_KEY = Symbol.for("pi-extensible-workflows.workflow-registry");
123
+ const globalRegistry = globalThis as typeof globalThis & Record<symbol, WorkflowRegistryHost | undefined>;
124
+ function createWorkflowRegistryApi(registry: WorkflowRegistry): WorkflowRegistryApi {
125
+ return {
126
+ get frozen() { return registry.frozen; },
127
+ freeze: () => { registry.freeze(); },
128
+ register: (extension) => { registry.register(extension); },
129
+ function: (name) => registry.function(name),
130
+ functions: () => registry.functions(),
131
+ catalog: () => registry.catalog(),
132
+ catalogIndex: () => registry.catalogIndex(),
133
+ catalogDetail: (name) => registry.catalogDetail(name),
134
+ globals: () => registry.globals(),
135
+ invokeFunction: (...args) => registry.invokeFunction(...args),
136
+ variables: () => registry.variables(),
137
+ agentSetupHooks: () => registry.agentSetupHooks(),
138
+ };
139
+ }
140
+ function workflowRegistryHost(): WorkflowRegistryHost {
141
+ return globalRegistry[WORKFLOW_REGISTRY_KEY] ??= { api: createWorkflowRegistryApi(new WorkflowRegistry()) };
142
+ }
143
+ export function resetWorkflowRegistry(): void {
144
+ workflowRegistryHost().api = createWorkflowRegistryApi(new WorkflowRegistry());
145
+ }
146
+ export function beginWorkflowExtensionLoading(): void {
147
+ if (workflowRegistryHost().api.frozen) resetWorkflowRegistry();
148
+ }
149
+ export function loadingRegistry(): WorkflowRegistryApi { return workflowRegistryHost().api; }
150
+ beginWorkflowExtensionLoading();
151
+ export function registerWorkflowExtension(extension: WorkflowExtension): void { loadingRegistry().register(extension); }
152
+ export function workflowCatalog(): WorkflowCatalog { return loadingRegistry().catalog(); }
153
+ export function workflowCatalogIndex(): WorkflowCatalogIndex { return loadingRegistry().catalogIndex(); }
154
+ export function workflowCatalogDetail(name: string): WorkflowCatalogFunction | WorkflowCatalogVariable | WorkflowCatalogError { return loadingRegistry().catalogDetail(name); }
155
+ export function registeredWorkflowFunctions(): Readonly<Record<string, WorkflowFunction>> { return loadingRegistry().functions(); }
156
+
157
+ export type { WorkflowCatalog, WorkflowCatalogError, WorkflowCatalogFunction, WorkflowCatalogIndex, WorkflowCatalogIndexFunction, WorkflowCatalogIndexVariable, WorkflowCatalogVariable } from "./types.js";
@@ -215,7 +215,7 @@ export async function loadSessionReport(path: string, home = homedir()): Promise
215
215
  const args = call.arguments;
216
216
  const agents = loaded ? await Promise.all(loaded.run.agents.map(agentReport)) : [];
217
217
  const models = mergedModels(agents.flatMap(({ attempts }) => attempts.map(({ models: attemptModels }) => attemptModels)));
218
- const name = typeof args.name === "string" ? args.name : typeof args.workflow === "string" ? args.workflow : loaded?.run.workflowName ?? "workflow";
218
+ const name = loaded?.run.workflowName ?? (typeof args.workflow === "string" ? args.workflow : typeof args.name === "string" ? args.name : "workflow");
219
219
  const description = typeof args.description === "string" ? args.description : loaded?.snapshot.metadata.description;
220
220
  const script = typeof args.script === "string" && args.script.trim() ? args.script : loaded?.snapshot.script;
221
221
  let staticCalls: StaticWorkflowCall[] = [];
package/src/types.ts ADDED
@@ -0,0 +1,113 @@
1
+ export const RUN_STATES = ["queued", "running", "pausing", "paused", "awaiting_input", "completed", "failed", "stopped", "interrupted", "budget_exhausted"] as const;
2
+ export const AGENT_STATES = ["queued", "running", "waiting_for_child", "paused", "retrying", "completed", "failed", "cancelled"] as const;
3
+ export const WORKFLOW_CALL_KINDS = ["agent", "parallel", "pipeline", "checkpoint", "phase", "withWorktree", "shell"] as const;
4
+ export type WorkflowCallKind = (typeof WORKFLOW_CALL_KINDS)[number];
5
+ export const WORKFLOW_RUN_STARTED_EVENT = "workflow:run-started";
6
+ export const WORKFLOW_RUN_RESUMED_EVENT = "workflow:run-resumed";
7
+ export const WORKFLOW_RUN_STATE_CHANGED_EVENT = "workflow:run-state-changed";
8
+ export const WORKFLOW_RUN_COMPLETED_EVENT = "workflow:run-completed";
9
+ export const WORKFLOW_RUN_FAILED_EVENT = "workflow:run-failed";
10
+ export const WORKFLOW_AGENT_STATE_CHANGED_EVENT = "workflow:agent-state-changed";
11
+ export const WORKFLOW_PHASE_CHANGED_EVENT = "workflow:phase-changed";
12
+ export const WORKFLOW_CHECKPOINT_STATE_CHANGED_EVENT = "workflow:checkpoint-state-changed";
13
+ export const WORKFLOW_BUDGET_EVENT = "workflow:budget-event";
14
+ export const WORKFLOW_WORKTREE_CREATED_EVENT = "workflow:worktree-created";
15
+ export const ERROR_CODES = [
16
+ "CONFIG_ERROR", "INVALID_SETTINGS", "INVALID_SYNTAX", "INVALID_METADATA", "DUPLICATE_NAME", "INVALID_SCHEMA", "UNKNOWN_MODEL", "UNKNOWN_TOOL", "UNKNOWN_AGENT_TYPE",
17
+ "RUN_OWNED", "REGISTRY_FROZEN", "GLOBAL_COLLISION", "MISSING_WORKFLOW", "RPC_LIMIT_EXCEEDED", "SHELL_FAILED", "AGENT_TIMEOUT", "AGENT_FAILED", "RESULT_INVALID",
18
+ "CANCELLED", "WORKER_UNRESPONSIVE", "WORKTREE_FAILED", "RESUME_INCOMPATIBLE", "BUDGET_EXHAUSTED", "INTERNAL_ERROR",
19
+ ] as const;
20
+
21
+ export type RunState = (typeof RUN_STATES)[number];
22
+ export type AgentState = (typeof AGENT_STATES)[number];
23
+ export type WorkflowErrorCode = (typeof ERROR_CODES)[number];
24
+ export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
25
+ export type JsonSchema = { [key: string]: JsonValue };
26
+ export interface AgentOptions {
27
+ label?: string;
28
+ model?: string;
29
+ thinking?: NonNullable<ModelSpec["thinking"]>;
30
+ tools?: string[];
31
+ role?: string;
32
+ outputSchema?: JsonSchema;
33
+ retries?: number;
34
+ timeoutMs?: number | null;
35
+ [key: string]: JsonValue;
36
+ }
37
+ export interface ShellOptions { timeoutMs?: number; env?: Record<string, string> }
38
+ export interface ShellResult { exitCode: number | null; stdout: string; stderr: string }
39
+ export type BudgetDimension = "tokens" | "costUsd" | "durationMs" | "agentLaunches";
40
+ export interface BudgetLimits { soft?: number; hard?: number }
41
+ export type WorkflowBudget = Partial<Record<BudgetDimension, BudgetLimits>>;
42
+ export type WorkflowBudgetPatch = Partial<Record<BudgetDimension, BudgetLimits | { soft?: number | null; hard?: number | null } | null>>;
43
+ export interface WorkflowBudgetUsage { tokens: number; costUsd: number; durationMs: number; agentLaunches: number }
44
+ export type BudgetEventType = "soft_crossed" | "hard_overrun" | "hard_exhausted" | "adjustment_requested" | "adjustment_approved" | "adjustment_rejected";
45
+ export interface BudgetEvent { type: BudgetEventType; budgetVersion: number; dimensions: readonly BudgetDimension[]; usage: WorkflowBudgetUsage; limits: WorkflowBudget; at: number; proposalId?: string; previous?: WorkflowBudget; proposed?: WorkflowBudget }
46
+ export interface BudgetApprovalRequest { kind: "budget"; proposalId: string; runId: string; consumed: WorkflowBudgetUsage; previous: WorkflowBudget; proposed: WorkflowBudget; budgetVersion: number }
47
+ export interface WorkflowErrorShape { code: WorkflowErrorCode; message: string }
48
+ export interface WorkflowEventBase { runId: string; sessionId: string; workflowName: string; cwd: string; runDirectory: string; timestamp: number }
49
+ export type WorkflowRunStartedEvent = WorkflowEventBase;
50
+ export type WorkflowRunResumedEvent = WorkflowEventBase;
51
+ export interface WorkflowRunStateChangedEvent extends WorkflowEventBase { previousState: RunState; state: RunState; reason?: string; errorCode?: WorkflowErrorCode }
52
+ export interface WorkflowRunCompletedEvent extends WorkflowEventBase { resultPath: string }
53
+ export interface WorkflowRunFailedEvent extends WorkflowEventBase { error: WorkflowErrorShape }
54
+ export interface WorkflowAgentStateChangedEvent extends WorkflowEventBase { agentId: string; displayLabel: string; role?: string; structuralPath: readonly string[]; parentId?: string; parentBreadcrumb?: string; worktreeOwner?: string; previousState?: AgentState; state: AgentState; attempt: number }
55
+ export interface WorkflowPhaseChangedEvent extends WorkflowEventBase { previousPhase?: string; phase: string }
56
+ export type WorkflowCheckpointState = "awaiting" | "approved" | "rejected";
57
+ export interface WorkflowCheckpointStateChangedEvent extends WorkflowEventBase { name: string; state: WorkflowCheckpointState }
58
+ export interface WorkflowBudgetEvent extends WorkflowEventBase { type: BudgetEventType; budgetVersion: number; dimensions: readonly BudgetDimension[]; usage: WorkflowBudgetUsage; limits: WorkflowBudget; proposalId?: string; previous?: WorkflowBudget; proposed?: WorkflowBudget }
59
+ export interface ModelSpec { provider: string; model: string; thinking?: "off" | "minimal" | "low" | "medium" | "high" | "xhigh" | "max" }
60
+ export interface WorkflowMetadata { name: string; description?: string }
61
+ export interface WorkflowSettings { concurrency: number; modelAliases?: Readonly<Record<string, string>>; disabledAgentResources?: Readonly<AgentResourceExclusions> }
62
+ export interface AgentResourceExclusions { skills: readonly string[]; extensions: readonly string[] }
63
+ export interface AgentResourcePolicy { globalSettingsPath: string; projectSettingsPath: string; projectTrusted: boolean; global: AgentResourceExclusions; project: AgentResourceExclusions; effective: AgentResourceExclusions; unmatchedSkills: string[]; unmatchedExtensions: string[] }
64
+ export interface AgentActivity { kind: "reasoning" | "tool" | "text"; text: string }
65
+ export interface AgentAccounting { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }
66
+ export interface AgentSetupSummary { hookNames: readonly string[]; model: ModelSpec; tools: readonly string[]; cwd: string; disabledAgentResources?: { skills: readonly string[]; extensions: readonly string[]; unmatchedSkills: readonly string[]; unmatchedExtensions: readonly string[] } }
67
+ export interface AgentAttemptSummary { attempt: number; sessionId: string; sessionFile: string; error?: { code: string; message: string }; accounting: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }; setup?: AgentSetupSummary }
68
+ export interface WorkflowWorktreeCreatedEvent extends WorkflowEventBase { owner: string; branch: string; path: string; base: string }
69
+ export interface WorkflowWorktreeReference { readonly path: string; readonly branch: string }
70
+ export interface AgentRecord { id: string; name: string; label?: string; path: string; state: AgentState; parentId?: string; structuralPath?: readonly string[]; parentBreadcrumb?: string; worktreeOwner?: string; role?: string; requestedModel?: string; model: ModelSpec; tools: readonly string[]; attempts: number; attemptDetails?: readonly AgentAttemptSummary[]; accounting?: { input: number; output: number; cacheRead: number; cacheWrite: number; cost: number }; toolCalls?: readonly { id: string; name: string; state: "running" | "completed" | "failed" }[]; activity?: AgentActivity | undefined }
71
+ export interface WorkflowRunEvent { type: string; message: string }
72
+ export interface WorkflowRetryProvenance { sourceRunId: string; lineageRootRunId: string; completedPaths: readonly string[]; incompletePaths: readonly string[]; namedWorktrees: readonly string[] }
73
+ export interface WorkflowPhaseRecord { phase: string; afterAgent: number }
74
+ export interface RunRecord { id: string; workflowName: string; cwd: string; sessionId: string; state: RunState; parentRunId?: string; retry?: WorkflowRetryProvenance; phase?: string; phaseHistory?: readonly WorkflowPhaseRecord[]; agents: readonly AgentRecord[]; error?: WorkflowErrorShape; budget?: WorkflowBudget; budgetVersion?: number; usage?: WorkflowBudgetUsage; budgetEvents?: readonly BudgetEvent[]; events?: readonly WorkflowRunEvent[] }
75
+ export const LAUNCH_SNAPSHOT_IDENTITY_VERSION = 5;
76
+ export interface AgentDefinition { prompt?: string; description?: string; model?: string; thinking?: NonNullable<ModelSpec["thinking"]>; tools?: readonly string[]; disabledAgentResources?: AgentResourceExclusions }
77
+ export interface LaunchSnapshot { identityVersion?: number; launchKind?: "inline" | "function"; functionName?: string; script: string; args: JsonValue; metadata: WorkflowMetadata; settings: WorkflowSettings; budget?: WorkflowBudget; settingsPath?: string; modelAliases?: Readonly<Record<string, string>>; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[]; roles?: Readonly<Record<string, AgentDefinition>>; projectRoles?: readonly string[]; schemas: readonly JsonSchema[] }
78
+ export interface PreflightCapabilities { models: ReadonlySet<string>; tools: ReadonlySet<string>; agentTypes: ReadonlySet<string>; modelAliases?: Readonly<Record<string, string>>; knownModels?: ReadonlySet<string>; settingsPath?: string; skipModelAvailability?: boolean }
79
+ export interface PreflightResult { metadata: WorkflowMetadata; referenced: { phases: readonly string[]; models: readonly string[]; tools: readonly string[]; agentTypes: readonly string[] }; schemas: readonly JsonSchema[]; dynamicAgentRoles: boolean }
80
+ export interface WorkflowOrchestrationContext { agent: (prompt: string, options?: Readonly<AgentOptions>) => Promise<JsonValue>; shell: (command: string, options?: ShellOptions) => Promise<ShellResult>; prompt: (template: string, values: Readonly<Record<string, JsonValue>>) => string; parallel: (...args: readonly unknown[]) => Promise<JsonValue>; pipeline: (...args: readonly unknown[]) => Promise<JsonValue>; withWorktree: (name: string, callback: WorkflowWorktreeCallback) => Promise<JsonValue>; checkpoint: (...args: readonly unknown[]) => Promise<boolean>; phase: (name: string) => void; log: (message: string) => void }
81
+ export interface WorkflowRunContext { cwd: string; sessionId: string; runId: string; workflow: Readonly<WorkflowMetadata>; args: JsonValue; signal: AbortSignal }
82
+ export interface WorkflowFunctionContext extends WorkflowOrchestrationContext { run: Readonly<WorkflowRunContext>; invoke: (name: string, input: Readonly<Record<string, JsonValue>>) => Promise<JsonValue> }
83
+ export type WorkflowWorktreeCallback = (reference: Readonly<WorkflowWorktreeReference>) => JsonValue | Promise<JsonValue>;
84
+ export interface WorkflowFunction { description: string; input: JsonSchema; output: JsonSchema; run: (input: Readonly<Record<string, JsonValue>>, context: Readonly<WorkflowFunctionContext>) => Promise<JsonValue> | JsonValue }
85
+ export interface WorkflowVariable { description: string; schema: JsonSchema; resolve: (run: Readonly<WorkflowRunContext>) => Promise<JsonValue> | JsonValue }
86
+ export interface AgentSetup { prompt: string; options: Record<string, JsonValue>; sessionInput: { extensionFactories?: unknown[]; [key: string]: unknown }; createSession: unknown }
87
+ export interface AgentSetupContext { readonly run: Readonly<WorkflowRunContext>; readonly identity: Readonly<AgentIdentity>; readonly attempt: number; readonly signal: AbortSignal }
88
+ export interface AgentSetupHook { priority?: number; setup: (agent: AgentSetup, context: Readonly<AgentSetupContext>) => void | Promise<void> }
89
+ export interface RegisteredAgentSetupHook { name: string; priority: number; setup: AgentSetupHook["setup"] }
90
+ export interface WorkflowExtension { version: string; headline: string; description: string; functions?: Readonly<Record<string, WorkflowFunction>>; variables?: Readonly<Record<string, WorkflowVariable>>; agentSetupHooks?: Readonly<Record<string, AgentSetupHook>> }
91
+ export interface WorkflowJournal { get(path: string): JsonValue | undefined; put(path: string, value: JsonValue): void }
92
+ export class WorkflowError extends Error { constructor(public readonly code: WorkflowErrorCode, message: string) { super(message); this.name = "WorkflowError"; } }
93
+ export interface WorkflowFailureAgent { id: string; label?: string; role?: string; structuralPath: readonly string[]; attempt: number; sessionId?: string; sessionFile?: string }
94
+ export interface WorkflowSiblingAgent { id: string; label?: string; role?: string; structuralPath: readonly string[] }
95
+ export interface WorkflowFailureDiagnostics { runId: string; workflowName: string; state: RunState; failedAt: string | null; error: WorkflowErrorShape; failedAgent?: WorkflowFailureAgent; completedSiblingAgents?: readonly WorkflowSiblingAgent[]; completedSiblingPaths: readonly (readonly string[])[]; retry?: { sourceRunId: string; action: string; completedPaths: readonly string[]; incompletePaths: readonly string[]; namedWorktrees: readonly string[]; warning: string }; artifacts: { runDirectory: string; statePath: string; journalPath: string } }
96
+ export interface CheckpointInput { name: string; prompt: string; context: JsonValue }
97
+ export interface AgentIdentity { structuralPath: readonly string[]; callSite: string; occurrence: number; parentBreadcrumb?: string; worktreeOwner?: string }
98
+ export interface ShellIdentity { structuralPath: readonly string[]; callSite: string; occurrence: number; worktreeOwner?: string }
99
+ export interface WorkflowBridge { agent?: (prompt: string, options: Readonly<Record<string, JsonValue>>, signal: AbortSignal, identity: AgentIdentity) => Promise<JsonValue>; shell?: (command: string, options: ShellOptions, signal: AbortSignal, identity: ShellIdentity) => Promise<ShellResult>; checkpoint?: (input: Readonly<Record<string, JsonValue>>, signal: AbortSignal) => boolean | Promise<boolean>; function?: (name: string, input: Readonly<Record<string, JsonValue>>, path: string, signal: AbortSignal, worktreeOwner?: string, structuralPath?: readonly string[]) => Promise<JsonValue>; worktree?: (owner: string, signal: AbortSignal) => Promise<Readonly<WorkflowWorktreeReference>>; functions?: Readonly<Record<string, { name: string }>>; variables?: Readonly<Record<string, JsonValue>>; phase?: (name: string) => void | Promise<void>; log?: (message: string) => void | Promise<void> }
100
+ export interface WorkflowExecution { result: Promise<JsonValue>; cancel: () => void }
101
+ export interface StaticWorkflowScope { kind: "parallel" | "pipeline"; name: string | null; key: string | null }
102
+ export type StaticWorkflowExecution = "parallel" | "sequential";
103
+ export interface StaticWorkflowCall { kind: WorkflowCallKind; start: number; end: number; name: string | null; prompt: string | null; model: string | null; label?: string | null; role: string | null; retries?: number | null; outputSchema?: JsonSchema | null; options?: Readonly<Record<string, JsonValue>> | null; optionKeys?: readonly string[]; execution?: StaticWorkflowExecution; structure?: readonly StaticWorkflowScope[] }
104
+ export interface WorkflowCatalogFunction { name: string; version: string; headline: string; extensionDescription: string; description: string; input: JsonSchema; output: JsonSchema }
105
+ export interface WorkflowCatalogVariable { name: string; version: string; headline: string; extensionDescription: string; description: string; schema: JsonSchema }
106
+ export interface WorkflowCatalog { functions: readonly WorkflowCatalogFunction[]; variables: readonly WorkflowCatalogVariable[]; modelAliases?: Readonly<Record<string, string>> }
107
+ export interface WorkflowCatalogIndexFunction { name: string; description: string; input: JsonSchema }
108
+ export interface WorkflowCatalogIndexVariable { name: string; description: string; schema: JsonSchema }
109
+ export interface WorkflowCatalogIndex { functions: readonly WorkflowCatalogIndexFunction[]; variables: readonly WorkflowCatalogIndexVariable[]; modelAliases?: Readonly<Record<string, string>> }
110
+ export interface WorkflowCatalogError { error: { code: "NOT_FOUND"; name: string; message: string } }
111
+ export interface WorkflowValidationParameters { name?: string; description?: string; script?: string; workflow?: string; args?: unknown }
112
+ export interface WorkflowValidationContext { cwd: string; projectTrusted: boolean; availableModels: ReadonlySet<string>; rootTools: ReadonlySet<string>; modelAliases?: Readonly<Record<string, string>>; knownModels?: ReadonlySet<string>; settingsPath?: string; agentDir?: string }
113
+ export interface ValidatedWorkflowLaunch { script: string; checked: PreflightResult; agentDefinitions: Readonly<Record<string, AgentDefinition>>; projectAgentDefinitions: Readonly<Record<string, AgentDefinition>>; roleNames: readonly string[]; functionName?: string }
package/src/utils.ts ADDED
@@ -0,0 +1,108 @@
1
+ import { ERROR_CODES, LAUNCH_SNAPSHOT_IDENTITY_VERSION, WorkflowError, type AgentResourceExclusions, type JsonValue, type ModelSpec, type WorkflowErrorCode } from "./types.js";
2
+
3
+ export function object(value: unknown): value is Record<string, unknown> { return typeof value === "object" && value !== null && !Array.isArray(value); }
4
+ export { object as isObject };
5
+ export function jsonValue(value: unknown, seen = new Set<object>()): value is JsonValue {
6
+ if (value === null || typeof value === "boolean" || typeof value === "string") return true;
7
+ if (typeof value === "number") return Number.isFinite(value);
8
+ if (typeof value !== "object" || seen.has(value)) return false;
9
+ if (!Array.isArray(value) && Object.getPrototypeOf(value) !== Object.prototype && Object.getPrototypeOf(value) !== null) return false;
10
+ const keys = Reflect.ownKeys(value);
11
+ if (keys.some((key) => typeof key !== "string")) return false;
12
+ seen.add(value);
13
+ const valid = (Array.isArray(value) ? Array.from(value) : keys.map((key) => (value as Record<string, unknown>)[key as string])).every((item) => jsonValue(item, seen));
14
+ seen.delete(value);
15
+ return valid;
16
+ }
17
+ export function jsonObject(value: unknown): value is Record<string, JsonValue> { return jsonValue(value) && object(value); }
18
+ export function positiveInteger(value: unknown): value is number { return Number.isInteger(value) && (value as number) > 0; }
19
+ export function deepFreeze<T>(value: T): T {
20
+ if (typeof value === "object" && value !== null && !Object.isFrozen(value)) {
21
+ Object.freeze(value);
22
+ for (const child of Object.values(value)) deepFreeze(child);
23
+ }
24
+ return value;
25
+ }
26
+ export function errorText(error: unknown): string { return error && typeof error === "object" && typeof (error as { message?: unknown }).message === "string" ? (error as { message: string }).message : error instanceof Error ? error.message : String(error); }
27
+ export function errorCode(error: unknown): WorkflowErrorCode | undefined {
28
+ if (error instanceof WorkflowError) return ERROR_CODES.includes(error.code) ? error.code : undefined;
29
+ if (!error || typeof error !== "object") return undefined;
30
+ const code = (error as { code?: unknown }).code;
31
+ return typeof code === "string" && ERROR_CODES.includes(code as WorkflowErrorCode) ? code as WorkflowErrorCode : undefined;
32
+ }
33
+ const WORKFLOW_AUTHORED_ERROR = Symbol("workflowAuthoredError");
34
+ export function markWorkflowAuthored(error: WorkflowError, authored = false): WorkflowError {
35
+ if (authored) Object.defineProperty(error, WORKFLOW_AUTHORED_ERROR, { value: true });
36
+ return error;
37
+ }
38
+ export function isWorkflowAuthored(error: unknown): boolean { return Boolean(error && typeof error === "object" && WORKFLOW_AUTHORED_ERROR in error); }
39
+ export function asWorkflowError(error: unknown): WorkflowError {
40
+ const code = errorCode(error);
41
+ return markWorkflowAuthored(error instanceof WorkflowError && code ? error : new WorkflowError(code ?? "INTERNAL_ERROR", errorText(error)), isWorkflowAuthored(error) || !code);
42
+ }
43
+ export function fail(code: WorkflowErrorCode, message: string): never { throw new WorkflowError(code, message); }
44
+
45
+ const THINKING_LEVELS = ["off", "minimal", "low", "medium", "high", "xhigh", "max"] as const;
46
+ const MODEL_ALIAS_NAME = /^[A-Za-z][A-Za-z0-9_-]*$/;
47
+ export function parseThinking(value: unknown): ModelSpec["thinking"] | undefined { return typeof value === "string" && THINKING_LEVELS.includes(value as (typeof THINKING_LEVELS)[number]) ? value as ModelSpec["thinking"] : undefined; }
48
+ export function parseModelReference(value: string): ModelSpec {
49
+ const match = /^([^/:\s]+)\/([^:\s]+)(?::([^:\s]+))?$/.exec(value);
50
+ if (!match?.[1] || !match[2]) fail("UNKNOWN_MODEL", `Invalid model spec: ${value}`);
51
+ const thinking = match[3];
52
+ if (thinking && !THINKING_LEVELS.includes(thinking as (typeof THINKING_LEVELS)[number])) fail("UNKNOWN_MODEL", `Invalid thinking level: ${thinking}`);
53
+ return { provider: match[1], model: match[2], ...(thinking ? { thinking: thinking as NonNullable<ModelSpec["thinking"]> } : {}) };
54
+ }
55
+ function aliasError(message: string, settingsPath: string): never { fail("CONFIG_ERROR", `${message} (settings: ${settingsPath})`); }
56
+ export function modelAliasName(value: string, aliases: Readonly<Record<string, string>>): string | undefined {
57
+ const name = /^([^/:\s]+)(?::[^:\s]+)?$/.exec(value)?.[1];
58
+ return name && Object.prototype.hasOwnProperty.call(aliases, name) ? name : undefined;
59
+ }
60
+ export function validateModelAliases(value: unknown, settingsPath = "workflow settings"): Readonly<Record<string, string>> {
61
+ if (!object(value)) aliasError("modelAliases must be an object", settingsPath);
62
+ const aliases: Record<string, string> = {};
63
+ for (const [name, target] of Object.entries(value)) {
64
+ if (!MODEL_ALIAS_NAME.test(name)) aliasError(`Invalid model alias name: ${name}`, settingsPath);
65
+ if (typeof target !== "string" || !target.trim()) aliasError(`Invalid model alias target for ${name}`, settingsPath);
66
+ aliases[name] = target;
67
+ }
68
+ for (const name of Object.keys(aliases)) {
69
+ try { resolveModelReference(name, aliases); } catch (error) { aliasError(`Invalid model alias target for ${name}: ${errorText(error)}`, settingsPath); }
70
+ }
71
+ return Object.freeze(aliases);
72
+ }
73
+ export function unknownModel(value: string, target: string | undefined, settingsPath?: string): never {
74
+ const resolved = target ? ` resolved to ${target}` : "";
75
+ const path = settingsPath ? ` (settings: ${settingsPath})` : "";
76
+ fail("UNKNOWN_MODEL", `Unknown model${target ? " alias" : ""} ${value}${resolved}${path}`);
77
+ }
78
+ export function resolveModelReference(value: string, aliases: Readonly<Record<string, string>> = {}, knownModels?: ReadonlySet<string>, settingsPath?: string): ModelSpec {
79
+ const resolveReference = (reference: string, chain: readonly string[]): ModelSpec => {
80
+ if (reference.includes("/")) return parseModelReference(reference);
81
+ const match = /^([^:\s]+)(?::([^:\s]+))?$/.exec(reference);
82
+ const thinking = match?.[2];
83
+ if (!match?.[1] || thinking && !THINKING_LEVELS.includes(thinking as (typeof THINKING_LEVELS)[number])) unknownModel(reference, undefined, settingsPath);
84
+ const alias = modelAliasName(reference, aliases);
85
+ if (alias) {
86
+ if (chain.includes(alias)) fail("UNKNOWN_MODEL", `Circular model alias: ${[...chain, alias].join(" -> ")}${settingsPath ? ` (settings: ${settingsPath})` : ""}`);
87
+ const parsed = resolveReference(aliases[alias] as string, [...chain, alias]);
88
+ return thinking ? { ...parsed, thinking: thinking as NonNullable<ModelSpec["thinking"]> } : parsed;
89
+ }
90
+ const candidates = [...(knownModels ?? [])].filter((model) => model.slice(model.indexOf("/") + 1) === match[1]);
91
+ if (candidates.length === 1) {
92
+ const parsed = parseModelReference(candidates[0] as string);
93
+ return thinking ? { ...parsed, thinking: thinking as NonNullable<ModelSpec["thinking"]> } : parsed;
94
+ }
95
+ unknownModel(reference, undefined, settingsPath);
96
+ };
97
+ return resolveReference(value, []);
98
+ }
99
+ export function modelCapability(value: string | ModelSpec, aliases?: Readonly<Record<string, string>>, knownModels?: ReadonlySet<string>, settingsPath?: string): string {
100
+ const parsed = typeof value === "string" ? resolveModelReference(value, aliases, knownModels, settingsPath) : value;
101
+ return `${parsed.provider}/${parsed.model}`;
102
+ }
103
+ export function aliasDrift(previous: Readonly<Record<string, string>>, current: Readonly<Record<string, string>>): string[] {
104
+ return [...new Set([...Object.keys(previous), ...Object.keys(current)])].sort().flatMap((name) => previous[name] === current[name] ? [] : [`${name}: ${previous[name] ?? "(missing)"} -> ${current[name] ?? "(missing)"}`]);
105
+ }
106
+ export function mergeAgentResourceExclusions(...values: (AgentResourceExclusions | undefined)[]): AgentResourceExclusions { return { skills: [...new Set(values.flatMap((value) => value?.skills ?? []))], extensions: [...new Set(values.flatMap((value) => value?.extensions ?? []))] }; }
107
+ export function createLaunchSnapshot(input: Omit<import("./types.js").LaunchSnapshot, "identityVersion"> & { identityVersion?: number }): Readonly<import("./types.js").LaunchSnapshot> { return deepFreeze(structuredClone({ ...input, launchKind: input.launchKind ?? (input.functionName ? "function" : "inline"), identityVersion: input.identityVersion ?? LAUNCH_SNAPSHOT_IDENTITY_VERSION })); }
108
+ export function loadLaunchSnapshot(input: import("./types.js").LaunchSnapshot): Readonly<import("./types.js").LaunchSnapshot> { return deepFreeze(structuredClone(input)); }