@rigkit/engine 0.2.2 → 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,11 @@ 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
182
  let configImportCounter = 0;
183
183
 
184
184
  export class DevMachineEngine {
@@ -306,6 +306,10 @@ export class DevMachineEngine {
306
306
  return this.listRuntimeOperationEntries().map((entry) => entry.summary);
307
307
  }
308
308
 
309
+ listRuntimeWorkspaceOperations(): EngineOperationSummary[] {
310
+ return this.listRuntimeWorkspaceOperationEntries().map((entry) => entry.summary);
311
+ }
312
+
309
313
  private listRuntimeOperationEntries(): RuntimeOperationEntry[] {
310
314
  const configOperations = this.listConfigOperationEntries();
311
315
  const configOperationIds = new Set(configOperations.map((entry) => entry.summary.id));
@@ -319,6 +323,16 @@ export class DevMachineEngine {
319
323
  ];
320
324
  }
321
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
+
322
336
  private listConfigOperationEntries(): RuntimeOperationEntry[] {
323
337
  return this.listConfigOperationSummaries().map((summary) => ({
324
338
  summary,
@@ -341,18 +355,65 @@ export class DevMachineEngine {
341
355
  source: "config" as const,
342
356
  title: operation.title,
343
357
  description: operation.description,
344
- createsWorkspace: operation.createsWorkspace,
345
- requiredHostMethods: operation.requiredHostMethods,
346
- requiredHostCapabilities: operation.requiredHostCapabilities,
347
358
  inputFields: operation.input?.fields ?? [],
348
359
  };
349
360
  }),
350
361
  );
351
362
  }
352
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
+
353
414
  private listCoreOperationEntries(): RuntimeOperationEntry[] {
354
415
  const workflows = this.listWorkflows();
355
- const hasWorkspaceCreator = workflows.some((workflow) => workflow.create || workflow.workspace);
416
+ const hasWorkspaceCreator = workflows.some((workflow) => workflow.workspace);
356
417
  const workflowField = stringField({
357
418
  name: "workflow",
358
419
  required: false,
@@ -421,7 +482,7 @@ export class DevMachineEngine {
421
482
  source: "core",
422
483
  kind: "command",
423
484
  title: "Create",
424
- description: "Create a workspace from the resolved workflow artifact",
485
+ description: "Create a workspace",
425
486
  createsWorkspace: true,
426
487
  inputFields: [
427
488
  workflowField,
@@ -436,7 +497,7 @@ export class DevMachineEngine {
436
497
  },
437
498
  async (input) => {
438
499
  const parsed = parseCoreOperationInput("create", input.input);
439
- return await this.fork({
500
+ return await this.createWorkspace({
440
501
  workflow: optionalStringInput("create", parsed, "workflow"),
441
502
  name: requiredStringInput("create", parsed, "name"),
442
503
  });
@@ -444,117 +505,40 @@ export class DevMachineEngine {
444
505
  ),
445
506
  ]
446
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) =>
447
520
  coreOperation(
448
521
  {
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"],
522
+ workflow: workflow.name,
523
+ id: "remove",
533
524
  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
- ],
525
+ kind: "workspace-action",
526
+ title: "Remove",
527
+ description: "Remove a workspace",
528
+ inputFields: [],
541
529
  cli: {
542
- positionals: [{ name: "workspace", index: 0 }],
543
530
  options: [
544
- { name: "workflow", flag: "--workflow" },
545
- { name: "yes", flag: "--yes", aliases: ["-y"], required: true, type: "boolean", runtime: false },
531
+ { name: "yes", flag: "--yes", aliases: ["-y"], type: "boolean", runtime: false },
546
532
  ],
547
533
  },
548
534
  },
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
- ];
535
+ async (input) =>
536
+ await this.removeWorkspace({
537
+ workflow: input.workflow,
538
+ workspace: input.workspace,
539
+ }),
540
+ )
541
+ );
558
542
  }
559
543
 
560
544
  listNodeRuns(): WorkflowNodeRunRecord[] {
@@ -566,6 +550,15 @@ export class DevMachineEngine {
566
550
  }
567
551
 
568
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
+ }
569
562
  const operation = this.findRuntimeOperationEntry(input.operation);
570
563
  if (!operation) throw new EngineOperationNotFoundError(input.operation);
571
564
  return await operation.run({ workflow: input.workflow, input: input.input });
@@ -590,10 +583,44 @@ export class DevMachineEngine {
590
583
  workflow: workflow.name,
591
584
  });
592
585
  if (result !== undefined) assertJsonValue(result, `Operation ${operation.id} result`);
593
- if (operation.createsWorkspace) return this.saveOperationWorkspace(workflow, operation, input.input, result);
594
586
  return result ?? null;
595
587
  }
596
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
+
597
624
  async plan(input: { workflow?: string; machine?: string } = {}): Promise<WorkflowPlan> {
598
625
  const workflow = this.getWorkflow(input.workflow ?? input.machine);
599
626
  const providers = await this.createProviders(workflow);
@@ -626,158 +653,106 @@ export class DevMachineEngine {
626
653
  providers,
627
654
  mode: "apply",
628
655
  });
629
- const workspaceSource = this.resolveWorkspaceSource(workflow, result.context, providers, { required: false });
630
656
 
631
657
  return {
632
658
  context: result.context,
633
- snapshotId: snapshotIdOf(workspaceSource),
634
- workspaceSource,
635
659
  plan: result.plan,
636
660
  };
637
661
  }
