@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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigkit/engine",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
package/src/authoring.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { RESERVED_WORKFLOW_OPERATION_IDS } from "./types.ts";
1
2
  import type {
2
3
  EnvResolver,
3
4
  RigkitConfigDefinition,
@@ -5,14 +6,14 @@ import type {
5
6
  OutputSchema,
6
7
  OutputSchemaValue,
7
8
  WorkflowDefinition,
8
- WorkflowCreateDefinition,
9
- WorkflowCreateHandler,
10
9
  WorkflowInputFieldDefinition,
11
10
  WorkflowInputShape,
12
11
  WorkflowOperationDefinition,
13
12
  WorkflowOperationInputBuilder,
14
13
  WorkflowOperationInputHelpers,
15
14
  WorkflowOperationOptions,
15
+ WorkflowWorkspaceOperationDefinition,
16
+ WorkflowWorkspaceOperationOptions,
16
17
  WorkflowNodeDefinition,
17
18
  WorkflowProviderDefinition,
18
19
  WorkflowProviderMap,
@@ -25,7 +26,7 @@ import type {
25
26
  } from "./types.ts";
26
27
 
27
28
  const reservedTaskContextKeys = new Set(["ctx", "runtime", "providers"]);
28
- const reservedHostOperationIds = new Set(["init", "doctor", "projects", "run", "ls", "help", "version", "completion"]);
29
+ const reservedHostOperationIds = new Set<string>(RESERVED_WORKFLOW_OPERATION_IDS);
29
30
 
30
31
  const readEnv = (name: string, fallback?: string): string => {
31
32
  const value = process.env[name];
@@ -90,12 +91,11 @@ export function defineProvider<
90
91
  const ProviderId extends string,
91
92
  const Config extends object,
92
93
  Runtime = unknown,
93
- WorkspaceContext extends object = object,
94
94
  >(
95
95
  providerId: ProviderId,
96
- config: WorkflowProviderDefinition<ProviderId, Config, Runtime, WorkspaceContext>["config"],
96
+ config: WorkflowProviderDefinition<ProviderId, Config, Runtime>["config"],
97
97
  plugin?: unknown,
98
- ): WorkflowProviderDefinition<ProviderId, Config, Runtime, WorkspaceContext> {
98
+ ): WorkflowProviderDefinition<ProviderId, Config, Runtime> {
99
99
  return {
100
100
  kind: "rigkit.provider",
101
101
  providerId,
@@ -120,14 +120,28 @@ export function isProviderDefinition(value: unknown): value is WorkflowProviderD
120
120
  return Boolean(value && typeof value === "object" && getKind(value) === "rigkit.provider");
121
121
  }
122
122
 
123
- function createSequence<Providers extends WorkflowProviderMap, InputContext extends JsonObject, OutputContext extends JsonObject>(
123
+ function createSequence<
124
+ Providers extends WorkflowProviderMap,
125
+ InputContext extends JsonObject,
126
+ OutputContext extends JsonObject,
127
+ WorkspaceData extends object = JsonObject,
128
+ OperationIds extends string = never,
129
+ WorkspaceOperationIds extends string = never,
130
+ >(
124
131
  app: WorkflowDefinition<string, Providers>,
125
132
  name: string,
126
133
  children: readonly WorkflowNodeDefinition<Providers, any, any>[],
127
- workspace?: WorkflowWorkspaceDefinition<Providers, OutputContext>,
128
- create?: WorkflowCreateDefinition<Providers, OutputContext>,
134
+ workspace?: WorkflowWorkspaceDefinition<Providers, OutputContext, any>,
129
135
  operations: readonly WorkflowOperationDefinition<Providers, any>[] = [],
130
- ): WorkflowSequenceBuilder<Providers, InputContext, OutputContext> {
136
+ workspaceOperations: readonly WorkflowWorkspaceOperationDefinition<Providers, OutputContext, WorkspaceData, any>[] = [],
137
+ ): WorkflowSequenceBuilder<
138
+ Providers,
139
+ InputContext,
140
+ OutputContext,
141
+ WorkspaceData,
142
+ OperationIds,
143
+ WorkspaceOperationIds
144
+ > {
131
145
  const node = {
132
146
  kind: "rigkit.workflow-node" as const,
133
147
  nodeKind: "sequence" as const,
@@ -135,15 +149,15 @@ function createSequence<Providers extends WorkflowProviderMap, InputContext exte
135
149
  workflow: app,
136
150
  children,
137
151
  workspaceDefinition: workspace,
138
- createDefinition: create,
139
152
  operations,
153
+ workspaceOperations,
140
154
  task: (
141
155
  taskName: string,
142
156
  optionsOrHandler: WorkflowTaskOptions | WorkflowTaskHandler<Providers, OutputContext, any>,
143
157
  maybeHandler?: WorkflowTaskHandler<Providers, OutputContext, any>,
144
158
  ) => {
145
159
  const task = createTask(app, taskName, optionsOrHandler as any, maybeHandler as any);
146
- return createSequence(app, name, [...children, task], workspace, create, operations);
160
+ return createSequence(app, name, [...children, task], workspace, operations, workspaceOperations);
147
161
  },
148
162
  step: (
149
163
  taskName: string,
@@ -151,11 +165,11 @@ function createSequence<Providers extends WorkflowProviderMap, InputContext exte
151
165
  maybeHandler?: WorkflowTaskHandler<Providers, OutputContext, any>,
152
166
  ) => {
153
167
  const task = createTask(app, taskName, optionsOrHandler as any, maybeHandler as any);
154
- return createSequence(app, name, [...children, task], workspace, create, operations);
168
+ return createSequence(app, name, [...children, task], workspace, operations, workspaceOperations);
155
169
  },
156
170
  add: (child: WorkflowNodeDefinition<Providers, any, any>) => {
157
171
  assertSameWorkflow(app, child);
158
- return createSequence(app, name, [...children, child], workspace, create, operations);
172
+ return createSequence(app, name, [...children, child], workspace, operations, workspaceOperations);
159
173
  },
160
174
  parallel: (branches: Record<string, WorkflowNodeDefinition<Providers, any, any>>) => {
161
175
  for (const [branchName, branch] of Object.entries(branches)) {
@@ -173,17 +187,39 @@ function createSequence<Providers extends WorkflowProviderMap, InputContext exte
173
187
  workflow: app,
174
188
  branches,
175
189
  };
176
- return createSequence(app, name, [...children, parallelNode], workspace, create, operations);
190
+ return createSequence(app, name, [...children, parallelNode], workspace, operations, workspaceOperations);
191
+ },
192
+ workspace: (definition: WorkflowWorkspaceDefinition<Providers, OutputContext, any>) =>
193
+ createSequence(app, name, children, definition, operations, workspaceOperations),
194
+ operation: (id: string, options: WorkflowOperationOptions<Providers, any>) => {
195
+ const operation = createOperation(id, options);
196
+ assertUniqueOperationId(operations, operation.id, "Operation");
197
+ return createSequence(app, name, children, workspace, [...operations, operation], workspaceOperations);
198
+ },
199
+ workspaceOperation: (id: string, options: WorkflowWorkspaceOperationOptions<Providers, OutputContext, any, any>) => {
200
+ const operation = createWorkspaceOperation(id, options);
201
+ assertUniqueOperationId(workspaceOperations, operation.id, "Workspace operation");
202
+ return createSequence(app, name, children, workspace, operations, [...workspaceOperations, operation]);
177
203
  },
178
- workspace: (definition: WorkflowWorkspaceDefinition<Providers, OutputContext>) =>
179
- createSequence(app, name, children, definition, create, operations),
180
- create: (handler: WorkflowCreateHandler<Providers, OutputContext, any>) =>
181
- createSequence(app, name, children, workspace, { handler }, operations),
182
- operation: (id: string, options: WorkflowOperationOptions<Providers, any>) =>
183
- createSequence(app, name, children, workspace, create, [...operations, createOperation(id, options)]),
184
204
  };
185
205
 
186
- return node as unknown as WorkflowSequenceBuilder<Providers, InputContext, OutputContext>;
206
+ return node as unknown as WorkflowSequenceBuilder<
207
+ Providers,
208
+ InputContext,
209
+ OutputContext,
210
+ WorkspaceData,
211
+ OperationIds,
212
+ WorkspaceOperationIds
213
+ >;
214
+ }
215
+
216
+ function assertUniqueOperationId(
217
+ operations: readonly { id: string }[],
218
+ id: string,
219
+ label: string,
220
+ ): void {
221
+ if (!operations.some((operation) => operation.id === id)) return;
222
+ throw new Error(`${label} id ${id} is already defined`);
187
223
  }
188
224
 
189
225
  function createOperation<Providers extends WorkflowProviderMap, Input extends object>(
@@ -199,9 +235,6 @@ function createOperation<Providers extends WorkflowProviderMap, Input extends ob
199
235
  id: normalized,
200
236
  title: options.title,
201
237
  description: options.description,
202
- createsWorkspace: options.createsWorkspace,
203
- requiredHostMethods: normalizeHostMethodRequirements(options.requiredHostMethods),
204
- requiredHostCapabilities: normalizeHostCapabilityRequirements(options.requiredHostCapabilities),
205
238
  input: typeof options.input === "function"
206
239
  ? options.input(createOperationInputHelpers())
207
240
  : options.input,
@@ -209,31 +242,30 @@ function createOperation<Providers extends WorkflowProviderMap, Input extends ob
209
242
  };
210
243
  }
211
244
 
212
- function normalizeHostMethodRequirements(
213
- methods: WorkflowOperationOptions<any, any>["requiredHostMethods"],
214
- ) {
215
- return methods?.map((method) => {
216
- const id = method.id.trim();
217
- if (!id) throw new Error(`Host method requirements must have non-empty ids`);
218
- return {
219
- id,
220
- ...(method.modes?.length ? { modes: [...method.modes] } : {}),
221
- };
222
- });
223
- }
224
-
225
- function normalizeHostCapabilityRequirements(
226
- capabilities: WorkflowOperationOptions<any, any>["requiredHostCapabilities"],
227
- ) {
228
- return capabilities?.map((capability) => {
229
- const id = capability.id.trim();
230
- if (!id) throw new Error(`Host capability requirements must have non-empty ids`);
231
- const schemaHash = capability.schemaHash?.trim();
232
- return {
233
- id,
234
- ...(schemaHash ? { schemaHash } : {}),
235
- };
236
- });
245
+ function createWorkspaceOperation<
246
+ Providers extends WorkflowProviderMap,
247
+ Context extends JsonObject,
248
+ Data extends JsonObject,
249
+ Input extends object,
250
+ >(
251
+ id: string,
252
+ options: WorkflowWorkspaceOperationOptions<Providers, Context, Data, Input>,
253
+ ): WorkflowWorkspaceOperationDefinition<Providers, Context, Data, Input> {
254
+ const normalized = id.trim();
255
+ if (!normalized) throw new Error(`Workspace operation ids must be non-empty`);
256
+ if (normalized.includes("/")) throw new Error(`Workspace operation ids cannot contain "/"`);
257
+ if (reservedHostOperationIds.has(normalized)) {
258
+ throw new Error(`Workspace operation id ${normalized} is reserved by the Rigkit host`);
259
+ }
260
+ return {
261
+ id: normalized,
262
+ title: options.title,
263
+ description: options.description,
264
+ input: typeof options.input === "function"
265
+ ? options.input(createOperationInputHelpers())
266
+ : options.input,
267
+ run: options.run,
268
+ };
237
269
  }
238
270
 
239
271
  function createOperationInputHelpers(): WorkflowOperationInputHelpers {
@@ -0,0 +1,67 @@
1
+ import { sequence } from "./authoring.ts";
2
+
3
+ sequence("normal-operation-ids")
4
+ .operation("open" as const, {
5
+ run: async () => null,
6
+ })
7
+ // @ts-expect-error duplicate operation ids are rejected for literal ids
8
+ .operation("open" as const, {
9
+ run: async () => null,
10
+ });
11
+
12
+ sequence("workspace-operation-ids")
13
+ .step("prepare", async () => ({ snapshotId: "snap-1" }))
14
+ .workspace({
15
+ create: async ({ workflow, workspace }) => {
16
+ const snapshotId: string = workflow.ctx.snapshotId;
17
+ const name: string = workspace.name;
18
+ void snapshotId;
19
+ void name;
20
+ return { vmId: "vm-1", test: "ok" };
21
+ },
22
+ remove: async ({ workspace }) => {
23
+ const vmId: string = workspace.ctx.vmId;
24
+ const test: string = workspace.ctx.test;
25
+ void vmId;
26
+ void test;
27
+ // @ts-expect-error workspace context is read-only
28
+ workspace.ctx.vmId = "next";
29
+ // @ts-expect-error provider resources are not part of the workspace authoring API
30
+ workspace.resources;
31
+ },
32
+ })
33
+ .workspaceOperation("open-cmux" as const, {
34
+ run: async ({ workspace }) => {
35
+ const vmId: string = workspace.ctx.vmId;
36
+ void vmId;
37
+ // @ts-expect-error missing data properties are rejected
38
+ workspace.ctx.missing;
39
+ return null;
40
+ },
41
+ })
42
+ // @ts-expect-error duplicate workspace operation ids are rejected for literal ids
43
+ .workspaceOperation("open-cmux" as const, {
44
+ run: async () => null,
45
+ });
46
+
47
+ sequence("reserved-operation-id")
48
+ // @ts-expect-error reserved operation ids are rejected for literal ids
49
+ .operation("create" as const, {
50
+ run: async () => null,
51
+ });
52
+
53
+ sequence("reserved-workspace-operation-id")
54
+ .workspace({
55
+ create: async () => ({}),
56
+ remove: async () => {},
57
+ })
58
+ // @ts-expect-error reserved workspace operation ids are rejected for literal ids
59
+ .workspaceOperation("remove" as const, {
60
+ run: async () => null,
61
+ });
62
+
63
+ sequence("slash-operation-id")
64
+ // @ts-expect-error operation ids cannot contain slashes
65
+ .operation("workspace/open" as const, {
66
+ run: async () => null,
67
+ });
@@ -6,19 +6,15 @@ export const workspaces = sqliteTable(
6
6
  {
7
7
  id: text("id").primaryKey(),
8
8
  name: text("name").notNull(),
9
- providerId: text("provider_id").notNull(),
10
9
  workflow: text("workflow").notNull(),
11
- resourceId: text("resource_id").notNull(),
12
- snapshotId: text("snapshot_id"),
13
- sourceRef: text("source_ref_json", { mode: "json" }).$type<JsonValue>().notNull(),
14
- context: text("context_json", { mode: "json" }).$type<Record<string, JsonValue>>().notNull(),
10
+ workflowCtx: text("workflow_ctx_json", { mode: "json" }).$type<Record<string, JsonValue>>().notNull(),
15
11
  createdAt: text("created_at").notNull(),
16
12
  updatedAt: text("updated_at").notNull(),
17
- metadata: text("metadata_json", { mode: "json" }).$type<Record<string, JsonValue>>().notNull(),
13
+ ctx: text("ctx_json", { mode: "json" }).$type<Record<string, JsonValue>>().notNull(),
18
14
  },
19
15
  (table) => [
20
16
  uniqueIndex("workspaces_name_idx").on(table.name),
21
- index("workspaces_provider_resource_idx").on(table.providerId, table.resourceId),
17
+ index("workspaces_workflow_idx").on(table.workflow),
22
18
  ],
23
19
  );
24
20
 
@@ -9,10 +9,9 @@ import { createStateStore } from "./state.ts";
9
9
  import type {
10
10
  BaseProviderPlugin,
11
11
  ProviderRuntimeContext,
12
- SshConnection,
13
12
  WorkflowProviderController,
14
13
  } from "./provider/types.ts";
15
- import type { DevMachineEvent, ExecResult, JsonValue, WorkspaceRecord } from "./types.ts";
14
+ import type { DevMachineEvent, ExecResult, JsonValue } from "./types.ts";
16
15
 
17
16
  describe("DevMachineEngine workflow runtime", () => {
18
17
  test("plans, applies graph nodes, reuses graph cache, and forks workspaces", async () => {
@@ -58,38 +57,48 @@ describe("DevMachineEngine workflow runtime", () => {
58
57
  return { vm: ctx.left.vm, summary: ctx.right.data };
59
58
  })
60
59
  .workspace({
61
- source: (ctx) => ctx.vm,
62
- cwd: "/workspace/repo",
63
- onCreated: async ({ providers, workspace, ctx, local, providerContext }) => {
64
- if (ctx.summary !== "right-ready") throw new Error("missing final context");
65
- if (providerContext.authority !== "fake-authority") throw new Error("missing provider context");
66
- const vm = providers.test.fromWorkspace(workspace);
60
+ create: async ({ providers, workflow, workspace, local }) => {
61
+ if (workflow.ctx.summary !== "right-ready") throw new Error("missing final context");
62
+ const vm = await providers.test.fromSnapshot(workflow.ctx.vm);
67
63
  await vm.exec("touch /tmp/workspace-" + workspace.name, { name: "mark workspace" });
68
- await local.open("vscode://" + workspace.name);
64
+ await local.open("created://" + workspace.name);
65
+ return {
66
+ summary: workflow.ctx.summary,
67
+ repoPath: "/workspace/repo",
68
+ vmId: vm.vmId,
69
+ };
70
+ },
71
+ remove: async ({ providers, workspace, workflow }) => {
72
+ if (workflow.ctx.summary !== "right-ready") throw new Error("missing final context on remove");
73
+ const vm = providers.test.fromId(workspace.ctx.vmId);
74
+ await vm.exec("touch /tmp/remove-" + workspace.name, { name: "mark workspace remove" });
69
75
  },
70
- onOpen: async ({ providers, workspace, ctx, providerContext }) => {
71
- if (ctx.summary !== "right-ready") throw new Error("missing final context on open");
72
- if (providerContext.authority !== "fake-authority") throw new Error("missing provider context on open");
73
- const vm = providers.test.fromWorkspace(workspace);
76
+ })
77
+ .workspaceOperation("open", {
78
+ run: async ({ providers, workspace, workflow, local }) => {
79
+ if (workflow.ctx.summary !== "right-ready") throw new Error("missing final context on open");
80
+ if (workspace.ctx.summary !== "right-ready") throw new Error("missing workspace context");
81
+ const vm = providers.test.fromId(workspace.ctx.vmId);
74
82
  await vm.exec("touch /tmp/open-" + workspace.name, { name: "mark workspace open" });
83
+ await local.open("open://" + workspace.name);
75
84
  },
76
85
  })
77
86
  .operation("mark", {
78
- requiredHostCapabilities: [{ id: "cmux.open", schemaHash: "sha256:cmux-open-schema" }],
79
87
  input: (workflow) =>
80
88
  workflow
81
89
  .workspaceInput({ name: "workspace", position: 0 })
82
90
  .extend({
83
91
  label: workflow.string({ defaultValue: "marked" }),
84
- }),
92
+ }),
85
93
  run: async ({ input, providers, local }) => {
86
- const vm = providers.test.fromWorkspace(input.workspace);
94
+ const vm = providers.test.fromId(input.workspace.ctx.vmId);
87
95
  await vm.exec("touch /tmp/mark-" + input.workspace.name, { name: "mark via operation" });
88
96
  await local.open("mark://" + input.workspace.name);
89
97
  return {
90
98
  workspace: input.workspace.name,
91
99
  label: input.label,
92
- cwd: input.workspace.cwd ?? null,
100
+ repoPath: input.workspace.ctx.repoPath,
101
+ summary: input.workspace.ctx.summary,
93
102
  };
94
103
  },
95
104
  });
@@ -124,7 +133,7 @@ describe("DevMachineEngine workflow runtime", () => {
124
133
  ]);
125
134
 
126
135
  const applied = await engine.apply();
127
- expect(applied.snapshotId).toBe("snap-2");
136
+ expect(applied.context.vm).toEqual({ provider: "test", kind: "vmSnapshot", snapshotId: "snap-2" });
128
137
  expect(events).toContainEqual({
129
138
  type: "log.output",
130
139
  nodePath: "base.first",
@@ -140,37 +149,33 @@ describe("DevMachineEngine workflow runtime", () => {
140
149
  expect(cached.finalContext?.summary).toBe("right-ready");
141
150
 
142
151
  const workspace = await engine.fork({ name: "work" });
143
- expect(workspace.snapshotId).toBe("snap-2");
144
152
  expect(workspace.name).toBe("work");
145
- expect(workspace.resourceId).toBe("workspace-work");
153
+ expect(workspace.ctx).toMatchObject({
154
+ summary: "right-ready",
155
+ repoPath: "/workspace/repo",
156
+ vmId: "vm-3",
157
+ });
146
158
  expect(engine.listWorkspaces()).toHaveLength(1);
147
- expect(opened).toEqual(["vscode://work"]);
148
- expect(provider.workspaceContextResourceIds).toEqual(["workspace-work"]);
149
- expect(provider.hasFile("workspace-work", "/tmp/workspace-work")).toBe(true);
159
+ expect(opened).toEqual(["created://work"]);
160
+ expect(provider.hasFile("vm-3", "/tmp/workspace-work")).toBe(true);
150
161
 
151
- const markOperation = engine.listOperations().find((operation) => operation.id === "mark");
152
- expect(markOperation?.requiredHostCapabilities).toEqual([
153
- { id: "cmux.open", schemaHash: "sha256:cmux-open-schema" },
154
- ]);
162
+ const openOperation = engine.listRuntimeWorkspaceOperations().find((operation) => operation.id === "open");
163
+ expect(openOperation?.id).toBe("open");
155
164
  const marked = await engine.runOperation({ operation: "mark", input: { workspace: "work" } });
156
- expect(marked).toEqual({ workspace: "work", label: "marked", cwd: "/workspace/repo" });
157
- expect(opened).toEqual(["vscode://work", "mark://work"]);
158
- expect(provider.hasFile("workspace-work", "/tmp/mark-work")).toBe(true);
165
+ expect(marked).toEqual({ workspace: "work", label: "marked", repoPath: "/workspace/repo", summary: "right-ready" });
166
+ expect(opened).toEqual(["created://work", "mark://work"]);
167
+ expect(provider.hasFile("vm-3", "/tmp/mark-work")).toBe(true);
159
168
 
160
- const terminal = await engine.attachTerminal({ workspaceOrVmId: "work", printOnly: true });
161
- expect(terminal.command).toBe("ssh workspace-work");
162
- expect(provider.workspaceContextResourceIds).toEqual(["workspace-work", "workspace-work"]);
163
- expect(provider.hasFile("workspace-work", "/tmp/open-work")).toBe(true);
169
+ await engine.runRuntimeOperation({ operation: "work/open" });
170
+ expect(opened).toEqual(["created://work", "mark://work", "open://work"]);
171
+ expect(provider.hasFile("vm-3", "/tmp/open-work")).toBe(true);
164
172
 
165
- const workspaceSnapshot = await engine.snapshotWorkspace({ workspace: "work", label: "verified-work" });
166
- expect(workspaceSnapshot.metadata.snapshotId).toBe("snap-3");
167
- expect(workspaceSnapshot.nodeName).toBe("verified-work");
168
-
169
- await engine.deleteWorkspace({ workspace: "work" });
173
+ await engine.runRuntimeOperation({ operation: "work/remove" });
170
174
  expect(engine.listWorkspaces()).toHaveLength(0);
175
+ expect(provider.hasFile("vm-3", "/tmp/remove-work")).toBe(true);
171
176
  });
172
177
 
173
- test("creates workspaces from config create callbacks and exposes persisted workspace data", async () => {
178
+ test("creates workspaces from workspace definitions and exposes persisted workspace context", async () => {
174
179
  const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
175
180
  writeFileSync(
176
181
  join(projectDir, "rig.config.ts"),
@@ -188,27 +193,36 @@ describe("DevMachineEngine workflow runtime", () => {
188
193
  repoPath: "/workspace/repo",
189
194
  };
190
195
  })
191
- .create(async ({ ctx, name, providers }) => {
192
- const vm = await providers.test.fromSnapshot(ctx.vm);
193
- await vm.exec("touch /tmp/create-" + name, { name: "create workspace" });
194
- return {
195
- name,
196
- vmId: vm.vmId,
197
- sourceSnapshot: ctx.vm,
198
- repoPath: ctx.repoPath,
199
- ready: true,
200
- };
196
+ .workspace({
197
+ create: async ({ workflow, workspace, providers }) => {
198
+ const vm = await providers.test.fromSnapshot(workflow.ctx.vm);
199
+ await vm.exec("touch /tmp/create-" + workspace.name, { name: "create workspace" });
200
+ return {
201
+ name: workspace.name,
202
+ vmId: vm.vmId,
203
+ sourceSnapshot: workflow.ctx.vm,
204
+ repoPath: workflow.ctx.repoPath,
205
+ ready: true,
206
+ };
207
+ },
208
+ remove: async () => {},
201
209
  })
202
210
  .operation("inspect", {
203
211
  input: (workflow) => workflow.workspaceInput({ name: "workspace", position: 0 }),
204
212
  run: async ({ input, local }) => {
205
213
  await local.open("created://" + input.workspace.name);
206
214
  return {
207
- vmId: input.workspace.data.vmId,
208
- repoPath: input.workspace.data.repoPath,
209
- ready: input.workspace.data.ready,
215
+ vmId: input.workspace.ctx.vmId,
216
+ repoPath: input.workspace.ctx.repoPath,
217
+ ready: input.workspace.ctx.ready,
210
218
  };
211
219
  },
220
+ })
221
+ .workspaceOperation("status", {
222
+ run: async ({ workspace }) => ({
223
+ workspace: workspace.name,
224
+ vmId: workspace.ctx.vmId,
225
+ }),
212
226
  });
213
227
 
214
228
  export default defineConfig({
@@ -238,9 +252,7 @@ describe("DevMachineEngine workflow runtime", () => {
238
252
 
239
253
  const workspace = await engine.fork({ name: "created" });
240
254
  expect(workspace.name).toBe("created");
241
- expect(workspace.providerId).toBe("config");
242
- expect(workspace.resourceId).toBe("vm-2");
243
- expect(workspace.metadata).toMatchObject({
255
+ expect(workspace.ctx).toMatchObject({
244
256
  name: "created",
245
257
  vmId: "vm-2",
246
258
  repoPath: "/workspace/repo",
@@ -255,6 +267,9 @@ describe("DevMachineEngine workflow runtime", () => {
255
267
  ready: true,
256
268
  });
257
269
  expect(opened).toEqual(["created://created"]);
270
+
271
+ const status = await engine.runRuntimeOperation({ operation: "created/status" });
272
+ expect(status).toEqual({ workspace: "created", vmId: "vm-2" });
258
273
  });
259
274
 
260
275
  test("loads multiple workflows from defineConfig", async () => {
@@ -391,20 +406,16 @@ describe("DevMachineEngine workflow runtime", () => {
391
406
  state.saveWorkspace({
392
407
  id: "workspace-2",
393
408
  name: "demo",
394
- providerId: "freestyle",
395
409
  workflow: "smoke",
396
- resourceId: "resource-2",
397
- snapshotId: "snap-2",
398
- sourceRef: { snapshotId: "snap-2" },
399
- context: { ready: true },
410
+ workflowCtx: { ready: true },
400
411
  createdAt: now,
401
412
  updatedAt: now,
402
- metadata: { ready: true },
413
+ ctx: { ready: true },
403
414
  });
404
415
  expect(state.getWorkspace("demo")).toMatchObject({
405
416
  name: "demo",
406
417
  workflow: "smoke",
407
- resourceId: "resource-2",
418
+ ctx: { ready: true },
408
419
  });
409
420
  });
410
421
 
@@ -775,14 +786,13 @@ type FakeVm = {
775
786
  type FakeRuntime = {
776
787
  createVm(): Promise<FakeVm>;
777
788
  fromSnapshot(ref: FakeSnapshotRef): Promise<FakeVm>;
778
- fromWorkspace(workspace: Pick<WorkspaceRecord, "resourceId">): FakeVm;
789
+ fromId(vmId: string): FakeVm;
779
790
  openTerminal(label: string, command: string): Promise<{ finished: true }>;
780
791
  };
781
792
 
782
- class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime, { authority: string }> {
793
+ class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime> {
783
794
  readonly providerId = "test";
784
795
  snapshots: FakeSnapshotRef[] = [];
785
- workspaceContextResourceIds: string[] = [];
786
796
  private nextVm = 1;
787
797
  private files = new Map<string, Set<string>>();
788
798
  terminalStopped = 0;
@@ -797,7 +807,7 @@ class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime, {
797
807
  return {
798
808
  createVm: async () => this.createVm(context),
799
809
  fromSnapshot: async () => this.createVm(context),
800
- fromWorkspace: (workspace) => this.vmRuntime({ vmId: workspace.resourceId }, context),
810
+ fromId: (vmId) => this.vmRuntime({ vmId }, context),
801
811
  openTerminal: async (label, command) => {
802
812
  const completed = this.options.terminalCompleted ?? Promise.resolve({ finished: true as const });
803
813
  return await context.interaction.present({
@@ -817,42 +827,6 @@ class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime, {
817
827
  return isFakeSnapshotRef(ref);
818
828
  }
819
829
 
820
- workspace = {
821
- canUse: (ref: JsonValue) => isFakeSnapshotRef(ref),
822
- createWorkspace: async (ref: JsonValue, input: { name: string }) => {
823
- if (!isFakeSnapshotRef(ref)) throw new Error("bad ref");
824
- const resourceId = `workspace-${input.name}`;
825
- this.files.set(resourceId, new Set());
826
- return {
827
- providerId: "test",
828
- resourceId,
829
- snapshotId: ref.snapshotId,
830
- sourceRef: ref,
831
- };
832
- },
833
- deleteWorkspace: async () => {},
834
- snapshotWorkspace: async (workspace: WorkspaceRecord) => {
835
- const ref = this.createSnapshot({ vmId: workspace.resourceId });
836
- return {
837
- providerId: "test",
838
- resourceId: workspace.resourceId,
839
- snapshotId: ref.snapshotId,
840
- sourceRef: ref,
841
- };
842
- },
843
- ssh: async (workspaceOrResourceId: string): Promise<SshConnection> => ({
844
- kind: "ssh",
845
- host: "fake",
846
- username: workspaceOrResourceId,
847
- auth: { type: "token", token: "fake" },
848
- command: `ssh ${workspaceOrResourceId}`,
849
- }),
850
- workspaceContext: (workspace: WorkspaceRecord) => {
851
- this.workspaceContextResourceIds.push(workspace.resourceId);
852
- return { authority: "fake-authority" };
853
- },
854
- };
855
-
856
830
  hasFile(vmId: string, path: string): boolean {
857
831
  return this.files.get(vmId)?.has(path) ?? false;
858
832
  }