@rigkit/engine 0.0.0-canary-20260518T014918-c5bc0c2

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/src/engine.ts ADDED
@@ -0,0 +1,2604 @@
1
+ import { existsSync } from "node:fs";
2
+ import { dirname, join, resolve } from "node:path";
3
+ import { pathToFileURL } from "node:url";
4
+ import { isRigkitConfig, isProviderDefinition, isWorkflowNode } from "./authoring.ts";
5
+ import { runWithStepConsole, type ConsoleLevel, type StepConsoleSink } from "./console-intercept.ts";
6
+ import { loadDotEnv } from "./env-file.ts";
7
+ import { hash } from "./hash.ts";
8
+ import {
9
+ createFileProviderHostStorage,
10
+ defaultProviderHostStorageDir,
11
+ type ProviderHostStorageFactory,
12
+ } from "./host-storage.ts";
13
+ import { RESERVED_WORKFLOW_OPERATION_IDS, STEP_INVALIDATION_KIND } from "./types.ts";
14
+ import type {
15
+ BaseProviderPlugin,
16
+ InteractionPresenter,
17
+ InteractionPresentationRequest,
18
+ ProviderFactory,
19
+ ProviderRuntimeContext,
20
+ WorkflowProviderController,
21
+ } from "./provider/types.ts";
22
+ import {
23
+ createStateStore,
24
+ type SnapshotRecord,
25
+ type StateService,
26
+ type StateServiceFactory,
27
+ type WorkflowNodeRunRecord,
28
+ } from "./state.ts";
29
+ import type {
30
+ EventHandler,
31
+ JsonObject,
32
+ JsonValue,
33
+ LoadedProviderDefinition,
34
+ LoadedWorkflow,
35
+ LocalWorkspaceRuntime,
36
+ OutputSchema,
37
+ ProviderRuntimeMap,
38
+ WorkflowInputFieldDefinition,
39
+ WorkflowDefinition,
40
+ WorkflowCacheScope,
41
+ WorkflowEvent,
42
+ WorkflowLogStream,
43
+ WorkflowNodeKind,
44
+ WorkflowNodeDefinition,
45
+ WorkflowOperationDefinition,
46
+ WorkflowPlan,
47
+ WorkflowPlanNode,
48
+ WorkflowProviderMap,
49
+ WorkflowStepInvalidation,
50
+ WorkflowTaskCacheTTL,
51
+ WorkflowTaskNode,
52
+ WorkspaceRecord,
53
+ WorkspaceRuntimeRecord,
54
+ WorkflowWorkspaceOperationDefinition,
55
+ } from "./types.ts";
56
+
57
+ export type CreateDevMachineEngineOptions = {
58
+ projectDir?: string;
59
+ configPath?: string;
60
+ statePath?: string;
61
+ state?: StateService;
62
+ providers?: BaseProviderPlugin[];
63
+ providerFactory?: ProviderFactory;
64
+ stateFactory?: StateServiceFactory;
65
+ globalFragmentStateLocator?: GlobalFragmentStateLocator;
66
+ hostStorageDir?: string;
67
+ hostStorageFactory?: ProviderHostStorageFactory;
68
+ interaction?: {
69
+ present?: InteractionPresenter;
70
+ };
71
+ local?: Partial<LocalWorkspaceRuntime>;
72
+ };
73
+
74
+ export type { InteractionPresenter, InteractionPresentationRequest };
75
+
76
+ export type GlobalFragmentStateLocator = (fragment: GlobalFragmentStateLocationInput) =>
77
+ | string
78
+ | { statePath: string };
79
+
80
+ export type GlobalFragmentStateLocationInput = {
81
+ hash: string;
82
+ workflow: string;
83
+ nodePath: string;
84
+ nodeName: string;
85
+ nodeKind: WorkflowNodeKind;
86
+ };
87
+
88
+ export type EngineLoadResult = {
89
+ workflow: LoadedWorkflow;
90
+ workflows: LoadedWorkflow[];
91
+ projectDir: string;
92
+ configPath: string;
93
+ statePath: string;
94
+ };
95
+
96
+ export type EngineProjectInfo = {
97
+ projectDir: string;
98
+ configPath: string;
99
+ statePath: string;
100
+ workflows: WorkflowSummary[];
101
+ workflow?: WorkflowSummary;
102
+ };
103
+
104
+ export type EngineCacheScope = WorkflowCacheScope;
105
+
106
+ export type EngineCacheEntry = {
107
+ scope: EngineCacheScope;
108
+ workflow: string;
109
+ nodePath: string;
110
+ nodeName: string;
111
+ nodeKind: string;
112
+ runId: string;
113
+ invalidated: boolean;
114
+ createdAt: string;
115
+ fragmentHash?: string;
116
+ };
117
+
118
+ export type EngineCacheList = {
119
+ entries: EngineCacheEntry[];
120
+ };
121
+
122
+ export type EngineCacheClearScope = EngineCacheScope | "all";
123
+
124
+ export type EngineCacheClearResult = {
125
+ deleted: number;
126
+ };
127
+
128
+ export type WorkflowSummary = {
129
+ name: string;
130
+ providers: string[];
131
+ nodes: string[];
132
+ operations: string[];
133
+ createsWorkspace: boolean;
134
+ workspace?: LoadedWorkflow["workspace"];
135
+ };
136
+
137
+ export type EngineOperationSource = "core" | "config";
138
+
139
+ export type EngineOperationKind = "command" | "workspace-action";
140
+
141
+ export type EngineOperationCliPosition = {
142
+ name: string;
143
+ index: number;
144
+ };
145
+
146
+ export type EngineOperationCliOption = {
147
+ name: string;
148
+ flag: string;
149
+ aliases?: string[];
150
+ required?: boolean;
151
+ runtime?: boolean;
152
+ type?: "string" | "boolean" | "number";
153
+ };
154
+
155
+ export type EngineOperationCli = {
156
+ positionals?: EngineOperationCliPosition[];
157
+ options?: EngineOperationCliOption[];
158
+ };
159
+
160
+ export type EngineOperationSummary = {
161
+ workflow: string;
162
+ id: string;
163
+ aliases?: readonly string[];
164
+ source?: EngineOperationSource;
165
+ kind?: EngineOperationKind;
166
+ title?: string;
167
+ description?: string;
168
+ createsWorkspace?: boolean;
169
+ inputFields: readonly WorkflowInputFieldDefinition[];
170
+ cli?: EngineOperationCli;
171
+ };
172
+
173
+ export class EngineOperationValidationError extends Error {
174
+ readonly operation: string;
175
+
176
+ constructor(input: { operation: string; message: string; cause?: unknown }) {
177
+ super(input.message, { cause: input.cause });
178
+ this.name = "EngineOperationValidationError";
179
+ this.operation = input.operation;
180
+ }
181
+ }
182
+
183
+ export class EngineOperationNotFoundError extends Error {
184
+ readonly operation: string;
185
+
186
+ constructor(operation: string) {
187
+ super(`Unknown operation ${operation}`);
188
+ this.name = "EngineOperationNotFoundError";
189
+ this.operation = operation;
190
+ }
191
+ }
192
+
193
+ type ProviderControllers = Record<string, WorkflowProviderController>;
194
+
195
+ type EvaluationMode = "plan" | "apply";
196
+
197
+ type EvaluationState = {
198
+ context: Record<string, JsonValue>;
199
+ upstreamRunIds: string[];
200
+ previousTasks: EvaluationPreviousTask[];
201
+ known: boolean;
202
+ blockedReason?: string;
203
+ };
204
+
205
+ type EvaluationPreviousTask = {
206
+ name: string;
207
+ path: string;
208
+ cache: EvaluationCacheTarget;
209
+ };
210
+
211
+ type EvaluationCacheTarget = {
212
+ scope: WorkflowCacheScope;
213
+ workflow: string;
214
+ nodePath: string;
215
+ state: StateService;
216
+ fragmentHash?: string;
217
+ };
218
+
219
+ type EvaluationResult = EvaluationState & {
220
+ planNodes: WorkflowPlanNode[];
221
+ };
222
+
223
+ type EvaluateNodeInput = {
224
+ workflow: LoadedWorkflow;
225
+ node: WorkflowNodeDefinition<any, any, any>;
226
+ providers: ProviderControllers;
227
+ providerFingerprint: string;
228
+ mode: EvaluationMode;
229
+ cache: EvaluationCacheTarget;
230
+ configStack: JsonObject[];
231
+ state: EvaluationState;
232
+ prefix: string[];
233
+ cachePrefix: string[];
234
+ root: boolean;
235
+ suppressSequenceName?: string;
236
+ suppressCacheSequenceName?: string;
237
+ planNodes: WorkflowPlanNode[];
238
+ index: { value: number };
239
+ };
240
+
241
+ type RuntimeOperationEntry = {
242
+ readonly summary: EngineOperationSummary;
243
+ readonly run: (input: { workflow?: string; input?: unknown }) => Promise<unknown>;
244
+ };
245
+
246
+ type RuntimeWorkspaceOperationEntry = {
247
+ readonly summary: EngineOperationSummary;
248
+ readonly run: (input: { workspace: string; workflow?: string; input?: unknown }) => Promise<unknown>;
249
+ };
250
+
251
+ class StepInvalidationRestart extends Error {
252
+ readonly workflow: string;
253
+ readonly target: string;
254
+ readonly targetNodePath: string;
255
+ readonly currentNodePath: string;
256
+ readonly invalidatedRunIds: string[];
257
+
258
+ constructor(input: {
259
+ workflow: string;
260
+ target: string;
261
+ targetNodePath: string;
262
+ currentNodePath: string;
263
+ invalidatedRunIds: string[];
264
+ }) {
265
+ super(`Task ${input.currentNodePath} invalidated ${input.targetNodePath}`);
266
+ this.name = "StepInvalidationRestart";
267
+ this.workflow = input.workflow;
268
+ this.target = input.target;
269
+ this.targetNodePath = input.targetNodePath;
270
+ this.currentNodePath = input.currentNodePath;
271
+ this.invalidatedRunIds = input.invalidatedRunIds;
272
+ }
273
+ }
274
+
275
+ let configImportCounter = 0;
276
+
277
+ // The engine owns the workflow graph, cache, and event emission for one
278
+ // project. The runtime daemon hosts a single long-lived instance per project.
279
+ export class DevMachineEngine {
280
+ private readonly projectDir: string;
281
+ private readonly configPath: string;
282
+ private readonly statePath: string;
283
+ private state: StateService | undefined;
284
+ private providers: BaseProviderPlugin[];
285
+ private readonly providerFactory: ProviderFactory;
286
+ private readonly stateFactory: StateServiceFactory;
287
+ private readonly globalFragmentStateLocator: GlobalFragmentStateLocator;
288
+ private readonly globalFragmentStates = new Map<string, { input: GlobalFragmentStateLocationInput; state: StateService }>();
289
+ private evaluationFragmentHashes: Set<string> | undefined;
290
+ private readonly hostStorageDir: string;
291
+ private readonly hostStorageFactory: ProviderHostStorageFactory;
292
+ private readonly providerHostStorage = new Map<string, ReturnType<ProviderHostStorageFactory>>();
293
+ private readonly interactionPresenter: InteractionPresenter;
294
+ private readonly local: LocalWorkspaceRuntime;
295
+ private readonly handlers = new Set<EventHandler>();
296
+ private workflows = new Map<string, LoadedWorkflow>();
297
+
298
+ constructor(options: CreateDevMachineEngineOptions = {}) {
299
+ this.configPath = options.configPath
300
+ ? resolve(options.configPath)
301
+ : join(resolve(options.projectDir ?? process.cwd()), "rig.config.ts");
302
+ this.projectDir = resolve(options.configPath ? dirname(this.configPath) : options.projectDir ?? process.cwd());
303
+ this.statePath = options.state?.path ?? (options.statePath ? resolve(options.statePath) : join(this.projectDir, ".rigkit", "state.sqlite"));
304
+ this.state = options.state;
305
+ this.providers = options.providers ?? [];
306
+ this.providerFactory = options.providerFactory ?? ((input) => this.createProviderFromPlugin(input));
307
+ this.stateFactory = options.stateFactory ?? createStateStore;
308
+ this.globalFragmentStateLocator = options.globalFragmentStateLocator ?? ((fragment) => ({
309
+ statePath: join(this.projectDir, ".rigkit", "fragments", fragment.hash, "state.sqlite"),
310
+ }));
311
+ this.hostStorageDir = options.hostStorageDir ? resolve(options.hostStorageDir) : defaultProviderHostStorageDir();
312
+ this.hostStorageFactory = options.hostStorageFactory ?? createFileProviderHostStorage;
313
+ this.interactionPresenter = options.interaction?.present ?? defaultInteractionPresenter;
314
+ this.local = {
315
+ open: options.local?.open ?? openLocalTarget,
316
+ command: options.local?.command ?? runLocalCommand,
317
+ requestCapability: options.local?.requestCapability ?? requestUnsupportedHostCapability,
318
+ requestCapabilitySession: options.local?.requestCapabilitySession ?? requestUnsupportedHostCapabilitySession,
319
+ };
320
+ }
321
+
322
+ onEvent(handler: EventHandler): () => void {
323
+ this.handlers.add(handler);
324
+ return () => this.handlers.delete(handler);
325
+ }
326
+
327
+ async load(): Promise<EngineLoadResult> {
328
+ loadDotEnv(this.projectDir);
329
+
330
+ if (!existsSync(this.configPath)) {
331
+ throw new Error(
332
+ `No Rigkit config found at ${this.configPath}. Create one with "rig init" or pass -config=<file>.`,
333
+ );
334
+ }
335
+
336
+ const moduleUrl = pathToFileURL(this.configPath);
337
+ moduleUrl.searchParams.set("t", `${Date.now()}-${configImportCounter++}`);
338
+ const mod = await import(moduleUrl.href);
339
+ const roots = normalizeDefinitions(mod.default ?? mod.workflow);
340
+ const loaded = await Promise.all(roots.map((root) => this.resolveWorkflow(root)));
341
+ const workflow = loaded[0];
342
+ if (!workflow) {
343
+ throw new Error(`rig.config.ts must define at least one workflow`);
344
+ }
345
+ this.providers = mergeProviderPlugins([
346
+ ...this.providers,
347
+ ...roots.flatMap((root) => Object.values(root.workflow.providers as WorkflowProviderMap))
348
+ .map((provider) => provider.plugin)
349
+ .filter(isBaseProviderPlugin),
350
+ ]);
351
+ this.state ??= this.stateFactory({
352
+ projectDir: this.projectDir,
353
+ configPath: this.configPath,
354
+ statePath: this.statePath,
355
+ });
356
+ await this.state.syncSchema();
357
+
358
+ this.workflows = new Map(loaded.map((item) => [item.name, item]));
359
+ if (this.workflows.size !== loaded.length) {
360
+ throw new Error(`Workflow names must be unique`);
361
+ }
362
+
363
+ for (const item of loaded) {
364
+ this.emit({ type: "definition.loaded", workflow: item.name });
365
+ }
366
+
367
+ return {
368
+ workflow,
369
+ workflows: loaded,
370
+ projectDir: this.projectDir,
371
+ configPath: this.configPath,
372
+ statePath: this.getStateService().path,
373
+ };
374
+ }
375
+
376
+ listWorkflows(): LoadedWorkflow[] {
377
+ return [...this.workflows.values()];
378
+ }
379
+
380
+ listMachines(): LoadedWorkflow[] {
381
+ return this.listWorkflows();
382
+ }
383
+
384
+ getProjectInfo(): EngineProjectInfo {
385
+ const workflows = this.listWorkflowSummaries();
386
+ return {
387
+ projectDir: this.projectDir,
388
+ configPath: this.configPath,
389
+ statePath: this.state?.path ?? this.statePath,
390
+ workflows,
391
+ workflow: workflows.length === 1 ? workflows[0] : undefined,
392
+ };
393
+ }
394
+
395
+ listWorkflowSummaries(): WorkflowSummary[] {
396
+ return this.listWorkflows().map((workflow) => summarizeWorkflow(workflow));
397
+ }
398
+
399
+ listWorkspaces(): WorkspaceRecord[] {
400
+ return this.getStateService().listWorkspaces();
401
+ }
402
+
403
+ listSnapshots(): SnapshotRecord[] {
404
+ return this.getStateService().listSnapshots();
405
+ }
406
+
407
+ listOperations(): EngineOperationSummary[] {
408
+ return this.listConfigOperationSummaries();
409
+ }
410
+
411
+ listRuntimeOperations(): EngineOperationSummary[] {
412
+ return this.listRuntimeOperationEntries().map((entry) => entry.summary);
413
+ }
414
+
415
+ listRuntimeWorkspaceOperations(): EngineOperationSummary[] {
416
+ return this.listRuntimeWorkspaceOperationEntries().map((entry) => entry.summary);
417
+ }
418
+
419
+ private listRuntimeOperationEntries(): RuntimeOperationEntry[] {
420
+ const configOperations = this.listConfigOperationEntries();
421
+ const configOperationIds = new Set(configOperations.map((entry) => entry.summary.id));
422
+ const coreOperations = this.listCoreOperationEntries();
423
+ return [
424
+ ...coreOperations.filter((entry) =>
425
+ !configOperationIds.has(entry.summary.id) &&
426
+ !entry.summary.aliases?.some((alias) => configOperationIds.has(alias))
427
+ ),
428
+ ...configOperations,
429
+ ];
430
+ }
431
+
432
+ private listRuntimeWorkspaceOperationEntries(): RuntimeWorkspaceOperationEntry[] {
433
+ const configOperations = this.listConfigWorkspaceOperationEntries();
434
+ const configOperationIds = new Set(configOperations.map((entry) => entry.summary.id));
435
+ const coreOperations = this.listCoreWorkspaceOperationEntries();
436
+ return [
437
+ ...coreOperations.filter((entry) => !configOperationIds.has(entry.summary.id)),
438
+ ...configOperations,
439
+ ];
440
+ }
441
+
442
+ private listConfigOperationEntries(): RuntimeOperationEntry[] {
443
+ return this.listConfigOperationSummaries().map((summary) => ({
444
+ summary,
445
+ run: async (input) =>
446
+ await this.runOperation({
447
+ operation: summary.id,
448
+ workflow: input.workflow,
449
+ input: input.input,
450
+ }),
451
+ }));
452
+ }
453
+
454
+ private listConfigOperationSummaries(): EngineOperationSummary[] {
455
+ return this.listWorkflows().flatMap((workflow) =>
456
+ workflow.operations.map((operation) => {
457
+ assertAllowedConfigOperationId(operation.id);
458
+ return {
459
+ workflow: workflow.name,
460
+ id: operation.id,
461
+ source: "config" as const,
462
+ title: operation.title,
463
+ description: operation.description,
464
+ inputFields: operation.input?.fields ?? [],
465
+ };
466
+ }),
467
+ );
468
+ }
469
+
470
+ private listConfigWorkspaceOperationEntries(): RuntimeWorkspaceOperationEntry[] {
471
+ return this.listWorkflows().flatMap((workflow) =>
472
+ workflow.workspaceOperations.map((operation) => ({
473
+ summary: this.workspaceOperationSummary(workflow, operation),
474
+ run: async (input) => {
475
+ const workspace = this.getStateService().getWorkspace(input.workspace);
476
+ if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
477
+ if (workspace.workflow !== workflow.name) {
478
+ throw new EngineOperationValidationError({
479
+ operation: operation.id,
480
+ message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
481
+ });
482
+ }
483
+ const providers = await this.createProviders(workflow);
484
+ return await this.runConfigWorkspaceOperation({
485
+ workflow,
486
+ providers,
487
+ workspace,
488
+ operation,
489
+ rawInput: input.input,
490
+ });
491
+ },
492
+ }))
493
+ );
494
+ }
495
+
496
+ private listConfigWorkspaceOperationSummaries(): EngineOperationSummary[] {
497
+ return this.listWorkflows().flatMap((workflow) =>
498
+ workflow.workspaceOperations.map((operation) => {
499
+ assertAllowedConfigOperationId(operation.id);
500
+ return this.workspaceOperationSummary(workflow, operation);
501
+ }),
502
+ );
503
+ }
504
+
505
+ private workspaceOperationSummary(
506
+ workflow: LoadedWorkflow,
507
+ operation: WorkflowWorkspaceOperationDefinition<any, any, any, any>,
508
+ ): EngineOperationSummary {
509
+ return {
510
+ workflow: workflow.name,
511
+ id: operation.id,
512
+ source: "config" as const,
513
+ kind: "workspace-action" as const,
514
+ title: operation.title,
515
+ description: operation.description,
516
+ inputFields: operation.input?.fields ?? [],
517
+ };
518
+ }
519
+
520
+ private listCoreOperationEntries(): RuntimeOperationEntry[] {
521
+ const workflows = this.listWorkflows();
522
+ const hasWorkspaceCreator = workflows.some((workflow) => workflow.workspace);
523
+ const workflowField = stringField({
524
+ name: "workflow",
525
+ required: false,
526
+ });
527
+ const coreOperation = (
528
+ summary: EngineOperationSummary,
529
+ run: RuntimeOperationEntry["run"],
530
+ ): RuntimeOperationEntry => ({ summary, run });
531
+
532
+ return [
533
+ coreOperation(
534
+ {
535
+ workflow: "",
536
+ id: "plan",
537
+ source: "core",
538
+ kind: "command",
539
+ title: "Plan",
540
+ description: "Show cached and pending steps",
541
+ inputFields: [workflowField],
542
+ cli: {
543
+ options: [
544
+ { name: "workflow", flag: "--workflow" },
545
+ ],
546
+ },
547
+ },
548
+ async (input) => {
549
+ const parsed = parseCoreOperationInput("plan", input.input);
550
+ return await this.plan({ workflow: optionalStringInput("plan", parsed, "workflow") });
551
+ },
552
+ ),
553
+ coreOperation(
554
+ {
555
+ workflow: "",
556
+ id: "apply",
557
+ source: "core",
558
+ kind: "command",
559
+ title: "Apply",
560
+ description: "Resolve the workflow, running pending nodes",
561
+ inputFields: [
562
+ workflowField,
563
+ booleanField({ name: "dryRun", required: false, defaultValue: false }),
564
+ ],
565
+ cli: {
566
+ options: [
567
+ { name: "workflow", flag: "--workflow" },
568
+ { name: "dryRun", flag: "--dry-run", type: "boolean" },
569
+ ],
570
+ },
571
+ },
572
+ async (input) => {
573
+ const parsed = parseCoreOperationInput("apply", input.input);
574
+ const workflow = optionalStringInput("apply", parsed, "workflow");
575
+ const dryRun = optionalBooleanInput("apply", parsed, "dryRun", false);
576
+ return dryRun
577
+ ? { dryRun: true, plan: await this.plan({ workflow }) }
578
+ : await this.apply({ workflow });
579
+ },
580
+ ),
581
+ ...(hasWorkspaceCreator
582
+ ? [
583
+ coreOperation(
584
+ {
585
+ workflow: "",
586
+ id: "create",
587
+ aliases: ["fork"],
588
+ source: "core",
589
+ kind: "command",
590
+ title: "Create",
591
+ description: "Create a workspace",
592
+ createsWorkspace: true,
593
+ inputFields: [
594
+ workflowField,
595
+ stringField({ name: "name", required: true }),
596
+ ],
597
+ cli: {
598
+ positionals: [
599
+ { name: "name", index: 0 },
600
+ ],
601
+ options: [
602
+ { name: "workflow", flag: "--workflow" },
603
+ { name: "name", flag: "--name", required: true },
604
+ ],
605
+ },
606
+ },
607
+ async (input) => {
608
+ const parsed = parseCoreOperationInput("create", input.input);
609
+ return await this.createWorkspace({
610
+ workflow: optionalStringInput("create", parsed, "workflow"),
611
+ name: requiredStringInput("create", parsed, "name"),
612
+ });
613
+ },
614
+ ),
615
+ ]
616
+ : []),
617
+ ];
618
+ }
619
+
620
+ private listCoreWorkspaceOperationEntries(): RuntimeWorkspaceOperationEntry[] {
621
+ const workflows = this.listWorkflows().filter((workflow) => workflow.workspace);
622
+ if (workflows.length === 0) return [];
623
+ const coreOperation = (
624
+ summary: EngineOperationSummary,
625
+ run: RuntimeWorkspaceOperationEntry["run"],
626
+ ): RuntimeWorkspaceOperationEntry => ({ summary, run });
627
+
628
+ return workflows.map((workflow) =>
629
+ coreOperation(
630
+ {
631
+ workflow: workflow.name,
632
+ id: "remove",
633
+ source: "core",
634
+ kind: "workspace-action",
635
+ title: "Remove",
636
+ description: "Remove a workspace",
637
+ inputFields: [],
638
+ cli: {
639
+ options: [
640
+ { name: "yes", flag: "--yes", aliases: ["-y"], type: "boolean", runtime: false },
641
+ ],
642
+ },
643
+ },
644
+ async (input) =>
645
+ await this.removeWorkspace({
646
+ workflow: input.workflow,
647
+ workspace: input.workspace,
648
+ }),
649
+ )
650
+ );
651
+ }
652
+
653
+ listNodeRuns(): WorkflowNodeRunRecord[] {
654
+ return this.getStateService().listNodeRuns();
655
+ }
656
+
657
+ async listCache(input: {
658
+ workflow?: string;
659
+ machine?: string;
660
+ includeUnreachable?: boolean;
661
+ } = {}): Promise<EngineCacheList> {
662
+ const workflow = this.getWorkflow(input.workflow ?? input.machine);
663
+ const providers = await this.createProviders(workflow);
664
+ const evaluated = await this.evaluate({
665
+ workflow,
666
+ providers,
667
+ mode: "plan",
668
+ });
669
+
670
+ // The plan tells us which row (by runId) would satisfy each cached node
671
+ // under the *current* code. Those are the only cache rows that matter.
672
+ const reachableRunIds = new Set<string>();
673
+ for (const node of evaluated.plan.nodes) {
674
+ if (node.status === "cached" && node.runId) reachableRunIds.add(node.runId);
675
+ }
676
+
677
+ if (input.includeUnreachable) {
678
+ const entries: EngineCacheEntry[] = [
679
+ ...this.getStateService()
680
+ .listNodeRuns()
681
+ .filter((run) => run.workflow === workflow.name)
682
+ .map((run) => cacheEntryForRun(run, "local")),
683
+ ];
684
+ for (const fragmentHash of evaluated.fragments) {
685
+ const fragment = this.globalFragmentStates.get(fragmentHash);
686
+ if (!fragment) continue;
687
+ entries.push(
688
+ ...fragment.state
689
+ .listNodeRuns()
690
+ .map((run) => cacheEntryForRun(run, "global", fragmentHash, workflow.name)),
691
+ );
692
+ }
693
+ entries.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
694
+ return { entries };
695
+ }
696
+
697
+ // Local state is per-project — anything not reachable is dead weight, so
698
+ // prune as a side effect. Global fragments are shared across projects;
699
+ // another project might still reach the row we'd consider unreachable
700
+ // here, so we only filter the display for globals — never delete.
701
+ const localRuns = this.getStateService().listNodeRuns()
702
+ .filter((run) => run.workflow === workflow.name);
703
+ const localStaleIds = localRuns
704
+ .filter((run) => !reachableRunIds.has(run.id))
705
+ .map((run) => run.id);
706
+ if (localStaleIds.length > 0) {
707
+ this.getStateService().deleteNodeRunsById(localStaleIds);
708
+ }
709
+
710
+ const entries: EngineCacheEntry[] = localRuns
711
+ .filter((run) => reachableRunIds.has(run.id))
712
+ .map((run) => cacheEntryForRun(run, "local"));
713
+
714
+ for (const fragmentHash of evaluated.fragments) {
715
+ const fragment = this.globalFragmentStates.get(fragmentHash);
716
+ if (!fragment) continue;
717
+ entries.push(
718
+ ...fragment.state
719
+ .listNodeRuns()
720
+ .filter((run) => reachableRunIds.has(run.id))
721
+ .map((run) => cacheEntryForRun(run, "global", fragmentHash, workflow.name)),
722
+ );
723
+ }
724
+
725
+ entries.sort((left, right) => right.createdAt.localeCompare(left.createdAt));
726
+ return { entries };
727
+ }
728
+
729
+ async invalidateCache(input: {
730
+ workflow?: string;
731
+ machine?: string;
732
+ nodePaths?: readonly string[];
733
+ } = {}): Promise<{ invalidated: number }> {
734
+ const workflow = this.getWorkflow(input.workflow ?? input.machine);
735
+ const state = this.getStateService();
736
+ // If no explicit paths, invalidate every cached node for this workflow.
737
+ const paths = input.nodePaths && input.nodePaths.length > 0
738
+ ? [...input.nodePaths]
739
+ : [...new Set(state.listNodeRuns()
740
+ .filter((run) => run.workflow === workflow.name && !run.invalidated)
741
+ .map((run) => run.nodePath))];
742
+ if (paths.length === 0) return { invalidated: 0 };
743
+ const ids = state.invalidateNodeRuns({ workflow: workflow.name, nodePaths: paths });
744
+ return { invalidated: ids.length };
745
+ }
746
+
747
+ async clearCache(input: {
748
+ workflow?: string;
749
+ machine?: string;
750
+ scope?: EngineCacheClearScope;
751
+ } = {}): Promise<EngineCacheClearResult> {
752
+ const scope = input.scope ?? "all";
753
+ const workflow = this.getWorkflow(input.workflow ?? input.machine);
754
+ const providers = await this.createProviders(workflow);
755
+ const evaluated = await this.evaluate({
756
+ workflow,
757
+ providers,
758
+ mode: "plan",
759
+ });
760
+
761
+ let deleted = 0;
762
+ if (scope === "all" || scope === "local") {
763
+ deleted += this.getStateService().clearNodeRuns({ workflow: workflow.name });
764
+ }
765
+ if (scope === "all" || scope === "global") {
766
+ for (const fragmentHash of evaluated.fragments) {
767
+ const fragment = this.globalFragmentStates.get(fragmentHash);
768
+ if (!fragment) continue;
769
+ deleted += fragment.state.clearNodeRuns();
770
+ }
771
+ }
772
+ return { deleted };
773
+ }
774
+
775
+ hasOperation(operationId: string): boolean {
776
+ return this.listWorkflows().some((workflow) => workflow.operations.some((operation) => operation.id === operationId));
777
+ }
778
+
779
+ async runRuntimeOperation(input: { operation: string; workflow?: string; input?: unknown }): Promise<unknown> {
780
+ const workspaceTarget = parseWorkspaceOperationId(input.operation);
781
+ if (workspaceTarget) {
782
+ return await this.runWorkspaceOperation({
783
+ workspace: workspaceTarget.workspace,
784
+ operation: workspaceTarget.operation,
785
+ workflow: input.workflow,
786
+ input: input.input,
787
+ });
788
+ }
789
+ const operation = this.findRuntimeOperationEntry(input.operation);
790
+ if (!operation) throw new EngineOperationNotFoundError(input.operation);
791
+ return await operation.run({ workflow: input.workflow, input: input.input });
792
+ }
793
+
794
+ async runOperation(input: { operation: string; workflow?: string; input?: unknown }): Promise<unknown> {
795
+ const { workflow, operation } = this.getWorkflowOperation(input.operation, input.workflow);
796
+ const providers = await this.createProviders(workflow);
797
+ const metadata: JsonObject = {};
798
+ const runtime = await this.createTaskRuntime({
799
+ workflow,
800
+ providers,
801
+ nodePath: `operation.${operation.id}`,
802
+ metadata,
803
+ });
804
+ const operationInput = this.resolveOperationInput(workflow, operation, input.input ?? {});
805
+ const operationNodePath = `operation.${operation.id}`;
806
+ const result = await this.withStepConsole(operationNodePath, () => operation.run({
807
+ ...runtime,
808
+ input: Object.freeze(operationInput),
809
+ providers: runtime,
810
+ local: this.local,
811
+ workflow: workflow.name,
812
+ step: this.createStepRuntime(workflow.name, operationNodePath, metadata),
813
+ }));
814
+ if (result !== undefined) assertJsonValue(result, `Operation ${operation.id} result`);
815
+ return result ?? null;
816
+ }
817
+
818
+ async runWorkspaceOperation(input: {
819
+ workspace: string;
820
+ operation: string;
821
+ workflow?: string;
822
+ input?: unknown;
823
+ }): Promise<unknown> {
824
+ const workspace = this.getStateService().getWorkspace(input.workspace);
825
+ if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
826
+ const workflow = this.getWorkflow(input.workflow ?? workspace.workflow);
827
+ if (workspace.workflow !== workflow.name) {
828
+ throw new EngineOperationValidationError({
829
+ operation: input.operation,
830
+ message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
831
+ });
832
+ }
833
+
834
+ const core = this.listCoreWorkspaceOperationEntries().find((entry) =>
835
+ entry.summary.workflow === workflow.name && entry.summary.id === input.operation
836
+ );
837
+ if (core) {
838
+ return await core.run({ workspace: workspace.name, workflow: workflow.name, input: input.input });
839
+ }
840
+
841
+ const operation = workflow.workspaceOperations.find((item) => item.id === input.operation);
842
+ if (!operation) throw new EngineOperationNotFoundError(`${workspace.name}/${input.operation}`);
843
+ const providers = await this.createProviders(workflow);
844
+ return await this.runConfigWorkspaceOperation({
845
+ workflow,
846
+ providers,
847
+ workspace,
848
+ operation,
849
+ rawInput: input.input,
850
+ });
851
+ }
852
+
853
+ async plan(input: { workflow?: string; machine?: string } = {}): Promise<WorkflowPlan> {
854
+ const workflow = this.getWorkflow(input.workflow ?? input.machine);
855
+ const providers = await this.createProviders(workflow);
856
+ const result = await this.evaluate({
857
+ workflow,
858
+ providers,
859
+ mode: "plan",
860
+ });
861
+
862
+ this.emit({
863
+ type: "plan.created",
864
+ workflow: workflow.name,
865
+ cachedNodeCount: result.plan.cachedNodeCount,
866
+ nodeCount: result.plan.nodeCount,
867
+ });
868
+
869
+ return result.plan;
870
+ }
871
+
872
+ async apply(input: { workflow?: string; machine?: string } = {}): Promise<{
873
+ context: Record<string, JsonValue>;
874
+ snapshotId?: string;
875
+ workspaceSource?: JsonValue;
876
+ plan: WorkflowPlan;
877
+ }> {
878
+ const workflow = this.getWorkflow(input.workflow ?? input.machine);
879
+ const providers = await this.createProviders(workflow);
880
+ const startedAt = Date.now();
881
+ this.emit({ type: "workflow.apply.started", workflow: workflow.name });
882
+ let result: { context: Record<string, JsonValue>; plan: WorkflowPlan } | undefined;
883
+ const maxRestarts = 8;
884
+ for (let attempt = 0; attempt <= maxRestarts; attempt++) {
885
+ try {
886
+ result = await this.evaluate({
887
+ workflow,
888
+ providers,
889
+ mode: "apply",
890
+ });
891
+ break;
892
+ } catch (error) {
893
+ if (!(error instanceof StepInvalidationRestart)) throw error;
894
+ if (attempt === maxRestarts) {
895
+ throw new Error(
896
+ `Task ${error.currentNodePath} repeatedly invalidated ${error.targetNodePath}; stopping after ${maxRestarts + 1} attempts`,
897
+ { cause: error },
898
+ );
899
+ }
900
+ }
901
+ }
902
+ if (!result) throw new Error(`Workflow ${workflow.name} did not produce an apply result`);
903
+
904
+ this.emit({
905
+ type: "workflow.apply.completed",
906
+ workflow: workflow.name,
907
+ nodeCount: result.plan.nodeCount,
908
+ cachedNodeCount: result.plan.cachedNodeCount,
909
+ durationMs: Date.now() - startedAt,
910
+ });
911
+
912
+ return {
913
+ context: result.context,
914
+ plan: result.plan,
915
+ };
916
+ }
917
+
918
+ async fork(input: { workflow?: string; machine?: string; name: string }): Promise<WorkspaceRecord> {
919
+ return await this.createWorkspace(input);
920
+ }
921
+
922
+ async createWorkspace(input: { workflow?: string; machine?: string; name: string }): Promise<WorkspaceRecord> {
923
+ assertValidWorkspaceName(input.name);
924
+ if (this.getStateService().getWorkspace(input.name)) {
925
+ throw new Error(`Workspace ${input.name} already exists`);
926
+ }
927
+
928
+ const applied = await this.apply({ workflow: input.workflow ?? input.machine });
929
+ const workflow = this.getWorkflow(input.workflow ?? input.machine);
930
+ const providers = await this.createProviders(workflow);
931
+ if (!workflow.workspace) {
932
+ throw new Error(`Workflow ${workflow.name} does not define a workspace`);
933
+ }
934
+
935
+ const now = new Date().toISOString();
936
+ const workspace: WorkspaceRecord = {
937
+ id: crypto.randomUUID(),
938
+ name: input.name,
939
+ workflow: workflow.name,
940
+ workflowCtx: { ...applied.context },
941
+ ctx: {},
942
+ createdAt: now,
943
+ updatedAt: now,
944
+ };
945
+
946
+ this.getStateService().saveWorkspace(workspace);
947
+ this.emit({ type: "workspace.create.started", workspaceName: input.name });
948
+ try {
949
+ await this.runWorkspaceCreate({
950
+ workflow,
951
+ providers,
952
+ workspace,
953
+ context: applied.context,
954
+ name: input.name,
955
+ });
956
+ } catch (error) {
957
+ this.getStateService().deleteWorkspace(workspace.name);
958
+ throw error;
959
+ }
960
+ const ready = this.getStateService().getWorkspace(input.name) ?? workspace;
961
+
962
+ this.emit({
963
+ type: "workspace.ready",
964
+ workspaceId: ready.name,
965
+ });
966
+
967
+ return ready;
968
+ }
969
+
970
+ async deleteWorkspace(input: { workspace: string; workflow?: string; machine?: string }): Promise<WorkspaceRecord> {
971
+ return await this.removeWorkspace(input);
972
+ }
973
+
974
+ async removeWorkspace(input: { workspace: string; workflow?: string; machine?: string }): Promise<WorkspaceRecord> {
975
+ const workspace = this.getStateService().getWorkspace(input.workspace);
976
+ if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
977
+
978
+ const workflow = this.getWorkflow(input.workflow ?? input.machine ?? workspace.workflow);
979
+ if (workspace.workflow !== workflow.name) {
980
+ throw new EngineOperationValidationError({
981
+ operation: "remove",
982
+ message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
983
+ });
984
+ }
985
+ if (!workflow.workspace) {
986
+ throw new Error(`Workflow ${workflow.name} does not define a workspace`);
987
+ }
988
+ const providers = await this.createProviders(workflow);
989
+ const metadata: JsonObject = {};
990
+ const runtime = await this.createTaskRuntime({
991
+ workflow,
992
+ providers,
993
+ nodePath: `workspace.${workspace.name}.remove`,
994
+ metadata,
995
+ });
996
+ const draft = cloneWorkspace(workspace);
997
+ const workspaceRuntime = this.createWorkspaceRuntime(draft);
998
+
999
+ const removeNodePath = `workspace.${workspace.name}.remove`;
1000
+ const workspaceDef = workflow.workspace;
1001
+ this.emit({ type: "workspace.remove.started", workspaceName: workspace.name });
1002
+ await this.withStepConsole(removeNodePath, () => workspaceDef.remove({
1003
+ ...runtime,
1004
+ workflow: {
1005
+ name: workflow.name,
1006
+ ctx: Object.freeze({ ...workspace.workflowCtx }),
1007
+ },
1008
+ workspace: workspaceRuntime,
1009
+ providers: runtime,
1010
+ local: this.local,
1011
+ step: this.createStepRuntime(workflow.name, removeNodePath, metadata),
1012
+ }));
1013
+
1014
+ this.getStateService().deleteWorkspace(input.workspace);
1015
+ this.emit({ type: "workspace.remove.completed", workspaceName: workspace.name });
1016
+ return workspace;
1017
+ }
1018
+
1019
+ private async evaluate(input: {
1020
+ workflow: LoadedWorkflow;
1021
+ providers: ProviderControllers;
1022
+ mode: EvaluationMode;
1023
+ }): Promise<{ context: Record<string, JsonValue>; plan: WorkflowPlan; fragments: Set<string> }> {
1024
+ const providerFingerprint = providerFingerprintFor(input.workflow);
1025
+ const planNodes: WorkflowPlanNode[] = [];
1026
+ const previousEvaluationFragmentHashes = this.evaluationFragmentHashes;
1027
+ const fragments = new Set<string>();
1028
+ this.evaluationFragmentHashes = fragments;
1029
+ let result!: EvaluationResult;
1030
+ try {
1031
+ result = await this.evaluateNode({
1032
+ workflow: input.workflow,
1033
+ providers: input.providers,
1034
+ providerFingerprint,
1035
+ mode: input.mode,
1036
+ node: input.workflow.root,
1037
+ cache: {
1038
+ scope: "local",
1039
+ workflow: input.workflow.name,
1040
+ nodePath: "",
1041
+ state: this.getStateService(),
1042
+ },
1043
+ configStack: [],
1044
+ state: {
1045
+ context: {},
1046
+ upstreamRunIds: [],
1047
+ previousTasks: [],
1048
+ known: true,
1049
+ },
1050
+ prefix: [],
1051
+ cachePrefix: [],
1052
+ root: true,
1053
+ planNodes,
1054
+ index: { value: 0 },
1055
+ });
1056
+ } finally {
1057
+ this.evaluationFragmentHashes = previousEvaluationFragmentHashes;
1058
+ }
1059
+ const cachedNodeCount = planNodes.filter((node) => node.status === "cached").length;
1060
+ const plan: WorkflowPlan = {
1061
+ workflow: input.workflow.name,
1062
+ providerFingerprint,
1063
+ cachedNodeCount,
1064
+ nodeCount: planNodes.length,
1065
+ nodes: planNodes,
1066
+ finalContext: result.known ? result.context : undefined,
1067
+ };
1068
+
1069
+ return {
1070
+ context: result.context,
1071
+ plan,
1072
+ fragments,
1073
+ };
1074
+ }
1075
+
1076
+ private async evaluateNode(input: EvaluateNodeInput): Promise<EvaluationResult> {
1077
+ if (input.node.cacheScope === "global" && input.cache.scope !== "global") {
1078
+ const nodePath = nodeDisplayPath(input.node, input.prefix, input.root, input.suppressSequenceName);
1079
+ const fragmentHash = globalFragmentHashFor({
1080
+ node: input.node,
1081
+ providerFingerprint: input.providerFingerprint,
1082
+ });
1083
+ const fragmentState = await this.getGlobalFragmentState({
1084
+ hash: fragmentHash,
1085
+ workflow: input.workflow.name,
1086
+ nodePath,
1087
+ nodeName: input.node.name,
1088
+ nodeKind: input.node.nodeKind,
1089
+ });
1090
+ return await this.evaluateNode({
1091
+ ...input,
1092
+ cache: {
1093
+ scope: "global",
1094
+ workflow: `fragment:${fragmentHash}`,
1095
+ nodePath: "",
1096
+ state: fragmentState,
1097
+ fragmentHash,
1098
+ },
1099
+ cachePrefix: [],
1100
+ suppressCacheSequenceName: undefined,
1101
+ });
1102
+ }
1103
+
1104
+ if (input.node.cacheScope === "local" && input.cache.scope !== "local") {
1105
+ return await this.evaluateNode({
1106
+ ...input,
1107
+ cache: {
1108
+ scope: "local",
1109
+ workflow: input.workflow.name,
1110
+ nodePath: "",
1111
+ state: this.getStateService(),
1112
+ },
1113
+ cachePrefix: input.prefix,
1114
+ suppressCacheSequenceName: input.suppressSequenceName,
1115
+ });
1116
+ }
1117
+
1118
+ const configuredInput = input.node.config
1119
+ ? { ...input, configStack: [...input.configStack, input.node.config] }
1120
+ : input;
1121
+
1122
+ if (configuredInput.node.nodeKind === "task") {
1123
+ return await this.evaluateTask(configuredInput as EvaluateNodeInput & { node: WorkflowTaskNode<any, any, any> });
1124
+ }
1125
+
1126
+ if (configuredInput.node.nodeKind === "parallel") {
1127
+ return await this.evaluateParallel(configuredInput);
1128
+ }
1129
+
1130
+ const sequencePrefix = configuredInput.root || configuredInput.suppressSequenceName === configuredInput.node.name
1131
+ ? configuredInput.prefix
1132
+ : [...configuredInput.prefix, configuredInput.node.name];
1133
+ const cacheSequencePrefix = configuredInput.root || configuredInput.suppressCacheSequenceName === configuredInput.node.name
1134
+ ? configuredInput.cachePrefix
1135
+ : [...configuredInput.cachePrefix, configuredInput.node.name];
1136
+ let state = configuredInput.state;
1137
+
1138
+ for (const child of sequenceChildren(configuredInput.node)) {
1139
+ const result = await this.evaluateNode({
1140
+ ...configuredInput,
1141
+ node: child,
1142
+ state,
1143
+ prefix: sequencePrefix,
1144
+ cachePrefix: cacheSequencePrefix,
1145
+ root: false,
1146
+ });
1147
+ state = {
1148
+ context: result.context,
1149
+ upstreamRunIds: result.upstreamRunIds,
1150
+ previousTasks: result.previousTasks,
1151
+ known: result.known,
1152
+ blockedReason: result.blockedReason,
1153
+ };
1154
+ }
1155
+
1156
+ return { ...state, planNodes: configuredInput.planNodes };
1157
+ }
1158
+
1159
+ private async evaluateParallel(input: EvaluateNodeInput): Promise<EvaluationResult> {
1160
+ const branches = parallelBranches(input.node);
1161
+ const branchOutputs: Record<string, JsonValue> = {};
1162
+ const joinedRunIds: string[] = [];
1163
+ let joinedPreviousTasks = [...input.state.previousTasks];
1164
+ let known = input.state.known;
1165
+ let blockedReason = input.state.blockedReason;
1166
+
1167
+ for (const [branchName, branch] of Object.entries(branches)) {
1168
+ if (branchName in input.state.context) {
1169
+ throw new Error(`Parallel branch ${branchName} conflicts with an existing context key`);
1170
+ }
1171
+
1172
+ const branchState = await this.evaluateNode({
1173
+ ...input,
1174
+ node: branch,
1175
+ state: {
1176
+ context: { ...input.state.context },
1177
+ upstreamRunIds: [...input.state.upstreamRunIds],
1178
+ previousTasks: [...input.state.previousTasks],
1179
+ known: input.state.known,
1180
+ blockedReason: input.state.blockedReason,
1181
+ },
1182
+ prefix: [...input.prefix, branchName],
1183
+ cachePrefix: [...input.cachePrefix, branchName],
1184
+ root: false,
1185
+ suppressSequenceName: branchName,
1186
+ suppressCacheSequenceName: branchName,
1187
+ });
1188
+
1189
+ if (branchState.known) {
1190
+ branchOutputs[branchName] = branchState.context;
1191
+ joinedRunIds.push(...branchState.upstreamRunIds);
1192
+ joinedPreviousTasks = mergePreviousTasks(joinedPreviousTasks, branchState.previousTasks);
1193
+ } else {
1194
+ known = false;
1195
+ blockedReason ??= branchState.blockedReason ?? `depends on ${branchName}`;
1196
+ }
1197
+ }
1198
+
1199
+ return {
1200
+ context: known ? { ...input.state.context, ...branchOutputs } : { ...input.state.context },
1201
+ upstreamRunIds: known ? joinedRunIds.sort() : [],
1202
+ previousTasks: known ? joinedPreviousTasks : input.state.previousTasks,
1203
+ known,
1204
+ blockedReason,
1205
+ planNodes: input.planNodes,
1206
+ };
1207
+ }
1208
+
1209
+ private async evaluateTask(input: EvaluateNodeInput & { node: WorkflowTaskNode<any, any, any> }): Promise<EvaluationResult> {
1210
+ const nodePath = [...input.prefix, input.node.name].join(".");
1211
+ const cacheNodePath = [...input.cachePrefix, input.node.name].join(".");
1212
+ const upstreamRunIds = [...input.state.upstreamRunIds];
1213
+ const nodeKey = hash({
1214
+ cache: "task-v4",
1215
+ kind: "task",
1216
+ path: cacheNodePath,
1217
+ name: input.node.name,
1218
+ config: input.configStack,
1219
+ handler: functionFingerprintFor(input.node.handler),
1220
+ output: input.node.options?.output ?? null,
1221
+ });
1222
+ const planIndex = input.index.value++;
1223
+
1224
+ if (!input.state.known) {
1225
+ input.planNodes.push({
1226
+ index: planIndex,
1227
+ path: nodePath,
1228
+ name: input.node.name,
1229
+ status: "pending",
1230
+ reason: input.state.blockedReason ?? "upstream output is pending",
1231
+ upstreamRunIds,
1232
+ });
1233
+ return {
1234
+ context: input.state.context,
1235
+ upstreamRunIds: [],
1236
+ previousTasks: input.state.previousTasks,
1237
+ known: false,
1238
+ blockedReason: input.state.blockedReason ?? `depends on ${nodePath}`,
1239
+ planNodes: input.planNodes,
1240
+ };
1241
+ }
1242
+
1243
+ const cached = await this.findReusableTaskRun({
1244
+ state: input.cache.state,
1245
+ workflow: input.cache.workflow,
1246
+ nodePath: cacheNodePath,
1247
+ displayNodePath: nodePath,
1248
+ nodeKey,
1249
+ providerFingerprint: input.providerFingerprint,
1250
+ upstreamRunIds,
1251
+ providers: input.providers,
1252
+ outputSchema: input.node.options?.output,
1253
+ cacheTTL: input.node.options?.cacheTTL,
1254
+ });
1255
+
1256
+ if (cached) {
1257
+ const previousTasks = appendPreviousTask(input.state.previousTasks, {
1258
+ name: input.node.name,
1259
+ path: nodePath,
1260
+ cache: {
1261
+ ...input.cache,
1262
+ nodePath: cacheNodePath,
1263
+ },
1264
+ });
1265
+ this.emit({ type: "node.cached", nodePath, runId: cached.id });
1266
+ input.planNodes.push({
1267
+ index: planIndex,
1268
+ path: nodePath,
1269
+ name: input.node.name,
1270
+ status: "cached",
1271
+ runId: cached.id,
1272
+ upstreamRunIds,
1273
+ });
1274
+ return {
1275
+ context: cached.output,
1276
+ upstreamRunIds: [cached.id],
1277
+ previousTasks,
1278
+ known: true,
1279
+ planNodes: input.planNodes,
1280
+ };
1281
+ }
1282
+
1283
+ input.planNodes.push({
1284
+ index: planIndex,
1285
+ path: nodePath,
1286
+ name: input.node.name,
1287
+ status: "pending",
1288
+ reason: "no reusable node run",
1289
+ upstreamRunIds,
1290
+ });
1291
+
1292
+ if (input.mode === "plan") {
1293
+ return {
1294
+ context: input.state.context,
1295
+ upstreamRunIds: [],
1296
+ previousTasks: input.state.previousTasks,
1297
+ known: false,
1298
+ blockedReason: `depends on ${nodePath}`,
1299
+ planNodes: input.planNodes,
1300
+ };
1301
+ }
1302
+
1303
+ this.emit({ type: "node.started", nodePath });
1304
+ const metadata: JsonObject = {};
1305
+ const runtime = await this.createTaskRuntime({
1306
+ workflow: input.workflow,
1307
+ providers: input.providers,
1308
+ nodePath,
1309
+ metadata,
1310
+ });
1311
+ const config = Object.freeze(mergeConfigStack(input.configStack));
1312
+ const step = this.createStepRuntime(
1313
+ input.workflow.name,
1314
+ nodePath,
1315
+ metadata,
1316
+ input.state.context,
1317
+ input.state.previousTasks,
1318
+ );
1319
+ const result = await this.withStepConsole(nodePath, () => input.node.handler({
1320
+ ...runtime,
1321
+ providers: runtime,
1322
+ step,
1323
+ config,
1324
+ }));
1325
+ if (isStepInvalidation(result)) {
1326
+ const targetTask = input.state.previousTasks.find((task) => task.path === result.targetNodePath);
1327
+ const invalidatedRunIds = targetTask
1328
+ ? this.invalidateTaskCaches([
1329
+ targetTask.cache,
1330
+ {
1331
+ ...input.cache,
1332
+ nodePath: cacheNodePath,
1333
+ },
1334
+ ])
1335
+ : this.invalidateTaskCaches([{
1336
+ ...input.cache,
1337
+ nodePath: cacheNodePath,
1338
+ }]);
1339
+ throw new StepInvalidationRestart({
1340
+ workflow: input.workflow.name,
1341
+ target: result.target,
1342
+ targetNodePath: result.targetNodePath,
1343
+ currentNodePath: nodePath,
1344
+ invalidatedRunIds,
1345
+ });
1346
+ }
1347
+ const output = normalizeTaskOutput(
1348
+ nodePath,
1349
+ result,
1350
+ input.node.options?.output,
1351
+ "fresh",
1352
+ input.state.context,
1353
+ );
1354
+ if (!output) {
1355
+ throw new Error(`Task ${nodePath} output failed schema validation`);
1356
+ }
1357
+ const artifacts = collectArtifacts(output);
1358
+ const record: WorkflowNodeRunRecord = {
1359
+ id: crypto.randomUUID(),
1360
+ workflow: input.cache.workflow,
1361
+ nodePath: cacheNodePath,
1362
+ nodeName: input.node.name,
1363
+ nodeKind: input.node.nodeKind,
1364
+ nodeKey,
1365
+ providerFingerprint: input.providerFingerprint,
1366
+ upstreamRunIds,
1367
+ output,
1368
+ artifacts,
1369
+ invalidated: false,
1370
+ createdAt: new Date().toISOString(),
1371
+ metadata,
1372
+ };
1373
+
1374
+ input.cache.state.saveNodeRun(record);
1375
+ for (const artifact of artifacts) {
1376
+ const providerId = providerIdOf(artifact);
1377
+ this.emit({
1378
+ type: "artifact.created",
1379
+ nodePath,
1380
+ providerId: providerId ?? "unknown",
1381
+ kind: kindOf(artifact) ?? "artifact",
1382
+ ref: artifact,
1383
+ });
1384
+ }
1385
+ this.emit({ type: "node.completed", nodePath, runId: record.id });
1386
+
1387
+ const previousTasks = appendPreviousTask(input.state.previousTasks, {
1388
+ name: input.node.name,
1389
+ path: nodePath,
1390
+ cache: {
1391
+ ...input.cache,
1392
+ nodePath: cacheNodePath,
1393
+ },
1394
+ });
1395
+
1396
+ return {
1397
+ context: output,
1398
+ upstreamRunIds: [record.id],
1399
+ previousTasks,
1400
+ known: true,
1401
+ planNodes: input.planNodes,
1402
+ };
1403
+ }
1404
+
1405
+ private async findReusableTaskRun(input: {
1406
+ state: StateService;
1407
+ workflow: string;
1408
+ nodePath: string;
1409
+ displayNodePath: string;
1410
+ nodeKey: string;
1411
+ providerFingerprint: string;
1412
+ upstreamRunIds: readonly string[];
1413
+ providers: ProviderControllers;
1414
+ outputSchema?: OutputSchema;
1415
+ cacheTTL?: WorkflowTaskCacheTTL;
1416
+ }): Promise<WorkflowNodeRunRecord | undefined> {
1417
+ const cached = input.state.findReusableNodeRun(input);
1418
+ if (!cached) return undefined;
1419
+ if (!isCacheFresh(cached.createdAt, input.cacheTTL)) return undefined;
1420
+
1421
+ const parsed = normalizeTaskOutput(input.displayNodePath, cached.output, input.outputSchema, "cached");
1422
+ if (!parsed) return undefined;
1423
+
1424
+ for (const artifact of cached.artifacts) {
1425
+ const providerId = providerIdOf(artifact);
1426
+ if (!providerId) return undefined;
1427
+ const provider = Object.values(input.providers).find((controller) => controller.providerId === providerId);
1428
+ if (provider?.validateArtifact && !await provider.validateArtifact(artifact)) return undefined;
1429
+ }
1430
+
1431
+ return {
1432
+ ...cached,
1433
+ output: parsed,
1434
+ };
1435
+ }
1436
+
1437
+ private async createTaskRuntime(input: {
1438
+ workflow: LoadedWorkflow;
1439
+ providers: ProviderControllers;
1440
+ nodePath: string;
1441
+ metadata: JsonObject;
1442
+ }): Promise<ProviderRuntimeMap<WorkflowProviderMap>> {
1443
+ const entries = await Promise.all(
1444
+ Object.entries(input.providers).map(async ([name, provider]) => {
1445
+ const runtimeContext: ProviderRuntimeContext = {
1446
+ workflow: input.workflow.name,
1447
+ nodePath: input.nodePath,
1448
+ emit: (event) => this.emit(event),
1449
+ interaction: {
1450
+ present: async (session) => {
1451
+ const interactionId = session.id ?? crypto.randomUUID();
1452
+ this.emit({
1453
+ type: "interaction.awaiting_user",
1454
+ nodePath: input.nodePath,
1455
+ interactionId,
1456
+ label: session.title,
1457
+ title: session.title,
1458
+ url: session.url,
1459
+ instructions: session.instructions,
1460
+ });
1461
+
1462
+ try {
1463
+ await this.interactionPresenter({
1464
+ id: interactionId,
1465
+ nodePath: input.nodePath,
1466
+ title: session.title,
1467
+ url: session.url,
1468
+ instructions: session.instructions,
1469
+ });
1470
+
1471
+ const result = await session.completed;
1472
+ this.emit({
1473
+ type: "interaction.completed",
1474
+ nodePath: input.nodePath,
1475
+ interactionId,
1476
+ label: session.title,
1477
+ title: session.title,
1478
+ });
1479
+ return result;
1480
+ } finally {
1481
+ await session.stop();
1482
+ }
1483
+ },
1484
+ },
1485
+ local: this.local,
1486
+ metadata: (metadata) => {
1487
+ Object.assign(input.metadata, metadata);
1488
+ },
1489
+ };
1490
+ return [name, await provider.runtime(runtimeContext)] as const;
1491
+ }),
1492
+ );
1493
+
1494
+ return Object.fromEntries(entries) as ProviderRuntimeMap<WorkflowProviderMap>;
1495
+ }
1496
+
1497
+ private async runWorkspaceCreate(input: {
1498
+ workflow: LoadedWorkflow;
1499
+ providers: ProviderControllers;
1500
+ workspace: WorkspaceRecord;
1501
+ context: Record<string, JsonValue>;
1502
+ name: string;
1503
+ }): Promise<void> {
1504
+ if (!input.workflow.workspace) {
1505
+ throw new Error(`Workflow ${input.workflow.name} does not define a workspace`);
1506
+ }
1507
+ const draft = cloneWorkspace(input.workspace);
1508
+ const metadata: JsonObject = {};
1509
+ const providers = await this.createTaskRuntime({
1510
+ workflow: input.workflow,
1511
+ providers: input.providers,
1512
+ nodePath: `workspace.${input.name}.create`,
1513
+ metadata,
1514
+ });
1515
+
1516
+ const createNodePath = `workspace.${input.name}.create`;
1517
+ const workspaceDef = input.workflow.workspace;
1518
+ try {
1519
+ const data = await this.withStepConsole(createNodePath, () => workspaceDef.create({
1520
+ ...providers,
1521
+ workflow: {
1522
+ name: input.workflow.name,
1523
+ ctx: Object.freeze({ ...input.context }),
1524
+ },
1525
+ workspace: {
1526
+ name: input.name,
1527
+ },
1528
+ providers,
1529
+ local: this.local,
1530
+ step: this.createStepRuntime(input.workflow.name, createNodePath, metadata),
1531
+ }));
1532
+ assertJsonValue(data, `Workflow ${input.workflow.name} workspace create result`);
1533
+ if (!isPlainObject(data)) {
1534
+ throw new Error(`Workflow ${input.workflow.name} workspace create result must be an object`);
1535
+ }
1536
+ draft.ctx = { ...data };
1537
+ } finally {
1538
+ draft.updatedAt = new Date().toISOString();
1539
+ this.getStateService().saveWorkspace(draft);
1540
+ }
1541
+ }
1542
+
1543
+ private async runConfigWorkspaceOperation(input: {
1544
+ workflow: LoadedWorkflow;
1545
+ providers: ProviderControllers;
1546
+ workspace: WorkspaceRecord;
1547
+ operation: WorkflowWorkspaceOperationDefinition<any, any, any, any>;
1548
+ rawInput: unknown;
1549
+ }): Promise<unknown> {
1550
+ const metadata: JsonObject = {};
1551
+ const providers = await this.createTaskRuntime({
1552
+ workflow: input.workflow,
1553
+ providers: input.providers,
1554
+ nodePath: `workspace.${input.workspace.name}.${input.operation.id}`,
1555
+ metadata,
1556
+ });
1557
+ const draft = cloneWorkspace(input.workspace);
1558
+ const workspace = this.createWorkspaceRuntime(draft);
1559
+ const operationInput = this.resolveOperationInput(input.workflow, input.operation, input.rawInput ?? {});
1560
+
1561
+ const workspaceOperationNodePath = `workspace.${input.workspace.name}.${input.operation.id}`;
1562
+ this.emit({
1563
+ type: "workspace.operation.started",
1564
+ workspaceName: input.workspace.name,
1565
+ operationId: input.operation.id,
1566
+ });
1567
+ const result = await this.withStepConsole(workspaceOperationNodePath, () => input.operation.run({
1568
+ ...providers,
1569
+ workflow: {
1570
+ name: input.workflow.name,
1571
+ ctx: Object.freeze({ ...input.workspace.workflowCtx }),
1572
+ },
1573
+ input: Object.freeze(operationInput),
1574
+ workspace,
1575
+ providers,
1576
+ local: this.local,
1577
+ step: this.createStepRuntime(
1578
+ input.workflow.name,
1579
+ workspaceOperationNodePath,
1580
+ metadata,
1581
+ ),
1582
+ }));
1583
+ if (result !== undefined) assertJsonValue(result, `Workspace operation ${input.operation.id} result`);
1584
+ this.emit({
1585
+ type: "workspace.operation.completed",
1586
+ workspaceName: input.workspace.name,
1587
+ operationId: input.operation.id,
1588
+ });
1589
+ return result ?? null;
1590
+ }
1591
+
1592
+ private createWorkspaceRuntime<Data extends object>(draft: WorkspaceRecord): WorkspaceRuntimeRecord<Data> {
1593
+ return Object.freeze({
1594
+ name: draft.name,
1595
+ ctx: Object.freeze({ ...draft.ctx }) as Data,
1596
+ }) as WorkspaceRuntimeRecord<Data>;
1597
+ }
1598
+
1599
+ // Wraps a user step handler in an AsyncLocalStorage scope so that any
1600
+ // console.log / debug / warn / error invoked inside (transitively, through
1601
+ // any helper or third-party SDK) is captured and emitted as a log.output
1602
+ // event tied to this step's node path.
1603
+ private withStepConsole<T>(nodePath: string, fn: () => Promise<T> | T): Promise<T> | T {
1604
+ const sink: StepConsoleSink = ({ level, message }) => {
1605
+ this.emit({
1606
+ type: "log.output",
1607
+ nodePath,
1608
+ stream: consoleLevelToLogStream(level),
1609
+ data: message,
1610
+ });
1611
+ };
1612
+ return runWithStepConsole(sink, fn);
1613
+ }
1614
+
1615
+ private createStepRuntime<Context extends JsonObject = JsonObject>(
1616
+ workflow: string,
1617
+ nodePath: string,
1618
+ metadata: JsonObject,
1619
+ context: Context = {} as Context,
1620
+ previousTasks: readonly EvaluationPreviousTask[] = [],
1621
+ ) {
1622
+ return {
1623
+ workflow,
1624
+ nodePath,
1625
+ ctx: Object.freeze({ ...context }) as Readonly<Context>,
1626
+ metadata: (value: JsonObject) => {
1627
+ Object.assign(metadata, value);
1628
+ },
1629
+ invalidate: <Target extends string>(target: Target) => {
1630
+ const matches = previousTasks.filter((task) => task.name === target || task.path === target);
1631
+ if (matches.length === 0) {
1632
+ throw new Error(`Task ${nodePath} cannot invalidate ${target} because it has not run earlier in this workflow`);
1633
+ }
1634
+ if (matches.length > 1) {
1635
+ throw new Error(`Task ${nodePath} cannot invalidate ${target} because it matches multiple earlier tasks`);
1636
+ }
1637
+ return {
1638
+ kind: STEP_INVALIDATION_KIND,
1639
+ target,
1640
+ targetNodePath: matches[0]!.path,
1641
+ };
1642
+ },
1643
+ };
1644
+ }
1645
+
1646
+ private getWorkflow(name: string | undefined): LoadedWorkflow {
1647
+ if (this.workflows.size === 0) {
1648
+ throw new Error(`No workflows loaded. Call engine.load() first.`);
1649
+ }
1650
+
1651
+ if (name) {
1652
+ const workflow = this.workflows.get(name);
1653
+ if (!workflow) throw new Error(`Unknown workflow ${name}`);
1654
+ return workflow;
1655
+ }
1656
+
1657
+ if (this.workflows.size === 1) return [...this.workflows.values()][0]!;
1658
+
1659
+ throw new Error(`Multiple workflows are defined; pass a workflow name`);
1660
+ }
1661
+
1662
+ private findRuntimeOperationEntry(operationId: string): RuntimeOperationEntry | undefined {
1663
+ return this.listRuntimeOperationEntries().find((entry) =>
1664
+ entry.summary.id === operationId || entry.summary.aliases?.includes(operationId)
1665
+ );
1666
+ }
1667
+
1668
+ private getWorkflowOperation(operationId: string, workflowName: string | undefined): {
1669
+ workflow: LoadedWorkflow;
1670
+ operation: WorkflowOperationDefinition<any, any>;
1671
+ } {
1672
+ const workflows = workflowName ? [this.getWorkflow(workflowName)] : this.listWorkflows();
1673
+ const matches = workflows.flatMap((workflow) =>
1674
+ workflow.operations
1675
+ .filter((operation) => operation.id === operationId)
1676
+ .map((operation) => ({ workflow, operation })),
1677
+ );
1678
+ if (matches.length === 1) return matches[0]!;
1679
+ if (matches.length > 1) throw new Error(`Multiple workflows define operation ${operationId}; pass a workflow name`);
1680
+ throw new EngineOperationNotFoundError(operationId);
1681
+ }
1682
+
1683
+ private resolveOperationInput(
1684
+ workflow: LoadedWorkflow,
1685
+ operation: { id: string; input?: { fields: readonly WorkflowInputFieldDefinition[] } },
1686
+ value: unknown,
1687
+ ): Record<string, unknown> {
1688
+ const raw = isPlainObject(value) ? value : {};
1689
+ const fields = operation.input?.fields ?? [];
1690
+ if (fields.length === 0) return { ...raw };
1691
+
1692
+ const resolved: Record<string, unknown> = {};
1693
+ for (const field of fields) {
1694
+ const rawValue = raw[field.name] ?? field.defaultValue;
1695
+ if (rawValue === undefined || rawValue === null || rawValue === "") {
1696
+ if (field.required ?? true) {
1697
+ throw new EngineOperationValidationError({
1698
+ operation: operation.id,
1699
+ message: `Operation ${operation.id} requires ${field.name}`,
1700
+ });
1701
+ }
1702
+ continue;
1703
+ }
1704
+
1705
+ if (field.kind === "workspace") {
1706
+ if (typeof rawValue !== "string") {
1707
+ throw new EngineOperationValidationError({
1708
+ operation: operation.id,
1709
+ message: `Operation ${operation.id} input ${field.name} must be a workspace name`,
1710
+ });
1711
+ }
1712
+ const workspace = this.getStateService().findWorkspace(rawValue);
1713
+ if (!workspace) {
1714
+ throw new EngineOperationValidationError({
1715
+ operation: operation.id,
1716
+ message: `Unknown workspace ${rawValue}`,
1717
+ });
1718
+ }
1719
+ if (workspace.workflow !== workflow.name) {
1720
+ throw new EngineOperationValidationError({
1721
+ operation: operation.id,
1722
+ message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
1723
+ });
1724
+ }
1725
+ resolved[field.name] = this.createWorkspaceRuntime(cloneWorkspace(workspace));
1726
+ continue;
1727
+ }
1728
+
1729
+ if (field.kind === "string") {
1730
+ if (typeof rawValue !== "string") {
1731
+ throw new EngineOperationValidationError({
1732
+ operation: operation.id,
1733
+ message: `Operation ${operation.id} input ${field.name} must be a string`,
1734
+ });
1735
+ }
1736
+ resolved[field.name] = rawValue;
1737
+ continue;
1738
+ }
1739
+
1740
+ if (field.kind === "boolean") {
1741
+ if (typeof rawValue !== "boolean") {
1742
+ throw new EngineOperationValidationError({
1743
+ operation: operation.id,
1744
+ message: `Operation ${operation.id} input ${field.name} must be a boolean`,
1745
+ });
1746
+ }
1747
+ resolved[field.name] = rawValue;
1748
+ continue;
1749
+ }
1750
+
1751
+ if (field.kind === "number") {
1752
+ if (typeof rawValue !== "number" || !Number.isFinite(rawValue)) {
1753
+ throw new EngineOperationValidationError({
1754
+ operation: operation.id,
1755
+ message: `Operation ${operation.id} input ${field.name} must be a number`,
1756
+ });
1757
+ }
1758
+ resolved[field.name] = rawValue;
1759
+ }
1760
+ }
1761
+
1762
+ return resolved;
1763
+ }
1764
+
1765
+ private getStateService(): StateService {
1766
+ if (!this.state) {
1767
+ throw new Error(`No state database loaded. Call engine.load() first.`);
1768
+ }
1769
+ return this.state;
1770
+ }
1771
+
1772
+ private async getGlobalFragmentState(input: GlobalFragmentStateLocationInput): Promise<StateService> {
1773
+ this.evaluationFragmentHashes?.add(input.hash);
1774
+ const existing = this.globalFragmentStates.get(input.hash);
1775
+ if (existing) return existing.state;
1776
+
1777
+ const located = this.globalFragmentStateLocator(input);
1778
+ const statePath = typeof located === "string" ? located : located.statePath;
1779
+ const state = this.stateFactory({
1780
+ projectDir: this.projectDir,
1781
+ configPath: this.configPath,
1782
+ statePath,
1783
+ source: {
1784
+ kind: "global-fragment",
1785
+ hash: input.hash,
1786
+ workflow: input.workflow,
1787
+ nodePath: input.nodePath,
1788
+ nodeName: input.nodeName,
1789
+ nodeKind: input.nodeKind,
1790
+ },
1791
+ });
1792
+ await state.syncSchema();
1793
+ this.globalFragmentStates.set(input.hash, { input, state });
1794
+ return state;
1795
+ }
1796
+
1797
+ private invalidateTaskCaches(targets: EvaluationCacheTarget[]): string[] {
1798
+ const grouped = new Map<string, { target: EvaluationCacheTarget; nodePaths: Set<string> }>();
1799
+ for (const target of targets) {
1800
+ const key = `${target.state.path}\0${target.workflow}`;
1801
+ const existing = grouped.get(key);
1802
+ if (existing) {
1803
+ existing.nodePaths.add(target.nodePath);
1804
+ } else {
1805
+ grouped.set(key, { target, nodePaths: new Set([target.nodePath]) });
1806
+ }
1807
+ }
1808
+
1809
+ return [...grouped.values()].flatMap(({ target, nodePaths }) =>
1810
+ target.state.invalidateNodeRuns({
1811
+ workflow: target.workflow,
1812
+ nodePaths: [...nodePaths],
1813
+ })
1814
+ );
1815
+ }
1816
+
1817
+ private async createProviders(workflow: LoadedWorkflow): Promise<ProviderControllers> {
1818
+ const entries = await Promise.all(
1819
+ Object.entries(workflow.providers).map(async ([name, provider]) => {
1820
+ const controller = await this.providerFactory({
1821
+ provider,
1822
+ storage: this.getStateService().providerStorage(provider.providerId),
1823
+ hostStorage: this.getProviderHostStorage(provider.providerId),
1824
+ local: this.local,
1825
+ });
1826
+ return [name, controller] as const;
1827
+ }),
1828
+ );
1829
+ return Object.fromEntries(entries);
1830
+ }
1831
+
1832
+ private async createProviderFromPlugin(input: Parameters<ProviderFactory>[0]): Promise<WorkflowProviderController> {
1833
+ const plugin = this.providers.find((provider) => provider.providerId === input.provider.providerId);
1834
+ if (!plugin) {
1835
+ throw new Error(
1836
+ `Provider ${input.provider.providerId} does not implement the Rigkit workflow provider contract. ` +
1837
+ `Register a provider plugin to use it in workflow tasks.`,
1838
+ );
1839
+ }
1840
+ return await plugin.createProvider(input);
1841
+ }
1842
+
1843
+ private getProviderHostStorage(providerId: string): ReturnType<ProviderHostStorageFactory> {
1844
+ let storage = this.providerHostStorage.get(providerId);
1845
+ if (!storage) {
1846
+ storage = this.hostStorageFactory({
1847
+ providerId,
1848
+ rootDir: this.hostStorageDir,
1849
+ });
1850
+ this.providerHostStorage.set(providerId, storage);
1851
+ }
1852
+ return storage;
1853
+ }
1854
+
1855
+ private async resolveWorkflow(root: WorkflowNodeDefinition<any, any, any>): Promise<LoadedWorkflow> {
1856
+ const providers: Record<string, LoadedProviderDefinition> = {};
1857
+ for (const [name, definition] of Object.entries(root.workflow.providers)) {
1858
+ if (!isProviderDefinition(definition)) {
1859
+ throw new Error(`Workflow ${root.workflow.name} provider ${name} is invalid`);
1860
+ }
1861
+ providers[name] = await resolveProviderDefinition(definition);
1862
+ }
1863
+
1864
+ return {
1865
+ name: root.workflow.name,
1866
+ providers,
1867
+ root,
1868
+ workspace: root.workspaceDefinition,
1869
+ operations: root.operations ?? [],
1870
+ workspaceOperations: root.workspaceOperations ?? [],
1871
+ };
1872
+ }
1873
+
1874
+ private emit(event: WorkflowEvent): void {
1875
+ for (const handler of this.handlers) handler(event);
1876
+ }
1877
+ }
1878
+
1879
+ export async function createDevMachineEngine(
1880
+ options: CreateDevMachineEngineOptions = {},
1881
+ ): Promise<DevMachineEngine> {
1882
+ return new DevMachineEngine(options);
1883
+ }
1884
+
1885
+ function cloneWorkspace(workspace: WorkspaceRecord): WorkspaceRecord {
1886
+ return {
1887
+ ...workspace,
1888
+ workflowCtx: { ...workspace.workflowCtx },
1889
+ ctx: { ...workspace.ctx },
1890
+ };
1891
+ }
1892
+
1893
+ function parseWorkspaceOperationId(value: string): { workspace: string; operation: string } | undefined {
1894
+ const slash = value.indexOf("/");
1895
+ if (slash <= 0 || slash === value.length - 1) return undefined;
1896
+ return {
1897
+ workspace: value.slice(0, slash),
1898
+ operation: value.slice(slash + 1),
1899
+ };
1900
+ }
1901
+
1902
+ const workspaceNamePattern = /^(?!-)[A-Za-z0-9._-]+$/;
1903
+
1904
+ function assertValidWorkspaceName(value: string): void {
1905
+ if (!value) throw new Error(`create requires a workspace name`);
1906
+ if (!workspaceNamePattern.test(value)) {
1907
+ throw new Error(
1908
+ `Workspace name "${value}" is invalid. Use only letters, numbers, ".", "_", and "-", and do not start with "-".`,
1909
+ );
1910
+ }
1911
+ }
1912
+
1913
+ async function resolveProviderDefinition(
1914
+ definition: WorkflowDefinition<any, any>["providers"][string],
1915
+ ): Promise<LoadedProviderDefinition> {
1916
+ return {
1917
+ providerId: definition.providerId,
1918
+ config: await resolveConfigObject(definition.config),
1919
+ plugin: definition.plugin,
1920
+ };
1921
+ }
1922
+
1923
+ async function resolveConfigObject(value: unknown): Promise<Record<string, unknown>> {
1924
+ const resolved = await resolveConfigValue(value);
1925
+ if (!resolved || typeof resolved !== "object" || Array.isArray(resolved)) {
1926
+ throw new Error(`Provider config must resolve to an object`);
1927
+ }
1928
+ return resolved as Record<string, unknown>;
1929
+ }
1930
+
1931
+ async function resolveConfigValue(value: unknown): Promise<unknown> {
1932
+ if (typeof value === "function") {
1933
+ return await (value as () => unknown)();
1934
+ }
1935
+
1936
+ if (Array.isArray(value)) {
1937
+ return await Promise.all(value.map((item) => resolveConfigValue(item)));
1938
+ }
1939
+
1940
+ if (value && typeof value === "object") {
1941
+ const entries = await Promise.all(
1942
+ Object.entries(value).map(async ([key, entry]) => [key, await resolveConfigValue(entry)] as const),
1943
+ );
1944
+ return Object.fromEntries(entries);
1945
+ }
1946
+
1947
+ return value;
1948
+ }
1949
+
1950
+ function normalizeDefinitions(value: unknown): WorkflowNodeDefinition<any, any, any>[] {
1951
+ if (isRigkitConfig(value)) {
1952
+ return Object.entries(value.workflows).map(([name, node]) =>
1953
+ attachWorkflowProviders(name, node, value.providers)
1954
+ );
1955
+ }
1956
+
1957
+ if (Array.isArray(value)) {
1958
+ throw new Error(`rig.config.ts must default export a workflow node or defineConfig(...)`);
1959
+ }
1960
+ if (!isWorkflowNode(value)) {
1961
+ throw new Error(`rig.config.ts must default export a node created with workflow(...).sequence(...) or defineConfig(...)`);
1962
+ }
1963
+ return [value];
1964
+ }
1965
+
1966
+ function attachWorkflowProviders(
1967
+ name: string,
1968
+ node: WorkflowNodeDefinition<any, any, any>,
1969
+ providers: WorkflowProviderMap,
1970
+ ): WorkflowNodeDefinition<any, any, any> {
1971
+ const workflow: WorkflowDefinition<string, any> = {
1972
+ ...node.workflow,
1973
+ name: node.workflow.name || name,
1974
+ providers,
1975
+ };
1976
+ return attachWorkflow(node, workflow);
1977
+ }
1978
+
1979
+ function attachWorkflow(
1980
+ node: WorkflowNodeDefinition<any, any, any>,
1981
+ workflow: WorkflowDefinition<string, any>,
1982
+ ): WorkflowNodeDefinition<any, any, any> {
1983
+ if (node.nodeKind === "parallel") {
1984
+ return {
1985
+ ...node,
1986
+ workflow,
1987
+ branches: Object.fromEntries(
1988
+ Object.entries(parallelBranches(node)).map(([name, branch]) => [name, attachWorkflow(branch, workflow)]),
1989
+ ),
1990
+ } as WorkflowNodeDefinition<any, any, any>;
1991
+ }
1992
+ if (node.nodeKind === "sequence") {
1993
+ return {
1994
+ ...node,
1995
+ workflow,
1996
+ children: sequenceChildren(node).map((child) => attachWorkflow(child, workflow)),
1997
+ } as WorkflowNodeDefinition<any, any, any>;
1998
+ }
1999
+ return {
2000
+ ...node,
2001
+ workflow,
2002
+ };
2003
+ }
2004
+
2005
+ function summarizeWorkflow(workflow: LoadedWorkflow): WorkflowSummary {
2006
+ return {
2007
+ name: workflow.name,
2008
+ providers: Object.entries(workflow.providers).map(([name, provider]) => `${name}:${provider.providerId}`),
2009
+ nodes: collectNodePaths(workflow.root),
2010
+ operations: workflow.operations.map((operation) => operation.id),
2011
+ createsWorkspace: Boolean(workflow.workspace),
2012
+ workspace: workflow.workspace,
2013
+ };
2014
+ }
2015
+
2016
+ function collectNodePaths(root: WorkflowNodeDefinition<any, any, any>): string[] {
2017
+ const paths: string[] = [];
2018
+ walk(root, [], true);
2019
+ return paths;
2020
+
2021
+ function walk(node: WorkflowNodeDefinition<any, any, any>, prefix: string[], rootNode: boolean, suppress?: string): void {
2022
+ if (node.nodeKind === "task") {
2023
+ paths.push([...prefix, node.name].join("."));
2024
+ return;
2025
+ }
2026
+ if (node.nodeKind === "parallel") {
2027
+ for (const [branchName, branch] of Object.entries(parallelBranches(node))) {
2028
+ walk(branch, [...prefix, branchName], false, branchName);
2029
+ }
2030
+ return;
2031
+ }
2032
+ const sequencePrefix = rootNode || suppress === node.name ? prefix : [...prefix, node.name];
2033
+ for (const child of sequenceChildren(node)) walk(child, sequencePrefix, false);
2034
+ }
2035
+ }
2036
+
2037
+ function providerFingerprintFor(workflow: LoadedWorkflow): string {
2038
+ return hash({
2039
+ cache: "provider-v2",
2040
+ providers: Object.fromEntries(
2041
+ Object.entries(workflow.providers).map(([name, provider]) => [
2042
+ name,
2043
+ {
2044
+ providerId: provider.providerId,
2045
+ config: provider.config,
2046
+ plugin: providerPluginFingerprint(provider.plugin),
2047
+ },
2048
+ ]),
2049
+ ),
2050
+ });
2051
+ }
2052
+
2053
+ function providerPluginFingerprint(plugin: unknown): unknown {
2054
+ if (!isBaseProviderPlugin(plugin)) return null;
2055
+ return {
2056
+ providerId: plugin.providerId,
2057
+ createProvider: functionFingerprintFor(plugin.createProvider),
2058
+ };
2059
+ }
2060
+
2061
+ function globalFragmentHashFor(input: {
2062
+ node: WorkflowNodeDefinition<any, any, any>;
2063
+ providerFingerprint: string;
2064
+ }): string {
2065
+ return `sha256-${hash({
2066
+ cache: "fragment-v1",
2067
+ graph: graphFingerprintFor(input.node),
2068
+ providerFingerprint: input.providerFingerprint,
2069
+ })}`;
2070
+ }
2071
+
2072
+ function graphFingerprintFor(node: WorkflowNodeDefinition<any, any, any>): unknown {
2073
+ if (node.nodeKind === "task") {
2074
+ const task = node as WorkflowTaskNode<any, any, any>;
2075
+ return {
2076
+ kind: "task",
2077
+ name: task.name,
2078
+ scope: task.cacheScope ?? null,
2079
+ config: task.config ?? null,
2080
+ handler: functionFingerprintFor(task.handler),
2081
+ output: task.options?.output ?? null,
2082
+ cacheTTL: task.options?.cacheTTL ?? null,
2083
+ };
2084
+ }
2085
+
2086
+ if (node.nodeKind === "parallel") {
2087
+ return {
2088
+ kind: "parallel",
2089
+ name: node.name,
2090
+ scope: node.cacheScope ?? null,
2091
+ config: node.config ?? null,
2092
+ branches: Object.fromEntries(
2093
+ Object.entries(parallelBranches(node)).map(([name, branch]) => [name, graphFingerprintFor(branch)]),
2094
+ ),
2095
+ };
2096
+ }
2097
+
2098
+ return {
2099
+ kind: "sequence",
2100
+ name: node.name,
2101
+ scope: node.cacheScope ?? null,
2102
+ config: node.config ?? null,
2103
+ children: sequenceChildren(node).map((child) => graphFingerprintFor(child)),
2104
+ };
2105
+ }
2106
+
2107
+ function nodeDisplayPath(
2108
+ node: WorkflowNodeDefinition<any, any, any>,
2109
+ prefix: string[],
2110
+ root: boolean,
2111
+ suppressSequenceName?: string,
2112
+ ): string {
2113
+ if (node.nodeKind === "task") return [...prefix, node.name].join(".");
2114
+ if (node.nodeKind === "sequence") {
2115
+ return (root || suppressSequenceName === node.name ? prefix : [...prefix, node.name]).join(".");
2116
+ }
2117
+ return [...prefix, node.name].join(".");
2118
+ }
2119
+
2120
+ function cacheEntryForRun(
2121
+ run: WorkflowNodeRunRecord,
2122
+ scope: WorkflowCacheScope,
2123
+ fragmentHash?: string,
2124
+ workflow?: string,
2125
+ ): EngineCacheEntry {
2126
+ return {
2127
+ scope,
2128
+ workflow: workflow ?? run.workflow,
2129
+ nodePath: run.nodePath,
2130
+ nodeName: run.nodeName,
2131
+ nodeKind: run.nodeKind,
2132
+ runId: run.id,
2133
+ invalidated: run.invalidated,
2134
+ createdAt: run.createdAt,
2135
+ ...(fragmentHash ? { fragmentHash } : {}),
2136
+ };
2137
+ }
2138
+
2139
+ function mergeConfigStack(configStack: readonly JsonObject[]): JsonObject {
2140
+ return Object.assign({}, ...configStack);
2141
+ }
2142
+
2143
+ function functionFingerprintFor(fn: Function): { name: string; length: number; source: string } {
2144
+ return {
2145
+ name: fn.name,
2146
+ length: fn.length,
2147
+ source: Function.prototype.toString.call(fn),
2148
+ };
2149
+ }
2150
+
2151
+ function sequenceChildren(node: WorkflowNodeDefinition<any, any, any>): readonly WorkflowNodeDefinition<any, any, any>[] {
2152
+ return (node as { children?: readonly WorkflowNodeDefinition<any, any, any>[] }).children ?? [];
2153
+ }
2154
+
2155
+ function parallelBranches(node: WorkflowNodeDefinition<any, any, any>): Record<string, WorkflowNodeDefinition<any, any, any>> {
2156
+ return (node as { branches?: Record<string, WorkflowNodeDefinition<any, any, any>> }).branches ?? {};
2157
+ }
2158
+
2159
+ function appendPreviousTask(
2160
+ tasks: readonly EvaluationPreviousTask[],
2161
+ task: EvaluationPreviousTask,
2162
+ ): EvaluationPreviousTask[] {
2163
+ return mergePreviousTasks([...tasks], [task]);
2164
+ }
2165
+
2166
+ function mergePreviousTasks(
2167
+ left: readonly EvaluationPreviousTask[],
2168
+ right: readonly EvaluationPreviousTask[],
2169
+ ): EvaluationPreviousTask[] {
2170
+ const seen = new Set<string>();
2171
+ const result: EvaluationPreviousTask[] = [];
2172
+ for (const task of [...left, ...right]) {
2173
+ if (seen.has(task.path)) continue;
2174
+ seen.add(task.path);
2175
+ result.push(task);
2176
+ }
2177
+ return result;
2178
+ }
2179
+
2180
+ function normalizeTaskOutput(
2181
+ nodePath: string,
2182
+ result: unknown,
2183
+ schema: OutputSchema | undefined,
2184
+ source: "fresh" | "cached",
2185
+ currentContext: Record<string, JsonValue> = {},
2186
+ ): Record<string, JsonValue> | undefined {
2187
+ if (source === "fresh") {
2188
+ if (result === undefined) return { ...currentContext };
2189
+ if (!isPlainObject(result) || !("ctx" in result)) {
2190
+ throw new Error(`Task ${nodePath} must return { ctx: { ... } } or step.invalidate(...)`);
2191
+ }
2192
+ const ctx = result.ctx;
2193
+ const value = schema ? parseWithSchema(schema, ctx, source) : ctx;
2194
+ if (!isPlainObject(value)) {
2195
+ throw new Error(`Task ${nodePath} ctx must be a JSON-serializable object`);
2196
+ }
2197
+
2198
+ for (const [key, item] of Object.entries(value)) {
2199
+ assertJsonValue(item, `Task ${nodePath} ctx value ${key}`);
2200
+ }
2201
+
2202
+ return value as Record<string, JsonValue>;
2203
+ }
2204
+
2205
+ const value = schema ? parseWithSchema(schema, result, source) : result;
2206
+ if (value === undefined) return schema ? undefined : {};
2207
+ if (!isPlainObject(value)) {
2208
+ if (source === "cached") return undefined;
2209
+ throw new Error(`Task ${nodePath} cached ctx must be a JSON-serializable object`);
2210
+ }
2211
+
2212
+ for (const [key, item] of Object.entries(value)) {
2213
+ assertJsonValue(item, `Task ${nodePath} ctx value ${key}`);
2214
+ }
2215
+
2216
+ return value as Record<string, JsonValue>;
2217
+ }
2218
+
2219
+ function isCacheFresh(createdAt: string, ttl: WorkflowTaskCacheTTL | undefined): boolean {
2220
+ const ttlMs = parseCacheTTL(ttl);
2221
+ if (ttlMs === undefined) return true;
2222
+ if (ttlMs <= 0) return false;
2223
+ const createdTime = Date.parse(createdAt);
2224
+ if (Number.isNaN(createdTime)) return false;
2225
+ return Date.now() - createdTime <= ttlMs;
2226
+ }
2227
+
2228
+ function parseCacheTTL(ttl: WorkflowTaskCacheTTL | undefined): number | undefined {
2229
+ if (ttl === undefined) return undefined;
2230
+ if (typeof ttl === "number") {
2231
+ assertFiniteTTL(ttl, "cacheTTL");
2232
+ return ttl;
2233
+ }
2234
+ if (typeof ttl === "string") return parseCacheTTLString(ttl);
2235
+
2236
+ const total =
2237
+ (ttl.seconds ?? 0) * 1000 +
2238
+ (ttl.minutes ?? 0) * 60 * 1000 +
2239
+ (ttl.hours ?? 0) * 60 * 60 * 1000 +
2240
+ (ttl.days ?? 0) * 24 * 60 * 60 * 1000;
2241
+ assertFiniteTTL(total, "cacheTTL");
2242
+ return total;
2243
+ }
2244
+
2245
+ function parseCacheTTLString(value: string): number {
2246
+ const input = value.trim();
2247
+ const match = input.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i);
2248
+ if (!match) {
2249
+ throw new Error(`cacheTTL must be a number, an object, or a string like "30m", "6h", or "1d"`);
2250
+ }
2251
+ const amount = Number(match[1]);
2252
+ assertFiniteTTL(amount, "cacheTTL");
2253
+ const unit = match[2].toLowerCase();
2254
+ const multiplier =
2255
+ unit === "ms" ? 1
2256
+ : unit === "s" ? 1000
2257
+ : unit === "m" ? 60 * 1000
2258
+ : unit === "h" ? 60 * 60 * 1000
2259
+ : 24 * 60 * 60 * 1000;
2260
+ return amount * multiplier;
2261
+ }
2262
+
2263
+ function assertFiniteTTL(value: number, label: string): void {
2264
+ if (!Number.isFinite(value) || value < 0) {
2265
+ throw new Error(`${label} must be a finite non-negative duration`);
2266
+ }
2267
+ }
2268
+
2269
+ function isStepInvalidation(value: unknown): value is WorkflowStepInvalidation<string> {
2270
+ return isPlainObject(value) &&
2271
+ value.kind === STEP_INVALIDATION_KIND &&
2272
+ typeof value.target === "string" &&
2273
+ typeof value.targetNodePath === "string";
2274
+ }
2275
+
2276
+ function parseWithSchema(
2277
+ schema: OutputSchema,
2278
+ value: unknown,
2279
+ source: "fresh" | "cached",
2280
+ ): unknown {
2281
+ if ("safeParse" in schema && typeof schema.safeParse === "function") {
2282
+ const result = schema.safeParse(value);
2283
+ if (result.success) return result.data;
2284
+ if (source === "cached") return undefined;
2285
+ throw new Error(`Task output failed schema validation`);
2286
+ }
2287
+
2288
+ if ("parse" in schema && typeof schema.parse === "function") {
2289
+ try {
2290
+ return schema.parse(value);
2291
+ } catch (error) {
2292
+ if (source === "cached") return undefined;
2293
+ throw error;
2294
+ }
2295
+ }
2296
+
2297
+ return value;
2298
+ }
2299
+
2300
+ function collectArtifacts(value: unknown): JsonValue[] {
2301
+ const artifacts: JsonValue[] = [];
2302
+ visit(value);
2303
+ return artifacts;
2304
+
2305
+ function visit(item: unknown): void {
2306
+ if (Array.isArray(item)) {
2307
+ for (const child of item) visit(child);
2308
+ return;
2309
+ }
2310
+ if (!isPlainObject(item)) return;
2311
+ if (typeof item.provider === "string" && typeof item.kind === "string") {
2312
+ artifacts.push(item as JsonValue);
2313
+ }
2314
+ for (const child of Object.values(item)) visit(child);
2315
+ }
2316
+ }
2317
+
2318
+ function providerIdOf(value: unknown): string | undefined {
2319
+ return isPlainObject(value) && typeof value.provider === "string" ? value.provider : undefined;
2320
+ }
2321
+
2322
+ function kindOf(value: unknown): string | undefined {
2323
+ return isPlainObject(value) && typeof value.kind === "string" ? value.kind : undefined;
2324
+ }
2325
+
2326
+ function assertJsonValue(value: unknown, label: string): asserts value is JsonValue {
2327
+ if (
2328
+ value === null ||
2329
+ typeof value === "string" ||
2330
+ typeof value === "number" ||
2331
+ typeof value === "boolean"
2332
+ ) {
2333
+ return;
2334
+ }
2335
+
2336
+ if (Array.isArray(value)) {
2337
+ value.forEach((item, index) => assertJsonValue(item, `${label}[${index}]`));
2338
+ return;
2339
+ }
2340
+
2341
+ if (isPlainObject(value)) {
2342
+ for (const [key, item] of Object.entries(value)) {
2343
+ assertJsonValue(item, `${label}.${key}`);
2344
+ }
2345
+ return;
2346
+ }
2347
+
2348
+ throw new Error(`${label} must be JSON-serializable`);
2349
+ }
2350
+
2351
+ function isJsonValue(value: unknown): value is JsonValue {
2352
+ try {
2353
+ assertJsonValue(value, "value");
2354
+ return true;
2355
+ } catch {
2356
+ return false;
2357
+ }
2358
+ }
2359
+
2360
+ const RESERVED_HOST_OPERATION_IDS = new Set<string>(RESERVED_WORKFLOW_OPERATION_IDS);
2361
+
2362
+ const CORE_OPERATION_INPUT_FIELDS: Record<string, readonly string[]> = {
2363
+ plan: ["workflow"],
2364
+ apply: ["workflow", "dryRun"],
2365
+ create: ["workflow", "name"],
2366
+ ssh: ["workflow", "workspaceOrVmId", "user", "print"],
2367
+ snapshot: ["workflow", "workspace", "label"],
2368
+ };
2369
+
2370
+ function assertAllowedConfigOperationId(operationId: string): void {
2371
+ if (!RESERVED_HOST_OPERATION_IDS.has(operationId)) return;
2372
+ throw new Error(
2373
+ `Config operation "${operationId}" conflicts with a reserved Rigkit host command. ` +
2374
+ `Choose a different operation id.`,
2375
+ );
2376
+ }
2377
+
2378
+ function stringField(options: {
2379
+ name: string;
2380
+ description?: string;
2381
+ position?: number;
2382
+ required?: boolean;
2383
+ defaultValue?: string;
2384
+ }): WorkflowInputFieldDefinition<string> {
2385
+ return {
2386
+ kind: "string",
2387
+ name: options.name,
2388
+ description: options.description,
2389
+ position: options.position,
2390
+ required: options.required,
2391
+ defaultValue: options.defaultValue,
2392
+ };
2393
+ }
2394
+
2395
+ function booleanField(options: {
2396
+ name: string;
2397
+ description?: string;
2398
+ position?: number;
2399
+ required?: boolean;
2400
+ defaultValue?: boolean;
2401
+ }): WorkflowInputFieldDefinition<boolean> {
2402
+ return {
2403
+ kind: "boolean",
2404
+ name: options.name,
2405
+ description: options.description,
2406
+ position: options.position,
2407
+ required: options.required,
2408
+ defaultValue: options.defaultValue,
2409
+ };
2410
+ }
2411
+
2412
+ function parseCoreOperationInput(operation: string, value: unknown): Record<string, unknown> {
2413
+ const raw = value === undefined ? {} : value;
2414
+ if (!isPlainObject(raw)) {
2415
+ throw new EngineOperationValidationError({
2416
+ operation,
2417
+ message: `Operation ${operation} input must be an object`,
2418
+ });
2419
+ }
2420
+
2421
+ const allowed = new Set(CORE_OPERATION_INPUT_FIELDS[operation] ?? []);
2422
+ const excess = Object.keys(raw).find((key) => !allowed.has(key));
2423
+ if (excess) {
2424
+ throw new EngineOperationValidationError({
2425
+ operation,
2426
+ message: `Operation ${operation} does not accept input ${excess}`,
2427
+ });
2428
+ }
2429
+
2430
+ return raw;
2431
+ }
2432
+
2433
+ function requiredStringInput(operation: string, input: Record<string, unknown>, name: string): string {
2434
+ const value = input[name];
2435
+ if (typeof value !== "string") {
2436
+ throw new EngineOperationValidationError({
2437
+ operation,
2438
+ message: `Operation ${operation} requires ${name}`,
2439
+ });
2440
+ }
2441
+ const trimmed = value.trim();
2442
+ if (!trimmed) {
2443
+ throw new EngineOperationValidationError({
2444
+ operation,
2445
+ message: `Operation ${operation} requires ${name}`,
2446
+ });
2447
+ }
2448
+ return trimmed;
2449
+ }
2450
+
2451
+ function optionalStringInput(operation: string, input: Record<string, unknown>, name: string): string | undefined {
2452
+ const value = input[name];
2453
+ if (value === undefined) return undefined;
2454
+ if (typeof value !== "string") {
2455
+ throw new EngineOperationValidationError({
2456
+ operation,
2457
+ message: `Operation ${operation} input ${name} must be a string`,
2458
+ });
2459
+ }
2460
+ const trimmed = value.trim();
2461
+ if (!trimmed) {
2462
+ throw new EngineOperationValidationError({
2463
+ operation,
2464
+ message: `Operation ${operation} input ${name} must be non-empty`,
2465
+ });
2466
+ }
2467
+ return trimmed;
2468
+ }
2469
+
2470
+ function optionalBooleanInput(
2471
+ operation: string,
2472
+ input: Record<string, unknown>,
2473
+ name: string,
2474
+ defaultValue: boolean,
2475
+ ): boolean {
2476
+ const value = input[name];
2477
+ if (value === undefined) return defaultValue;
2478
+ if (typeof value !== "boolean") {
2479
+ throw new EngineOperationValidationError({
2480
+ operation,
2481
+ message: `Operation ${operation} input ${name} must be a boolean`,
2482
+ });
2483
+ }
2484
+ return value;
2485
+ }
2486
+
2487
+ function isPlainObject(value: unknown): value is Record<string, unknown> {
2488
+ return Boolean(value && typeof value === "object" && !Array.isArray(value) && Object.getPrototypeOf(value) === Object.prototype);
2489
+ }
2490
+
2491
+ async function openLocalTarget(target: string): Promise<void> {
2492
+ const command =
2493
+ process.platform === "darwin"
2494
+ ? ["open", target]
2495
+ : process.platform === "win32"
2496
+ ? ["cmd", "/c", "start", "", target]
2497
+ : ["xdg-open", target];
2498
+
2499
+ const proc = Bun.spawn(command, {
2500
+ stdin: "ignore",
2501
+ stdout: "ignore",
2502
+ stderr: "ignore",
2503
+ });
2504
+ const code = await proc.exited;
2505
+ if (code !== 0) {
2506
+ throw new Error(`Failed to open ${target}`);
2507
+ }
2508
+ }
2509
+
2510
+ async function requestUnsupportedHostCapability<Result = unknown>(capability: string): Promise<Result> {
2511
+ throw new Error(
2512
+ `Host capability ${capability} is unavailable outside a runtime host. ` +
2513
+ `Run this operation through Rigkit CLI or another host that supports typed host capabilities.`,
2514
+ );
2515
+ }
2516
+
2517
+ async function requestUnsupportedHostCapabilitySession<Result = unknown>(
2518
+ capability: string,
2519
+ ): Promise<{ result: Result; closed: Promise<void> }> {
2520
+ return {
2521
+ result: await requestUnsupportedHostCapability<Result>(capability),
2522
+ closed: Promise.resolve(),
2523
+ };
2524
+ }
2525
+
2526
+ async function runLocalCommand(input: {
2527
+ argv: string[];
2528
+ cwd?: string;
2529
+ env?: Record<string, string | undefined>;
2530
+ stdin?: string | null;
2531
+ mode?: "capture" | "interactive";
2532
+ }): Promise<{ exitCode: number; stdout: string | null; stderr: string | null }> {
2533
+ if (input.argv.length === 0) {
2534
+ throw new Error(`Local command argv must not be empty`);
2535
+ }
2536
+
2537
+ if (input.mode === "interactive") {
2538
+ const proc = Bun.spawn(input.argv, {
2539
+ cwd: input.cwd,
2540
+ env: input.env ? { ...process.env, ...input.env } : process.env,
2541
+ stdin: input.stdin === undefined || input.stdin === null ? "inherit" : "pipe",
2542
+ stdout: "inherit",
2543
+ stderr: "inherit",
2544
+ });
2545
+ if (input.stdin !== undefined && input.stdin !== null) {
2546
+ const stdin = proc.stdin;
2547
+ if (!stdin) throw new Error(`Local command stdin is unavailable`);
2548
+ stdin.write(input.stdin);
2549
+ stdin.end();
2550
+ }
2551
+ return { exitCode: await proc.exited, stdout: null, stderr: null };
2552
+ }
2553
+
2554
+ const proc = Bun.spawn(input.argv, {
2555
+ cwd: input.cwd,
2556
+ env: input.env ? { ...process.env, ...input.env } : process.env,
2557
+ stdin: input.stdin === undefined || input.stdin === null ? "ignore" : "pipe",
2558
+ stdout: "pipe",
2559
+ stderr: "pipe",
2560
+ });
2561
+ if (input.stdin !== undefined && input.stdin !== null) {
2562
+ const stdin = proc.stdin;
2563
+ if (!stdin) throw new Error(`Local command stdin is unavailable`);
2564
+ stdin.write(input.stdin);
2565
+ stdin.end();
2566
+ }
2567
+ const [exitCode, stdout, stderr] = await Promise.all([
2568
+ proc.exited,
2569
+ new Response(proc.stdout).text(),
2570
+ new Response(proc.stderr).text(),
2571
+ ]);
2572
+
2573
+ return { exitCode, stdout, stderr };
2574
+ }
2575
+
2576
+ function isBaseProviderPlugin(value: unknown): value is BaseProviderPlugin {
2577
+ return Boolean(
2578
+ value &&
2579
+ typeof value === "object" &&
2580
+ typeof (value as BaseProviderPlugin).providerId === "string" &&
2581
+ typeof (value as BaseProviderPlugin).createProvider === "function",
2582
+ );
2583
+ }
2584
+
2585
+ function mergeProviderPlugins(plugins: BaseProviderPlugin[]): BaseProviderPlugin[] {
2586
+ return [...new Map(plugins.map((plugin) => [plugin.providerId, plugin])).values()];
2587
+ }
2588
+
2589
+ async function defaultInteractionPresenter(request: InteractionPresentationRequest): Promise<void> {
2590
+ console.error(`\nInteractive task: ${request.title}`);
2591
+ if (request.instructions) console.error(request.instructions);
2592
+ console.error(`Open ${request.url}`);
2593
+ }
2594
+
2595
+ function consoleLevelToLogStream(level: ConsoleLevel): WorkflowLogStream {
2596
+ switch (level) {
2597
+ case "debug": return "debug";
2598
+ case "warn": return "warn";
2599
+ case "error": return "stderr";
2600
+ case "info":
2601
+ case "log":
2602
+ default: return "info";
2603
+ }
2604
+ }