638
662
 
639
663
  async fork(input: { workflow?: string; machine?: string; name: string }): Promise<WorkspaceRecord> {
640
- 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
+ }
641
672
 
642
673
  const applied = await this.apply({ workflow: input.workflow ?? input.machine });
643
674
  const workflow = this.getWorkflow(input.workflow ?? input.machine);
644
675
  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
- });
676
+ if (!workflow.workspace) {
677
+ throw new Error(`Workflow ${workflow.name} does not define a workspace`);
652
678
  }
653
679
 
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
680
  const now = new Date().toISOString();
659
-
660
681
  const workspace: WorkspaceRecord = {
661
682
  id: crypto.randomUUID(),
662
683
  name: input.name,
663
- providerId,
664
684
  workflow: workflow.name,
665
- resourceId: created.resourceId,
666
- snapshotId: created.snapshotId,
667
- sourceRef: created.sourceRef ?? sourceRef,
668
- context: { ...applied.context },
685
+ workflowCtx: { ...applied.context },
686
+ ctx: {},
669
687
  createdAt: now,
670
688
  updatedAt: now,
671
- metadata: created.metadata ?? {},
672
689
  };
673
690
 
674
691
  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({
692
+ try {
693
+ await this.runWorkspaceCreate({
709
694
  workflow,
710
695
  providers,
711
- workspaceProvider,
712
696
  workspace,
713
- context: workspace.context,
697
+ context: applied.context,
698
+ name: input.name,
714
699
  });
700
+ } catch (error) {
701
+ this.getStateService().deleteWorkspace(workspace.name);
702
+ throw error;
715
703
  }
716
- const terminal = await workspaceProvider.ssh(workspace?.resourceId ?? input.workspaceOrVmId, { user: input.user });
704
+ const ready = this.getStateService().getWorkspace(input.name) ?? workspace;
717
705
 
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
- }
706
+ this.emit({
707
+ type: "workspace.ready",
708
+ workspaceId: ready.name,
709
+ });
726
710
 
727
- return { command: terminal.command };
711
+ return ready;
728
712
  }
729
713
 
730
714
  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;
715
+ return await this.removeWorkspace(input);
742
716
  }
743
717
 
744
- 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> {
745
719
  const workspace = this.getStateService().getWorkspace(input.workspace);
746
720
  if (!workspace) throw new Error(`Unknown workspace ${input.workspace}`);
747
721
 
748
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
+ }
749
732
  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,
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 }),
776
748
  },
777
- };
749
+ workspace: workspaceRuntime,
750
+ providers: runtime,
751
+ local: this.local,
752
+ });
778
753
 
779
- this.getStateService().saveNodeRun(record);
780
- return record;
754
+ this.getStateService().deleteWorkspace(input.workspace);
755
+ return workspace;
781
756
  }
782
757
 
