@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.
@@ -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,23 +406,69 @@ 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
 
422
+ test("invalidates task cache when handler source changes", async () => {
423
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-handler-cache-"));
424
+ const statePath = join(projectDir, ".rigkit", "state.sqlite");
425
+ mkdirSync(join(projectDir, ".rigkit"));
426
+ const writeConfig = (configPath: string, value: string) =>
427
+ writeFileSync(
428
+ configPath,
429
+ `
430
+ import { workflow } from "${import.meta.dir}/index.ts";
431
+
432
+ const app = workflow("handler-cache", { providers: {} });
433
+
434
+ export default app.sequence("root").task("value", async () => {
435
+ return { value: "${value}" };
436
+ });
437
+ `,
438
+ );
439
+
440
+ const firstConfigPath = join(projectDir, "rig.one.config.ts");
441
+ const secondConfigPath = join(projectDir, "rig.two.config.ts");
442
+ writeConfig(firstConfigPath, "one");
443
+ writeConfig(secondConfigPath, "two");
444
+
445
+ const first = await createDevMachineEngine({
446
+ projectDir,
447
+ configPath: firstConfigPath,
448
+ statePath,
449
+ });
450
+ await first.load();
451
+ const applied = await first.apply();
452
+ expect(applied.context.value).toBe("one");
453
+
454
+ const cached = await first.plan();
455
+ expect(cached.cachedNodeCount).toBe(1);
456
+
457
+ const second = await createDevMachineEngine({
458
+ projectDir,
459
+ configPath: secondConfigPath,
460
+ statePath,
461
+ });
462
+ await second.load();
463
+ const changed = await second.plan();
464
+ expect(changed.cachedNodeCount).toBe(0);
465
+ expect(changed.nodes[0]?.status).toBe("pending");
466
+
467
+ const reapplied = await second.apply();
468
+ expect(reapplied.context.value).toBe("two");
469
+ expect(second.listNodeRuns()).toHaveLength(2);
470
+ });
471
+
411
472
  test("stores provider JSON state in Rigkit-owned provider storage", async () => {
412
473
  const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
413
474
  const plugin: BaseProviderPlugin = {
@@ -725,14 +786,13 @@ type FakeVm = {
725
786
  type FakeRuntime = {
726
787
  createVm(): Promise<FakeVm>;
727
788
  fromSnapshot(ref: FakeSnapshotRef): Promise<FakeVm>;
728
- fromWorkspace(workspace: Pick<WorkspaceRecord, "resourceId">): FakeVm;
789
+ fromId(vmId: string): FakeVm;
729
790
  openTerminal(label: string, command: string): Promise<{ finished: true }>;
730
791
  };
731
792
 
732
- class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime, { authority: string }> {
793
+ class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime> {
733
794
  readonly providerId = "test";
734
795
  snapshots: FakeSnapshotRef[] = [];
735
- workspaceContextResourceIds: string[] = [];
736
796
  private nextVm = 1;
737
797
  private files = new Map<string, Set<string>>();
738
798
  terminalStopped = 0;
@@ -747,7 +807,7 @@ class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime, {
747
807
  return {
748
808
  createVm: async () => this.createVm(context),
749
809
  fromSnapshot: async () => this.createVm(context),
750
- fromWorkspace: (workspace) => this.vmRuntime({ vmId: workspace.resourceId }, context),
810
+ fromId: (vmId) => this.vmRuntime({ vmId }, context),
751
811
  openTerminal: async (label, command) => {
752
812
  const completed = this.options.terminalCompleted ?? Promise.resolve({ finished: true as const });
753
813
  return await context.interaction.present({
@@ -767,42 +827,6 @@ class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime, {
767
827
  return isFakeSnapshotRef(ref);
768
828
  }
769
829
 
770
- workspace = {
771
- canUse: (ref: JsonValue) => isFakeSnapshotRef(ref),
772
- createWorkspace: async (ref: JsonValue, input: { name: string }) => {
773
- if (!isFakeSnapshotRef(ref)) throw new Error("bad ref");
774
- const resourceId = `workspace-${input.name}`;
775
- this.files.set(resourceId, new Set());
776
- return {
777
- providerId: "test",
778
- resourceId,
779
- snapshotId: ref.snapshotId,
780
- sourceRef: ref,
781
- };
782
- },
783
- deleteWorkspace: async () => {},
784
- snapshotWorkspace: async (workspace: WorkspaceRecord) => {
785
- const ref = this.createSnapshot({ vmId: workspace.resourceId });
786
- return {
787
- providerId: "test",
788
- resourceId: workspace.resourceId,
789
- snapshotId: ref.snapshotId,
790
- sourceRef: ref,
791
- };
792
- },
793
- ssh: async (workspaceOrResourceId: string): Promise<SshConnection> => ({
794
- kind: "ssh",
795
- host: "fake",
796
- username: workspaceOrResourceId,
797
- auth: { type: "token", token: "fake" },
798
- command: `ssh ${workspaceOrResourceId}`,
799
- }),
800
- workspaceContext: (workspace: WorkspaceRecord) => {
801
- this.workspaceContextResourceIds.push(workspace.resourceId);
802
- return { authority: "fake-authority" };
803
- },
804
- };
805
-
806
830
  hasFile(vmId: string, path: string): boolean {
807
831
  return this.files.get(vmId)?.has(path) ?? false;
808
832
  }