@rigkit/engine 0.2.1 → 0.2.3

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,15 @@ 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 { RESERVED_WORKFLOW_OPERATION_IDS } from "./types.ts";
7
8
  import type {
8
9
  BaseProviderPlugin,
9
10
  InteractionPresenter,
10
11
  InteractionPresentationRequest,
11
12
  ProviderFactory,
12
13
  ProviderRuntimeContext,
13
- SshConnection,
14
14
  WorkflowProviderController,
15
- WorkflowWorkspaceProvider,
16
15
  } from "./provider/types.ts";
17
16
  import {
18
17
  createStateStore,
@@ -30,20 +29,18 @@ import type {
30
29
  LocalWorkspaceRuntime,
31
30
  OutputSchema,
32
31
  ProviderRuntimeMap,
33
- ProviderWorkspaceContext,
34
32
  WorkflowInputFieldDefinition,
35
33
  WorkflowDefinition,
36
34
  WorkflowEvent,
37
35
  WorkflowNodeDefinition,
38
36
  WorkflowOperationDefinition,
39
- WorkflowHostCapabilityRequirement,
40
- WorkflowHostMethodRequirement,
41
37
  WorkflowPlan,
42
38
  WorkflowPlanNode,
43
39
  WorkflowProviderMap,
44
40
  WorkflowTaskNode,
45
41
  WorkspaceRecord,
46
42
  WorkspaceRuntimeRecord,
43
+ WorkflowWorkspaceOperationDefinition,
47
44
  } from "./types.ts";
48
45
 
49
46
  export type CreateDevMachineEngineOptions = {
@@ -119,8 +116,6 @@ export type EngineOperationSummary = {
119
116
  title?: string;
120
117
  description?: string;
121
118
  createsWorkspace?: boolean;
122
- requiredHostMethods?: readonly WorkflowHostMethodRequirement[];
123
- requiredHostCapabilities?: readonly WorkflowHostCapabilityRequirement[];
124
119
  inputFields: readonly WorkflowInputFieldDefinition[];
125
120
  cli?: EngineOperationCli;
126
121
  };
@@ -179,6 +174,13 @@ type RuntimeOperationEntry = {
179
174
  readonly run: (input: { workflow?: string; input?: unknown }) => Promise<unknown>;
180
175
  };
181
176
 
177
+ type RuntimeWorkspaceOperationEntry = {
178
+ readonly summary: EngineOperationSummary;
179
+ readonly run: (input: { workspace: string; workflow?: string; input?: unknown }) => Promise<unknown>;
180
+ };
181
+
182
+ let configImportCounter = 0;
183
+
182
184
  export class DevMachineEngine {
183
185
  private readonly projectDir: string;
184
186
  private readonly configPath: string;
@@ -226,7 +228,7 @@ export class DevMachineEngine {
226
228
  }
227
229
 
228
230
  const moduleUrl = pathToFileURL(this.configPath);
229
- moduleUrl.searchParams.set("t", String(Date.now()));
231
+ moduleUrl.searchParams.set("t", `${Date.now()}-${configImportCounter++}`);
230
232
  const mod = await import(moduleUrl.href);
231
233
  const roots = normalizeDefinitions(mod.default ?? mod.workflow);
232
234
  const loaded = await Promise.all(roots.map((root) => this.resolveWorkflow(root)));
@@ -304,6 +306,10 @@ export class DevMachineEngine {
304
306
  return this.listRuntimeOperationEntries().map((entry) => entry.summary);
305
307
  }
306
308
 
309
+ listRuntimeWorkspaceOperations(): EngineOperationSummary[] {
310
+ return this.listRuntimeWorkspaceOperationEntries().map((entry) => entry.summary);
311
+ }
312
+
307
313
  private listRuntimeOperationEntries(): RuntimeOperationEntry[] {
308
314
  const configOperations = this.listConfigOperationEntries();
309
315
  const configOperationIds = new Set(configOperations.map((entry) => entry.summary.id));
@@ -317,6 +323,16 @@ export class DevMachineEngine {
317
323
  ];
318
324
  }
319
325
 
326
+ private listRuntimeWorkspaceOperationEntries(): RuntimeWorkspaceOperationEntry[] {
327
+ const configOperations = this.listConfigWorkspaceOperationEntries();
328
+ const configOperationIds = new Set(configOperations.map((entry) => entry.summary.id));
329
+ const coreOperations = this.listCoreWorkspaceOperationEntries();
330
+ return [
331
+ ...coreOperations.filter((entry) => !configOperationIds.has(entry.summary.id)),
332
+ ...configOperations,
333
+ ];
334
+ }
335
+
320
336
  private listConfigOperationEntries(): RuntimeOperationEntry[] {
321
337
  return this.listConfigOperationSummaries().map((summary) => ({
322
338
  summary,
@@ -339,18 +355,65 @@ export class DevMachineEngine {
339
355
  source: "config" as const,
340
356
  title: operation.title,
341
357
  description: operation.description,
342
- createsWorkspace: operation.createsWorkspace,
343
- requiredHostMethods: operation.requiredHostMethods,
344
- requiredHostCapabilities: operation.requiredHostCapabilities,
345
358
  inputFields: operation.input?.fields ?? [],
346
359
  };
347
360
  }),
348
361
  );
349
362
  }
350
363
 
364
+ private listConfigWorkspaceOperationEntries(): RuntimeWorkspaceOperationEntry[] {
365
+ return this.listWorkflows().flatMap((workflow) =>
366
+ workflow.workspaceOperations.map((operation) => ({
367
+ summary: this.workspaceOperationSummary(workflow, operation),
368
+ run: async (input) => {
369
+ const workspace = this.getStateService().getWorkspace(input.workspace);
370
+ if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
371
+ if (workspace.workflow !== workflow.name) {
372
+ throw new EngineOperationValidationError({
373
+ operation: operation.id,
374
+ message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
375
+ });
376
+ }
377
+ const providers = await this.createProviders(workflow);
378
+ return await this.runConfigWorkspaceOperation({
379
+ workflow,
380
+ providers,
381
+ workspace,
382
+ operation,
383
+ rawInput: input.input,
384
+ });
385
+ },
386
+ }))
387
+ );
388
+ }
389
+
390
+ private listConfigWorkspaceOperationSummaries(): EngineOperationSummary[] {
391
+ return this.listWorkflows().flatMap((workflow) =>
392
+ workflow.workspaceOperations.map((operation) => {
393
+ assertAllowedConfigOperationId(operation.id);
394
+ return this.workspaceOperationSummary(workflow, operation);
395
+ }),
396
+ );
397
+ }
398
+
399
+ private workspaceOperationSummary(
400
+ workflow: LoadedWorkflow,
401
+ operation: WorkflowWorkspaceOperationDefinition<any, any, any, any>,
402
+ ): EngineOperationSummary {
403
+ return {
404
+ workflow: workflow.name,
405
+ id: operation.id,
406
+ source: "config" as const,
407
+ kind: "workspace-action" as const,
408
+ title: operation.title,
409
+ description: operation.description,
410
+ inputFields: operation.input?.fields ?? [],
411
+ };
412
+ }
413
+
351
414
  private listCoreOperationEntries(): RuntimeOperationEntry[] {
352
415
  const workflows = this.listWorkflows();
353
- const hasWorkspaceCreator = workflows.some((workflow) => workflow.create || workflow.workspace);
416
+ const hasWorkspaceCreator = workflows.some((workflow) => workflow.workspace);
354
417
  const workflowField = stringField({
355
418
  name: "workflow",
356
419
  required: false,
@@ -419,7 +482,7 @@ export class DevMachineEngine {
419
482
  source: "core",
420
483
  kind: "command",
421
484
  title: "Create",
422
- description: "Create a workspace from the resolved workflow artifact",
485
+ description: "Create a workspace",
423
486
  createsWorkspace: true,
424
487
  inputFields: [
425
488
  workflowField,
@@ -434,7 +497,7 @@ export class DevMachineEngine {
434
497
  },
435
498
  async (input) => {
436
499
  const parsed = parseCoreOperationInput("create", input.input);
437
- return await this.fork({
500
+ return await this.createWorkspace({
438
501
  workflow: optionalStringInput("create", parsed, "workflow"),
439
502
  name: requiredStringInput("create", parsed, "name"),
440
503
  });
@@ -442,117 +505,40 @@ export class DevMachineEngine {
442
505
  ),
443
506
  ]
444
507
  : []),
508
+ ];
509
+ }
510
+
511
+ private listCoreWorkspaceOperationEntries(): RuntimeWorkspaceOperationEntry[] {
512
+ const workflows = this.listWorkflows().filter((workflow) => workflow.workspace);
513
+ if (workflows.length === 0) return [];
514
+ const coreOperation = (
515
+ summary: EngineOperationSummary,
516
+ run: RuntimeWorkspaceOperationEntry["run"],
517
+ ): RuntimeWorkspaceOperationEntry => ({ summary, run });
518
+
519
+ return workflows.map((workflow) =>
445
520
  coreOperation(
446
521
  {
447
- workflow: "",
448
- id: "ssh",
449
- source: "core",
450
- kind: "command",
451
- title: "SSH",
452
- description: "Get an SSH command for a workspace or VM",
453
- requiredHostMethods: [{ id: "host.command.run", modes: ["interactive"] }],
454
- inputFields: [
455
- workflowField,
456
- stringField({ name: "workspaceOrVmId", position: 0, required: true }),
457
- stringField({ name: "user", required: false }),
458
- booleanField({ name: "print", required: false, defaultValue: false }),
459
- ],
460
- cli: {
461
- positionals: [
462
- { name: "workspaceOrVmId", index: 0 },
463
- ],
464
- options: [
465
- { name: "workflow", flag: "--workflow" },
466
- { name: "user", flag: "--user" },
467
- { name: "print", flag: "--print", type: "boolean" },
468
- ],
469
- },
470
- },
471
- async (input) => {
472
- const parsed = parseCoreOperationInput("ssh", input.input);
473
- const workspaceOrVmId = requiredStringInput("ssh", parsed, "workspaceOrVmId");
474
- const terminal = await this.attachTerminal({
475
- workflow: optionalStringInput("ssh", parsed, "workflow"),
476
- workspaceOrVmId,
477
- printOnly: true,
478
- user: optionalStringInput("ssh", parsed, "user"),
479
- });
480
- if (optionalBooleanInput("ssh", parsed, "print", false)) return terminal;
481
- const commandResult = await (this.local.command ?? runLocalCommand)({
482
- argv: ["sh", "-lc", terminal.command],
483
- cwd: this.projectDir,
484
- mode: "interactive",
485
- reason: `Open an SSH session to ${workspaceOrVmId}`,
486
- presentation: {
487
- visible: true,
488
- label: "SSH into workspace",
489
- },
490
- });
491
- return { ...terminal, commandResult };
492
- },
493
- ),
494
- coreOperation(
495
- {
496
- workflow: "",
497
- id: "snapshot",
498
- source: "core",
499
- kind: "command",
500
- title: "Snapshot",
501
- description: "Capture a snapshot from a workspace VM",
502
- inputFields: [
503
- workflowField,
504
- stringField({ name: "workspace", position: 0, required: true }),
505
- stringField({ name: "label", required: false }),
506
- ],
507
- cli: {
508
- positionals: [
509
- { name: "workspace", index: 0 },
510
- ],
511
- options: [
512
- { name: "workflow", flag: "--workflow" },
513
- { name: "label", flag: "--label" },
514
- ],
515
- },
516
- },
517
- async (input) => {
518
- const parsed = parseCoreOperationInput("snapshot", input.input);
519
- return await this.snapshotWorkspace({
520
- workflow: optionalStringInput("snapshot", parsed, "workflow"),
521
- workspace: requiredStringInput("snapshot", parsed, "workspace"),
522
- label: optionalStringInput("snapshot", parsed, "label"),
523
- });
524
- },
525
- ),
526
- coreOperation(
527
- {
528
- workflow: "",
529
- id: "delete",
530
- aliases: ["rm"],
522
+ workflow: workflow.name,
523
+ id: "remove",
531
524
  source: "core",
532
- kind: "command",
533
- title: "Delete",
534
- description: "Delete a workspace VM and remove it from state",
535
- inputFields: [
536
- workflowField,
537
- stringField({ name: "workspace", position: 0, required: true }),
538
- ],
525
+ kind: "workspace-action",
526
+ title: "Remove",
527
+ description: "Remove a workspace",
528
+ inputFields: [],
539
529
  cli: {
540
- positionals: [{ name: "workspace", index: 0 }],
541
530
  options: [
542
- { name: "workflow", flag: "--workflow" },
543
- { name: "yes", flag: "--yes", aliases: ["-y"], required: true, type: "boolean", runtime: false },
531
+ { name: "yes", flag: "--yes", aliases: ["-y"], type: "boolean", runtime: false },
544
532
  ],
545
533
  },
546
534
  },
547
- async (input) => {
548
- const parsed = parseCoreOperationInput("delete", input.input);
549
- return await this.deleteWorkspace({
550
- workflow: optionalStringInput("delete", parsed, "workflow"),
551
- workspace: requiredStringInput("delete", parsed, "workspace"),
552
- });
553
- },
554
- ),
555
- ];
535
+ async (input) =>
536
+ await this.removeWorkspace({
537
+ workflow: input.workflow,
538
+ workspace: input.workspace,
539
+ }),
540
+ )
541
+ );
556
542
  }
557
543
 
558
544
  listNodeRuns(): WorkflowNodeRunRecord[] {
@@ -564,6 +550,15 @@ export class DevMachineEngine {
564
550
  }
565
551
 
566
552
  async runRuntimeOperation(input: { operation: string; workflow?: string; input?: unknown }): Promise<unknown> {
553
+ const workspaceTarget = parseWorkspaceOperationId(input.operation);
554
+ if (workspaceTarget) {
555
+ return await this.runWorkspaceOperation({
556
+ workspace: workspaceTarget.workspace,
557
+ operation: workspaceTarget.operation,
558
+ workflow: input.workflow,
559
+ input: input.input,
560
+ });
561
+ }
567
562
  const operation = this.findRuntimeOperationEntry(input.operation);
568
563
  if (!operation) throw new EngineOperationNotFoundError(input.operation);
569
564
  return await operation.run({ workflow: input.workflow, input: input.input });
@@ -588,10 +583,44 @@ export class DevMachineEngine {
588
583
  workflow: workflow.name,
589
584
  });
590
585
  if (result !== undefined) assertJsonValue(result, `Operation ${operation.id} result`);
591
- if (operation.createsWorkspace) return this.saveOperationWorkspace(workflow, operation, input.input, result);
592
586
  return result ?? null;
593
587
  }
594
588
 
589
+ async runWorkspaceOperation(input: {
590
+ workspace: string;
591
+ operation: string;
592
+ workflow?: string;
593
+ input?: unknown;
594
+ }): Promise<unknown> {
595
+ const workspace = this.getStateService().getWorkspace(input.workspace);
596
+ if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
597
+ const workflow = this.getWorkflow(input.workflow ?? workspace.workflow);
598
+ if (workspace.workflow !== workflow.name) {
599
+ throw new EngineOperationValidationError({
600
+ operation: input.operation,
601
+ message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
602
+ });
603
+ }
604
+
605
+ const core = this.listCoreWorkspaceOperationEntries().find((entry) =>
606
+ entry.summary.workflow === workflow.name && entry.summary.id === input.operation
607
+ );
608
+ if (core) {
609
+ return await core.run({ workspace: workspace.name, workflow: workflow.name, input: input.input });
610
+ }
611
+
612
+ const operation = workflow.workspaceOperations.find((item) => item.id === input.operation);
613
+ if (!operation) throw new EngineOperationNotFoundError(`${workspace.name}/${input.operation}`);
614
+ const providers = await this.createProviders(workflow);
615
+ return await this.runConfigWorkspaceOperation({
616
+ workflow,
617
+ providers,
618
+ workspace,
619
+ operation,
620
+ rawInput: input.input,
621
+ });
622
+ }
623
+
595
624
  async plan(input: { workflow?: string; machine?: string } = {}): Promise<WorkflowPlan> {
596
625
  const workflow = this.getWorkflow(input.workflow ?? input.machine);
597
626
  const providers = await this.createProviders(workflow);
@@ -624,158 +653,106 @@ export class DevMachineEngine {
624
653
  providers,
625
654
  mode: "apply",
626
655
  });
627
- const workspaceSource = this.resolveWorkspaceSource(workflow, result.context, providers, { required: false });
628
656
 
629
657
  return {
630
658
  context: result.context,
631
- snapshotId: snapshotIdOf(workspaceSource),
632
- workspaceSource,
633
659
  plan: result.plan,
634
660
  };
635
661
  }
636
662
 
637
663
  async fork(input: { workflow?: string; machine?: string; name: string }): Promise<WorkspaceRecord> {
638
- if (!input.name) throw new Error(`fork requires a workspace name`);
664
+ return await this.createWorkspace(input);
665
+ }
666
+
667
+ async createWorkspace(input: { workflow?: string; machine?: string; name: string }): Promise<WorkspaceRecord> {
668
+ if (!input.name) throw new Error(`create requires a workspace name`);
669
+ if (this.getStateService().getWorkspace(input.name)) {
670
+ throw new Error(`Workspace ${input.name} already exists`);
671
+ }
639
672
 
640
673
  const applied = await this.apply({ workflow: input.workflow ?? input.machine });
641
674
  const workflow = this.getWorkflow(input.workflow ?? input.machine);
642
675
  const providers = await this.createProviders(workflow);
643
- if (workflow.create) {
644
- return await this.createWorkspaceFromCallback({
645
- workflow,
646
- providers,
647
- context: applied.context,
648
- name: input.name,
649
- });
676
+ if (!workflow.workspace) {
677
+ throw new Error(`Workflow ${workflow.name} does not define a workspace`);
650
678
  }
651
679
 
652
- const sourceRef = this.resolveWorkspaceSource(workflow, applied.context, providers, { required: true })!;
653
- const workspaceProvider = this.findWorkspaceProvider(providers, sourceRef);
654
- const created = await workspaceProvider.createWorkspace(sourceRef, { name: input.name });
655
- const providerId = created.providerId ?? providerIdOf(sourceRef) ?? this.providerIdForWorkspaceProvider(providers, workspaceProvider);
656
680
  const now = new Date().toISOString();
657
-
658
681
  const workspace: WorkspaceRecord = {
659
682
  id: crypto.randomUUID(),
660
683
  name: input.name,
661
- providerId,
662
684
  workflow: workflow.name,
663
- resourceId: created.resourceId,
664
- snapshotId: created.snapshotId,
665
- sourceRef: created.sourceRef ?? sourceRef,
666
- context: { ...applied.context },
685
+ workflowCtx: { ...applied.context },
686
+ ctx: {},
667
687
  createdAt: now,
668
688
  updatedAt: now,
669
- metadata: created.metadata ?? {},
670
689
  };
671
690
 
672
691
  this.getStateService().saveWorkspace(workspace);
673
- await this.runWorkspaceCreatedHook({
674
- workflow,
675
- providers,
676
- workspaceProvider,
677
- workspace,
678
- context: applied.context,
679
- });
680
-
681
- this.emit({
682
- type: "workspace.ready",
683
- workspaceId: input.name,
684
- providerId: workspace.providerId,
685
- resourceId: workspace.resourceId,
686
- snapshotId: workspace.snapshotId,
687
- });
688
-
689
- return workspace;
690
- }
691
-
692
- async attachTerminal(input: {
693
- workspaceOrVmId: string;
694
- workflow?: string;
695
- machine?: string;
696
- printOnly?: boolean;
697
- user?: string;
698
- }): Promise<{ command: string }> {
699
- const workspace = this.getStateService().findWorkspace(input.workspaceOrVmId);
700
- const workflow = this.getWorkflow(input.workflow ?? input.machine ?? workspace?.workflow);
701
- const providers = await this.createProviders(workflow);
702
- const workspaceProvider = workspace
703
- ? this.workspaceProviderById(providers, workspace.providerId)
704
- : this.singleWorkspaceProvider(providers);
705
- if (workspace) {
706
- await this.runWorkspaceOpenHook({
692
+ try {
693
+ await this.runWorkspaceCreate({
707
694
  workflow,
708
695
  providers,
709
- workspaceProvider,
710
696
  workspace,
711
- context: workspace.context,
697
+ context: applied.context,
698
+ name: input.name,
712
699
  });
700
+ } catch (error) {
701
+ this.getStateService().deleteWorkspace(workspace.name);
702
+ throw error;
713
703
  }
714
- const terminal = await workspaceProvider.ssh(workspace?.resourceId ?? input.workspaceOrVmId, { user: input.user });
704
+ const ready = this.getStateService().getWorkspace(input.name) ?? workspace;
715
705
 
716
- if (!input.printOnly) {
717
- const proc = Bun.spawn(["sh", "-lc", terminal.command], {
718
- stdin: "inherit",
719
- stdout: "inherit",
720
- stderr: "inherit",
721
- });
722
- await proc.exited;
723
- }
706
+ this.emit({
707
+ type: "workspace.ready",
708
+ workspaceId: ready.name,
709
+ });
724
710
 
725
- return { command: terminal.command };
711
+ return ready;
726
712
  }
727
713
 
728
714
  async deleteWorkspace(input: { workspace: string; workflow?: string; machine?: string }): Promise<WorkspaceRecord> {
729
- const workspace = this.getStateService().getWorkspace(input.workspace);
730
- if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
731
-
732
- const workflow = this.getWorkflow(input.workflow ?? input.machine ?? workspace.workflow);
733
- const providers = await this.createProviders(workflow);
734
- const workspaceProvider = this.workspaceProviderById(providers, workspace.providerId);
735
- await workspaceProvider.deleteWorkspace(workspace);
736
-
737
- this.getStateService().deleteWorkspace(input.workspace);
738
-
739
- return workspace;
715
+ return await this.removeWorkspace(input);
740
716
  }
741
717
 
742
- async snapshotWorkspace(input: { workspace: string; label?: string; workflow?: string; machine?: string }): Promise<WorkflowNodeRunRecord> {
718
+ async removeWorkspace(input: { workspace: string; workflow?: string; machine?: string }): Promise<WorkspaceRecord> {
743
719
  const workspace = this.getStateService().getWorkspace(input.workspace);
744
720
  if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
745
721
 
746
722
  const workflow = this.getWorkflow(input.workflow ?? input.machine ?? workspace.workflow);
723
+ if (workspace.workflow !== workflow.name) {
724
+ throw new EngineOperationValidationError({
725
+ operation: "remove",
726
+ message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
727
+ });
728
+ }
729
+ if (!workflow.workspace) {
730
+ throw new Error(`Workflow ${workflow.name} does not define a workspace`);
731
+ }
747
732
  const providers = await this.createProviders(workflow);
748
- const workspaceProvider = this.workspaceProviderById(providers, workspace.providerId);
749
- const snapshot = await workspaceProvider.snapshotWorkspace(workspace);
750
- const sourceRef = snapshot.sourceRef ?? workspace.sourceRef;
751
- const providerFingerprint = providerFingerprintFor(workflow);
752
- const now = new Date().toISOString();
753
- const record: WorkflowNodeRunRecord = {
754
- id: crypto.randomUUID(),
755
- workflow: workflow.name,
756
- nodePath: `workspace.${workspace.name}`,
757
- nodeName: input.label ?? `workspace:${workspace.name}`,
758
- nodeKind: "workspace-snapshot",
759
- nodeKey: hash({
760
- kind: "workspace-snapshot",
761
- workspace: workspace.name,
762
- label: input.label ?? null,
763
- }),
764
- providerFingerprint,
765
- upstreamRunIds: [],
766
- output: { sourceRef },
767
- artifacts: collectArtifacts(sourceRef),
768
- invalidated: false,
769
- createdAt: now,
770
- metadata: {
771
- workspace: workspace.name,
772
- label: input.label ?? null,
773
- snapshotId: snapshot.snapshotId ?? null,
733
+ const metadata: JsonObject = {};
734
+ const runtime = await this.createTaskRuntime({
735
+ workflow,
736
+ providers,
737
+ nodePath: `workspace.${workspace.name}.remove`,
738
+ metadata,
739
+ });
740
+ const draft = cloneWorkspace(workspace);
741
+ const workspaceRuntime = this.createWorkspaceRuntime(draft);
742
+
743
+ await workflow.workspace.remove({
744
+ ...runtime,
745
+ workflow: {
746
+ name: workflow.name,
747
+ ctx: Object.freeze({ ...workspace.workflowCtx }),
774
748
  },
775
- };
749
+ workspace: workspaceRuntime,
750
+ providers: runtime,
751
+ local: this.local,
752
+ });
776
753
 
777
- this.getStateService().saveNodeRun(record);
778
- return record;
754
+ this.getStateService().deleteWorkspace(input.workspace);
755
+ return workspace;
779
756
  }
780
757
 
781
758
  private async evaluate(input: {
@@ -898,10 +875,13 @@ export class DevMachineEngine {
898
875
  const nodePath = [...input.prefix, input.node.name].join(".");
899
876
  const upstreamRunIds = [...input.state.upstreamRunIds];
900
877
  const nodeKey = hash({
878
+ cache: "task-v2",
901
879
  kind: "task",
902
880
  path: nodePath,
903
881
  name: input.node.name,
904
882
  version: input.node.options?.version ?? null,
883
+ handler: functionFingerprintFor(input.node.handler),
884
+ output: input.node.options?.output ?? null,
905
885
  });
906
886
  const planIndex = input.index.value++;
907
887
 
@@ -1129,65 +1109,87 @@ export class DevMachineEngine {
1129
1109
  return Object.fromEntries(entries) as ProviderRuntimeMap<WorkflowProviderMap>;
1130
1110
  }
1131
1111
 
1132
- private async runWorkspaceCreatedHook(input: {
1112
+ private async runWorkspaceCreate(input: {
1133
1113
  workflow: LoadedWorkflow;
1134
1114
  providers: ProviderControllers;
1135
- workspaceProvider: WorkflowWorkspaceProvider;
1136
1115
  workspace: WorkspaceRecord;
1137
1116
  context: Record<string, JsonValue>;
1117
+ name: string;
1138
1118
  }): Promise<void> {
1139
- await this.runWorkspaceHook("created", input.workflow.workspace?.onCreated, input);
1119
+ if (!input.workflow.workspace) {
1120
+ throw new Error(`Workflow ${input.workflow.name} does not define a workspace`);
1121
+ }
1122
+ const draft = cloneWorkspace(input.workspace);
1123
+ const metadata: JsonObject = {};
1124
+ const providers = await this.createTaskRuntime({
1125
+ workflow: input.workflow,
1126
+ providers: input.providers,
1127
+ nodePath: `workspace.${input.name}.create`,
1128
+ metadata,
1129
+ });
1130
+
1131
+ try {
1132
+ const data = await input.workflow.workspace.create({
1133
+ ...providers,
1134
+ workflow: {
1135
+ name: input.workflow.name,
1136
+ ctx: Object.freeze({ ...input.context }),
1137
+ },
1138
+ workspace: {
1139
+ name: input.name,
1140
+ },
1141
+ providers,
1142
+ local: this.local,
1143
+ });
1144
+ assertJsonValue(data, `Workflow ${input.workflow.name} workspace create result`);
1145
+ if (!isPlainObject(data)) {
1146
+ throw new Error(`Workflow ${input.workflow.name} workspace create result must be an object`);
1147
+ }
1148
+ draft.ctx = { ...data };
1149
+ } finally {
1150
+ draft.updatedAt = new Date().toISOString();
1151
+ this.getStateService().saveWorkspace(draft);
1152
+ }
1140
1153
  }
1141
1154
 
1142
- private async runWorkspaceOpenHook(input: {
1155
+ private async runConfigWorkspaceOperation(input: {
1143
1156
  workflow: LoadedWorkflow;
1144
1157
  providers: ProviderControllers;
1145
- workspaceProvider: WorkflowWorkspaceProvider;
1146
1158
  workspace: WorkspaceRecord;
1147
- context: Record<string, JsonValue>;
1148
- }): Promise<void> {
1149
- await this.runWorkspaceHook("open", input.workflow.workspace?.onOpen, input);
1150
- }
1151
-
1152
- private async runWorkspaceHook(
1153
- lifecycle: "created" | "open",
1154
- hook: ((context: {
1155
- workspace: WorkspaceRuntimeRecord;
1156
- ctx: Readonly<Record<string, JsonValue>>;
1157
- providers: ProviderRuntimeMap<WorkflowProviderMap>;
1158
- providerContext: ProviderWorkspaceContext;
1159
- local: LocalWorkspaceRuntime;
1160
- }) => Promise<void> | void) | undefined,
1161
- input: {
1162
- workflow: LoadedWorkflow;
1163
- providers: ProviderControllers;
1164
- workspaceProvider: WorkflowWorkspaceProvider;
1165
- workspace: WorkspaceRecord;
1166
- context: Record<string, JsonValue>;
1167
- },
1168
- ): Promise<void> {
1169
- if (!hook) return;
1170
-
1171
- const providerContext = await input.workspaceProvider.workspaceContext?.(input.workspace) ?? {};
1172
- const workspace: WorkspaceRuntimeRecord = {
1173
- ...input.workspace,
1174
- cwd: resolveWorkspaceCwd(input.workflow, input.context),
1175
- };
1159
+ operation: WorkflowWorkspaceOperationDefinition<any, any, any, any>;
1160
+ rawInput: unknown;
1161
+ }): Promise<unknown> {
1176
1162
  const metadata: JsonObject = {};
1177
1163
  const providers = await this.createTaskRuntime({
1178
1164
  workflow: input.workflow,
1179
1165
  providers: input.providers,
1180
- nodePath: `workspace.${input.workspace.name}.${lifecycle}`,
1166
+ nodePath: `workspace.${input.workspace.name}.${input.operation.id}`,
1181
1167
  metadata,
1182
1168
  });
1183
-
1184
- await hook({
1169
+ const draft = cloneWorkspace(input.workspace);
1170
+ const workspace = this.createWorkspaceRuntime(draft);
1171
+ const operationInput = this.resolveOperationInput(input.workflow, input.operation, input.rawInput ?? {});
1172
+
1173
+ const result = await input.operation.run({
1174
+ ...providers,
1175
+ workflow: {
1176
+ name: input.workflow.name,
1177
+ ctx: Object.freeze({ ...input.workspace.workflowCtx }),
1178
+ },
1179
+ input: Object.freeze(operationInput),
1185
1180
  workspace,
1186
- ctx: Object.freeze({ ...input.context }),
1187
1181
  providers,
1188
- providerContext: normalizeProviderWorkspaceContext(providerContext),
1189
1182
  local: this.local,
1190
1183
  });
1184
+ if (result !== undefined) assertJsonValue(result, `Workspace operation ${input.operation.id} result`);
1185
+ return result ?? null;
1186
+ }
1187
+
1188
+ private createWorkspaceRuntime<Data extends object>(draft: WorkspaceRecord): WorkspaceRuntimeRecord<Data> {
1189
+ return Object.freeze({
1190
+ name: draft.name,
1191
+ ctx: Object.freeze({ ...draft.ctx }) as Data,
1192
+ }) as WorkspaceRuntimeRecord<Data>;
1191
1193
  }
1192
1194
 
1193
1195
  private getWorkflow(name: string | undefined): LoadedWorkflow {
@@ -1229,7 +1231,7 @@ export class DevMachineEngine {
1229
1231
 
1230
1232
  private resolveOperationInput(
1231
1233
  workflow: LoadedWorkflow,
1232
- operation: WorkflowOperationDefinition<any, any>,
1234
+ operation: { id: string; input?: { fields: readonly WorkflowInputFieldDefinition[] } },
1233
1235
  value: unknown,
1234
1236
  ): Record<string, unknown> {
1235
1237
  const raw = isPlainObject(value) ? value : {};
@@ -1269,11 +1271,7 @@ export class DevMachineEngine {
1269
1271
  message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
1270
1272
  });
1271
1273
  }
1272
- resolved[field.name] = {
1273
- ...workspace,
1274
- cwd: resolveWorkspaceCwd(workflow, workspace.context),
1275
- data: workspace.metadata,
1276
- };
1274
+ resolved[field.name] = this.createWorkspaceRuntime(cloneWorkspace(workspace));
1277
1275
  continue;
1278
1276
  }
1279
1277
 
@@ -1313,115 +1311,6 @@ export class DevMachineEngine {
1313
1311
  return resolved;
1314
1312
  }
1315
1313
 
1316
- private saveOperationWorkspace(
1317
- workflow: LoadedWorkflow,
1318
- operation: WorkflowOperationDefinition<any, any>,
1319
- rawInput: unknown,
1320
- result: unknown,
1321
- ): WorkspaceRecord {
1322
- if (!isPlainObject(result)) {
1323
- throw new Error(`Operation ${operation.id} must return an object when createsWorkspace is true`);
1324
- }
1325
- const data = result as Record<string, JsonValue>;
1326
- const raw = isPlainObject(rawInput) ? rawInput : {};
1327
- const name = typeof data.name === "string"
1328
- ? data.name
1329
- : typeof raw.name === "string"
1330
- ? raw.name
1331
- : undefined;
1332
- if (!name) throw new Error(`Operation ${operation.id} must return or receive a workspace name`);
1333
- const resourceId = typeof data.resourceId === "string"
1334
- ? data.resourceId
1335
- : typeof data.vmId === "string"
1336
- ? data.vmId
1337
- : name;
1338
- const now = new Date().toISOString();
1339
- const workspace: WorkspaceRecord = {
1340
- id: crypto.randomUUID(),
1341
- name,
1342
- providerId: typeof data.providerId === "string" ? data.providerId : "config",
1343
- workflow: workflow.name,
1344
- resourceId,
1345
- snapshotId: typeof data.snapshotId === "string" ? data.snapshotId : undefined,
1346
- sourceRef: isJsonValue(data.sourceRef) ? data.sourceRef : data,
1347
- context: {},
1348
- createdAt: now,
1349
- updatedAt: now,
1350
- metadata: data,
1351
- };
1352
- this.getStateService().saveWorkspace(workspace);
1353
- this.emit({
1354
- type: "workspace.ready",
1355
- workspaceId: workspace.name,
1356
- providerId: workspace.providerId,
1357
- resourceId: workspace.resourceId,
1358
- snapshotId: workspace.snapshotId,
1359
- });
1360
- return workspace;
1361
- }
1362
-
1363
- private async createWorkspaceFromCallback(input: {
1364
- workflow: LoadedWorkflow;
1365
- providers: ProviderControllers;
1366
- context: Record<string, JsonValue>;
1367
- name: string;
1368
- }): Promise<WorkspaceRecord> {
1369
- if (!input.workflow.create) {
1370
- throw new Error(`Workflow ${input.workflow.name} does not define a create callback`);
1371
- }
1372
- const metadata: JsonObject = {};
1373
- const runtime = await this.createTaskRuntime({
1374
- workflow: input.workflow,
1375
- providers: input.providers,
1376
- nodePath: `create.${input.name}`,
1377
- metadata,
1378
- });
1379
- const result = await input.workflow.create.handler({
1380
- ...runtime,
1381
- ctx: Object.freeze({ ...input.context }),
1382
- name: input.name,
1383
- providers: runtime,
1384
- local: this.local,
1385
- workflow: input.workflow.name,
1386
- });
1387
- assertJsonValue(result, `Workflow ${input.workflow.name} create result`);
1388
- if (!isPlainObject(result)) {
1389
- throw new Error(`Workflow ${input.workflow.name} create result must be an object`);
1390
- }
1391
-
1392
- const data = result as Record<string, JsonValue>;
1393
- const name = typeof data.name === "string" ? data.name : input.name;
1394
- const resourceId = typeof data.resourceId === "string"
1395
- ? data.resourceId
1396
- : typeof data.vmId === "string"
1397
- ? data.vmId
1398
- : name;
1399
- const now = new Date().toISOString();
1400
- const workspace: WorkspaceRecord = {
1401
- id: crypto.randomUUID(),
1402
- name,
1403
- providerId: typeof data.providerId === "string" ? data.providerId : "config",
1404
- workflow: input.workflow.name,
1405
- resourceId,
1406
- snapshotId: typeof data.snapshotId === "string" ? data.snapshotId : undefined,
1407
- sourceRef: isJsonValue(data.sourceRef) ? data.sourceRef : data,
1408
- context: { ...input.context },
1409
- createdAt: now,
1410
- updatedAt: now,
1411
- metadata: data,
1412
- };
1413
-
1414
- this.getStateService().saveWorkspace(workspace);
1415
- this.emit({
1416
- type: "workspace.ready",
1417
- workspaceId: workspace.name,
1418
- providerId: workspace.providerId,
1419
- resourceId: workspace.resourceId,
1420
- snapshotId: workspace.snapshotId,
1421
- });
1422
- return workspace;
1423
- }
1424
-
1425
1314
  private getStateService(): StateService {
1426
1315
  if (!this.state) {
1427
1316
  throw new Error(`No state database loaded. Call engine.load() first.`);
@@ -1467,75 +1356,11 @@ export class DevMachineEngine {
1467
1356
  providers,
1468
1357
  root,
1469
1358
  workspace: root.workspaceDefinition,
1470
- create: root.createDefinition,
1471
1359
  operations: root.operations ?? [],
1360
+ workspaceOperations: root.workspaceOperations ?? [],
1472
1361
  };
1473
1362
  }
1474
1363
 
1475
- private resolveWorkspaceSource(
1476
- workflow: LoadedWorkflow,
1477
- context: Record<string, JsonValue>,
1478
- providers: ProviderControllers,
1479
- options: { required: boolean },
1480
- ): JsonValue | undefined {
1481
- const source = workflow.workspace?.source?.(context);
1482
- if (source !== undefined) {
1483
- assertJsonValue(source, `Workflow ${workflow.name} workspace source`);
1484
- return source;
1485
- }
1486
-
1487
- const candidates = collectArtifacts(context).filter((artifact) =>
1488
- Object.values(providers).some((provider) => provider.workspace?.canUse(artifact)),
1489
- );
1490
-
1491
- if (candidates.length === 1) return candidates[0];
1492
- if (!options.required && candidates.length === 0) return undefined;
1493
- if (candidates.length === 0) {
1494
- throw new Error(`Workflow ${workflow.name} did not produce a provider artifact that can be forked`);
1495
- }
1496
- throw new Error(`Workflow ${workflow.name} produced multiple forkable artifacts; configure workspace.source`);
1497
- }
1498
-
1499
- private findWorkspaceProvider(
1500
- providers: ProviderControllers,
1501
- sourceRef: JsonValue,
1502
- ): WorkflowWorkspaceProvider {
1503
- const provider = Object.values(providers).find((controller) => controller.workspace?.canUse(sourceRef));
1504
- if (!provider?.workspace) {
1505
- throw new Error(`No workflow provider can create a workspace from ${stableJson(sourceRef)}`);
1506
- }
1507
- return provider.workspace;
1508
- }
1509
-
1510
- private workspaceProviderById(
1511
- providers: ProviderControllers,
1512
- providerId: string,
1513
- ): WorkflowWorkspaceProvider {
1514
- const provider = Object.values(providers).find((controller) => controller.providerId === providerId);
1515
- if (!provider?.workspace) {
1516
- throw new Error(`Provider ${providerId} does not support workspaces`);
1517
- }
1518
- return provider.workspace;
1519
- }
1520
-
1521
- private singleWorkspaceProvider(providers: ProviderControllers): WorkflowWorkspaceProvider {
1522
- const workspaceProviders = Object.values(providers).filter((provider) => provider.workspace);
1523
- if (workspaceProviders.length !== 1 || !workspaceProviders[0]?.workspace) {
1524
- throw new Error(`Expected exactly one workspace-capable provider`);
1525
- }
1526
- return workspaceProviders[0].workspace;
1527
- }
1528
-
1529
- private providerIdForWorkspaceProvider(
1530
- providers: ProviderControllers,
1531
- workspaceProvider: WorkflowWorkspaceProvider,
1532
- ): string {
1533
- for (const provider of Object.values(providers)) {
1534
- if (provider.workspace === workspaceProvider) return provider.providerId;
1535
- }
1536
- return "unknown";
1537
- }
1538
-
1539
1364
  private emit(event: WorkflowEvent): void {
1540
1365
  for (const handler of this.handlers) handler(event);
1541
1366
  }
@@ -1547,6 +1372,23 @@ export async function createDevMachineEngine(
1547
1372
  return new DevMachineEngine(options);
1548
1373
  }
1549
1374
 
1375
+ function cloneWorkspace(workspace: WorkspaceRecord): WorkspaceRecord {
1376
+ return {
1377
+ ...workspace,
1378
+ workflowCtx: { ...workspace.workflowCtx },
1379
+ ctx: { ...workspace.ctx },
1380
+ };
1381
+ }
1382
+
1383
+ function parseWorkspaceOperationId(value: string): { workspace: string; operation: string } | undefined {
1384
+ const slash = value.indexOf("/");
1385
+ if (slash <= 0 || slash === value.length - 1) return undefined;
1386
+ return {
1387
+ workspace: value.slice(0, slash),
1388
+ operation: value.slice(slash + 1),
1389
+ };
1390
+ }
1391
+
1550
1392
  async function resolveProviderDefinition(
1551
1393
  definition: WorkflowDefinition<any, any>["providers"][string],
1552
1394
  ): Promise<LoadedProviderDefinition> {
@@ -1645,7 +1487,7 @@ function summarizeWorkflow(workflow: LoadedWorkflow): WorkflowSummary {
1645
1487
  providers: Object.entries(workflow.providers).map(([name, provider]) => `${name}:${provider.providerId}`),
1646
1488
  nodes: collectNodePaths(workflow.root),
1647
1489
  operations: workflow.operations.map((operation) => operation.id),
1648
- createsWorkspace: Boolean(workflow.create || workflow.workspace),
1490
+ createsWorkspace: Boolean(workflow.workspace),
1649
1491
  workspace: workflow.workspace,
1650
1492
  };
1651
1493
  }
@@ -1673,18 +1515,36 @@ function collectNodePaths(root: WorkflowNodeDefinition<any, any, any>): string[]
1673
1515
 
1674
1516
  function providerFingerprintFor(workflow: LoadedWorkflow): string {
1675
1517
  return hash({
1518
+ cache: "provider-v2",
1676
1519
  providers: Object.fromEntries(
1677
1520
  Object.entries(workflow.providers).map(([name, provider]) => [
1678
1521
  name,
1679
1522
  {
1680
1523
  providerId: provider.providerId,
1681
1524
  config: provider.config,
1525
+ plugin: providerPluginFingerprint(provider.plugin),
1682
1526
  },
1683
1527
  ]),
1684
1528
  ),
1685
1529
  });
1686
1530
  }
1687
1531
 
1532
+ function providerPluginFingerprint(plugin: unknown): unknown {
1533
+ if (!isBaseProviderPlugin(plugin)) return null;
1534
+ return {
1535
+ providerId: plugin.providerId,
1536
+ createProvider: functionFingerprintFor(plugin.createProvider),
1537
+ };
1538
+ }
1539
+
1540
+ function functionFingerprintFor(fn: Function): { name: string; length: number; source: string } {
1541
+ return {
1542
+ name: fn.name,
1543
+ length: fn.length,
1544
+ source: Function.prototype.toString.call(fn),
1545
+ };
1546
+ }
1547
+
1688
1548
  function sequenceChildren(node: WorkflowNodeDefinition<any, any, any>): readonly WorkflowNodeDefinition<any, any, any>[] {
1689
1549
  return (node as { children?: readonly WorkflowNodeDefinition<any, any, any>[] }).children ?? [];
1690
1550
  }
@@ -1763,23 +1623,6 @@ function kindOf(value: unknown): string | undefined {
1763
1623
  return isPlainObject(value) && typeof value.kind === "string" ? value.kind : undefined;
1764
1624
  }
1765
1625
 
1766
- function snapshotIdOf(value: unknown): string | undefined {
1767
- return isPlainObject(value) && typeof value.snapshotId === "string" ? value.snapshotId : undefined;
1768
- }
1769
-
1770
- function resolveWorkspaceCwd(workflow: LoadedWorkflow, context: Record<string, JsonValue>): string | undefined {
1771
- const cwd = workflow.workspace?.cwd;
1772
- return typeof cwd === "function" ? cwd(context) : cwd;
1773
- }
1774
-
1775
- function normalizeProviderWorkspaceContext(value: unknown): ProviderWorkspaceContext {
1776
- if (value === undefined) return {};
1777
- if (!isPlainObject(value)) {
1778
- throw new Error(`Provider workspace context must be an object`);
1779
- }
1780
- return value as ProviderWorkspaceContext;
1781
- }
1782
-
1783
1626
  function assertJsonValue(value: unknown, label: string): asserts value is JsonValue {
1784
1627
  if (
1785
1628
  value === null ||
@@ -1814,15 +1657,7 @@ function isJsonValue(value: unknown): value is JsonValue {
1814
1657
  }
1815
1658
  }
1816
1659
 
1817
- const RESERVED_HOST_OPERATION_IDS = new Set([
1818
- "completion",
1819
- "doctor",
1820
- "help",
1821
- "init",
1822
- "projects",
1823
- "run",
1824
- "version",
1825
- ]);
1660
+ const RESERVED_HOST_OPERATION_IDS = new Set<string>(RESERVED_WORKFLOW_OPERATION_IDS);
1826
1661
 
1827
1662
  const CORE_OPERATION_INPUT_FIELDS: Record<string, readonly string[]> = {
1828
1663
  plan: ["workflow"],
@@ -1830,7 +1665,6 @@ const CORE_OPERATION_INPUT_FIELDS: Record<string, readonly string[]> = {
1830
1665
  create: ["workflow", "name"],
1831
1666
  ssh: ["workflow", "workspaceOrVmId", "user", "print"],
1832
1667
  snapshot: ["workflow", "workspace", "label"],
1833
- delete: ["workflow", "workspace"],
1834
1668
  };
1835
1669
 
1836
1670
  function assertAllowedConfigOperationId(operationId: string): void {