@rigkit/engine 0.2.2 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Database } from "bun:sqlite";
3
- import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { createDevMachineEngine, type InteractionPresentationRequest } from "./engine.ts";
@@ -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 () => {
@@ -28,68 +27,78 @@ describe("DevMachineEngine workflow runtime", () => {
28
27
  },
29
28
  });
30
29
 
31
- const base = app.sequence("base").task("first", async ({ runtime, test }) => {
32
- runtime.log("preparing base\\n", { label: "setup" });
30
+ const base = app.sequence("base").task("first", async ({ step, test }) => {
31
+ step.log("preparing base\\n", { label: "setup" });
33
32
  const vm = await test.createVm();
34
33
  await vm.exec("touch /tmp/first", { name: "touch first" });
35
34
  if (!(await vm.exists("/tmp/first"))) throw new Error("first was not created");
36
- return { first: true, vm: await vm.snapshotRef() };
35
+ return { ctx: { first: true, vm: await vm.snapshotRef() } };
37
36
  });
38
37
 
39
- const left = app.sequence("left").task("second", async ({ ctx, test }) => {
40
- if (!ctx.first) throw new Error("missing first context");
41
- const vm = await test.fromSnapshot(ctx.vm);
38
+ const left = app.sequence("left").task("second", async ({ step, test }) => {
39
+ if (!step.ctx.first) throw new Error("missing first context");
40
+ const vm = await test.fromSnapshot(step.ctx.vm);
42
41
  await vm.exec("touch /tmp/second", { name: "touch second" });
43
- return { second: true, vm: await vm.snapshotRef() };
42
+ return { ctx: { second: true, vm: await vm.snapshotRef() } };
44
43
  });
45
44
 
46
- const right = app.sequence("right").task("data", async ({ ctx }) => {
47
- if (!ctx.first) throw new Error("missing first context");
48
- return { data: "right-ready" };
45
+ const right = app.sequence("right").task("data", async ({ step }) => {
46
+ if (!step.ctx.first) throw new Error("missing first context");
47
+ return { ctx: { data: "right-ready" } };
49
48
  });
50
49
 
51
50
  export default app
52
51
  .sequence("root")
53
52
  .add(base)
54
53
  .parallel({ left, right })
55
- .task("join", async ({ ctx }) => {
56
- if (!ctx.left.second) throw new Error("missing left context");
57
- if (ctx.right.data !== "right-ready") throw new Error("missing right context");
58
- return { vm: ctx.left.vm, summary: ctx.right.data };
54
+ .task("join", async ({ step }) => {
55
+ if (!step.ctx.left.second) throw new Error("missing left context");
56
+ if (step.ctx.right.data !== "right-ready") throw new Error("missing right context");
57
+ return { ctx: { vm: step.ctx.left.vm, summary: step.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);
159
-
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);
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);
164
168
 
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");
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);
168
172
 
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"),
@@ -184,31 +189,42 @@ describe("DevMachineEngine workflow runtime", () => {
184
189
  const vm = await providers.test.createVm();
185
190
  await vm.exec("touch /tmp/template", { name: "prepare template" });
186
191
  return {
187
- vm: await vm.snapshotRef(),
188
- repoPath: "/workspace/repo",
192
+ ctx: {
193
+ vm: await vm.snapshotRef(),
194
+ repoPath: "/workspace/repo",
195
+ },
189
196
  };
190
197
  })
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
- };
198
+ .workspace({
199
+ create: async ({ workflow, workspace, providers }) => {
200
+ const vm = await providers.test.fromSnapshot(workflow.ctx.vm);
201
+ await vm.exec("touch /tmp/create-" + workspace.name, { name: "create workspace" });
202
+ return {
203
+ name: workspace.name,
204
+ vmId: vm.vmId,
205
+ sourceSnapshot: workflow.ctx.vm,
206
+ repoPath: workflow.ctx.repoPath,
207
+ ready: true,
208
+ };
209
+ },
210
+ remove: async () => {},
201
211
  })
202
212
  .operation("inspect", {
203
213
  input: (workflow) => workflow.workspaceInput({ name: "workspace", position: 0 }),
204
214
  run: async ({ input, local }) => {
205
215
  await local.open("created://" + input.workspace.name);
206
216
  return {
207
- vmId: input.workspace.data.vmId,
208
- repoPath: input.workspace.data.repoPath,
209
- ready: input.workspace.data.ready,
217
+ vmId: input.workspace.ctx.vmId,
218
+ repoPath: input.workspace.ctx.repoPath,
219
+ ready: input.workspace.ctx.ready,
210
220
  };
211
221
  },
222
+ })
223
+ .workspaceOperation("status", {
224
+ run: async ({ workspace }) => ({
225
+ workspace: workspace.name,
226
+ vmId: workspace.ctx.vmId,
227
+ }),
212
228
  });
213
229
 
214
230
  export default defineConfig({
@@ -238,9 +254,7 @@ describe("DevMachineEngine workflow runtime", () => {
238
254
 
239
255
  const workspace = await engine.fork({ name: "created" });
240
256
  expect(workspace.name).toBe("created");
241
- expect(workspace.providerId).toBe("config");
242
- expect(workspace.resourceId).toBe("vm-2");
243
- expect(workspace.metadata).toMatchObject({
257
+ expect(workspace.ctx).toMatchObject({
244
258
  name: "created",
245
259
  vmId: "vm-2",
246
260
  repoPath: "/workspace/repo",
@@ -255,6 +269,9 @@ describe("DevMachineEngine workflow runtime", () => {
255
269
  ready: true,
256
270
  });
257
271
  expect(opened).toEqual(["created://created"]);
272
+
273
+ const status = await engine.runRuntimeOperation({ operation: "created/status" });
274
+ expect(status).toEqual({ workspace: "created", vmId: "vm-2" });
258
275
  });
259
276
 
260
277
  test("loads multiple workflows from defineConfig", async () => {
@@ -264,8 +281,8 @@ describe("DevMachineEngine workflow runtime", () => {
264
281
  `
265
282
  import { defineConfig, sequence } from "${import.meta.dir}/index.ts";
266
283
 
267
- const api = sequence("api").step("ready", async () => ({ api: true }));
268
- const web = sequence("web").step("ready", async () => ({ web: true }));
284
+ const api = sequence("api").step("ready", async () => ({ ctx: { api: true } }));
285
+ const web = sequence("web").step("ready", async () => ({ ctx: { web: true } }));
269
286
 
270
287
  export default defineConfig({
271
288
  providers: {},
@@ -295,7 +312,7 @@ describe("DevMachineEngine workflow runtime", () => {
295
312
  `
296
313
  import { defineConfig, sequence } from "${import.meta.dir}/index.ts";
297
314
 
298
- const root = sequence("factory-test").step("ready", async () => ({ ready: true }));
315
+ const root = sequence("factory-test").step("ready", async () => ({ ctx: { ready: true } }));
299
316
 
300
317
  export default defineConfig({
301
318
  providers: {},
@@ -391,20 +408,16 @@ describe("DevMachineEngine workflow runtime", () => {
391
408
  state.saveWorkspace({
392
409
  id: "workspace-2",
393
410
  name: "demo",
394
- providerId: "freestyle",
395
411
  workflow: "smoke",
396
- resourceId: "resource-2",
397
- snapshotId: "snap-2",
398
- sourceRef: { snapshotId: "snap-2" },
399
- context: { ready: true },
412
+ workflowCtx: { ready: true },
400
413
  createdAt: now,
401
414
  updatedAt: now,
402
- metadata: { ready: true },
415
+ ctx: { ready: true },
403
416
  });
404
417
  expect(state.getWorkspace("demo")).toMatchObject({
405
418
  name: "demo",
406
419
  workflow: "smoke",
407
- resourceId: "resource-2",
420
+ ctx: { ready: true },
408
421
  });
409
422
  });
410
423
 
@@ -421,7 +434,7 @@ describe("DevMachineEngine workflow runtime", () => {
421
434
  const app = workflow("handler-cache", { providers: {} });
422
435
 
423
436
  export default app.sequence("root").task("value", async () => {
424
- return { value: "${value}" };
437
+ return { ctx: { value: "${value}" } };
425
438
  });
426
439
  `,
427
440
  );
@@ -479,7 +492,7 @@ describe("DevMachineEngine workflow runtime", () => {
479
492
  },
480
493
  });
481
494
 
482
- export default app.sequence("root").task("ready", async () => ({ ready: true }));
495
+ export default app.sequence("root").task("ready", async () => ({ ctx: { ready: true } }));
483
496
  `,
484
497
  );
485
498
 
@@ -522,6 +535,73 @@ describe("DevMachineEngine workflow runtime", () => {
522
535
  expect(metadata["config.path"]).toBe(join(projectDir, "rig.config.ts"));
523
536
  });
524
537
 
538
+ test("stores provider host JSON state outside project state", async () => {
539
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
540
+ const hostStorageDir = join(projectDir, ".host-storage");
541
+ const opened: string[] = [];
542
+ const plugin: BaseProviderPlugin = {
543
+ providerId: "test",
544
+ async createProvider({ storage, hostStorage, local }) {
545
+ storage.set("project", { value: "state" });
546
+ hostStorage.set("token", { value: "secret" });
547
+ await local.open("rigkit://provider-auth");
548
+ return new FakeWorkflowProvider();
549
+ },
550
+ };
551
+
552
+ writeFileSync(
553
+ join(projectDir, "rig.config.ts"),
554
+ `
555
+ import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
556
+
557
+ const app = workflow("provider-host-storage", {
558
+ providers: {
559
+ test: defineProvider("test", {}),
560
+ },
561
+ });
562
+
563
+ export default app.sequence("root").task("ready", async () => ({ ctx: { ready: true } }));
564
+ `,
565
+ );
566
+
567
+ const engine = await createDevMachineEngine({
568
+ projectDir,
569
+ hostStorageDir,
570
+ providers: [plugin],
571
+ local: {
572
+ open: async (target) => {
573
+ opened.push(target);
574
+ },
575
+ },
576
+ });
577
+
578
+ await engine.load();
579
+ await engine.plan();
580
+
581
+ expect(opened).toEqual(["rigkit://provider-auth"]);
582
+
583
+ const files = readdirSync(hostStorageDir);
584
+ expect(files).toHaveLength(1);
585
+ const hostState = JSON.parse(readFileSync(join(hostStorageDir, files[0]!), "utf8"));
586
+ expect(hostState.records.token.value.value).toBe("secret");
587
+
588
+ const main = new Database(engine.getProjectInfo().statePath);
589
+ const projectRow = main
590
+ .query<{ value_json: string }, []>(
591
+ "select value_json from provider_state where provider_id = 'test' and key = 'project'",
592
+ )
593
+ .get();
594
+ const leakedHostRow = main
595
+ .query<{ value_json: string }, []>(
596
+ "select value_json from provider_state where provider_id = 'test' and key = 'token'",
597
+ )
598
+ .get();
599
+ main.close();
600
+
601
+ expect(projectRow ? JSON.parse(projectRow.value_json).value : undefined).toBe("state");
602
+ expect(leakedHostRow).toBeNull();
603
+ });
604
+
525
605
  test("rejects task outputs that are not JSON serializable", async () => {
526
606
  const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
527
607
  writeFileSync(
@@ -536,7 +616,7 @@ describe("DevMachineEngine workflow runtime", () => {
536
616
  });
537
617
 
538
618
  export default app.sequence("bad").task("returns-function", async () => {
539
- return { fn: () => "nope" };
619
+ return { ctx: { fn: () => "nope" } };
540
620
  });
541
621
  `,
542
622
  );
@@ -565,7 +645,7 @@ describe("DevMachineEngine workflow runtime", () => {
565
645
 
566
646
  export default app.sequence("auth").task("login", async ({ test }) => {
567
647
  const result = await test.openTerminal("GitHub auth", "gh auth login");
568
- return { finished: result.finished };
648
+ return { ctx: { finished: result.finished } };
569
649
  });
570
650
  `,
571
651
  );
@@ -611,7 +691,7 @@ describe("DevMachineEngine workflow runtime", () => {
611
691
 
612
692
  export default app.sequence("auth").task("login", async ({ test }) => {
613
693
  const result = await test.openTerminal("GitHub auth", "gh auth login");
614
- return { finished: result.finished };
694
+ return { ctx: { finished: result.finished } };
615
695
  });
616
696
  `,
617
697
  );
@@ -665,7 +745,7 @@ describe("DevMachineEngine workflow runtime", () => {
665
745
  export default app.sequence("setup").task("touch", async ({ test }) => {
666
746
  const vm = await test.createVm();
667
747
  await vm.exec("touch /tmp/setup", { name: "touch setup" });
668
- return { vm: await vm.snapshotRef() };
748
+ return { ctx: { vm: await vm.snapshotRef() } };
669
749
  });
670
750
  `,
671
751
  );
@@ -723,8 +803,8 @@ describe("DevMachineEngine workflow runtime", () => {
723
803
 
724
804
  export default app.sequence("schema").task("value", { output: schema }, async () => {
725
805
  return process.env.RIGKIT_SCHEMA_MODE === "next"
726
- ? { value: "ok", next: true }
727
- : { value: "ok" };
806
+ ? { ctx: { value: "ok", next: true } }
807
+ : { ctx: { value: "ok" } };
728
808
  });
729
809
  `,
730
810
  );
@@ -756,6 +836,94 @@ describe("DevMachineEngine workflow runtime", () => {
756
836
  }
757
837
  }
758
838
  });
839
+
840
+ test("expires task cache when cacheTTL has elapsed", async () => {
841
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-cache-ttl-"));
842
+ writeFileSync(
843
+ join(projectDir, "rig.config.ts"),
844
+ `
845
+ import { sequence } from "${import.meta.dir}/index.ts";
846
+
847
+ export default sequence("ttl").task("daily-check", { cacheTTL: "1d" }, async () => {
848
+ return { ctx: { checked: true } };
849
+ });
850
+ `,
851
+ );
852
+
853
+ const engine = await createDevMachineEngine({ projectDir });
854
+ await engine.load();
855
+ await engine.apply();
856
+ expect((await engine.plan()).cachedNodeCount).toBe(1);
857
+
858
+ const db = new Database(engine.getProjectInfo().statePath);
859
+ db.run("update workflow_node_runs set created_at = ?", [
860
+ new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
861
+ ]);
862
+ db.close();
863
+
864
+ const expired = await engine.plan();
865
+ expect(expired.cachedNodeCount).toBe(0);
866
+ expect(expired.nodes[0]?.status).toBe("pending");
867
+ });
868
+
869
+ test("step.invalidate invalidates a previous task and replays from that point", async () => {
870
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-invalidate-"));
871
+ const previous = {
872
+ authCount: process.env.RIGKIT_AUTH_COUNT,
873
+ checkCount: process.env.RIGKIT_CHECK_COUNT,
874
+ forceReauth: process.env.RIGKIT_FORCE_REAUTH,
875
+ };
876
+ process.env.RIGKIT_AUTH_COUNT = "0";
877
+ process.env.RIGKIT_CHECK_COUNT = "0";
878
+ process.env.RIGKIT_FORCE_REAUTH = "0";
879
+
880
+ writeFileSync(
881
+ join(projectDir, "rig.config.ts"),
882
+ `
883
+ import { sequence } from "${import.meta.dir}/index.ts";
884
+
885
+ export default sequence("reauth")
886
+ .task("prepare", async () => ({ ctx: { prepared: true } }))
887
+ .task("github-auth", async () => {
888
+ const count = Number(process.env.RIGKIT_AUTH_COUNT ?? "0") + 1;
889
+ process.env.RIGKIT_AUTH_COUNT = String(count);
890
+ return { ctx: { token: "token-" + count } };
891
+ })
892
+ .task("check-auth", { cacheTTL: 0 }, async ({ step }) => {
893
+ const count = Number(process.env.RIGKIT_CHECK_COUNT ?? "0") + 1;
894
+ process.env.RIGKIT_CHECK_COUNT = String(count);
895
+ if (process.env.RIGKIT_FORCE_REAUTH === "1") {
896
+ process.env.RIGKIT_FORCE_REAUTH = "0";
897
+ return step.invalidate("github-auth");
898
+ }
899
+ return { ctx: step.ctx };
900
+ });
901
+ `,
902
+ );
903
+
904
+ try {
905
+ const engine = await createDevMachineEngine({ projectDir });
906
+ await engine.load();
907
+
908
+ const first = await engine.apply();
909
+ expect(first.context.token).toBe("token-1");
910
+ expect(process.env.RIGKIT_AUTH_COUNT).toBe("1");
911
+ expect(process.env.RIGKIT_CHECK_COUNT).toBe("1");
912
+
913
+ process.env.RIGKIT_FORCE_REAUTH = "1";
914
+ const second = await engine.apply();
915
+ expect(second.context.token).toBe("token-2");
916
+ expect(process.env.RIGKIT_AUTH_COUNT).toBe("2");
917
+ expect(process.env.RIGKIT_CHECK_COUNT).toBe("3");
918
+
919
+ const validRuns = engine.listNodeRuns().filter((run) => !run.invalidated);
920
+ expect(validRuns.map((run) => run.nodePath).sort()).toEqual(["check-auth", "github-auth", "prepare"]);
921
+ } finally {
922
+ restoreEnv("RIGKIT_AUTH_COUNT", previous.authCount);
923
+ restoreEnv("RIGKIT_CHECK_COUNT", previous.checkCount);
924
+ restoreEnv("RIGKIT_FORCE_REAUTH", previous.forceReauth);
925
+ }
926
+ });
759
927
  });
760
928
 
761
929
  type FakeSnapshotRef = {
@@ -775,14 +943,13 @@ type FakeVm = {
775
943
  type FakeRuntime = {
776
944
  createVm(): Promise<FakeVm>;
777
945
  fromSnapshot(ref: FakeSnapshotRef): Promise<FakeVm>;
778
- fromWorkspace(workspace: Pick<WorkspaceRecord, "resourceId">): FakeVm;
946
+ fromId(vmId: string): FakeVm;
779
947
  openTerminal(label: string, command: string): Promise<{ finished: true }>;
780
948
  };
781
949
 
782
- class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime, { authority: string }> {
950
+ class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime> {
783
951
  readonly providerId = "test";
784
952
  snapshots: FakeSnapshotRef[] = [];
785
- workspaceContextResourceIds: string[] = [];
786
953
  private nextVm = 1;
787
954
  private files = new Map<string, Set<string>>();
788
955
  terminalStopped = 0;
@@ -797,7 +964,7 @@ class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime, {
797
964
  return {
798
965
  createVm: async () => this.createVm(context),
799
966
  fromSnapshot: async () => this.createVm(context),
800
- fromWorkspace: (workspace) => this.vmRuntime({ vmId: workspace.resourceId }, context),
967
+ fromId: (vmId) => this.vmRuntime({ vmId }, context),
801
968
  openTerminal: async (label, command) => {
802
969
  const completed = this.options.terminalCompleted ?? Promise.resolve({ finished: true as const });
803
970
  return await context.interaction.present({
@@ -817,42 +984,6 @@ class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime, {
817
984
  return isFakeSnapshotRef(ref);
818
985
  }
819
986
 
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
987
  hasFile(vmId: string, path: string): boolean {
857
988
  return this.files.get(vmId)?.has(path) ?? false;
858
989
  }
@@ -905,6 +1036,14 @@ function result(ok: boolean): ExecResult {
905
1036
  return { stdout: "", stderr: "", exitCode: ok ? 0 : 1, ok };
906
1037
  }
907
1038
 
1039
+ function restoreEnv(name: string, value: string | undefined): void {
1040
+ if (value === undefined) {
1041
+ delete process.env[name];
1042
+ } else {
1043
+ process.env[name] = value;
1044
+ }
1045
+ }
1046
+
908
1047
  function isFakeSnapshotRef(value: unknown): value is FakeSnapshotRef {
909
1048
  return Boolean(
910
1049
  value &&