@rigkit/engine 0.0.0-canary-20260518T014918-c5bc0c2
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/README.md +5 -0
- package/package.json +34 -0
- package/src/authoring.ts +530 -0
- package/src/authoring.typecheck.ts +114 -0
- package/src/console-intercept.test.ts +121 -0
- package/src/console-intercept.ts +75 -0
- package/src/db/index.ts +157 -0
- package/src/db/schema/core.ts +71 -0
- package/src/db/schema/index.ts +7 -0
- package/src/engine.test.ts +1244 -0
- package/src/engine.ts +2604 -0
- package/src/env-file.ts +52 -0
- package/src/hash.ts +21 -0
- package/src/host-storage.ts +128 -0
- package/src/index.ts +46 -0
- package/src/provider/types.ts +113 -0
- package/src/state.ts +386 -0
- package/src/types.ts +873 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { createDevMachineEngine, type InteractionPresentationRequest } from "./engine.ts";
|
|
7
|
+
import { RIGKIT_STATE_SCHEMA_VERSION } from "./db/index.ts";
|
|
8
|
+
import { createStateStore } from "./state.ts";
|
|
9
|
+
import type {
|
|
10
|
+
BaseProviderPlugin,
|
|
11
|
+
ProviderRuntimeContext,
|
|
12
|
+
WorkflowProviderController,
|
|
13
|
+
} from "./provider/types.ts";
|
|
14
|
+
import type { DevMachineEvent, ExecResult, JsonValue } from "./types.ts";
|
|
15
|
+
|
|
16
|
+
describe("DevMachineEngine workflow runtime", () => {
|
|
17
|
+
test("plans, applies graph nodes, reuses graph cache, and forks workspaces", async () => {
|
|
18
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
19
|
+
writeFileSync(
|
|
20
|
+
join(projectDir, "rig.config.ts"),
|
|
21
|
+
`
|
|
22
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
23
|
+
|
|
24
|
+
const app = workflow("test", {
|
|
25
|
+
providers: {
|
|
26
|
+
test: defineProvider("test", { token: "test-key" }),
|
|
27
|
+
},
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
const base = app.sequence("base").task("first", async ({ step, test }) => {
|
|
31
|
+
console.log("preparing base");
|
|
32
|
+
const vm = await test.createVm();
|
|
33
|
+
await vm.exec("touch /tmp/first", { name: "touch first" });
|
|
34
|
+
if (!(await vm.exists("/tmp/first"))) throw new Error("first was not created");
|
|
35
|
+
return { ctx: { first: true, vm: await vm.snapshotRef() } };
|
|
36
|
+
});
|
|
37
|
+
|
|
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);
|
|
41
|
+
await vm.exec("touch /tmp/second", { name: "touch second" });
|
|
42
|
+
return { ctx: { second: true, vm: await vm.snapshotRef() } };
|
|
43
|
+
});
|
|
44
|
+
|
|
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" } };
|
|
48
|
+
});
|
|
49
|
+
|
|
50
|
+
export default app
|
|
51
|
+
.sequence("root")
|
|
52
|
+
.add(base)
|
|
53
|
+
.parallel({ left, right })
|
|
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 } };
|
|
58
|
+
})
|
|
59
|
+
.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);
|
|
63
|
+
await vm.exec("touch /tmp/workspace-" + workspace.name, { name: "mark workspace" });
|
|
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" });
|
|
75
|
+
},
|
|
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);
|
|
82
|
+
await vm.exec("touch /tmp/open-" + workspace.name, { name: "mark workspace open" });
|
|
83
|
+
await local.open("open://" + workspace.name);
|
|
84
|
+
},
|
|
85
|
+
})
|
|
86
|
+
.operation("mark", {
|
|
87
|
+
input: (workflow) =>
|
|
88
|
+
workflow
|
|
89
|
+
.workspaceInput({ name: "workspace", position: 0 })
|
|
90
|
+
.extend({
|
|
91
|
+
label: workflow.string({ defaultValue: "marked" }),
|
|
92
|
+
}),
|
|
93
|
+
run: async ({ input, providers, local }) => {
|
|
94
|
+
const vm = providers.test.fromId(input.workspace.ctx.vmId);
|
|
95
|
+
await vm.exec("touch /tmp/mark-" + input.workspace.name, { name: "mark via operation" });
|
|
96
|
+
await local.open("mark://" + input.workspace.name);
|
|
97
|
+
return {
|
|
98
|
+
workspace: input.workspace.name,
|
|
99
|
+
label: input.label,
|
|
100
|
+
repoPath: input.workspace.ctx.repoPath,
|
|
101
|
+
summary: input.workspace.ctx.summary,
|
|
102
|
+
};
|
|
103
|
+
},
|
|
104
|
+
});
|
|
105
|
+
`,
|
|
106
|
+
);
|
|
107
|
+
|
|
108
|
+
const opened: string[] = [];
|
|
109
|
+
const events: DevMachineEvent[] = [];
|
|
110
|
+
const provider = new FakeWorkflowProvider();
|
|
111
|
+
const engine = await createDevMachineEngine({
|
|
112
|
+
projectDir,
|
|
113
|
+
providerFactory: async () => provider,
|
|
114
|
+
local: {
|
|
115
|
+
open: async (target) => {
|
|
116
|
+
opened.push(target);
|
|
117
|
+
},
|
|
118
|
+
},
|
|
119
|
+
});
|
|
120
|
+
engine.onEvent((event) => events.push(event));
|
|
121
|
+
|
|
122
|
+
await engine.load();
|
|
123
|
+
|
|
124
|
+
const initial = await engine.plan();
|
|
125
|
+
expect(initial.workflow).toBe("test");
|
|
126
|
+
expect(initial.cachedNodeCount).toBe(0);
|
|
127
|
+
expect(initial.nodeCount).toBe(4);
|
|
128
|
+
expect(initial.nodes.map((node) => node.path)).toEqual([
|
|
129
|
+
"base.first",
|
|
130
|
+
"left.second",
|
|
131
|
+
"right.data",
|
|
132
|
+
"join",
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
const applied = await engine.apply();
|
|
136
|
+
expect(applied.context.vm).toEqual({ provider: "test", kind: "vmSnapshot", snapshotId: "snap-2" });
|
|
137
|
+
expect(events).toContainEqual({
|
|
138
|
+
type: "log.output",
|
|
139
|
+
nodePath: "base.first",
|
|
140
|
+
stream: "info",
|
|
141
|
+
data: "preparing base",
|
|
142
|
+
});
|
|
143
|
+
expect(provider.snapshots).toHaveLength(2);
|
|
144
|
+
expect(engine.listNodeRuns()).toHaveLength(4);
|
|
145
|
+
|
|
146
|
+
const cached = await engine.plan();
|
|
147
|
+
expect(cached.cachedNodeCount).toBe(4);
|
|
148
|
+
expect(cached.finalContext?.summary).toBe("right-ready");
|
|
149
|
+
|
|
150
|
+
const workspace = await engine.fork({ name: "work" });
|
|
151
|
+
expect(workspace.name).toBe("work");
|
|
152
|
+
expect(workspace.ctx).toMatchObject({
|
|
153
|
+
summary: "right-ready",
|
|
154
|
+
repoPath: "/workspace/repo",
|
|
155
|
+
vmId: "vm-3",
|
|
156
|
+
});
|
|
157
|
+
expect(engine.listWorkspaces()).toHaveLength(1);
|
|
158
|
+
expect(opened).toEqual(["created://work"]);
|
|
159
|
+
expect(provider.hasFile("vm-3", "/tmp/workspace-work")).toBe(true);
|
|
160
|
+
|
|
161
|
+
const openOperation = engine.listRuntimeWorkspaceOperations().find((operation) => operation.id === "open");
|
|
162
|
+
expect(openOperation?.id).toBe("open");
|
|
163
|
+
const marked = await engine.runOperation({ operation: "mark", input: { workspace: "work" } });
|
|
164
|
+
expect(marked).toEqual({ workspace: "work", label: "marked", repoPath: "/workspace/repo", summary: "right-ready" });
|
|
165
|
+
expect(opened).toEqual(["created://work", "mark://work"]);
|
|
166
|
+
expect(provider.hasFile("vm-3", "/tmp/mark-work")).toBe(true);
|
|
167
|
+
|
|
168
|
+
await engine.runRuntimeOperation({ operation: "work/open" });
|
|
169
|
+
expect(opened).toEqual(["created://work", "mark://work", "open://work"]);
|
|
170
|
+
expect(provider.hasFile("vm-3", "/tmp/open-work")).toBe(true);
|
|
171
|
+
|
|
172
|
+
await engine.runRuntimeOperation({ operation: "work/remove" });
|
|
173
|
+
expect(engine.listWorkspaces()).toHaveLength(0);
|
|
174
|
+
expect(provider.hasFile("vm-3", "/tmp/remove-work")).toBe(true);
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
test("creates workspaces from workspace definitions and exposes persisted workspace context", async () => {
|
|
178
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
179
|
+
writeFileSync(
|
|
180
|
+
join(projectDir, "rig.config.ts"),
|
|
181
|
+
`
|
|
182
|
+
import { defineConfig, defineProvider, sequence } from "${import.meta.dir}/index.ts";
|
|
183
|
+
|
|
184
|
+
const test = defineProvider("test", { token: "test-key" });
|
|
185
|
+
|
|
186
|
+
const root = sequence("create-test")
|
|
187
|
+
.step("prepare", async ({ providers }) => {
|
|
188
|
+
const vm = await providers.test.createVm();
|
|
189
|
+
await vm.exec("touch /tmp/template", { name: "prepare template" });
|
|
190
|
+
return {
|
|
191
|
+
ctx: {
|
|
192
|
+
vm: await vm.snapshotRef(),
|
|
193
|
+
repoPath: "/workspace/repo",
|
|
194
|
+
},
|
|
195
|
+
};
|
|
196
|
+
})
|
|
197
|
+
.workspace({
|
|
198
|
+
create: async ({ workflow, workspace, providers }) => {
|
|
199
|
+
const vm = await providers.test.fromSnapshot(workflow.ctx.vm);
|
|
200
|
+
await vm.exec("touch /tmp/create-" + workspace.name, { name: "create workspace" });
|
|
201
|
+
return {
|
|
202
|
+
name: workspace.name,
|
|
203
|
+
vmId: vm.vmId,
|
|
204
|
+
sourceSnapshot: workflow.ctx.vm,
|
|
205
|
+
repoPath: workflow.ctx.repoPath,
|
|
206
|
+
ready: true,
|
|
207
|
+
};
|
|
208
|
+
},
|
|
209
|
+
remove: async () => {},
|
|
210
|
+
})
|
|
211
|
+
.operation("inspect", {
|
|
212
|
+
input: (workflow) => workflow.workspaceInput({ name: "workspace", position: 0 }),
|
|
213
|
+
run: async ({ input, local }) => {
|
|
214
|
+
await local.open("created://" + input.workspace.name);
|
|
215
|
+
return {
|
|
216
|
+
vmId: input.workspace.ctx.vmId,
|
|
217
|
+
repoPath: input.workspace.ctx.repoPath,
|
|
218
|
+
ready: input.workspace.ctx.ready,
|
|
219
|
+
};
|
|
220
|
+
},
|
|
221
|
+
})
|
|
222
|
+
.workspaceOperation("status", {
|
|
223
|
+
run: async ({ workspace }) => ({
|
|
224
|
+
workspace: workspace.name,
|
|
225
|
+
vmId: workspace.ctx.vmId,
|
|
226
|
+
}),
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
export default defineConfig({
|
|
230
|
+
providers: { test },
|
|
231
|
+
workflows: { root },
|
|
232
|
+
});
|
|
233
|
+
`,
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
const opened: string[] = [];
|
|
237
|
+
const provider = new FakeWorkflowProvider();
|
|
238
|
+
const engine = await createDevMachineEngine({
|
|
239
|
+
projectDir,
|
|
240
|
+
providerFactory: () => provider,
|
|
241
|
+
local: {
|
|
242
|
+
open: async (target) => {
|
|
243
|
+
opened.push(target);
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
await engine.load();
|
|
249
|
+
const projectInfo = engine.getProjectInfo();
|
|
250
|
+
expect(projectInfo.workflow?.createsWorkspace).toBe(true);
|
|
251
|
+
expect(projectInfo.workflows.map((workflow) => workflow.name)).toEqual(["create-test"]);
|
|
252
|
+
expect(engine.listOperations().map((operation) => operation.id)).toEqual(["inspect"]);
|
|
253
|
+
|
|
254
|
+
const workspace = await engine.fork({ name: "created" });
|
|
255
|
+
expect(workspace.name).toBe("created");
|
|
256
|
+
expect(workspace.ctx).toMatchObject({
|
|
257
|
+
name: "created",
|
|
258
|
+
vmId: "vm-2",
|
|
259
|
+
repoPath: "/workspace/repo",
|
|
260
|
+
ready: true,
|
|
261
|
+
});
|
|
262
|
+
expect(provider.hasFile("vm-2", "/tmp/create-created")).toBe(true);
|
|
263
|
+
|
|
264
|
+
const inspected = await engine.runOperation({ operation: "inspect", input: { workspace: "created" } });
|
|
265
|
+
expect(inspected).toEqual({
|
|
266
|
+
vmId: "vm-2",
|
|
267
|
+
repoPath: "/workspace/repo",
|
|
268
|
+
ready: true,
|
|
269
|
+
});
|
|
270
|
+
expect(opened).toEqual(["created://created"]);
|
|
271
|
+
|
|
272
|
+
const status = await engine.runRuntimeOperation({ operation: "created/status" });
|
|
273
|
+
expect(status).toEqual({ workspace: "created", vmId: "vm-2" });
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test("rejects workspace names that are not shell-safe", async () => {
|
|
277
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
278
|
+
writeFileSync(
|
|
279
|
+
join(projectDir, "rig.config.ts"),
|
|
280
|
+
`
|
|
281
|
+
import { defineConfig, sequence } from "${import.meta.dir}/index.ts";
|
|
282
|
+
|
|
283
|
+
const root = sequence("workspace-names")
|
|
284
|
+
.step("ready", async () => ({ ctx: { ready: true } }))
|
|
285
|
+
.workspace({
|
|
286
|
+
create: async ({ workspace }) => ({ name: workspace.name }),
|
|
287
|
+
remove: async () => {},
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
export default defineConfig({
|
|
291
|
+
providers: {},
|
|
292
|
+
workflows: { root },
|
|
293
|
+
});
|
|
294
|
+
`,
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
const engine = await createDevMachineEngine({ projectDir });
|
|
298
|
+
await engine.load();
|
|
299
|
+
|
|
300
|
+
await expect(engine.fork({ name: "" })).rejects.toThrow("create requires a workspace name");
|
|
301
|
+
for (const name of ["some workspace", "some/workspace", "-workspace"]) {
|
|
302
|
+
await expect(engine.fork({ name })).rejects.toThrow("Workspace name");
|
|
303
|
+
}
|
|
304
|
+
expect(engine.listWorkspaces()).toEqual([]);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test("loads multiple workflows from defineConfig", async () => {
|
|
308
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
309
|
+
writeFileSync(
|
|
310
|
+
join(projectDir, "rig.config.ts"),
|
|
311
|
+
`
|
|
312
|
+
import { defineConfig, sequence } from "${import.meta.dir}/index.ts";
|
|
313
|
+
|
|
314
|
+
const api = sequence("api").step("ready", async () => ({ ctx: { api: true } }));
|
|
315
|
+
const web = sequence("web").step("ready", async () => ({ ctx: { web: true } }));
|
|
316
|
+
|
|
317
|
+
export default defineConfig({
|
|
318
|
+
providers: {},
|
|
319
|
+
workflows: { api, web },
|
|
320
|
+
});
|
|
321
|
+
`,
|
|
322
|
+
);
|
|
323
|
+
|
|
324
|
+
const engine = await createDevMachineEngine({
|
|
325
|
+
projectDir,
|
|
326
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
await engine.load();
|
|
330
|
+
|
|
331
|
+
expect(engine.listWorkflowSummaries().map((workflow) => workflow.name)).toEqual(["api", "web"]);
|
|
332
|
+
expect(engine.getProjectInfo().workflow).toBeUndefined();
|
|
333
|
+
await expect(engine.plan()).rejects.toThrow("Multiple workflows are defined");
|
|
334
|
+
expect((await engine.plan({ workflow: "api" })).workflow).toBe("api");
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test("creates state through an injectable state service factory", async () => {
|
|
338
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
339
|
+
const statePath = join(projectDir, "custom-state.sqlite");
|
|
340
|
+
writeFileSync(
|
|
341
|
+
join(projectDir, "rig.config.ts"),
|
|
342
|
+
`
|
|
343
|
+
import { defineConfig, sequence } from "${import.meta.dir}/index.ts";
|
|
344
|
+
|
|
345
|
+
const root = sequence("factory-test").step("ready", async () => ({ ctx: { ready: true } }));
|
|
346
|
+
|
|
347
|
+
export default defineConfig({
|
|
348
|
+
providers: {},
|
|
349
|
+
workflows: { root },
|
|
350
|
+
});
|
|
351
|
+
`,
|
|
352
|
+
);
|
|
353
|
+
|
|
354
|
+
const configPath = join(projectDir, "rig.config.ts");
|
|
355
|
+
const calls: Array<{ projectDir: string; configPath?: string; statePath?: string }> = [];
|
|
356
|
+
const engine = await createDevMachineEngine({
|
|
357
|
+
projectDir,
|
|
358
|
+
statePath,
|
|
359
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
360
|
+
stateFactory: (options) => {
|
|
361
|
+
calls.push({
|
|
362
|
+
projectDir: options.projectDir,
|
|
363
|
+
configPath: options.configPath,
|
|
364
|
+
statePath: options.statePath,
|
|
365
|
+
});
|
|
366
|
+
return createStateStore(options);
|
|
367
|
+
},
|
|
368
|
+
});
|
|
369
|
+
|
|
370
|
+
await engine.load();
|
|
371
|
+
|
|
372
|
+
expect(calls).toEqual([{ projectDir, configPath, statePath }]);
|
|
373
|
+
expect(engine.getProjectInfo().statePath).toBe(statePath);
|
|
374
|
+
|
|
375
|
+
const state = createStateStore({ projectDir, statePath: join(projectDir, "provided-state.sqlite") });
|
|
376
|
+
const engineWithState = await createDevMachineEngine({
|
|
377
|
+
projectDir,
|
|
378
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
379
|
+
state,
|
|
380
|
+
stateFactory: () => {
|
|
381
|
+
throw new Error("stateFactory should not be called when state is provided");
|
|
382
|
+
},
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
await engineWithState.load();
|
|
386
|
+
expect(engineWithState.getProjectInfo().statePath).toBe(state.path);
|
|
387
|
+
});
|
|
388
|
+
|
|
389
|
+
test("resets stale state before applying the Drizzle push schema", async () => {
|
|
390
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
391
|
+
const statePath = join(projectDir, ".rigkit", "state.sqlite");
|
|
392
|
+
mkdirSync(join(projectDir, ".rigkit"));
|
|
393
|
+
const legacy = new Database(statePath, { create: true });
|
|
394
|
+
legacy.run(`
|
|
395
|
+
CREATE TABLE workspaces (
|
|
396
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
397
|
+
name TEXT NOT NULL,
|
|
398
|
+
provider_id TEXT NOT NULL,
|
|
399
|
+
vm_id TEXT NOT NULL,
|
|
400
|
+
machine TEXT NOT NULL,
|
|
401
|
+
snapshot_id TEXT NOT NULL,
|
|
402
|
+
created_at TEXT NOT NULL,
|
|
403
|
+
updated_at TEXT NOT NULL,
|
|
404
|
+
metadata_json TEXT NOT NULL
|
|
405
|
+
)
|
|
406
|
+
`);
|
|
407
|
+
legacy
|
|
408
|
+
.query(`
|
|
409
|
+
INSERT INTO workspaces (
|
|
410
|
+
id, name, provider_id, vm_id, machine, snapshot_id, created_at, updated_at, metadata_json
|
|
411
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
412
|
+
`)
|
|
413
|
+
.run(
|
|
414
|
+
"workspace-1",
|
|
415
|
+
"demo",
|
|
416
|
+
"freestyle",
|
|
417
|
+
"vm-1",
|
|
418
|
+
"smoke",
|
|
419
|
+
"snap-1",
|
|
420
|
+
"2026-05-10T00:00:00.000Z",
|
|
421
|
+
"2026-05-10T00:00:00.000Z",
|
|
422
|
+
JSON.stringify({ ready: true }),
|
|
423
|
+
);
|
|
424
|
+
legacy.close();
|
|
425
|
+
|
|
426
|
+
const state = createStateStore({ projectDir, statePath });
|
|
427
|
+
const result = await state.syncSchema();
|
|
428
|
+
const workspaces = state.listWorkspaces();
|
|
429
|
+
|
|
430
|
+
expect(result.applied).toEqual([RIGKIT_STATE_SCHEMA_VERSION]);
|
|
431
|
+
expect(result.hasDataLoss).toBe(true);
|
|
432
|
+
expect(result.warnings.some((warning) => warning.startsWith("Reset Rigkit state database after Drizzle push failed"))).toBe(
|
|
433
|
+
true,
|
|
434
|
+
);
|
|
435
|
+
expect(workspaces).toEqual([]);
|
|
436
|
+
|
|
437
|
+
const now = new Date().toISOString();
|
|
438
|
+
state.saveWorkspace({
|
|
439
|
+
id: "workspace-2",
|
|
440
|
+
name: "demo",
|
|
441
|
+
workflow: "smoke",
|
|
442
|
+
workflowCtx: { ready: true },
|
|
443
|
+
createdAt: now,
|
|
444
|
+
updatedAt: now,
|
|
445
|
+
ctx: { ready: true },
|
|
446
|
+
});
|
|
447
|
+
expect(state.getWorkspace("demo")).toMatchObject({
|
|
448
|
+
name: "demo",
|
|
449
|
+
workflow: "smoke",
|
|
450
|
+
ctx: { ready: true },
|
|
451
|
+
});
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test("invalidates task cache when handler source changes", async () => {
|
|
455
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-handler-cache-"));
|
|
456
|
+
const statePath = join(projectDir, ".rigkit", "state.sqlite");
|
|
457
|
+
mkdirSync(join(projectDir, ".rigkit"));
|
|
458
|
+
const writeConfig = (configPath: string, value: string) =>
|
|
459
|
+
writeFileSync(
|
|
460
|
+
configPath,
|
|
461
|
+
`
|
|
462
|
+
import { workflow } from "${import.meta.dir}/index.ts";
|
|
463
|
+
|
|
464
|
+
const app = workflow("handler-cache", { providers: {} });
|
|
465
|
+
|
|
466
|
+
export default app.sequence("root").task("value", async () => {
|
|
467
|
+
return { ctx: { value: "${value}" } };
|
|
468
|
+
});
|
|
469
|
+
`,
|
|
470
|
+
);
|
|
471
|
+
|
|
472
|
+
const firstConfigPath = join(projectDir, "rig.one.config.ts");
|
|
473
|
+
const secondConfigPath = join(projectDir, "rig.two.config.ts");
|
|
474
|
+
writeConfig(firstConfigPath, "one");
|
|
475
|
+
writeConfig(secondConfigPath, "two");
|
|
476
|
+
|
|
477
|
+
const first = await createDevMachineEngine({
|
|
478
|
+
projectDir,
|
|
479
|
+
configPath: firstConfigPath,
|
|
480
|
+
statePath,
|
|
481
|
+
});
|
|
482
|
+
await first.load();
|
|
483
|
+
const applied = await first.apply();
|
|
484
|
+
expect(applied.context.value).toBe("one");
|
|
485
|
+
|
|
486
|
+
const cached = await first.plan();
|
|
487
|
+
expect(cached.cachedNodeCount).toBe(1);
|
|
488
|
+
|
|
489
|
+
const second = await createDevMachineEngine({
|
|
490
|
+
projectDir,
|
|
491
|
+
configPath: secondConfigPath,
|
|
492
|
+
statePath,
|
|
493
|
+
});
|
|
494
|
+
await second.load();
|
|
495
|
+
const changed = await second.plan();
|
|
496
|
+
expect(changed.cachedNodeCount).toBe(0);
|
|
497
|
+
expect(changed.nodes[0]?.status).toBe("pending");
|
|
498
|
+
|
|
499
|
+
const reapplied = await second.apply();
|
|
500
|
+
expect(reapplied.context.value).toBe("two");
|
|
501
|
+
expect(second.listNodeRuns()).toHaveLength(2);
|
|
502
|
+
});
|
|
503
|
+
|
|
504
|
+
test("stores globally scoped sequence runs in fragment state and busts downstream local cache", async () => {
|
|
505
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-global-fragment-"));
|
|
506
|
+
const statePath = join(projectDir, ".rigkit", "state.sqlite");
|
|
507
|
+
const fragmentRoot = join(projectDir, "fragments");
|
|
508
|
+
mkdirSync(join(projectDir, ".rigkit"));
|
|
509
|
+
|
|
510
|
+
const writeConfig = (configPath: string, value: string) =>
|
|
511
|
+
writeFileSync(
|
|
512
|
+
configPath,
|
|
513
|
+
`
|
|
514
|
+
import { sequence } from "${import.meta.dir}/index.ts";
|
|
515
|
+
|
|
516
|
+
const deps = sequence("deps")
|
|
517
|
+
.configure({ value: "${value}" })
|
|
518
|
+
.task("prepare", async ({ config }) => ({ ctx: { value: String(config.value) } }))
|
|
519
|
+
.global();
|
|
520
|
+
|
|
521
|
+
export default sequence("site")
|
|
522
|
+
.add(deps)
|
|
523
|
+
.task("install", async ({ step }) => ({ ctx: { installed: step.ctx.value } }));
|
|
524
|
+
`,
|
|
525
|
+
);
|
|
526
|
+
|
|
527
|
+
const firstConfigPath = join(projectDir, "rig.one.config.ts");
|
|
528
|
+
const secondConfigPath = join(projectDir, "rig.two.config.ts");
|
|
529
|
+
writeConfig(firstConfigPath, "one");
|
|
530
|
+
writeConfig(secondConfigPath, "two");
|
|
531
|
+
|
|
532
|
+
const engineOptions = {
|
|
533
|
+
projectDir,
|
|
534
|
+
statePath,
|
|
535
|
+
globalFragmentStateLocator: (fragment: { hash: string }) => ({
|
|
536
|
+
statePath: join(fragmentRoot, fragment.hash, "state.sqlite"),
|
|
537
|
+
}),
|
|
538
|
+
};
|
|
539
|
+
|
|
540
|
+
const first = await createDevMachineEngine({ ...engineOptions, configPath: firstConfigPath });
|
|
541
|
+
await first.load();
|
|
542
|
+
await first.apply();
|
|
543
|
+
expect((await first.plan()).cachedNodeCount).toBe(2);
|
|
544
|
+
expect(first.listNodeRuns().map((run) => run.nodePath)).toEqual(["install"]);
|
|
545
|
+
|
|
546
|
+
const fragmentHashes = readdirSync(fragmentRoot);
|
|
547
|
+
expect(fragmentHashes).toHaveLength(1);
|
|
548
|
+
const fragmentDb = new Database(join(fragmentRoot, fragmentHashes[0]!, "state.sqlite"));
|
|
549
|
+
const fragmentRuns = fragmentDb
|
|
550
|
+
.query<{ node_path: string }, []>("select node_path from workflow_node_runs order by node_path")
|
|
551
|
+
.all();
|
|
552
|
+
fragmentDb.close();
|
|
553
|
+
expect(fragmentRuns.map((run) => run.node_path)).toEqual(["deps.prepare"]);
|
|
554
|
+
|
|
555
|
+
const cache = await first.listCache();
|
|
556
|
+
expect(cache.entries.map((entry) => entry.scope).sort()).toEqual(["global", "local"]);
|
|
557
|
+
|
|
558
|
+
const second = await createDevMachineEngine({ ...engineOptions, configPath: secondConfigPath });
|
|
559
|
+
await second.load();
|
|
560
|
+
const changed = await second.plan();
|
|
561
|
+
expect(changed.cachedNodeCount).toBe(0);
|
|
562
|
+
|
|
563
|
+
const reapplied = await second.apply();
|
|
564
|
+
expect(reapplied.context.installed).toBe("two");
|
|
565
|
+
expect(readdirSync(fragmentRoot)).toHaveLength(2);
|
|
566
|
+
});
|
|
567
|
+
|
|
568
|
+
test("allows a later local task to invalidate an earlier global fragment task", async () => {
|
|
569
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-global-invalidates-"));
|
|
570
|
+
const statePath = join(projectDir, ".rigkit", "state.sqlite");
|
|
571
|
+
const fragmentRoot = join(projectDir, "fragments");
|
|
572
|
+
mkdirSync(join(projectDir, ".rigkit"));
|
|
573
|
+
|
|
574
|
+
const previous = {
|
|
575
|
+
installCount: process.env.RIGKIT_GLOBAL_INSTALL_COUNT,
|
|
576
|
+
authCount: process.env.RIGKIT_GLOBAL_AUTH_COUNT,
|
|
577
|
+
repoCount: process.env.RIGKIT_LOCAL_REPO_COUNT,
|
|
578
|
+
checkCount: process.env.RIGKIT_LOCAL_CHECK_COUNT,
|
|
579
|
+
forceReauth: process.env.RIGKIT_FORCE_GLOBAL_REAUTH,
|
|
580
|
+
};
|
|
581
|
+
process.env.RIGKIT_GLOBAL_INSTALL_COUNT = "0";
|
|
582
|
+
process.env.RIGKIT_GLOBAL_AUTH_COUNT = "0";
|
|
583
|
+
process.env.RIGKIT_LOCAL_REPO_COUNT = "0";
|
|
584
|
+
process.env.RIGKIT_LOCAL_CHECK_COUNT = "0";
|
|
585
|
+
process.env.RIGKIT_FORCE_GLOBAL_REAUTH = "0";
|
|
586
|
+
|
|
587
|
+
writeFileSync(
|
|
588
|
+
join(projectDir, "rig.config.ts"),
|
|
589
|
+
`
|
|
590
|
+
import { sequence } from "${import.meta.dir}/index.ts";
|
|
591
|
+
|
|
592
|
+
const base = sequence("base")
|
|
593
|
+
.task("install", async () => {
|
|
594
|
+
const count = Number(process.env.RIGKIT_GLOBAL_INSTALL_COUNT ?? "0") + 1;
|
|
595
|
+
process.env.RIGKIT_GLOBAL_INSTALL_COUNT = String(count);
|
|
596
|
+
return { ctx: { installed: "install-" + count } };
|
|
597
|
+
})
|
|
598
|
+
.task("auth", async ({ step }) => {
|
|
599
|
+
const count = Number(process.env.RIGKIT_GLOBAL_AUTH_COUNT ?? "0") + 1;
|
|
600
|
+
process.env.RIGKIT_GLOBAL_AUTH_COUNT = String(count);
|
|
601
|
+
return { ctx: { ...step.ctx, token: "token-" + count } };
|
|
602
|
+
})
|
|
603
|
+
.global();
|
|
604
|
+
|
|
605
|
+
const repo = sequence("repo")
|
|
606
|
+
.task("clone", async ({ step }) => {
|
|
607
|
+
const count = Number(process.env.RIGKIT_LOCAL_REPO_COUNT ?? "0") + 1;
|
|
608
|
+
process.env.RIGKIT_LOCAL_REPO_COUNT = String(count);
|
|
609
|
+
return { ctx: { ...step.ctx, repoToken: step.ctx.token, repoCount: count } };
|
|
610
|
+
});
|
|
611
|
+
|
|
612
|
+
export default sequence("root")
|
|
613
|
+
.add(base)
|
|
614
|
+
.add(repo)
|
|
615
|
+
.task("check-auth", { cacheTTL: 0 }, async ({ step }) => {
|
|
616
|
+
const count = Number(process.env.RIGKIT_LOCAL_CHECK_COUNT ?? "0") + 1;
|
|
617
|
+
process.env.RIGKIT_LOCAL_CHECK_COUNT = String(count);
|
|
618
|
+
if (process.env.RIGKIT_FORCE_GLOBAL_REAUTH === "1") {
|
|
619
|
+
process.env.RIGKIT_FORCE_GLOBAL_REAUTH = "0";
|
|
620
|
+
return step.invalidate("auth");
|
|
621
|
+
}
|
|
622
|
+
return { ctx: step.ctx };
|
|
623
|
+
});
|
|
624
|
+
`,
|
|
625
|
+
);
|
|
626
|
+
|
|
627
|
+
try {
|
|
628
|
+
const engine = await createDevMachineEngine({
|
|
629
|
+
projectDir,
|
|
630
|
+
statePath,
|
|
631
|
+
globalFragmentStateLocator: (fragment: { hash: string }) => ({
|
|
632
|
+
statePath: join(fragmentRoot, fragment.hash, "state.sqlite"),
|
|
633
|
+
}),
|
|
634
|
+
});
|
|
635
|
+
await engine.load();
|
|
636
|
+
|
|
637
|
+
const first = await engine.apply();
|
|
638
|
+
expect(first.context.token).toBe("token-1");
|
|
639
|
+
expect(first.context.repoToken).toBe("token-1");
|
|
640
|
+
expect(process.env.RIGKIT_GLOBAL_INSTALL_COUNT).toBe("1");
|
|
641
|
+
expect(process.env.RIGKIT_GLOBAL_AUTH_COUNT).toBe("1");
|
|
642
|
+
expect(process.env.RIGKIT_LOCAL_REPO_COUNT).toBe("1");
|
|
643
|
+
expect(process.env.RIGKIT_LOCAL_CHECK_COUNT).toBe("1");
|
|
644
|
+
|
|
645
|
+
process.env.RIGKIT_FORCE_GLOBAL_REAUTH = "1";
|
|
646
|
+
const second = await engine.apply();
|
|
647
|
+
expect(second.context.token).toBe("token-2");
|
|
648
|
+
expect(second.context.repoToken).toBe("token-2");
|
|
649
|
+
expect(process.env.RIGKIT_GLOBAL_INSTALL_COUNT).toBe("1");
|
|
650
|
+
expect(process.env.RIGKIT_GLOBAL_AUTH_COUNT).toBe("2");
|
|
651
|
+
expect(process.env.RIGKIT_LOCAL_REPO_COUNT).toBe("2");
|
|
652
|
+
expect(process.env.RIGKIT_LOCAL_CHECK_COUNT).toBe("3");
|
|
653
|
+
expect(readdirSync(fragmentRoot)).toHaveLength(1);
|
|
654
|
+
} finally {
|
|
655
|
+
restoreEnv("RIGKIT_GLOBAL_INSTALL_COUNT", previous.installCount);
|
|
656
|
+
restoreEnv("RIGKIT_GLOBAL_AUTH_COUNT", previous.authCount);
|
|
657
|
+
restoreEnv("RIGKIT_LOCAL_REPO_COUNT", previous.repoCount);
|
|
658
|
+
restoreEnv("RIGKIT_LOCAL_CHECK_COUNT", previous.checkCount);
|
|
659
|
+
restoreEnv("RIGKIT_FORCE_GLOBAL_REAUTH", previous.forceReauth);
|
|
660
|
+
}
|
|
661
|
+
});
|
|
662
|
+
|
|
663
|
+
test("stores provider JSON state in Rigkit-owned provider storage", async () => {
|
|
664
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
665
|
+
const plugin: BaseProviderPlugin = {
|
|
666
|
+
providerId: "test",
|
|
667
|
+
createProvider({ storage }) {
|
|
668
|
+
storage.set("ready", { value: "provider" });
|
|
669
|
+
return new FakeWorkflowProvider();
|
|
670
|
+
},
|
|
671
|
+
};
|
|
672
|
+
|
|
673
|
+
writeFileSync(
|
|
674
|
+
join(projectDir, "rig.config.ts"),
|
|
675
|
+
`
|
|
676
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
677
|
+
|
|
678
|
+
const app = workflow("provider-storage", {
|
|
679
|
+
providers: {
|
|
680
|
+
test: defineProvider("test", {}),
|
|
681
|
+
},
|
|
682
|
+
});
|
|
683
|
+
|
|
684
|
+
export default app.sequence("root").task("ready", async () => ({ ctx: { ready: true } }));
|
|
685
|
+
`,
|
|
686
|
+
);
|
|
687
|
+
|
|
688
|
+
const engine = await createDevMachineEngine({
|
|
689
|
+
projectDir,
|
|
690
|
+
providers: [plugin],
|
|
691
|
+
});
|
|
692
|
+
|
|
693
|
+
await engine.load();
|
|
694
|
+
await engine.plan();
|
|
695
|
+
|
|
696
|
+
const statePath = engine.getProjectInfo().statePath;
|
|
697
|
+
const main = new Database(statePath);
|
|
698
|
+
const mainTables = main
|
|
699
|
+
.query<{ name: string }, []>("select name from sqlite_master where type = 'table' order by name")
|
|
700
|
+
.all()
|
|
701
|
+
.map((row) => row.name);
|
|
702
|
+
|
|
703
|
+
expect(mainTables).toContain("workspaces");
|
|
704
|
+
expect(mainTables).toContain("provider_state");
|
|
705
|
+
expect(mainTables).toContain("runtime_metadata");
|
|
706
|
+
expect(mainTables).not.toContain("provider_local_state");
|
|
707
|
+
|
|
708
|
+
const row = main
|
|
709
|
+
.query<{ value_json: string }, []>(
|
|
710
|
+
"select value_json from provider_state where provider_id = 'test' and key = 'ready'",
|
|
711
|
+
)
|
|
712
|
+
.get();
|
|
713
|
+
const metadataRows = main
|
|
714
|
+
.query<{ key: string; value_json: string }, []>(
|
|
715
|
+
"select key, value_json from runtime_metadata order by key",
|
|
716
|
+
)
|
|
717
|
+
.all();
|
|
718
|
+
main.close();
|
|
719
|
+
|
|
720
|
+
expect(row ? JSON.parse(row.value_json).value : undefined).toBe("provider");
|
|
721
|
+
const metadata = Object.fromEntries(metadataRows.map((item) => [item.key, JSON.parse(item.value_json)]));
|
|
722
|
+
expect(metadata["state.schemaVersion"]).toBe(RIGKIT_STATE_SCHEMA_VERSION);
|
|
723
|
+
expect(metadata["project.dir"]).toBe(projectDir);
|
|
724
|
+
expect(metadata["config.path"]).toBe(join(projectDir, "rig.config.ts"));
|
|
725
|
+
});
|
|
726
|
+
|
|
727
|
+
test("stores provider host JSON state outside project state", async () => {
|
|
728
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
729
|
+
const hostStorageDir = join(projectDir, ".host-storage");
|
|
730
|
+
const opened: string[] = [];
|
|
731
|
+
const plugin: BaseProviderPlugin = {
|
|
732
|
+
providerId: "test",
|
|
733
|
+
async createProvider({ storage, hostStorage, local }) {
|
|
734
|
+
storage.set("project", { value: "state" });
|
|
735
|
+
hostStorage.set("token", { value: "secret" });
|
|
736
|
+
await local.open("rigkit://provider-auth");
|
|
737
|
+
return new FakeWorkflowProvider();
|
|
738
|
+
},
|
|
739
|
+
};
|
|
740
|
+
|
|
741
|
+
writeFileSync(
|
|
742
|
+
join(projectDir, "rig.config.ts"),
|
|
743
|
+
`
|
|
744
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
745
|
+
|
|
746
|
+
const app = workflow("provider-host-storage", {
|
|
747
|
+
providers: {
|
|
748
|
+
test: defineProvider("test", {}),
|
|
749
|
+
},
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
export default app.sequence("root").task("ready", async () => ({ ctx: { ready: true } }));
|
|
753
|
+
`,
|
|
754
|
+
);
|
|
755
|
+
|
|
756
|
+
const engine = await createDevMachineEngine({
|
|
757
|
+
projectDir,
|
|
758
|
+
hostStorageDir,
|
|
759
|
+
providers: [plugin],
|
|
760
|
+
local: {
|
|
761
|
+
open: async (target) => {
|
|
762
|
+
opened.push(target);
|
|
763
|
+
},
|
|
764
|
+
},
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
await engine.load();
|
|
768
|
+
await engine.plan();
|
|
769
|
+
|
|
770
|
+
expect(opened).toEqual(["rigkit://provider-auth"]);
|
|
771
|
+
|
|
772
|
+
const files = readdirSync(hostStorageDir);
|
|
773
|
+
expect(files).toHaveLength(1);
|
|
774
|
+
const hostState = JSON.parse(readFileSync(join(hostStorageDir, files[0]!), "utf8"));
|
|
775
|
+
expect(hostState.records.token.value.value).toBe("secret");
|
|
776
|
+
|
|
777
|
+
const main = new Database(engine.getProjectInfo().statePath);
|
|
778
|
+
const projectRow = main
|
|
779
|
+
.query<{ value_json: string }, []>(
|
|
780
|
+
"select value_json from provider_state where provider_id = 'test' and key = 'project'",
|
|
781
|
+
)
|
|
782
|
+
.get();
|
|
783
|
+
const leakedHostRow = main
|
|
784
|
+
.query<{ value_json: string }, []>(
|
|
785
|
+
"select value_json from provider_state where provider_id = 'test' and key = 'token'",
|
|
786
|
+
)
|
|
787
|
+
.get();
|
|
788
|
+
main.close();
|
|
789
|
+
|
|
790
|
+
expect(projectRow ? JSON.parse(projectRow.value_json).value : undefined).toBe("state");
|
|
791
|
+
expect(leakedHostRow).toBeNull();
|
|
792
|
+
});
|
|
793
|
+
|
|
794
|
+
test("rejects task outputs that are not JSON serializable", async () => {
|
|
795
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
796
|
+
writeFileSync(
|
|
797
|
+
join(projectDir, "rig.config.ts"),
|
|
798
|
+
`
|
|
799
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
800
|
+
|
|
801
|
+
const app = workflow("test", {
|
|
802
|
+
providers: {
|
|
803
|
+
test: defineProvider("test", { token: "test-key" }),
|
|
804
|
+
},
|
|
805
|
+
});
|
|
806
|
+
|
|
807
|
+
export default app.sequence("bad").task("returns-function", async () => {
|
|
808
|
+
return { ctx: { fn: () => "nope" } };
|
|
809
|
+
});
|
|
810
|
+
`,
|
|
811
|
+
);
|
|
812
|
+
|
|
813
|
+
const engine = await createDevMachineEngine({
|
|
814
|
+
projectDir,
|
|
815
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
816
|
+
});
|
|
817
|
+
|
|
818
|
+
await engine.load();
|
|
819
|
+
await expect(engine.apply()).rejects.toThrow("must be JSON-serializable");
|
|
820
|
+
});
|
|
821
|
+
|
|
822
|
+
test("routes terminal interactions through provider runtimes", async () => {
|
|
823
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
824
|
+
writeFileSync(
|
|
825
|
+
join(projectDir, "rig.config.ts"),
|
|
826
|
+
`
|
|
827
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
828
|
+
|
|
829
|
+
const app = workflow("test", {
|
|
830
|
+
providers: {
|
|
831
|
+
test: defineProvider("test", { token: "test-key" }),
|
|
832
|
+
},
|
|
833
|
+
});
|
|
834
|
+
|
|
835
|
+
export default app.sequence("auth").task("login", async ({ test }) => {
|
|
836
|
+
const result = await test.openTerminal("GitHub auth", "gh auth login");
|
|
837
|
+
return { ctx: { finished: result.finished } };
|
|
838
|
+
});
|
|
839
|
+
`,
|
|
840
|
+
);
|
|
841
|
+
|
|
842
|
+
const interactions: InteractionPresentationRequest[] = [];
|
|
843
|
+
const engine = await createDevMachineEngine({
|
|
844
|
+
projectDir,
|
|
845
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
846
|
+
interaction: {
|
|
847
|
+
present: async (request) => {
|
|
848
|
+
interactions.push(request);
|
|
849
|
+
},
|
|
850
|
+
},
|
|
851
|
+
});
|
|
852
|
+
|
|
853
|
+
await engine.load();
|
|
854
|
+
const applied = await engine.apply();
|
|
855
|
+
|
|
856
|
+
expect(interactions).toEqual([
|
|
857
|
+
{
|
|
858
|
+
nodePath: "login",
|
|
859
|
+
id: "fake-terminal",
|
|
860
|
+
title: "GitHub auth",
|
|
861
|
+
url: "http://127.0.0.1/fake-terminal",
|
|
862
|
+
instructions: undefined,
|
|
863
|
+
},
|
|
864
|
+
]);
|
|
865
|
+
expect(applied.context.finished).toBe(true);
|
|
866
|
+
});
|
|
867
|
+
|
|
868
|
+
test("waits for provider-owned interaction completion before resuming tasks", async () => {
|
|
869
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
870
|
+
writeFileSync(
|
|
871
|
+
join(projectDir, "rig.config.ts"),
|
|
872
|
+
`
|
|
873
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
874
|
+
|
|
875
|
+
const app = workflow("test", {
|
|
876
|
+
providers: {
|
|
877
|
+
test: defineProvider("test", { token: "test-key" }),
|
|
878
|
+
},
|
|
879
|
+
});
|
|
880
|
+
|
|
881
|
+
export default app.sequence("auth").task("login", async ({ test }) => {
|
|
882
|
+
const result = await test.openTerminal("GitHub auth", "gh auth login");
|
|
883
|
+
return { ctx: { finished: result.finished } };
|
|
884
|
+
});
|
|
885
|
+
`,
|
|
886
|
+
);
|
|
887
|
+
|
|
888
|
+
let complete!: (result: { finished: true }) => void;
|
|
889
|
+
const completed = new Promise<{ finished: true }>((resolve) => {
|
|
890
|
+
complete = resolve;
|
|
891
|
+
});
|
|
892
|
+
const provider = new FakeWorkflowProvider({
|
|
893
|
+
terminalCompleted: completed,
|
|
894
|
+
});
|
|
895
|
+
const engine = await createDevMachineEngine({
|
|
896
|
+
projectDir,
|
|
897
|
+
providerFactory: () => provider,
|
|
898
|
+
interaction: {
|
|
899
|
+
present: async () => {},
|
|
900
|
+
},
|
|
901
|
+
});
|
|
902
|
+
|
|
903
|
+
await engine.load();
|
|
904
|
+
let applied: Awaited<ReturnType<typeof engine.apply>> | undefined;
|
|
905
|
+
const applying = engine.apply().then((result) => {
|
|
906
|
+
applied = result;
|
|
907
|
+
});
|
|
908
|
+
|
|
909
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
910
|
+
expect(applied).toBeUndefined();
|
|
911
|
+
expect(provider.terminalStopped).toBe(0);
|
|
912
|
+
|
|
913
|
+
complete({ finished: true });
|
|
914
|
+
await applying;
|
|
915
|
+
|
|
916
|
+
expect(applied?.context.finished).toBe(true);
|
|
917
|
+
expect(provider.terminalStopped).toBe(1);
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
test("provider config contributes to the workflow cache fingerprint", async () => {
|
|
921
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
922
|
+
const previousToken = process.env.RIGKIT_TEST_PROVIDER_TOKEN;
|
|
923
|
+
writeFileSync(
|
|
924
|
+
join(projectDir, "rig.config.ts"),
|
|
925
|
+
`
|
|
926
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
927
|
+
|
|
928
|
+
const app = workflow("test", {
|
|
929
|
+
providers: {
|
|
930
|
+
test: defineProvider("test", { token: () => process.env.RIGKIT_TEST_PROVIDER_TOKEN }),
|
|
931
|
+
},
|
|
932
|
+
});
|
|
933
|
+
|
|
934
|
+
export default app.sequence("setup").task("touch", async ({ test }) => {
|
|
935
|
+
const vm = await test.createVm();
|
|
936
|
+
await vm.exec("touch /tmp/setup", { name: "touch setup" });
|
|
937
|
+
return { ctx: { vm: await vm.snapshotRef() } };
|
|
938
|
+
});
|
|
939
|
+
`,
|
|
940
|
+
);
|
|
941
|
+
|
|
942
|
+
try {
|
|
943
|
+
const provider = new FakeWorkflowProvider();
|
|
944
|
+
process.env.RIGKIT_TEST_PROVIDER_TOKEN = "one";
|
|
945
|
+
const first = await createDevMachineEngine({
|
|
946
|
+
projectDir,
|
|
947
|
+
providerFactory: () => provider,
|
|
948
|
+
});
|
|
949
|
+
await first.load();
|
|
950
|
+
await first.apply();
|
|
951
|
+
expect((await first.plan()).cachedNodeCount).toBe(1);
|
|
952
|
+
|
|
953
|
+
process.env.RIGKIT_TEST_PROVIDER_TOKEN = "two";
|
|
954
|
+
const second = await createDevMachineEngine({
|
|
955
|
+
projectDir,
|
|
956
|
+
providerFactory: () => provider,
|
|
957
|
+
});
|
|
958
|
+
await second.load();
|
|
959
|
+
expect((await second.plan()).cachedNodeCount).toBe(0);
|
|
960
|
+
} finally {
|
|
961
|
+
if (previousToken === undefined) {
|
|
962
|
+
delete process.env.RIGKIT_TEST_PROVIDER_TOKEN;
|
|
963
|
+
} else {
|
|
964
|
+
process.env.RIGKIT_TEST_PROVIDER_TOKEN = previousToken;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
});
|
|
968
|
+
|
|
969
|
+
test("treats cached output schema failures as cache misses", async () => {
|
|
970
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
971
|
+
const previousMode = process.env.RIGKIT_SCHEMA_MODE;
|
|
972
|
+
writeFileSync(
|
|
973
|
+
join(projectDir, "rig.config.ts"),
|
|
974
|
+
`
|
|
975
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
976
|
+
|
|
977
|
+
const app = workflow("test", {
|
|
978
|
+
providers: {
|
|
979
|
+
test: defineProvider("test", { token: "test-key" }),
|
|
980
|
+
},
|
|
981
|
+
});
|
|
982
|
+
|
|
983
|
+
const schema = {
|
|
984
|
+
parse(value) {
|
|
985
|
+
if (!value || typeof value !== "object") throw new Error("not an object");
|
|
986
|
+
if (process.env.RIGKIT_SCHEMA_MODE === "next" && value.next !== true) {
|
|
987
|
+
throw new Error("missing next");
|
|
988
|
+
}
|
|
989
|
+
return value;
|
|
990
|
+
},
|
|
991
|
+
};
|
|
992
|
+
|
|
993
|
+
export default app.sequence("schema").task("value", { output: schema }, async () => {
|
|
994
|
+
return process.env.RIGKIT_SCHEMA_MODE === "next"
|
|
995
|
+
? { ctx: { value: "ok", next: true } }
|
|
996
|
+
: { ctx: { value: "ok" } };
|
|
997
|
+
});
|
|
998
|
+
`,
|
|
999
|
+
);
|
|
1000
|
+
|
|
1001
|
+
try {
|
|
1002
|
+
process.env.RIGKIT_SCHEMA_MODE = "old";
|
|
1003
|
+
const first = await createDevMachineEngine({
|
|
1004
|
+
projectDir,
|
|
1005
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
1006
|
+
});
|
|
1007
|
+
await first.load();
|
|
1008
|
+
await first.apply();
|
|
1009
|
+
expect((await first.plan()).cachedNodeCount).toBe(1);
|
|
1010
|
+
|
|
1011
|
+
process.env.RIGKIT_SCHEMA_MODE = "next";
|
|
1012
|
+
const second = await createDevMachineEngine({
|
|
1013
|
+
projectDir,
|
|
1014
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
1015
|
+
});
|
|
1016
|
+
await second.load();
|
|
1017
|
+
const plan = await second.plan();
|
|
1018
|
+
expect(plan.cachedNodeCount).toBe(0);
|
|
1019
|
+
expect(plan.nodes[0]?.status).toBe("pending");
|
|
1020
|
+
} finally {
|
|
1021
|
+
if (previousMode === undefined) {
|
|
1022
|
+
delete process.env.RIGKIT_SCHEMA_MODE;
|
|
1023
|
+
} else {
|
|
1024
|
+
process.env.RIGKIT_SCHEMA_MODE = previousMode;
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
|
|
1029
|
+
test("expires task cache when cacheTTL has elapsed", async () => {
|
|
1030
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-cache-ttl-"));
|
|
1031
|
+
writeFileSync(
|
|
1032
|
+
join(projectDir, "rig.config.ts"),
|
|
1033
|
+
`
|
|
1034
|
+
import { sequence } from "${import.meta.dir}/index.ts";
|
|
1035
|
+
|
|
1036
|
+
export default sequence("ttl").task("daily-check", { cacheTTL: "1d" }, async () => {
|
|
1037
|
+
return { ctx: { checked: true } };
|
|
1038
|
+
});
|
|
1039
|
+
`,
|
|
1040
|
+
);
|
|
1041
|
+
|
|
1042
|
+
const engine = await createDevMachineEngine({ projectDir });
|
|
1043
|
+
await engine.load();
|
|
1044
|
+
await engine.apply();
|
|
1045
|
+
expect((await engine.plan()).cachedNodeCount).toBe(1);
|
|
1046
|
+
|
|
1047
|
+
const db = new Database(engine.getProjectInfo().statePath);
|
|
1048
|
+
db.run("update workflow_node_runs set created_at = ?", [
|
|
1049
|
+
new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
|
|
1050
|
+
]);
|
|
1051
|
+
db.close();
|
|
1052
|
+
|
|
1053
|
+
const expired = await engine.plan();
|
|
1054
|
+
expect(expired.cachedNodeCount).toBe(0);
|
|
1055
|
+
expect(expired.nodes[0]?.status).toBe("pending");
|
|
1056
|
+
});
|
|
1057
|
+
|
|
1058
|
+
test("step.invalidate invalidates a previous task and replays from that point", async () => {
|
|
1059
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-invalidate-"));
|
|
1060
|
+
const previous = {
|
|
1061
|
+
authCount: process.env.RIGKIT_AUTH_COUNT,
|
|
1062
|
+
checkCount: process.env.RIGKIT_CHECK_COUNT,
|
|
1063
|
+
forceReauth: process.env.RIGKIT_FORCE_REAUTH,
|
|
1064
|
+
};
|
|
1065
|
+
process.env.RIGKIT_AUTH_COUNT = "0";
|
|
1066
|
+
process.env.RIGKIT_CHECK_COUNT = "0";
|
|
1067
|
+
process.env.RIGKIT_FORCE_REAUTH = "0";
|
|
1068
|
+
|
|
1069
|
+
writeFileSync(
|
|
1070
|
+
join(projectDir, "rig.config.ts"),
|
|
1071
|
+
`
|
|
1072
|
+
import { sequence } from "${import.meta.dir}/index.ts";
|
|
1073
|
+
|
|
1074
|
+
export default sequence("reauth")
|
|
1075
|
+
.task("prepare", async () => ({ ctx: { prepared: true } }))
|
|
1076
|
+
.task("github-auth", async () => {
|
|
1077
|
+
const count = Number(process.env.RIGKIT_AUTH_COUNT ?? "0") + 1;
|
|
1078
|
+
process.env.RIGKIT_AUTH_COUNT = String(count);
|
|
1079
|
+
return { ctx: { token: "token-" + count } };
|
|
1080
|
+
})
|
|
1081
|
+
.task("check-auth", { cacheTTL: 0 }, async ({ step }) => {
|
|
1082
|
+
const count = Number(process.env.RIGKIT_CHECK_COUNT ?? "0") + 1;
|
|
1083
|
+
process.env.RIGKIT_CHECK_COUNT = String(count);
|
|
1084
|
+
if (process.env.RIGKIT_FORCE_REAUTH === "1") {
|
|
1085
|
+
process.env.RIGKIT_FORCE_REAUTH = "0";
|
|
1086
|
+
return step.invalidate("github-auth");
|
|
1087
|
+
}
|
|
1088
|
+
return { ctx: step.ctx };
|
|
1089
|
+
});
|
|
1090
|
+
`,
|
|
1091
|
+
);
|
|
1092
|
+
|
|
1093
|
+
try {
|
|
1094
|
+
const engine = await createDevMachineEngine({ projectDir });
|
|
1095
|
+
await engine.load();
|
|
1096
|
+
|
|
1097
|
+
const first = await engine.apply();
|
|
1098
|
+
expect(first.context.token).toBe("token-1");
|
|
1099
|
+
expect(process.env.RIGKIT_AUTH_COUNT).toBe("1");
|
|
1100
|
+
expect(process.env.RIGKIT_CHECK_COUNT).toBe("1");
|
|
1101
|
+
|
|
1102
|
+
process.env.RIGKIT_FORCE_REAUTH = "1";
|
|
1103
|
+
const second = await engine.apply();
|
|
1104
|
+
expect(second.context.token).toBe("token-2");
|
|
1105
|
+
expect(process.env.RIGKIT_AUTH_COUNT).toBe("2");
|
|
1106
|
+
expect(process.env.RIGKIT_CHECK_COUNT).toBe("3");
|
|
1107
|
+
|
|
1108
|
+
const validRuns = engine.listNodeRuns().filter((run) => !run.invalidated);
|
|
1109
|
+
expect(validRuns.map((run) => run.nodePath).sort()).toEqual(["check-auth", "github-auth", "prepare"]);
|
|
1110
|
+
} finally {
|
|
1111
|
+
restoreEnv("RIGKIT_AUTH_COUNT", previous.authCount);
|
|
1112
|
+
restoreEnv("RIGKIT_CHECK_COUNT", previous.checkCount);
|
|
1113
|
+
restoreEnv("RIGKIT_FORCE_REAUTH", previous.forceReauth);
|
|
1114
|
+
}
|
|
1115
|
+
});
|
|
1116
|
+
});
|
|
1117
|
+
|
|
1118
|
+
type FakeSnapshotRef = {
|
|
1119
|
+
provider: "test";
|
|
1120
|
+
kind: "vmSnapshot";
|
|
1121
|
+
snapshotId: string;
|
|
1122
|
+
};
|
|
1123
|
+
|
|
1124
|
+
type FakeVm = {
|
|
1125
|
+
vmId: string;
|
|
1126
|
+
exec(command: string, options?: { name?: string }): Promise<ExecResult>;
|
|
1127
|
+
probe(command: string, options?: { name?: string }): Promise<ExecResult>;
|
|
1128
|
+
exists(path: string): Promise<boolean>;
|
|
1129
|
+
snapshotRef(): Promise<FakeSnapshotRef>;
|
|
1130
|
+
};
|
|
1131
|
+
|
|
1132
|
+
type FakeRuntime = {
|
|
1133
|
+
createVm(): Promise<FakeVm>;
|
|
1134
|
+
fromSnapshot(ref: FakeSnapshotRef): Promise<FakeVm>;
|
|
1135
|
+
fromId(vmId: string): FakeVm;
|
|
1136
|
+
openTerminal(label: string, command: string): Promise<{ finished: true }>;
|
|
1137
|
+
};
|
|
1138
|
+
|
|
1139
|
+
class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime> {
|
|
1140
|
+
readonly providerId = "test";
|
|
1141
|
+
snapshots: FakeSnapshotRef[] = [];
|
|
1142
|
+
private nextVm = 1;
|
|
1143
|
+
private files = new Map<string, Set<string>>();
|
|
1144
|
+
terminalStopped = 0;
|
|
1145
|
+
|
|
1146
|
+
constructor(
|
|
1147
|
+
private readonly options: {
|
|
1148
|
+
terminalCompleted?: Promise<{ finished: true }>;
|
|
1149
|
+
} = {},
|
|
1150
|
+
) {}
|
|
1151
|
+
|
|
1152
|
+
runtime(context: ProviderRuntimeContext): FakeRuntime {
|
|
1153
|
+
return {
|
|
1154
|
+
createVm: async () => this.createVm(context),
|
|
1155
|
+
fromSnapshot: async () => this.createVm(context),
|
|
1156
|
+
fromId: (vmId) => this.vmRuntime({ vmId }, context),
|
|
1157
|
+
openTerminal: async (label, command) => {
|
|
1158
|
+
const completed = this.options.terminalCompleted ?? Promise.resolve({ finished: true as const });
|
|
1159
|
+
return await context.interaction.present({
|
|
1160
|
+
id: "fake-terminal",
|
|
1161
|
+
title: label,
|
|
1162
|
+
url: "http://127.0.0.1/fake-terminal",
|
|
1163
|
+
completed,
|
|
1164
|
+
stop: () => {
|
|
1165
|
+
this.terminalStopped += 1;
|
|
1166
|
+
},
|
|
1167
|
+
});
|
|
1168
|
+
},
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
validateArtifact(ref: JsonValue): boolean {
|
|
1173
|
+
return isFakeSnapshotRef(ref);
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
hasFile(vmId: string, path: string): boolean {
|
|
1177
|
+
return this.files.get(vmId)?.has(path) ?? false;
|
|
1178
|
+
}
|
|
1179
|
+
|
|
1180
|
+
private async createVm(context: ProviderRuntimeContext): Promise<FakeVm> {
|
|
1181
|
+
const vm = { vmId: `vm-${this.nextVm++}` };
|
|
1182
|
+
this.files.set(vm.vmId, new Set());
|
|
1183
|
+
context.emit({ type: "vm.created", providerId: "test", vmId: vm.vmId });
|
|
1184
|
+
return this.vmRuntime(vm, context);
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
private vmRuntime(vm: { vmId: string }, _context: ProviderRuntimeContext): FakeVm {
|
|
1188
|
+
return {
|
|
1189
|
+
vmId: vm.vmId,
|
|
1190
|
+
exec: async (command) => this.exec(vm.vmId, command, true),
|
|
1191
|
+
probe: async (command) => this.exec(vm.vmId, command, false),
|
|
1192
|
+
exists: async (path) => this.hasFile(vm.vmId, path),
|
|
1193
|
+
snapshotRef: async () => this.createSnapshot(vm),
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
private exec(vmId: string, command: string, throwOnFailure: boolean): ExecResult {
|
|
1198
|
+
const files = this.files.get(vmId)!;
|
|
1199
|
+
const touch = /^touch (.+)$/.exec(command);
|
|
1200
|
+
if (touch) files.add(touch[1]!);
|
|
1201
|
+
|
|
1202
|
+
const exists = /^test -e (.+)$/.exec(command);
|
|
1203
|
+
if (exists) {
|
|
1204
|
+
const path = exists[1]!.replace(/^'|'$/g, "");
|
|
1205
|
+
return result(files.has(path));
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
const output = result(true);
|
|
1209
|
+
if (throwOnFailure && !output.ok) throw new Error("command failed");
|
|
1210
|
+
return output;
|
|
1211
|
+
}
|
|
1212
|
+
|
|
1213
|
+
private createSnapshot(vm: { vmId: string }): FakeSnapshotRef {
|
|
1214
|
+
const snapshot = {
|
|
1215
|
+
provider: "test" as const,
|
|
1216
|
+
kind: "vmSnapshot" as const,
|
|
1217
|
+
snapshotId: `snap-${this.snapshots.length + 1}`,
|
|
1218
|
+
};
|
|
1219
|
+
this.snapshots.push(snapshot);
|
|
1220
|
+
return snapshot;
|
|
1221
|
+
}
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
function result(ok: boolean): ExecResult {
|
|
1225
|
+
return { stdout: "", stderr: "", exitCode: ok ? 0 : 1, ok };
|
|
1226
|
+
}
|
|
1227
|
+
|
|
1228
|
+
function restoreEnv(name: string, value: string | undefined): void {
|
|
1229
|
+
if (value === undefined) {
|
|
1230
|
+
delete process.env[name];
|
|
1231
|
+
} else {
|
|
1232
|
+
process.env[name] = value;
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
|
|
1236
|
+
function isFakeSnapshotRef(value: unknown): value is FakeSnapshotRef {
|
|
1237
|
+
return Boolean(
|
|
1238
|
+
value &&
|
|
1239
|
+
typeof value === "object" &&
|
|
1240
|
+
(value as FakeSnapshotRef).provider === "test" &&
|
|
1241
|
+
(value as FakeSnapshotRef).kind === "vmSnapshot" &&
|
|
1242
|
+
typeof (value as FakeSnapshotRef).snapshotId === "string",
|
|
1243
|
+
);
|
|
1244
|
+
}
|