@rigkit/engine 0.2.2 → 0.2.4

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 CHANGED
@@ -3,16 +3,20 @@ import { dirname, join, resolve } from "node:path";
3
3
  import { pathToFileURL } from "node:url";
4
4
  import { isRigkitConfig, isProviderDefinition, isWorkflowNode } from "./authoring.ts";
5
5
  import { loadDotEnv } from "./env-file.ts";
6
- import { hash, stableJson } from "./hash.ts";
6
+ import { hash } from "./hash.ts";
7
+ import {
8
+ createFileProviderHostStorage,
9
+ defaultProviderHostStorageDir,
10
+ type ProviderHostStorageFactory,
11
+ } from "./host-storage.ts";
12
+ import { RESERVED_WORKFLOW_OPERATION_IDS, STEP_INVALIDATION_KIND } from "./types.ts";
7
13
  import type {
8
14
  BaseProviderPlugin,
9
15
  InteractionPresenter,
10
16
  InteractionPresentationRequest,
11
17
  ProviderFactory,
12
18
  ProviderRuntimeContext,
13
- SshConnection,
14
19
  WorkflowProviderController,
15
- WorkflowWorkspaceProvider,
16
20
  } from "./provider/types.ts";
17
21
  import {
18
22
  createStateStore,
@@ -30,20 +34,21 @@ import type {
30
34
  LocalWorkspaceRuntime,
31
35
  OutputSchema,
32
36
  ProviderRuntimeMap,
33
- ProviderWorkspaceContext,
34
37
  WorkflowInputFieldDefinition,
35
38
  WorkflowDefinition,
36
39
  WorkflowEvent,
40
+ WorkflowLogStream,
37
41
  WorkflowNodeDefinition,
38
42
  WorkflowOperationDefinition,
39
- WorkflowHostCapabilityRequirement,
40
- WorkflowHostMethodRequirement,
41
43
  WorkflowPlan,
42
44
  WorkflowPlanNode,
43
45
  WorkflowProviderMap,
46
+ WorkflowStepInvalidation,
47
+ WorkflowTaskCacheTTL,
44
48
  WorkflowTaskNode,
45
49
  WorkspaceRecord,
46
50
  WorkspaceRuntimeRecord,
51
+ WorkflowWorkspaceOperationDefinition,
47
52
  } from "./types.ts";
48
53
 
49
54
  export type CreateDevMachineEngineOptions = {
@@ -54,6 +59,8 @@ export type CreateDevMachineEngineOptions = {
54
59
  providers?: BaseProviderPlugin[];
55
60
  providerFactory?: ProviderFactory;
56
61
  stateFactory?: StateServiceFactory;
62
+ hostStorageDir?: string;
63
+ hostStorageFactory?: ProviderHostStorageFactory;
57
64
  interaction?: {
58
65
  present?: InteractionPresenter;
59
66
  };
@@ -119,8 +126,6 @@ export type EngineOperationSummary = {
119
126
  title?: string;
120
127
  description?: string;
121
128
  createsWorkspace?: boolean;
122
- requiredHostMethods?: readonly WorkflowHostMethodRequirement[];
123
- requiredHostCapabilities?: readonly WorkflowHostCapabilityRequirement[];
124
129
  inputFields: readonly WorkflowInputFieldDefinition[];
125
130
  cli?: EngineOperationCli;
126
131
  };
@@ -152,10 +157,16 @@ type EvaluationMode = "plan" | "apply";
152
157
  type EvaluationState = {
153
158
  context: Record<string, JsonValue>;
154
159
  upstreamRunIds: string[];
160
+ previousTasks: EvaluationPreviousTask[];
155
161
  known: boolean;
156
162
  blockedReason?: string;
157
163
  };
158
164
 
165
+ type EvaluationPreviousTask = {
166
+ name: string;
167
+ path: string;
168
+ };
169
+
159
170
  type EvaluationResult = EvaluationState & {
160
171
  planNodes: WorkflowPlanNode[];
161
172
  };
@@ -179,6 +190,35 @@ type RuntimeOperationEntry = {
179
190
  readonly run: (input: { workflow?: string; input?: unknown }) => Promise<unknown>;
180
191
  };
181
192
 
193
+ type RuntimeWorkspaceOperationEntry = {
194
+ readonly summary: EngineOperationSummary;
195
+ readonly run: (input: { workspace: string; workflow?: string; input?: unknown }) => Promise<unknown>;
196
+ };
197
+
198
+ class StepInvalidationRestart extends Error {
199
+ readonly workflow: string;
200
+ readonly target: string;
201
+ readonly targetNodePath: string;
202
+ readonly currentNodePath: string;
203
+ readonly invalidatedRunIds: string[];
204
+
205
+ constructor(input: {
206
+ workflow: string;
207
+ target: string;
208
+ targetNodePath: string;
209
+ currentNodePath: string;
210
+ invalidatedRunIds: string[];
211
+ }) {
212
+ super(`Task ${input.currentNodePath} invalidated ${input.targetNodePath}`);
213
+ this.name = "StepInvalidationRestart";
214
+ this.workflow = input.workflow;
215
+ this.target = input.target;
216
+ this.targetNodePath = input.targetNodePath;
217
+ this.currentNodePath = input.currentNodePath;
218
+ this.invalidatedRunIds = input.invalidatedRunIds;
219
+ }
220
+ }
221
+
182
222
  let configImportCounter = 0;
183
223
 
184
224
  export class DevMachineEngine {
@@ -189,6 +229,9 @@ export class DevMachineEngine {
189
229
  private providers: BaseProviderPlugin[];
190
230
  private readonly providerFactory: ProviderFactory;
191
231
  private readonly stateFactory: StateServiceFactory;
232
+ private readonly hostStorageDir: string;
233
+ private readonly hostStorageFactory: ProviderHostStorageFactory;
234
+ private readonly providerHostStorage = new Map<string, ReturnType<ProviderHostStorageFactory>>();
192
235
  private readonly interactionPresenter: InteractionPresenter;
193
236
  private readonly local: LocalWorkspaceRuntime;
194
237
  private readonly handlers = new Set<EventHandler>();
@@ -204,6 +247,8 @@ export class DevMachineEngine {
204
247
  this.providers = options.providers ?? [];
205
248
  this.providerFactory = options.providerFactory ?? ((input) => this.createProviderFromPlugin(input));
206
249
  this.stateFactory = options.stateFactory ?? createStateStore;
250
+ this.hostStorageDir = options.hostStorageDir ? resolve(options.hostStorageDir) : defaultProviderHostStorageDir();
251
+ this.hostStorageFactory = options.hostStorageFactory ?? createFileProviderHostStorage;
207
252
  this.interactionPresenter = options.interaction?.present ?? defaultInteractionPresenter;
208
253
  this.local = {
209
254
  open: options.local?.open ?? openLocalTarget,
@@ -306,6 +351,10 @@ export class DevMachineEngine {
306
351
  return this.listRuntimeOperationEntries().map((entry) => entry.summary);
307
352
  }
308
353
 
354
+ listRuntimeWorkspaceOperations(): EngineOperationSummary[] {
355
+ return this.listRuntimeWorkspaceOperationEntries().map((entry) => entry.summary);
356
+ }
357
+
309
358
  private listRuntimeOperationEntries(): RuntimeOperationEntry[] {
310
359
  const configOperations = this.listConfigOperationEntries();
311
360
  const configOperationIds = new Set(configOperations.map((entry) => entry.summary.id));
@@ -319,6 +368,16 @@ export class DevMachineEngine {
319
368
  ];
320
369
  }
321
370
 
371
+ private listRuntimeWorkspaceOperationEntries(): RuntimeWorkspaceOperationEntry[] {
372
+ const configOperations = this.listConfigWorkspaceOperationEntries();
373
+ const configOperationIds = new Set(configOperations.map((entry) => entry.summary.id));
374
+ const coreOperations = this.listCoreWorkspaceOperationEntries();
375
+ return [
376
+ ...coreOperations.filter((entry) => !configOperationIds.has(entry.summary.id)),
377
+ ...configOperations,
378
+ ];
379
+ }
380
+
322
381
  private listConfigOperationEntries(): RuntimeOperationEntry[] {
323
382
  return this.listConfigOperationSummaries().map((summary) => ({
324
383
  summary,
@@ -341,18 +400,65 @@ export class DevMachineEngine {
341
400
  source: "config" as const,
342
401
  title: operation.title,
343
402
  description: operation.description,
344
- createsWorkspace: operation.createsWorkspace,
345
- requiredHostMethods: operation.requiredHostMethods,
346
- requiredHostCapabilities: operation.requiredHostCapabilities,
347
403
  inputFields: operation.input?.fields ?? [],
348
404
  };
349
405
  }),
350
406
  );
351
407
  }
352
408
 
409
+ private listConfigWorkspaceOperationEntries(): RuntimeWorkspaceOperationEntry[] {
410
+ return this.listWorkflows().flatMap((workflow) =>
411
+ workflow.workspaceOperations.map((operation) => ({
412
+ summary: this.workspaceOperationSummary(workflow, operation),
413
+ run: async (input) => {
414
+ const workspace = this.getStateService().getWorkspace(input.workspace);
415
+ if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
416
+ if (workspace.workflow !== workflow.name) {
417
+ throw new EngineOperationValidationError({
418
+ operation: operation.id,
419
+ message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
420
+ });
421
+ }
422
+ const providers = await this.createProviders(workflow);
423
+ return await this.runConfigWorkspaceOperation({
424
+ workflow,
425
+ providers,
426
+ workspace,
427
+ operation,
428
+ rawInput: input.input,
429
+ });
430
+ },
431
+ }))
432
+ );
433
+ }
434
+
435
+ private listConfigWorkspaceOperationSummaries(): EngineOperationSummary[] {
436
+ return this.listWorkflows().flatMap((workflow) =>
437
+ workflow.workspaceOperations.map((operation) => {
438
+ assertAllowedConfigOperationId(operation.id);
439
+ return this.workspaceOperationSummary(workflow, operation);
440
+ }),
441
+ );
442
+ }
443
+
444
+ private workspaceOperationSummary(
445
+ workflow: LoadedWorkflow,
446
+ operation: WorkflowWorkspaceOperationDefinition<any, any, any, any>,
447
+ ): EngineOperationSummary {
448
+ return {
449
+ workflow: workflow.name,
450
+ id: operation.id,
451
+ source: "config" as const,
452
+ kind: "workspace-action" as const,
453
+ title: operation.title,
454
+ description: operation.description,
455
+ inputFields: operation.input?.fields ?? [],
456
+ };
457
+ }
458
+
353
459
  private listCoreOperationEntries(): RuntimeOperationEntry[] {
354
460
  const workflows = this.listWorkflows();
355
- const hasWorkspaceCreator = workflows.some((workflow) => workflow.create || workflow.workspace);
461
+ const hasWorkspaceCreator = workflows.some((workflow) => workflow.workspace);
356
462
  const workflowField = stringField({
357
463
  name: "workflow",
358
464
  required: false,
@@ -421,7 +527,7 @@ export class DevMachineEngine {
421
527
  source: "core",
422
528
  kind: "command",
423
529
  title: "Create",
424
- description: "Create a workspace from the resolved workflow artifact",
530
+ description: "Create a workspace",
425
531
  createsWorkspace: true,
426
532
  inputFields: [
427
533
  workflowField,
@@ -436,7 +542,7 @@ export class DevMachineEngine {
436
542
  },
437
543
  async (input) => {
438
544
  const parsed = parseCoreOperationInput("create", input.input);
439
- return await this.fork({
545
+ return await this.createWorkspace({
440
546
  workflow: optionalStringInput("create", parsed, "workflow"),
441
547
  name: requiredStringInput("create", parsed, "name"),
442
548
  });
@@ -444,117 +550,40 @@ export class DevMachineEngine {
444
550
  ),
445
551
  ]
446
552
  : []),
553
+ ];
554
+ }
555
+
556
+ private listCoreWorkspaceOperationEntries(): RuntimeWorkspaceOperationEntry[] {
557
+ const workflows = this.listWorkflows().filter((workflow) => workflow.workspace);
558
+ if (workflows.length === 0) return [];
559
+ const coreOperation = (
560
+ summary: EngineOperationSummary,
561
+ run: RuntimeWorkspaceOperationEntry["run"],
562
+ ): RuntimeWorkspaceOperationEntry => ({ summary, run });
563
+
564
+ return workflows.map((workflow) =>
447
565
  coreOperation(
448
566
  {
449
- workflow: "",
450
- id: "ssh",
451
- source: "core",
452
- kind: "command",
453
- title: "SSH",
454
- description: "Get an SSH command for a workspace or VM",
455
- requiredHostMethods: [{ id: "host.command.run", modes: ["interactive"] }],
456
- inputFields: [
457
- workflowField,
458
- stringField({ name: "workspaceOrVmId", position: 0, required: true }),
459
- stringField({ name: "user", required: false }),
460
- booleanField({ name: "print", required: false, defaultValue: false }),
461
- ],
462
- cli: {
463
- positionals: [
464
- { name: "workspaceOrVmId", index: 0 },
465
- ],
466
- options: [
467
- { name: "workflow", flag: "--workflow" },
468
- { name: "user", flag: "--user" },
469
- { name: "print", flag: "--print", type: "boolean" },
470
- ],
471
- },
472
- },
473
- async (input) => {
474
- const parsed = parseCoreOperationInput("ssh", input.input);
475
- const workspaceOrVmId = requiredStringInput("ssh", parsed, "workspaceOrVmId");
476
- const terminal = await this.attachTerminal({
477
- workflow: optionalStringInput("ssh", parsed, "workflow"),
478
- workspaceOrVmId,
479
- printOnly: true,
480
- user: optionalStringInput("ssh", parsed, "user"),
481
- });
482
- if (optionalBooleanInput("ssh", parsed, "print", false)) return terminal;
483
- const commandResult = await (this.local.command ?? runLocalCommand)({
484
- argv: ["sh", "-lc", terminal.command],
485
- cwd: this.projectDir,
486
- mode: "interactive",
487
- reason: `Open an SSH session to ${workspaceOrVmId}`,
488
- presentation: {
489
- visible: true,
490
- label: "SSH into workspace",
491
- },
492
- });
493
- return { ...terminal, commandResult };
494
- },
495
- ),
496
- coreOperation(
497
- {
498
- workflow: "",
499
- id: "snapshot",
500
- source: "core",
501
- kind: "command",
502
- title: "Snapshot",
503
- description: "Capture a snapshot from a workspace VM",
504
- inputFields: [
505
- workflowField,
506
- stringField({ name: "workspace", position: 0, required: true }),
507
- stringField({ name: "label", required: false }),
508
- ],
509
- cli: {
510
- positionals: [
511
- { name: "workspace", index: 0 },
512
- ],
513
- options: [
514
- { name: "workflow", flag: "--workflow" },
515
- { name: "label", flag: "--label" },
516
- ],
517
- },
518
- },
519
- async (input) => {
520
- const parsed = parseCoreOperationInput("snapshot", input.input);
521
- return await this.snapshotWorkspace({
522
- workflow: optionalStringInput("snapshot", parsed, "workflow"),
523
- workspace: requiredStringInput("snapshot", parsed, "workspace"),
524
- label: optionalStringInput("snapshot", parsed, "label"),
525
- });
526
- },
527
- ),
528
- coreOperation(
529
- {
530
- workflow: "",
531
- id: "delete",
532
- aliases: ["rm"],
567
+ workflow: workflow.name,
568
+ id: "remove",
533
569
  source: "core",
534
- kind: "command",
535
- title: "Delete",
536
- description: "Delete a workspace VM and remove it from state",
537
- inputFields: [
538
- workflowField,
539
- stringField({ name: "workspace", position: 0, required: true }),
540
- ],
570
+ kind: "workspace-action",
571
+ title: "Remove",
572
+ description: "Remove a workspace",
573
+ inputFields: [],
541
574
  cli: {
542
- positionals: [{ name: "workspace", index: 0 }],
543
575
  options: [
544
- { name: "workflow", flag: "--workflow" },
545
- { name: "yes", flag: "--yes", aliases: ["-y"], required: true, type: "boolean", runtime: false },
576
+ { name: "yes", flag: "--yes", aliases: ["-y"], type: "boolean", runtime: false },
546
577
  ],
547
578
  },
548
579
  },
549
- async (input) => {
550
- const parsed = parseCoreOperationInput("delete", input.input);
551
- return await this.deleteWorkspace({
552
- workflow: optionalStringInput("delete", parsed, "workflow"),
553
- workspace: requiredStringInput("delete", parsed, "workspace"),
554
- });
555
- },
556
- ),
557
- ];
580
+ async (input) =>
581
+ await this.removeWorkspace({
582
+ workflow: input.workflow,
583
+ workspace: input.workspace,
584
+ }),
585
+ )
586
+ );
558
587
  }
559
588
 
560
589
  listNodeRuns(): WorkflowNodeRunRecord[] {
@@ -566,6 +595,15 @@ export class DevMachineEngine {
566
595
  }
567
596
 
568
597
  async runRuntimeOperation(input: { operation: string; workflow?: string; input?: unknown }): Promise<unknown> {
598
+ const workspaceTarget = parseWorkspaceOperationId(input.operation);
599
+ if (workspaceTarget) {
600
+ return await this.runWorkspaceOperation({
601
+ workspace: workspaceTarget.workspace,
602
+ operation: workspaceTarget.operation,
603
+ workflow: input.workflow,
604
+ input: input.input,
605
+ });
606
+ }
569
607
  const operation = this.findRuntimeOperationEntry(input.operation);
570
608
  if (!operation) throw new EngineOperationNotFoundError(input.operation);
571
609
  return await operation.run({ workflow: input.workflow, input: input.input });
@@ -588,12 +626,47 @@ export class DevMachineEngine {
588
626
  providers: runtime,
589
627
  local: this.local,
590
628
  workflow: workflow.name,
629
+ step: this.createStepRuntime(workflow.name, `operation.${operation.id}`, metadata),
591
630
  });
592
631
  if (result !== undefined) assertJsonValue(result, `Operation ${operation.id} result`);
593
- if (operation.createsWorkspace) return this.saveOperationWorkspace(workflow, operation, input.input, result);
594
632
  return result ?? null;
595
633
  }
596
634
 
635
+ async runWorkspaceOperation(input: {
636
+ workspace: string;
637
+ operation: string;
638
+ workflow?: string;
639
+ input?: unknown;
640
+ }): Promise<unknown> {
641
+ const workspace = this.getStateService().getWorkspace(input.workspace);
642
+ if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
643
+ const workflow = this.getWorkflow(input.workflow ?? workspace.workflow);
644
+ if (workspace.workflow !== workflow.name) {
645
+ throw new EngineOperationValidationError({
646
+ operation: input.operation,
647
+ message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
648
+ });
649
+ }
650
+
651
+ const core = this.listCoreWorkspaceOperationEntries().find((entry) =>
652
+ entry.summary.workflow === workflow.name && entry.summary.id === input.operation
653
+ );
654
+ if (core) {
655
+ return await core.run({ workspace: workspace.name, workflow: workflow.name, input: input.input });
656
+ }
657
+
658
+ const operation = workflow.workspaceOperations.find((item) => item.id === input.operation);
659
+ if (!operation) throw new EngineOperationNotFoundError(`${workspace.name}/${input.operation}`);
660
+ const providers = await this.createProviders(workflow);
661
+ return await this.runConfigWorkspaceOperation({
662
+ workflow,
663
+ providers,
664
+ workspace,
665
+ operation,
666
+ rawInput: input.input,
667
+ });
668
+ }
669
+
597
670
  async plan(input: { workflow?: string; machine?: string } = {}): Promise<WorkflowPlan> {
598
671
  const workflow = this.getWorkflow(input.workflow ?? input.machine);
599
672
  const providers = await this.createProviders(workflow);
@@ -621,163 +694,128 @@ export class DevMachineEngine {
621
694
  }> {
622
695
  const workflow = this.getWorkflow(input.workflow ?? input.machine);
623
696
  const providers = await this.createProviders(workflow);
624
- const result = await this.evaluate({
625
- workflow,
626
- providers,
627
- mode: "apply",
628
- });
629
- const workspaceSource = this.resolveWorkspaceSource(workflow, result.context, providers, { required: false });
697
+ let result: { context: Record<string, JsonValue>; plan: WorkflowPlan } | undefined;
698
+ const maxRestarts = 8;
699
+ for (let attempt = 0; attempt <= maxRestarts; attempt++) {
700
+ try {
701
+ result = await this.evaluate({
702
+ workflow,
703
+ providers,
704
+ mode: "apply",
705
+ });
706
+ break;
707
+ } catch (error) {
708
+ if (!(error instanceof StepInvalidationRestart)) throw error;
709
+ if (attempt === maxRestarts) {
710
+ throw new Error(
711
+ `Task ${error.currentNodePath} repeatedly invalidated ${error.targetNodePath}; stopping after ${maxRestarts + 1} attempts`,
712
+ { cause: error },
713
+ );
714
+ }
715
+ }
716
+ }
717
+ if (!result) throw new Error(`Workflow ${workflow.name} did not produce an apply result`);
630
718
 
631
719
  return {
632
720
  context: result.context,
633
- snapshotId: snapshotIdOf(workspaceSource),
634
- workspaceSource,
635
721
  plan: result.plan,
636
722
  };
637
723
  }
638
724
 
639
725
  async fork(input: { workflow?: string; machine?: string; name: string }): Promise<WorkspaceRecord> {
640
- if (!input.name) throw new Error(`fork requires a workspace name`);
726
+ return await this.createWorkspace(input);
727
+ }
728
+
729
+ async createWorkspace(input: { workflow?: string; machine?: string; name: string }): Promise<WorkspaceRecord> {
730
+ if (!input.name) throw new Error(`create requires a workspace name`);
731
+ if (this.getStateService().getWorkspace(input.name)) {
732
+ throw new Error(`Workspace ${input.name} already exists`);
733
+ }
641
734
 
642
735
  const applied = await this.apply({ workflow: input.workflow ?? input.machine });
643
736
  const workflow = this.getWorkflow(input.workflow ?? input.machine);
644
737
  const providers = await this.createProviders(workflow);
645
- if (workflow.create) {
646
- return await this.createWorkspaceFromCallback({
647
- workflow,
648
- providers,
649
- context: applied.context,
650
- name: input.name,
651
- });
738
+ if (!workflow.workspace) {
739
+ throw new Error(`Workflow ${workflow.name} does not define a workspace`);
652
740
  }
653
741
 
654
- const sourceRef = this.resolveWorkspaceSource(workflow, applied.context, providers, { required: true })!;
655
- const workspaceProvider = this.findWorkspaceProvider(providers, sourceRef);
656
- const created = await workspaceProvider.createWorkspace(sourceRef, { name: input.name });
657
- const providerId = created.providerId ?? providerIdOf(sourceRef) ?? this.providerIdForWorkspaceProvider(providers, workspaceProvider);
658
742
  const now = new Date().toISOString();
659
-
660
743
  const workspace: WorkspaceRecord = {
661
744
  id: crypto.randomUUID(),
662
745
  name: input.name,
663
- providerId,
664
746
  workflow: workflow.name,
665
- resourceId: created.resourceId,
666
- snapshotId: created.snapshotId,
667
- sourceRef: created.sourceRef ?? sourceRef,
668
- context: { ...applied.context },
747
+ workflowCtx: { ...applied.context },
748
+ ctx: {},
669
749
  createdAt: now,
670
750
  updatedAt: now,
671
- metadata: created.metadata ?? {},
672
751
  };
673
752
 
674
753
  this.getStateService().saveWorkspace(workspace);
675
- await this.runWorkspaceCreatedHook({
676
- workflow,
677
- providers,
678
- workspaceProvider,
679
- workspace,
680
- context: applied.context,
681
- });
682
-
683
- this.emit({
684
- type: "workspace.ready",
685
- workspaceId: input.name,
686
- providerId: workspace.providerId,
687
- resourceId: workspace.resourceId,
688
- snapshotId: workspace.snapshotId,
689
- });
690
-
691
- return workspace;
692
- }
693
-
694
- async attachTerminal(input: {
695
- workspaceOrVmId: string;
696
- workflow?: string;
697
- machine?: string;
698
- printOnly?: boolean;
699
- user?: string;
700
- }): Promise<{ command: string }> {
701
- const workspace = this.getStateService().findWorkspace(input.workspaceOrVmId);
702
- const workflow = this.getWorkflow(input.workflow ?? input.machine ?? workspace?.workflow);
703
- const providers = await this.createProviders(workflow);
704
- const workspaceProvider = workspace
705
- ? this.workspaceProviderById(providers, workspace.providerId)
706
- : this.singleWorkspaceProvider(providers);
707
- if (workspace) {
708
- await this.runWorkspaceOpenHook({
754
+ try {
755
+ await this.runWorkspaceCreate({
709
756
  workflow,
710
757
  providers,
711
- workspaceProvider,
712
758
  workspace,
713
- context: workspace.context,
759
+ context: applied.context,
760
+ name: input.name,
714
761
  });
762
+ } catch (error) {
763
+ this.getStateService().deleteWorkspace(workspace.name);
764
+ throw error;
715
765
  }
716
- const terminal = await workspaceProvider.ssh(workspace?.resourceId ?? input.workspaceOrVmId, { user: input.user });
766
+ const ready = this.getStateService().getWorkspace(input.name) ?? workspace;
717
767
 
718
- if (!input.printOnly) {
719
- const proc = Bun.spawn(["sh", "-lc", terminal.command], {
720
- stdin: "inherit",
721
- stdout: "inherit",
722
- stderr: "inherit",
723
- });
724
- await proc.exited;
725
- }
768
+ this.emit({
769
+ type: "workspace.ready",
770
+ workspaceId: ready.name,
771
+ });
726
772
 
727
- return { command: terminal.command };
773
+ return ready;
728
774
  }
729
775
 
730
776
  async deleteWorkspace(input: { workspace: string; workflow?: string; machine?: string }): Promise<WorkspaceRecord> {
731
- const workspace = this.getStateService().getWorkspace(input.workspace);
732
- if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
733
-
734
- const workflow = this.getWorkflow(input.workflow ?? input.machine ?? workspace.workflow);
735
- const providers = await this.createProviders(workflow);
736
- const workspaceProvider = this.workspaceProviderById(providers, workspace.providerId);
737
- await workspaceProvider.deleteWorkspace(workspace);
738
-
739
- this.getStateService().deleteWorkspace(input.workspace);
740
-
741
- return workspace;
777
+ return await this.removeWorkspace(input);
742
778
  }
743
779
 
744
- async snapshotWorkspace(input: { workspace: string; label?: string; workflow?: string; machine?: string }): Promise<WorkflowNodeRunRecord> {
780
+ async removeWorkspace(input: { workspace: string; workflow?: string; machine?: string }): Promise<WorkspaceRecord> {
745
781
  const workspace = this.getStateService().getWorkspace(input.workspace);
746
782
  if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
747
783
 
748
784
  const workflow = this.getWorkflow(input.workflow ?? input.machine ?? workspace.workflow);
785
+ if (workspace.workflow !== workflow.name) {
786
+ throw new EngineOperationValidationError({
787
+ operation: "remove",
788
+ message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
789
+ });
790
+ }
791
+ if (!workflow.workspace) {
792
+ throw new Error(`Workflow ${workflow.name} does not define a workspace`);
793
+ }
749
794
  const providers = await this.createProviders(workflow);
750
- const workspaceProvider = this.workspaceProviderById(providers, workspace.providerId);
751
- const snapshot = await workspaceProvider.snapshotWorkspace(workspace);
752
- const sourceRef = snapshot.sourceRef ?? workspace.sourceRef;
753
- const providerFingerprint = providerFingerprintFor(workflow);
754
- const now = new Date().toISOString();
755
- const record: WorkflowNodeRunRecord = {
756
- id: crypto.randomUUID(),
757
- workflow: workflow.name,
758
- nodePath: `workspace.${workspace.name}`,
759
- nodeName: input.label ?? `workspace:${workspace.name}`,
760
- nodeKind: "workspace-snapshot",
761
- nodeKey: hash({
762
- kind: "workspace-snapshot",
763
- workspace: workspace.name,
764
- label: input.label ?? null,
765
- }),
766
- providerFingerprint,
767
- upstreamRunIds: [],
768
- output: { sourceRef },
769
- artifacts: collectArtifacts(sourceRef),
770
- invalidated: false,
771
- createdAt: now,
772
- metadata: {
773
- workspace: workspace.name,
774
- label: input.label ?? null,
775
- snapshotId: snapshot.snapshotId ?? null,
795
+ const metadata: JsonObject = {};
796
+ const runtime = await this.createTaskRuntime({
797
+ workflow,
798
+ providers,
799
+ nodePath: `workspace.${workspace.name}.remove`,
800
+ metadata,
801
+ });
802
+ const draft = cloneWorkspace(workspace);
803
+ const workspaceRuntime = this.createWorkspaceRuntime(draft);
804
+
805
+ await workflow.workspace.remove({
806
+ ...runtime,
807
+ workflow: {
808
+ name: workflow.name,
809
+ ctx: Object.freeze({ ...workspace.workflowCtx }),
776
810
  },
777
- };
811
+ workspace: workspaceRuntime,
812
+ providers: runtime,
813
+ local: this.local,
814
+ step: this.createStepRuntime(workflow.name, `workspace.${workspace.name}.remove`, metadata),
815
+ });
778
816
 
779
- this.getStateService().saveNodeRun(record);
780
- return record;
817
+ this.getStateService().deleteWorkspace(input.workspace);
818
+ return workspace;
781
819
  }
782
820
 
783
821
  private async evaluate(input: {
@@ -796,6 +834,7 @@ export class DevMachineEngine {
796
834
  state: {
797
835
  context: {},
798
836
  upstreamRunIds: [],
837
+ previousTasks: [],
799
838
  known: true,
800
839
  },
801
840
  prefix: [],
@@ -844,6 +883,7 @@ export class DevMachineEngine {
844
883
  state = {
845
884
  context: result.context,
846
885
  upstreamRunIds: result.upstreamRunIds,
886
+ previousTasks: result.previousTasks,
847
887
  known: result.known,
848
888
  blockedReason: result.blockedReason,
849
889
  };
@@ -856,6 +896,7 @@ export class DevMachineEngine {
856
896
  const branches = parallelBranches(input.node);
857
897
  const branchOutputs: Record<string, JsonValue> = {};
858
898
  const joinedRunIds: string[] = [];
899
+ let joinedPreviousTasks = [...input.state.previousTasks];
859
900
  let known = input.state.known;
860
901
  let blockedReason = input.state.blockedReason;
861
902
 
@@ -870,6 +911,7 @@ export class DevMachineEngine {
870
911
  state: {
871
912
  context: { ...input.state.context },
872
913
  upstreamRunIds: [...input.state.upstreamRunIds],
914
+ previousTasks: [...input.state.previousTasks],
873
915
  known: input.state.known,
874
916
  blockedReason: input.state.blockedReason,
875
917
  },
@@ -881,6 +923,7 @@ export class DevMachineEngine {
881
923
  if (branchState.known) {
882
924
  branchOutputs[branchName] = branchState.context;
883
925
  joinedRunIds.push(...branchState.upstreamRunIds);
926
+ joinedPreviousTasks = mergePreviousTasks(joinedPreviousTasks, branchState.previousTasks);
884
927
  } else {
885
928
  known = false;
886
929
  blockedReason ??= branchState.blockedReason ?? `depends on ${branchName}`;
@@ -890,6 +933,7 @@ export class DevMachineEngine {
890
933
  return {
891
934
  context: known ? { ...input.state.context, ...branchOutputs } : { ...input.state.context },
892
935
  upstreamRunIds: known ? joinedRunIds.sort() : [],
936
+ previousTasks: known ? joinedPreviousTasks : input.state.previousTasks,
893
937
  known,
894
938
  blockedReason,
895
939
  planNodes: input.planNodes,
@@ -900,7 +944,7 @@ export class DevMachineEngine {
900
944
  const nodePath = [...input.prefix, input.node.name].join(".");
901
945
  const upstreamRunIds = [...input.state.upstreamRunIds];
902
946
  const nodeKey = hash({
903
- cache: "task-v2",
947
+ cache: "task-v3",
904
948
  kind: "task",
905
949
  path: nodePath,
906
950
  name: input.node.name,
@@ -922,6 +966,7 @@ export class DevMachineEngine {
922
966
  return {
923
967
  context: input.state.context,
924
968
  upstreamRunIds: [],
969
+ previousTasks: input.state.previousTasks,
925
970
  known: false,
926
971
  blockedReason: input.state.blockedReason ?? `depends on ${nodePath}`,
927
972
  planNodes: input.planNodes,
@@ -936,9 +981,14 @@ export class DevMachineEngine {
936
981
  upstreamRunIds,
937
982
  providers: input.providers,
938
983
  outputSchema: input.node.options?.output,
984
+ cacheTTL: input.node.options?.cacheTTL,
939
985
  });
940
986
 
941
987
  if (cached) {
988
+ const previousTasks = appendPreviousTask(input.state.previousTasks, {
989
+ name: input.node.name,
990
+ path: nodePath,
991
+ });
942
992
  this.emit({ type: "node.cached", nodePath, runId: cached.id });
943
993
  input.planNodes.push({
944
994
  index: planIndex,
@@ -949,8 +999,9 @@ export class DevMachineEngine {
949
999
  upstreamRunIds,
950
1000
  });
951
1001
  return {
952
- context: { ...input.state.context, ...cached.output },
1002
+ context: cached.output,
953
1003
  upstreamRunIds: [cached.id],
1004
+ previousTasks,
954
1005
  known: true,
955
1006
  planNodes: input.planNodes,
956
1007
  };
@@ -969,6 +1020,7 @@ export class DevMachineEngine {
969
1020
  return {
970
1021
  context: input.state.context,
971
1022
  upstreamRunIds: [],
1023
+ previousTasks: input.state.previousTasks,
972
1024
  known: false,
973
1025
  blockedReason: `depends on ${nodePath}`,
974
1026
  planNodes: input.planNodes,
@@ -983,28 +1035,38 @@ export class DevMachineEngine {
983
1035
  nodePath,
984
1036
  metadata,
985
1037
  });
1038
+ const step = this.createStepRuntime(
1039
+ input.workflow.name,
1040
+ nodePath,
1041
+ metadata,
1042
+ input.state.context,
1043
+ input.state.previousTasks,
1044
+ );
986
1045
  const result = await input.node.handler({
987
1046
  ...runtime,
988
1047
  providers: runtime,
989
- ctx: Object.freeze({ ...input.state.context }),
990
- runtime: {
991
- workflow: input.workflow.name,
992
- nodePath,
993
- metadata: (value) => {
994
- Object.assign(metadata, value);
995
- },
996
- log: (data, options = {}) => {
997
- this.emit({
998
- type: "log.output",
999
- nodePath,
1000
- stream: options.stream ?? "info",
1001
- label: options.label,
1002
- data,
1003
- });
1004
- },
1005
- },
1048
+ step,
1006
1049
  });
1007
- const output = normalizeTaskOutput(nodePath, result, input.node.options?.output, "fresh");
1050
+ if (isStepInvalidation(result)) {
1051
+ const invalidatedRunIds = this.getStateService().invalidateNodeRuns({
1052
+ workflow: input.workflow.name,
1053
+ nodePaths: [result.targetNodePath, nodePath],
1054
+ });
1055
+ throw new StepInvalidationRestart({
1056
+ workflow: input.workflow.name,
1057
+ target: result.target,
1058
+ targetNodePath: result.targetNodePath,
1059
+ currentNodePath: nodePath,
1060
+ invalidatedRunIds,
1061
+ });
1062
+ }
1063
+ const output = normalizeTaskOutput(
1064
+ nodePath,
1065
+ result,
1066
+ input.node.options?.output,
1067
+ "fresh",
1068
+ input.state.context,
1069
+ );
1008
1070
  if (!output) {
1009
1071
  throw new Error(`Task ${nodePath} output failed schema validation`);
1010
1072
  }
@@ -1038,9 +1100,15 @@ export class DevMachineEngine {
1038
1100
  }
1039
1101
  this.emit({ type: "node.completed", nodePath, runId: record.id });
1040
1102
 
1103
+ const previousTasks = appendPreviousTask(input.state.previousTasks, {
1104
+ name: input.node.name,
1105
+ path: nodePath,
1106
+ });
1107
+
1041
1108
  return {
1042
- context: { ...input.state.context, ...output },
1109
+ context: output,
1043
1110
  upstreamRunIds: [record.id],
1111
+ previousTasks,
1044
1112
  known: true,
1045
1113
  planNodes: input.planNodes,
1046
1114
  };
@@ -1054,9 +1122,11 @@ export class DevMachineEngine {
1054
1122
  upstreamRunIds: readonly string[];
1055
1123
  providers: ProviderControllers;
1056
1124
  outputSchema?: OutputSchema;
1125
+ cacheTTL?: WorkflowTaskCacheTTL;
1057
1126
  }): Promise<WorkflowNodeRunRecord | undefined> {
1058
1127
  const cached = this.getStateService().findReusableNodeRun(input);
1059
1128
  if (!cached) return undefined;
1129
+ if (!isCacheFresh(cached.createdAt, input.cacheTTL)) return undefined;
1060
1130
 
1061
1131
  const parsed = normalizeTaskOutput(input.nodePath, cached.output, input.outputSchema, "cached");
1062
1132
  if (!parsed) return undefined;
@@ -1134,65 +1204,133 @@ export class DevMachineEngine {
1134
1204
  return Object.fromEntries(entries) as ProviderRuntimeMap<WorkflowProviderMap>;
1135
1205
  }
1136
1206
 
1137
- private async runWorkspaceCreatedHook(input: {
1207
+ private async runWorkspaceCreate(input: {
1138
1208
  workflow: LoadedWorkflow;
1139
1209
  providers: ProviderControllers;
1140
- workspaceProvider: WorkflowWorkspaceProvider;
1141
1210
  workspace: WorkspaceRecord;
1142
1211
  context: Record<string, JsonValue>;
1212
+ name: string;
1143
1213
  }): Promise<void> {
1144
- await this.runWorkspaceHook("created", input.workflow.workspace?.onCreated, input);
1214
+ if (!input.workflow.workspace) {
1215
+ throw new Error(`Workflow ${input.workflow.name} does not define a workspace`);
1216
+ }
1217
+ const draft = cloneWorkspace(input.workspace);
1218
+ const metadata: JsonObject = {};
1219
+ const providers = await this.createTaskRuntime({
1220
+ workflow: input.workflow,
1221
+ providers: input.providers,
1222
+ nodePath: `workspace.${input.name}.create`,
1223
+ metadata,
1224
+ });
1225
+
1226
+ try {
1227
+ const data = await input.workflow.workspace.create({
1228
+ ...providers,
1229
+ workflow: {
1230
+ name: input.workflow.name,
1231
+ ctx: Object.freeze({ ...input.context }),
1232
+ },
1233
+ workspace: {
1234
+ name: input.name,
1235
+ },
1236
+ providers,
1237
+ local: this.local,
1238
+ step: this.createStepRuntime(input.workflow.name, `workspace.${input.name}.create`, metadata),
1239
+ });
1240
+ assertJsonValue(data, `Workflow ${input.workflow.name} workspace create result`);
1241
+ if (!isPlainObject(data)) {
1242
+ throw new Error(`Workflow ${input.workflow.name} workspace create result must be an object`);
1243
+ }
1244
+ draft.ctx = { ...data };
1245
+ } finally {
1246
+ draft.updatedAt = new Date().toISOString();
1247
+ this.getStateService().saveWorkspace(draft);
1248
+ }
1145
1249
  }
1146
1250
 
1147
- private async runWorkspaceOpenHook(input: {
1251
+ private async runConfigWorkspaceOperation(input: {
1148
1252
  workflow: LoadedWorkflow;
1149
1253
  providers: ProviderControllers;
1150
- workspaceProvider: WorkflowWorkspaceProvider;
1151
1254
  workspace: WorkspaceRecord;
1152
- context: Record<string, JsonValue>;
1153
- }): Promise<void> {
1154
- await this.runWorkspaceHook("open", input.workflow.workspace?.onOpen, input);
1155
- }
1156
-
1157
- private async runWorkspaceHook(
1158
- lifecycle: "created" | "open",
1159
- hook: ((context: {
1160
- workspace: WorkspaceRuntimeRecord;
1161
- ctx: Readonly<Record<string, JsonValue>>;
1162
- providers: ProviderRuntimeMap<WorkflowProviderMap>;
1163
- providerContext: ProviderWorkspaceContext;
1164
- local: LocalWorkspaceRuntime;
1165
- }) => Promise<void> | void) | undefined,
1166
- input: {
1167
- workflow: LoadedWorkflow;
1168
- providers: ProviderControllers;
1169
- workspaceProvider: WorkflowWorkspaceProvider;
1170
- workspace: WorkspaceRecord;
1171
- context: Record<string, JsonValue>;
1172
- },
1173
- ): Promise<void> {
1174
- if (!hook) return;
1175
-
1176
- const providerContext = await input.workspaceProvider.workspaceContext?.(input.workspace) ?? {};
1177
- const workspace: WorkspaceRuntimeRecord = {
1178
- ...input.workspace,
1179
- cwd: resolveWorkspaceCwd(input.workflow, input.context),
1180
- };
1255
+ operation: WorkflowWorkspaceOperationDefinition<any, any, any, any>;
1256
+ rawInput: unknown;
1257
+ }): Promise<unknown> {
1181
1258
  const metadata: JsonObject = {};
1182
1259
  const providers = await this.createTaskRuntime({
1183
1260
  workflow: input.workflow,
1184
1261
  providers: input.providers,
1185
- nodePath: `workspace.${input.workspace.name}.${lifecycle}`,
1262
+ nodePath: `workspace.${input.workspace.name}.${input.operation.id}`,
1186
1263
  metadata,
1187
1264
  });
1188
-
1189
- await hook({
1265
+ const draft = cloneWorkspace(input.workspace);
1266
+ const workspace = this.createWorkspaceRuntime(draft);
1267
+ const operationInput = this.resolveOperationInput(input.workflow, input.operation, input.rawInput ?? {});
1268
+
1269
+ const result = await input.operation.run({
1270
+ ...providers,
1271
+ workflow: {
1272
+ name: input.workflow.name,
1273
+ ctx: Object.freeze({ ...input.workspace.workflowCtx }),
1274
+ },
1275
+ input: Object.freeze(operationInput),
1190
1276
  workspace,
1191
- ctx: Object.freeze({ ...input.context }),
1192
1277
  providers,
1193
- providerContext: normalizeProviderWorkspaceContext(providerContext),
1194
1278
  local: this.local,
1279
+ step: this.createStepRuntime(
1280
+ input.workflow.name,
1281
+ `workspace.${input.workspace.name}.${input.operation.id}`,
1282
+ metadata,
1283
+ ),
1195
1284
  });
1285
+ if (result !== undefined) assertJsonValue(result, `Workspace operation ${input.operation.id} result`);
1286
+ return result ?? null;
1287
+ }
1288
+
1289
+ private createWorkspaceRuntime<Data extends object>(draft: WorkspaceRecord): WorkspaceRuntimeRecord<Data> {
1290
+ return Object.freeze({
1291
+ name: draft.name,
1292
+ ctx: Object.freeze({ ...draft.ctx }) as Data,
1293
+ }) as WorkspaceRuntimeRecord<Data>;
1294
+ }
1295
+
1296
+ private createStepRuntime<Context extends JsonObject = JsonObject>(
1297
+ workflow: string,
1298
+ nodePath: string,
1299
+ metadata: JsonObject,
1300
+ context: Context = {} as Context,
1301
+ previousTasks: readonly EvaluationPreviousTask[] = [],
1302
+ ) {
1303
+ return {
1304
+ workflow,
1305
+ nodePath,
1306
+ ctx: Object.freeze({ ...context }) as Readonly<Context>,
1307
+ metadata: (value: JsonObject) => {
1308
+ Object.assign(metadata, value);
1309
+ },
1310
+ log: (data: string, options: { stream?: WorkflowLogStream; label?: string } = {}) => {
1311
+ this.emit({
1312
+ type: "log.output",
1313
+ nodePath,
1314
+ stream: options.stream ?? "info",
1315
+ label: options.label,
1316
+ data,
1317
+ });
1318
+ },
1319
+ invalidate: <Target extends string>(target: Target) => {
1320
+ const matches = previousTasks.filter((task) => task.name === target || task.path === target);
1321
+ if (matches.length === 0) {
1322
+ throw new Error(`Task ${nodePath} cannot invalidate ${target} because it has not run earlier in this workflow`);
1323
+ }
1324
+ if (matches.length > 1) {
1325
+ throw new Error(`Task ${nodePath} cannot invalidate ${target} because it matches multiple earlier tasks`);
1326
+ }
1327
+ return {
1328
+ kind: STEP_INVALIDATION_KIND,
1329
+ target,
1330
+ targetNodePath: matches[0]!.path,
1331
+ };
1332
+ },
1333
+ };
1196
1334
  }
1197
1335
 
1198
1336
  private getWorkflow(name: string | undefined): LoadedWorkflow {
@@ -1234,7 +1372,7 @@ export class DevMachineEngine {
1234
1372
 
1235
1373
  private resolveOperationInput(
1236
1374
  workflow: LoadedWorkflow,
1237
- operation: WorkflowOperationDefinition<any, any>,
1375
+ operation: { id: string; input?: { fields: readonly WorkflowInputFieldDefinition[] } },
1238
1376
  value: unknown,
1239
1377
  ): Record<string, unknown> {
1240
1378
  const raw = isPlainObject(value) ? value : {};
@@ -1274,11 +1412,7 @@ export class DevMachineEngine {
1274
1412
  message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
1275
1413
  });
1276
1414
  }
1277
- resolved[field.name] = {
1278
- ...workspace,
1279
- cwd: resolveWorkspaceCwd(workflow, workspace.context),
1280
- data: workspace.metadata,
1281
- };
1415
+ resolved[field.name] = this.createWorkspaceRuntime(cloneWorkspace(workspace));
1282
1416
  continue;
1283
1417
  }
1284
1418
 
@@ -1318,115 +1452,6 @@ export class DevMachineEngine {
1318
1452
  return resolved;
1319
1453
  }
1320
1454
 
1321
- private saveOperationWorkspace(
1322
- workflow: LoadedWorkflow,
1323
- operation: WorkflowOperationDefinition<any, any>,
1324
- rawInput: unknown,
1325
- result: unknown,
1326
- ): WorkspaceRecord {
1327
- if (!isPlainObject(result)) {
1328
- throw new Error(`Operation ${operation.id} must return an object when createsWorkspace is true`);
1329
- }
1330
- const data = result as Record<string, JsonValue>;
1331
- const raw = isPlainObject(rawInput) ? rawInput : {};
1332
- const name = typeof data.name === "string"
1333
- ? data.name
1334
- : typeof raw.name === "string"
1335
- ? raw.name
1336
- : undefined;
1337
- if (!name) throw new Error(`Operation ${operation.id} must return or receive a workspace name`);
1338
- const resourceId = typeof data.resourceId === "string"
1339
- ? data.resourceId
1340
- : typeof data.vmId === "string"
1341
- ? data.vmId
1342
- : name;
1343
- const now = new Date().toISOString();
1344
- const workspace: WorkspaceRecord = {
1345
- id: crypto.randomUUID(),
1346
- name,
1347
- providerId: typeof data.providerId === "string" ? data.providerId : "config",
1348
- workflow: workflow.name,
1349
- resourceId,
1350
- snapshotId: typeof data.snapshotId === "string" ? data.snapshotId : undefined,
1351
- sourceRef: isJsonValue(data.sourceRef) ? data.sourceRef : data,
1352
- context: {},
1353
- createdAt: now,
1354
- updatedAt: now,
1355
- metadata: data,
1356
- };
1357
- this.getStateService().saveWorkspace(workspace);
1358
- this.emit({
1359
- type: "workspace.ready",
1360
- workspaceId: workspace.name,
1361
- providerId: workspace.providerId,
1362
- resourceId: workspace.resourceId,
1363
- snapshotId: workspace.snapshotId,
1364
- });
1365
- return workspace;
1366
- }
1367
-
1368
- private async createWorkspaceFromCallback(input: {
1369
- workflow: LoadedWorkflow;
1370
- providers: ProviderControllers;
1371
- context: Record<string, JsonValue>;
1372
- name: string;
1373
- }): Promise<WorkspaceRecord> {
1374
- if (!input.workflow.create) {
1375
- throw new Error(`Workflow ${input.workflow.name} does not define a create callback`);
1376
- }
1377
- const metadata: JsonObject = {};
1378
- const runtime = await this.createTaskRuntime({
1379
- workflow: input.workflow,
1380
- providers: input.providers,
1381
- nodePath: `create.${input.name}`,
1382
- metadata,
1383
- });
1384
- const result = await input.workflow.create.handler({
1385
- ...runtime,
1386
- ctx: Object.freeze({ ...input.context }),
1387
- name: input.name,
1388
- providers: runtime,
1389
- local: this.local,
1390
- workflow: input.workflow.name,
1391
- });
1392
- assertJsonValue(result, `Workflow ${input.workflow.name} create result`);
1393
- if (!isPlainObject(result)) {
1394
- throw new Error(`Workflow ${input.workflow.name} create result must be an object`);
1395
- }
1396
-
1397
- const data = result as Record<string, JsonValue>;
1398
- const name = typeof data.name === "string" ? data.name : input.name;
1399
- const resourceId = typeof data.resourceId === "string"
1400
- ? data.resourceId
1401
- : typeof data.vmId === "string"
1402
- ? data.vmId
1403
- : name;
1404
- const now = new Date().toISOString();
1405
- const workspace: WorkspaceRecord = {
1406
- id: crypto.randomUUID(),
1407
- name,
1408
- providerId: typeof data.providerId === "string" ? data.providerId : "config",
1409
- workflow: input.workflow.name,
1410
- resourceId,
1411
- snapshotId: typeof data.snapshotId === "string" ? data.snapshotId : undefined,
1412
- sourceRef: isJsonValue(data.sourceRef) ? data.sourceRef : data,
1413
- context: { ...input.context },
1414
- createdAt: now,
1415
- updatedAt: now,
1416
- metadata: data,
1417
- };
1418
-
1419
- this.getStateService().saveWorkspace(workspace);
1420
- this.emit({
1421
- type: "workspace.ready",
1422
- workspaceId: workspace.name,
1423
- providerId: workspace.providerId,
1424
- resourceId: workspace.resourceId,
1425
- snapshotId: workspace.snapshotId,
1426
- });
1427
- return workspace;
1428
- }
1429
-
1430
1455
  private getStateService(): StateService {
1431
1456
  if (!this.state) {
1432
1457
  throw new Error(`No state database loaded. Call engine.load() first.`);
@@ -1440,6 +1465,8 @@ export class DevMachineEngine {
1440
1465
  const controller = await this.providerFactory({
1441
1466
  provider,
1442
1467
  storage: this.getStateService().providerStorage(provider.providerId),
1468
+ hostStorage: this.getProviderHostStorage(provider.providerId),
1469
+ local: this.local,
1443
1470
  });
1444
1471
  return [name, controller] as const;
1445
1472
  }),
@@ -1458,6 +1485,18 @@ export class DevMachineEngine {
1458
1485
  return await plugin.createProvider(input);
1459
1486
  }
1460
1487
 
1488
+ private getProviderHostStorage(providerId: string): ReturnType<ProviderHostStorageFactory> {
1489
+ let storage = this.providerHostStorage.get(providerId);
1490
+ if (!storage) {
1491
+ storage = this.hostStorageFactory({
1492
+ providerId,
1493
+ rootDir: this.hostStorageDir,
1494
+ });
1495
+ this.providerHostStorage.set(providerId, storage);
1496
+ }
1497
+ return storage;
1498
+ }
1499
+
1461
1500
  private async resolveWorkflow(root: WorkflowNodeDefinition<any, any, any>): Promise<LoadedWorkflow> {
1462
1501
  const providers: Record<string, LoadedProviderDefinition> = {};
1463
1502
  for (const [name, definition] of Object.entries(root.workflow.providers)) {
@@ -1472,75 +1511,11 @@ export class DevMachineEngine {
1472
1511
  providers,
1473
1512
  root,
1474
1513
  workspace: root.workspaceDefinition,
1475
- create: root.createDefinition,
1476
1514
  operations: root.operations ?? [],
1515
+ workspaceOperations: root.workspaceOperations ?? [],
1477
1516
  };
1478
1517
  }
1479
1518
 
1480
- private resolveWorkspaceSource(
1481
- workflow: LoadedWorkflow,
1482
- context: Record<string, JsonValue>,
1483
- providers: ProviderControllers,
1484
- options: { required: boolean },
1485
- ): JsonValue | undefined {
1486
- const source = workflow.workspace?.source?.(context);
1487
- if (source !== undefined) {
1488
- assertJsonValue(source, `Workflow ${workflow.name} workspace source`);
1489
- return source;
1490
- }
1491
-
1492
- const candidates = collectArtifacts(context).filter((artifact) =>
1493
- Object.values(providers).some((provider) => provider.workspace?.canUse(artifact)),
1494
- );
1495
-
1496
- if (candidates.length === 1) return candidates[0];
1497
- if (!options.required && candidates.length === 0) return undefined;
1498
- if (candidates.length === 0) {
1499
- throw new Error(`Workflow ${workflow.name} did not produce a provider artifact that can be forked`);
1500
- }
1501
- throw new Error(`Workflow ${workflow.name} produced multiple forkable artifacts; configure workspace.source`);
1502
- }
1503
-
1504
- private findWorkspaceProvider(
1505
- providers: ProviderControllers,
1506
- sourceRef: JsonValue,
1507
- ): WorkflowWorkspaceProvider {
1508
- const provider = Object.values(providers).find((controller) => controller.workspace?.canUse(sourceRef));
1509
- if (!provider?.workspace) {
1510
- throw new Error(`No workflow provider can create a workspace from ${stableJson(sourceRef)}`);
1511
- }
1512
- return provider.workspace;
1513
- }
1514
-
1515
- private workspaceProviderById(
1516
- providers: ProviderControllers,
1517
- providerId: string,
1518
- ): WorkflowWorkspaceProvider {
1519
- const provider = Object.values(providers).find((controller) => controller.providerId === providerId);
1520
- if (!provider?.workspace) {
1521
- throw new Error(`Provider ${providerId} does not support workspaces`);
1522
- }
1523
- return provider.workspace;
1524
- }
1525
-
1526
- private singleWorkspaceProvider(providers: ProviderControllers): WorkflowWorkspaceProvider {
1527
- const workspaceProviders = Object.values(providers).filter((provider) => provider.workspace);
1528
- if (workspaceProviders.length !== 1 || !workspaceProviders[0]?.workspace) {
1529
- throw new Error(`Expected exactly one workspace-capable provider`);
1530
- }
1531
- return workspaceProviders[0].workspace;
1532
- }
1533
-
1534
- private providerIdForWorkspaceProvider(
1535
- providers: ProviderControllers,
1536
- workspaceProvider: WorkflowWorkspaceProvider,
1537
- ): string {
1538
- for (const provider of Object.values(providers)) {
1539
- if (provider.workspace === workspaceProvider) return provider.providerId;
1540
- }
1541
- return "unknown";
1542
- }
1543
-
1544
1519
  private emit(event: WorkflowEvent): void {
1545
1520
  for (const handler of this.handlers) handler(event);
1546
1521
  }
@@ -1552,6 +1527,23 @@ export async function createDevMachineEngine(
1552
1527
  return new DevMachineEngine(options);
1553
1528
  }
1554
1529
 
1530
+ function cloneWorkspace(workspace: WorkspaceRecord): WorkspaceRecord {
1531
+ return {
1532
+ ...workspace,
1533
+ workflowCtx: { ...workspace.workflowCtx },
1534
+ ctx: { ...workspace.ctx },
1535
+ };
1536
+ }
1537
+
1538
+ function parseWorkspaceOperationId(value: string): { workspace: string; operation: string } | undefined {
1539
+ const slash = value.indexOf("/");
1540
+ if (slash <= 0 || slash === value.length - 1) return undefined;
1541
+ return {
1542
+ workspace: value.slice(0, slash),
1543
+ operation: value.slice(slash + 1),
1544
+ };
1545
+ }
1546
+
1555
1547
  async function resolveProviderDefinition(
1556
1548
  definition: WorkflowDefinition<any, any>["providers"][string],
1557
1549
  ): Promise<LoadedProviderDefinition> {
@@ -1650,7 +1642,7 @@ function summarizeWorkflow(workflow: LoadedWorkflow): WorkflowSummary {
1650
1642
  providers: Object.entries(workflow.providers).map(([name, provider]) => `${name}:${provider.providerId}`),
1651
1643
  nodes: collectNodePaths(workflow.root),
1652
1644
  operations: workflow.operations.map((operation) => operation.id),
1653
- createsWorkspace: Boolean(workflow.create || workflow.workspace),
1645
+ createsWorkspace: Boolean(workflow.workspace),
1654
1646
  workspace: workflow.workspace,
1655
1647
  };
1656
1648
  }
@@ -1716,26 +1708,123 @@ function parallelBranches(node: WorkflowNodeDefinition<any, any, any>): Record<s
1716
1708
  return (node as { branches?: Record<string, WorkflowNodeDefinition<any, any, any>> }).branches ?? {};
1717
1709
  }
1718
1710
 
1711
+ function appendPreviousTask(
1712
+ tasks: readonly EvaluationPreviousTask[],
1713
+ task: EvaluationPreviousTask,
1714
+ ): EvaluationPreviousTask[] {
1715
+ return mergePreviousTasks([...tasks], [task]);
1716
+ }
1717
+
1718
+ function mergePreviousTasks(
1719
+ left: readonly EvaluationPreviousTask[],
1720
+ right: readonly EvaluationPreviousTask[],
1721
+ ): EvaluationPreviousTask[] {
1722
+ const seen = new Set<string>();
1723
+ const result: EvaluationPreviousTask[] = [];
1724
+ for (const task of [...left, ...right]) {
1725
+ if (seen.has(task.path)) continue;
1726
+ seen.add(task.path);
1727
+ result.push(task);
1728
+ }
1729
+ return result;
1730
+ }
1731
+
1719
1732
  function normalizeTaskOutput(
1720
1733
  nodePath: string,
1721
1734
  result: unknown,
1722
1735
  schema: OutputSchema | undefined,
1723
1736
  source: "fresh" | "cached",
1737
+ currentContext: Record<string, JsonValue> = {},
1724
1738
  ): Record<string, JsonValue> | undefined {
1739
+ if (source === "fresh") {
1740
+ if (result === undefined) return { ...currentContext };
1741
+ if (!isPlainObject(result) || !("ctx" in result)) {
1742
+ throw new Error(`Task ${nodePath} must return { ctx: { ... } } or step.invalidate(...)`);
1743
+ }
1744
+ const ctx = result.ctx;
1745
+ const value = schema ? parseWithSchema(schema, ctx, source) : ctx;
1746
+ if (!isPlainObject(value)) {
1747
+ throw new Error(`Task ${nodePath} ctx must be a JSON-serializable object`);
1748
+ }
1749
+
1750
+ for (const [key, item] of Object.entries(value)) {
1751
+ assertJsonValue(item, `Task ${nodePath} ctx value ${key}`);
1752
+ }
1753
+
1754
+ return value as Record<string, JsonValue>;
1755
+ }
1756
+
1725
1757
  const value = schema ? parseWithSchema(schema, result, source) : result;
1726
- if (value === undefined) return source === "cached" && schema ? undefined : {};
1758
+ if (value === undefined) return schema ? undefined : {};
1727
1759
  if (!isPlainObject(value)) {
1728
1760
  if (source === "cached") return undefined;
1729
- throw new Error(`Task ${nodePath} must return an object with JSON-serializable context values`);
1761
+ throw new Error(`Task ${nodePath} cached ctx must be a JSON-serializable object`);
1730
1762
  }
1731
1763
 
1732
1764
  for (const [key, item] of Object.entries(value)) {
1733
- assertJsonValue(item, `Task ${nodePath} return value ${key}`);
1765
+ assertJsonValue(item, `Task ${nodePath} ctx value ${key}`);
1734
1766
  }
1735
1767
 
1736
1768
  return value as Record<string, JsonValue>;
1737
1769
  }
1738
1770
 
1771
+ function isCacheFresh(createdAt: string, ttl: WorkflowTaskCacheTTL | undefined): boolean {
1772
+ const ttlMs = parseCacheTTL(ttl);
1773
+ if (ttlMs === undefined) return true;
1774
+ if (ttlMs <= 0) return false;
1775
+ const createdTime = Date.parse(createdAt);
1776
+ if (Number.isNaN(createdTime)) return false;
1777
+ return Date.now() - createdTime <= ttlMs;
1778
+ }
1779
+
1780
+ function parseCacheTTL(ttl: WorkflowTaskCacheTTL | undefined): number | undefined {
1781
+ if (ttl === undefined) return undefined;
1782
+ if (typeof ttl === "number") {
1783
+ assertFiniteTTL(ttl, "cacheTTL");
1784
+ return ttl;
1785
+ }
1786
+ if (typeof ttl === "string") return parseCacheTTLString(ttl);
1787
+
1788
+ const total =
1789
+ (ttl.seconds ?? 0) * 1000 +
1790
+ (ttl.minutes ?? 0) * 60 * 1000 +
1791
+ (ttl.hours ?? 0) * 60 * 60 * 1000 +
1792
+ (ttl.days ?? 0) * 24 * 60 * 60 * 1000;
1793
+ assertFiniteTTL(total, "cacheTTL");
1794
+ return total;
1795
+ }
1796
+
1797
+ function parseCacheTTLString(value: string): number {
1798
+ const input = value.trim();
1799
+ const match = input.match(/^(\d+(?:\.\d+)?)\s*(ms|s|m|h|d)$/i);
1800
+ if (!match) {
1801
+ throw new Error(`cacheTTL must be a number, an object, or a string like "30m", "6h", or "1d"`);
1802
+ }
1803
+ const amount = Number(match[1]);
1804
+ assertFiniteTTL(amount, "cacheTTL");
1805
+ const unit = match[2].toLowerCase();
1806
+ const multiplier =
1807
+ unit === "ms" ? 1
1808
+ : unit === "s" ? 1000
1809
+ : unit === "m" ? 60 * 1000
1810
+ : unit === "h" ? 60 * 60 * 1000
1811
+ : 24 * 60 * 60 * 1000;
1812
+ return amount * multiplier;
1813
+ }
1814
+
1815
+ function assertFiniteTTL(value: number, label: string): void {
1816
+ if (!Number.isFinite(value) || value < 0) {
1817
+ throw new Error(`${label} must be a finite non-negative duration`);
1818
+ }
1819
+ }
1820
+
1821
+ function isStepInvalidation(value: unknown): value is WorkflowStepInvalidation<string> {
1822
+ return isPlainObject(value) &&
1823
+ value.kind === STEP_INVALIDATION_KIND &&
1824
+ typeof value.target === "string" &&
1825
+ typeof value.targetNodePath === "string";
1826
+ }
1827
+
1739
1828
  function parseWithSchema(
1740
1829
  schema: OutputSchema,
1741
1830
  value: unknown,
@@ -1786,23 +1875,6 @@ function kindOf(value: unknown): string | undefined {
1786
1875
  return isPlainObject(value) && typeof value.kind === "string" ? value.kind : undefined;
1787
1876
  }
1788
1877
 
1789
- function snapshotIdOf(value: unknown): string | undefined {
1790
- return isPlainObject(value) && typeof value.snapshotId === "string" ? value.snapshotId : undefined;
1791
- }
1792
-
1793
- function resolveWorkspaceCwd(workflow: LoadedWorkflow, context: Record<string, JsonValue>): string | undefined {
1794
- const cwd = workflow.workspace?.cwd;
1795
- return typeof cwd === "function" ? cwd(context) : cwd;
1796
- }
1797
-
1798
- function normalizeProviderWorkspaceContext(value: unknown): ProviderWorkspaceContext {
1799
- if (value === undefined) return {};
1800
- if (!isPlainObject(value)) {
1801
- throw new Error(`Provider workspace context must be an object`);
1802
- }
1803
- return value as ProviderWorkspaceContext;
1804
- }
1805
-
1806
1878
  function assertJsonValue(value: unknown, label: string): asserts value is JsonValue {
1807
1879
  if (
1808
1880
  value === null ||
@@ -1837,15 +1909,7 @@ function isJsonValue(value: unknown): value is JsonValue {
1837
1909
  }
1838
1910
  }
1839
1911
 
1840
- const RESERVED_HOST_OPERATION_IDS = new Set([
1841
- "completion",
1842
- "doctor",
1843
- "help",
1844
- "init",
1845
- "projects",
1846
- "run",
1847
- "version",
1848
- ]);
1912
+ const RESERVED_HOST_OPERATION_IDS = new Set<string>(RESERVED_WORKFLOW_OPERATION_IDS);
1849
1913
 
1850
1914
  const CORE_OPERATION_INPUT_FIELDS: Record<string, readonly string[]> = {
1851
1915
  plan: ["workflow"],
@@ -1853,7 +1917,6 @@ const CORE_OPERATION_INPUT_FIELDS: Record<string, readonly string[]> = {
1853
1917
  create: ["workflow", "name"],
1854
1918
  ssh: ["workflow", "workspaceOrVmId", "user", "print"],
1855
1919
  snapshot: ["workflow", "workspace", "label"],
1856
- delete: ["workflow", "workspace"],
1857
1920
  };
1858
1921
 
1859
1922
  function assertAllowedConfigOperationId(operationId: string): void {