783
758
  private async evaluate(input: {
@@ -1134,65 +1109,87 @@ export class DevMachineEngine {
1134
1109
  return Object.fromEntries(entries) as ProviderRuntimeMap<WorkflowProviderMap>;
1135
1110
  }
1136
1111
 
1137
- private async runWorkspaceCreatedHook(input: {
1112
+ private async runWorkspaceCreate(input: {
1138
1113
  workflow: LoadedWorkflow;
1139
1114
  providers: ProviderControllers;
1140
- workspaceProvider: WorkflowWorkspaceProvider;
1141
1115
  workspace: WorkspaceRecord;
1142
1116
  context: Record<string, JsonValue>;
1117
+ name: string;
1143
1118
  }): Promise<void> {
1144
- 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
+ }
1145
1153
  }
1146
1154
 
1147
- private async runWorkspaceOpenHook(input: {
1155
+ private async runConfigWorkspaceOperation(input: {
1148
1156
  workflow: LoadedWorkflow;
1149
1157
  providers: ProviderControllers;
1150
- workspaceProvider: WorkflowWorkspaceProvider;
1151
1158
  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
- };
1159
+ operation: WorkflowWorkspaceOperationDefinition<any, any, any, any>;
1160
+ rawInput: unknown;
1161
+ }): Promise<unknown> {
1181
1162
  const metadata: JsonObject = {};
1182
1163
  const providers = await this.createTaskRuntime({
1183
1164
  workflow: input.workflow,
1184
1165
  providers: input.providers,
1185
- nodePath: `workspace.${input.workspace.name}.${lifecycle}`,
1166
+ nodePath: `workspace.${input.workspace.name}.${input.operation.id}`,
1186
1167
  metadata,
1187
1168
  });
1188
-
1189
- 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),
1190
1180
  workspace,
1191
- ctx: Object.freeze({ ...input.context }),
1192
1181
  providers,
1193
- providerContext: normalizeProviderWorkspaceContext(providerContext),
1194
1182
  local: this.local,
1195
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>;
1196
1193
  }
1197
1194
 
1198
1195
  private getWorkflow(name: string | undefined): LoadedWorkflow {
@@ -1234,7 +1231,7 @@ export class DevMachineEngine {
1234
1231
 
1235
1232
  private resolveOperationInput(
1236
1233
  workflow: LoadedWorkflow,
1237
- operation: WorkflowOperationDefinition<any, any>,
1234
+ operation: { id: string; input?: { fields: readonly WorkflowInputFieldDefinition[] } },
1238
1235
  value: unknown,
1239
1236
  ): Record<string, unknown> {
1240
1237
  const raw = isPlainObject(value) ? value : {};
@@ -1274,11 +1271,7 @@ export class DevMachineEngine {
1274
1271
  message: `Workspace ${workspace.name} belongs to workflow ${workspace.workflow}, not ${workflow.name}`,
1275
1272
  });
1276
1273
  }
1277
- resolved[field.name] = {
1278
- ...workspace,
1279
- cwd: resolveWorkspaceCwd(workflow, workspace.context),
1280
- data: workspace.metadata,
1281
- };
1274
+ resolved[field.name] = this.createWorkspaceRuntime(cloneWorkspace(workspace));
1282
1275
  continue;
1283
1276
  }
1284
1277
 
@@ -1318,115 +1311,6 @@ export class DevMachineEngine {
1318
1311
  return resolved;
1319
1312
  }
1320
1313
 
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
1314
  private getStateService(): StateService {
1431
1315
  if (!this.state) {
1432
1316
  throw new Error(`No state database loaded. Call engine.load() first.`);
@@ -1472,75 +1356,11 @@ export class DevMachineEngine {
1472
1356
  providers,
1473
1357
  root,
1474
1358
  workspace: root.workspaceDefinition,
1475
- create: root.createDefinition,
1476
1359
  operations: root.operations ?? [],
1360
+ workspaceOperations: root.workspaceOperations ?? [],
1477
1361
  };
1478
1362
  }
1479
1363
 
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
1364
  private emit(event: WorkflowEvent): void {
1545
1365
  for (const handler of this.handlers) handler(event);
1546
1366
  }
@@ -1552,6 +1372,23 @@ export async function createDevMachineEngine(
1552
1372
  return new DevMachineEngine(options);
1553
1373
  }
1554
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
+
1555
1392
  async function resolveProviderDefinition(
1556
1393
  definition: WorkflowDefinition<any, any>["providers"][string],
1557
1394
  ): Promise<LoadedProviderDefinition> {
@@ -1650,7 +1487,7 @@ function summarizeWorkflow(workflow: LoadedWorkflow): WorkflowSummary {
1650
1487
  providers: Object.entries(workflow.providers).map(([name, provider]) => `${name}:${provider.providerId}`),
1651
1488
  nodes: collectNodePaths(workflow.root),
1652
1489
  operations: workflow.operations.map((operation) => operation.id),
1653
- createsWorkspace: Boolean(workflow.create || workflow.workspace),
1490
+ createsWorkspace: Boolean(workflow.workspace),
1654
1491
  workspace: workflow.workspace,
1655
1492
  };
1656
1493
  }
@@ -1786,23 +1623,6 @@ function kindOf(value: unknown): string | undefined {
1786
1623
  return isPlainObject(value) && typeof value.kind === "string" ? value.kind : undefined;
1787
1624
  }
1788
1625
 
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
1626
  function assertJsonValue(value: unknown, label: string): asserts value is JsonValue {
1807
1627
  if (
1808
1628
  value === null ||
@@ -1837,15 +1657,7 @@ function isJsonValue(value: unknown): value is JsonValue {
1837
1657
  }
1838
1658
  }
1839
1659
 
1840
- const RESERVED_HOST_OPERATION_IDS = new Set([
1841
- "completion",
1842
- "doctor",
1843
- "help",
1844
- "init",
1845
- "projects",
1846
- "run",
1847
- "version",
1848
- ]);
1660
+ const RESERVED_HOST_OPERATION_IDS = new Set<string>(RESERVED_WORKFLOW_OPERATION_IDS);
1849
1661
 
1850
1662
  const CORE_OPERATION_INPUT_FIELDS: Record<string, readonly string[]> = {
1851
1663
  plan: ["workflow"],
@@ -1853,7 +1665,6 @@ const CORE_OPERATION_INPUT_FIELDS: Record<string, readonly string[]> = {
1853
1665
  create: ["workflow", "name"],
1854
1666
  ssh: ["workflow", "workspaceOrVmId", "user", "print"],
1855
1667
  snapshot: ["workflow", "workspace", "label"],
1856
- delete: ["workflow", "workspace"],
1857
1668
  };
1858
1669
 
1859
1670
  function assertAllowedConfigOperationId(operationId: string): void {