@rigkit/engine 0.1.8
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 +334 -0
- package/src/db/index.ts +157 -0
- package/src/db/schema/core.ts +75 -0
- package/src/db/schema/index.ts +7 -0
- package/src/engine.test.ts +866 -0
- package/src/engine.ts +2059 -0
- package/src/env-file.ts +52 -0
- package/src/hash.ts +21 -0
- package/src/index.ts +34 -0
- package/src/provider/types.ts +139 -0
- package/src/state.ts +318 -0
- package/src/types.ts +604 -0
- package/src/version.ts +1 -0
|
@@ -0,0 +1,866 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
import { Database } from "bun:sqlite";
|
|
3
|
+
import { mkdirSync, mkdtempSync, 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
|
+
SshConnection,
|
|
13
|
+
WorkflowProviderController,
|
|
14
|
+
} from "./provider/types.ts";
|
|
15
|
+
import type { DevMachineEvent, ExecResult, JsonValue, WorkspaceRecord } from "./types.ts";
|
|
16
|
+
|
|
17
|
+
describe("DevMachineEngine workflow runtime", () => {
|
|
18
|
+
test("plans, applies graph nodes, reuses graph cache, and forks workspaces", async () => {
|
|
19
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
20
|
+
writeFileSync(
|
|
21
|
+
join(projectDir, "rig.config.ts"),
|
|
22
|
+
`
|
|
23
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
24
|
+
|
|
25
|
+
const app = workflow("test", {
|
|
26
|
+
providers: {
|
|
27
|
+
test: defineProvider("test", { token: "test-key" }),
|
|
28
|
+
},
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
const base = app.sequence("base").task("first", async ({ runtime, test }) => {
|
|
32
|
+
runtime.log("preparing base\\n", { label: "setup" });
|
|
33
|
+
const vm = await test.createVm();
|
|
34
|
+
await vm.exec("touch /tmp/first", { name: "touch first" });
|
|
35
|
+
if (!(await vm.exists("/tmp/first"))) throw new Error("first was not created");
|
|
36
|
+
return { first: true, vm: await vm.snapshotRef() };
|
|
37
|
+
});
|
|
38
|
+
|
|
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);
|
|
42
|
+
await vm.exec("touch /tmp/second", { name: "touch second" });
|
|
43
|
+
return { second: true, vm: await vm.snapshotRef() };
|
|
44
|
+
});
|
|
45
|
+
|
|
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" };
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
export default app
|
|
52
|
+
.sequence("root")
|
|
53
|
+
.add(base)
|
|
54
|
+
.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 };
|
|
59
|
+
})
|
|
60
|
+
.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);
|
|
67
|
+
await vm.exec("touch /tmp/workspace-" + workspace.name, { name: "mark workspace" });
|
|
68
|
+
await local.open("vscode://" + workspace.name);
|
|
69
|
+
},
|
|
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);
|
|
74
|
+
await vm.exec("touch /tmp/open-" + workspace.name, { name: "mark workspace open" });
|
|
75
|
+
},
|
|
76
|
+
})
|
|
77
|
+
.operation("mark", {
|
|
78
|
+
requiredHostCapabilities: [{ id: "cmux.open", schemaHash: "sha256:cmux-open-schema" }],
|
|
79
|
+
input: (workflow) =>
|
|
80
|
+
workflow
|
|
81
|
+
.workspaceInput({ name: "workspace", position: 0 })
|
|
82
|
+
.extend({
|
|
83
|
+
label: workflow.string({ defaultValue: "marked" }),
|
|
84
|
+
}),
|
|
85
|
+
run: async ({ input, providers, local }) => {
|
|
86
|
+
const vm = providers.test.fromWorkspace(input.workspace);
|
|
87
|
+
await vm.exec("touch /tmp/mark-" + input.workspace.name, { name: "mark via operation" });
|
|
88
|
+
await local.open("mark://" + input.workspace.name);
|
|
89
|
+
return {
|
|
90
|
+
workspace: input.workspace.name,
|
|
91
|
+
label: input.label,
|
|
92
|
+
cwd: input.workspace.cwd ?? null,
|
|
93
|
+
};
|
|
94
|
+
},
|
|
95
|
+
});
|
|
96
|
+
`,
|
|
97
|
+
);
|
|
98
|
+
|
|
99
|
+
const opened: string[] = [];
|
|
100
|
+
const events: DevMachineEvent[] = [];
|
|
101
|
+
const provider = new FakeWorkflowProvider();
|
|
102
|
+
const engine = await createDevMachineEngine({
|
|
103
|
+
projectDir,
|
|
104
|
+
providerFactory: async () => provider,
|
|
105
|
+
local: {
|
|
106
|
+
open: async (target) => {
|
|
107
|
+
opened.push(target);
|
|
108
|
+
},
|
|
109
|
+
},
|
|
110
|
+
});
|
|
111
|
+
engine.onEvent((event) => events.push(event));
|
|
112
|
+
|
|
113
|
+
await engine.load();
|
|
114
|
+
|
|
115
|
+
const initial = await engine.plan();
|
|
116
|
+
expect(initial.workflow).toBe("test");
|
|
117
|
+
expect(initial.cachedNodeCount).toBe(0);
|
|
118
|
+
expect(initial.nodeCount).toBe(4);
|
|
119
|
+
expect(initial.nodes.map((node) => node.path)).toEqual([
|
|
120
|
+
"base.first",
|
|
121
|
+
"left.second",
|
|
122
|
+
"right.data",
|
|
123
|
+
"join",
|
|
124
|
+
]);
|
|
125
|
+
|
|
126
|
+
const applied = await engine.apply();
|
|
127
|
+
expect(applied.snapshotId).toBe("snap-2");
|
|
128
|
+
expect(events).toContainEqual({
|
|
129
|
+
type: "log.output",
|
|
130
|
+
nodePath: "base.first",
|
|
131
|
+
stream: "info",
|
|
132
|
+
label: "setup",
|
|
133
|
+
data: "preparing base\n",
|
|
134
|
+
});
|
|
135
|
+
expect(provider.snapshots).toHaveLength(2);
|
|
136
|
+
expect(engine.listNodeRuns()).toHaveLength(4);
|
|
137
|
+
|
|
138
|
+
const cached = await engine.plan();
|
|
139
|
+
expect(cached.cachedNodeCount).toBe(4);
|
|
140
|
+
expect(cached.finalContext?.summary).toBe("right-ready");
|
|
141
|
+
|
|
142
|
+
const workspace = await engine.fork({ name: "work" });
|
|
143
|
+
expect(workspace.snapshotId).toBe("snap-2");
|
|
144
|
+
expect(workspace.name).toBe("work");
|
|
145
|
+
expect(workspace.resourceId).toBe("workspace-work");
|
|
146
|
+
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);
|
|
150
|
+
|
|
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
|
+
]);
|
|
155
|
+
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);
|
|
164
|
+
|
|
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" });
|
|
170
|
+
expect(engine.listWorkspaces()).toHaveLength(0);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
test("creates workspaces from config create callbacks and exposes persisted workspace data", async () => {
|
|
174
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
175
|
+
writeFileSync(
|
|
176
|
+
join(projectDir, "rig.config.ts"),
|
|
177
|
+
`
|
|
178
|
+
import { defineConfig, defineProvider, sequence } from "${import.meta.dir}/index.ts";
|
|
179
|
+
|
|
180
|
+
const test = defineProvider("test", { token: "test-key" });
|
|
181
|
+
|
|
182
|
+
const root = sequence("create-test")
|
|
183
|
+
.step("prepare", async ({ providers }) => {
|
|
184
|
+
const vm = await providers.test.createVm();
|
|
185
|
+
await vm.exec("touch /tmp/template", { name: "prepare template" });
|
|
186
|
+
return {
|
|
187
|
+
vm: await vm.snapshotRef(),
|
|
188
|
+
repoPath: "/workspace/repo",
|
|
189
|
+
};
|
|
190
|
+
})
|
|
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
|
+
};
|
|
201
|
+
})
|
|
202
|
+
.operation("inspect", {
|
|
203
|
+
input: (workflow) => workflow.workspaceInput({ name: "workspace", position: 0 }),
|
|
204
|
+
run: async ({ input, local }) => {
|
|
205
|
+
await local.open("created://" + input.workspace.name);
|
|
206
|
+
return {
|
|
207
|
+
vmId: input.workspace.data.vmId,
|
|
208
|
+
repoPath: input.workspace.data.repoPath,
|
|
209
|
+
ready: input.workspace.data.ready,
|
|
210
|
+
};
|
|
211
|
+
},
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
export default defineConfig({
|
|
215
|
+
providers: { test },
|
|
216
|
+
workflows: { root },
|
|
217
|
+
});
|
|
218
|
+
`,
|
|
219
|
+
);
|
|
220
|
+
|
|
221
|
+
const opened: string[] = [];
|
|
222
|
+
const provider = new FakeWorkflowProvider();
|
|
223
|
+
const engine = await createDevMachineEngine({
|
|
224
|
+
projectDir,
|
|
225
|
+
providerFactory: () => provider,
|
|
226
|
+
local: {
|
|
227
|
+
open: async (target) => {
|
|
228
|
+
opened.push(target);
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
await engine.load();
|
|
234
|
+
const projectInfo = engine.getProjectInfo();
|
|
235
|
+
expect(projectInfo.workflow?.createsWorkspace).toBe(true);
|
|
236
|
+
expect(projectInfo.workflows.map((workflow) => workflow.name)).toEqual(["create-test"]);
|
|
237
|
+
expect(engine.listOperations().map((operation) => operation.id)).toEqual(["inspect"]);
|
|
238
|
+
|
|
239
|
+
const workspace = await engine.fork({ name: "created" });
|
|
240
|
+
expect(workspace.name).toBe("created");
|
|
241
|
+
expect(workspace.providerId).toBe("config");
|
|
242
|
+
expect(workspace.resourceId).toBe("vm-2");
|
|
243
|
+
expect(workspace.metadata).toMatchObject({
|
|
244
|
+
name: "created",
|
|
245
|
+
vmId: "vm-2",
|
|
246
|
+
repoPath: "/workspace/repo",
|
|
247
|
+
ready: true,
|
|
248
|
+
});
|
|
249
|
+
expect(provider.hasFile("vm-2", "/tmp/create-created")).toBe(true);
|
|
250
|
+
|
|
251
|
+
const inspected = await engine.runOperation({ operation: "inspect", input: { workspace: "created" } });
|
|
252
|
+
expect(inspected).toEqual({
|
|
253
|
+
vmId: "vm-2",
|
|
254
|
+
repoPath: "/workspace/repo",
|
|
255
|
+
ready: true,
|
|
256
|
+
});
|
|
257
|
+
expect(opened).toEqual(["created://created"]);
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
test("loads multiple workflows from defineConfig", async () => {
|
|
261
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
262
|
+
writeFileSync(
|
|
263
|
+
join(projectDir, "rig.config.ts"),
|
|
264
|
+
`
|
|
265
|
+
import { defineConfig, sequence } from "${import.meta.dir}/index.ts";
|
|
266
|
+
|
|
267
|
+
const api = sequence("api").step("ready", async () => ({ api: true }));
|
|
268
|
+
const web = sequence("web").step("ready", async () => ({ web: true }));
|
|
269
|
+
|
|
270
|
+
export default defineConfig({
|
|
271
|
+
providers: {},
|
|
272
|
+
workflows: { api, web },
|
|
273
|
+
});
|
|
274
|
+
`,
|
|
275
|
+
);
|
|
276
|
+
|
|
277
|
+
const engine = await createDevMachineEngine({
|
|
278
|
+
projectDir,
|
|
279
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await engine.load();
|
|
283
|
+
|
|
284
|
+
expect(engine.listWorkflowSummaries().map((workflow) => workflow.name)).toEqual(["api", "web"]);
|
|
285
|
+
expect(engine.getProjectInfo().workflow).toBeUndefined();
|
|
286
|
+
await expect(engine.plan()).rejects.toThrow("Multiple workflows are defined");
|
|
287
|
+
expect((await engine.plan({ workflow: "api" })).workflow).toBe("api");
|
|
288
|
+
});
|
|
289
|
+
|
|
290
|
+
test("creates state through an injectable state service factory", async () => {
|
|
291
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
292
|
+
const statePath = join(projectDir, "custom-state.sqlite");
|
|
293
|
+
writeFileSync(
|
|
294
|
+
join(projectDir, "rig.config.ts"),
|
|
295
|
+
`
|
|
296
|
+
import { defineConfig, sequence } from "${import.meta.dir}/index.ts";
|
|
297
|
+
|
|
298
|
+
const root = sequence("factory-test").step("ready", async () => ({ ready: true }));
|
|
299
|
+
|
|
300
|
+
export default defineConfig({
|
|
301
|
+
providers: {},
|
|
302
|
+
workflows: { root },
|
|
303
|
+
});
|
|
304
|
+
`,
|
|
305
|
+
);
|
|
306
|
+
|
|
307
|
+
const configPath = join(projectDir, "rig.config.ts");
|
|
308
|
+
const calls: Array<{ projectDir: string; configPath?: string; statePath?: string }> = [];
|
|
309
|
+
const engine = await createDevMachineEngine({
|
|
310
|
+
projectDir,
|
|
311
|
+
statePath,
|
|
312
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
313
|
+
stateFactory: (options) => {
|
|
314
|
+
calls.push({
|
|
315
|
+
projectDir: options.projectDir,
|
|
316
|
+
configPath: options.configPath,
|
|
317
|
+
statePath: options.statePath,
|
|
318
|
+
});
|
|
319
|
+
return createStateStore(options);
|
|
320
|
+
},
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
await engine.load();
|
|
324
|
+
|
|
325
|
+
expect(calls).toEqual([{ projectDir, configPath, statePath }]);
|
|
326
|
+
expect(engine.getProjectInfo().statePath).toBe(statePath);
|
|
327
|
+
|
|
328
|
+
const state = createStateStore({ projectDir, statePath: join(projectDir, "provided-state.sqlite") });
|
|
329
|
+
const engineWithState = await createDevMachineEngine({
|
|
330
|
+
projectDir,
|
|
331
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
332
|
+
state,
|
|
333
|
+
stateFactory: () => {
|
|
334
|
+
throw new Error("stateFactory should not be called when state is provided");
|
|
335
|
+
},
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
await engineWithState.load();
|
|
339
|
+
expect(engineWithState.getProjectInfo().statePath).toBe(state.path);
|
|
340
|
+
});
|
|
341
|
+
|
|
342
|
+
test("resets stale state before applying the Drizzle push schema", async () => {
|
|
343
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
344
|
+
const statePath = join(projectDir, ".rigkit", "state.sqlite");
|
|
345
|
+
mkdirSync(join(projectDir, ".rigkit"));
|
|
346
|
+
const legacy = new Database(statePath, { create: true });
|
|
347
|
+
legacy.run(`
|
|
348
|
+
CREATE TABLE workspaces (
|
|
349
|
+
id TEXT PRIMARY KEY NOT NULL,
|
|
350
|
+
name TEXT NOT NULL,
|
|
351
|
+
provider_id TEXT NOT NULL,
|
|
352
|
+
vm_id TEXT NOT NULL,
|
|
353
|
+
machine TEXT NOT NULL,
|
|
354
|
+
snapshot_id TEXT NOT NULL,
|
|
355
|
+
created_at TEXT NOT NULL,
|
|
356
|
+
updated_at TEXT NOT NULL,
|
|
357
|
+
metadata_json TEXT NOT NULL
|
|
358
|
+
)
|
|
359
|
+
`);
|
|
360
|
+
legacy
|
|
361
|
+
.query(`
|
|
362
|
+
INSERT INTO workspaces (
|
|
363
|
+
id, name, provider_id, vm_id, machine, snapshot_id, created_at, updated_at, metadata_json
|
|
364
|
+
) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
365
|
+
`)
|
|
366
|
+
.run(
|
|
367
|
+
"workspace-1",
|
|
368
|
+
"demo",
|
|
369
|
+
"freestyle",
|
|
370
|
+
"vm-1",
|
|
371
|
+
"smoke",
|
|
372
|
+
"snap-1",
|
|
373
|
+
"2026-05-10T00:00:00.000Z",
|
|
374
|
+
"2026-05-10T00:00:00.000Z",
|
|
375
|
+
JSON.stringify({ ready: true }),
|
|
376
|
+
);
|
|
377
|
+
legacy.close();
|
|
378
|
+
|
|
379
|
+
const state = createStateStore({ projectDir, statePath });
|
|
380
|
+
const result = await state.syncSchema();
|
|
381
|
+
const workspaces = state.listWorkspaces();
|
|
382
|
+
|
|
383
|
+
expect(result.applied).toEqual([RIGKIT_STATE_SCHEMA_VERSION]);
|
|
384
|
+
expect(result.hasDataLoss).toBe(true);
|
|
385
|
+
expect(result.warnings.some((warning) => warning.startsWith("Reset Rigkit state database after Drizzle push failed"))).toBe(
|
|
386
|
+
true,
|
|
387
|
+
);
|
|
388
|
+
expect(workspaces).toEqual([]);
|
|
389
|
+
|
|
390
|
+
const now = new Date().toISOString();
|
|
391
|
+
state.saveWorkspace({
|
|
392
|
+
id: "workspace-2",
|
|
393
|
+
name: "demo",
|
|
394
|
+
providerId: "freestyle",
|
|
395
|
+
workflow: "smoke",
|
|
396
|
+
resourceId: "resource-2",
|
|
397
|
+
snapshotId: "snap-2",
|
|
398
|
+
sourceRef: { snapshotId: "snap-2" },
|
|
399
|
+
context: { ready: true },
|
|
400
|
+
createdAt: now,
|
|
401
|
+
updatedAt: now,
|
|
402
|
+
metadata: { ready: true },
|
|
403
|
+
});
|
|
404
|
+
expect(state.getWorkspace("demo")).toMatchObject({
|
|
405
|
+
name: "demo",
|
|
406
|
+
workflow: "smoke",
|
|
407
|
+
resourceId: "resource-2",
|
|
408
|
+
});
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
test("stores provider JSON state in Rigkit-owned provider storage", async () => {
|
|
412
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
413
|
+
const plugin: BaseProviderPlugin = {
|
|
414
|
+
providerId: "test",
|
|
415
|
+
createProvider({ storage }) {
|
|
416
|
+
storage.set("ready", { value: "provider" });
|
|
417
|
+
return new FakeWorkflowProvider();
|
|
418
|
+
},
|
|
419
|
+
};
|
|
420
|
+
|
|
421
|
+
writeFileSync(
|
|
422
|
+
join(projectDir, "rig.config.ts"),
|
|
423
|
+
`
|
|
424
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
425
|
+
|
|
426
|
+
const app = workflow("provider-storage", {
|
|
427
|
+
providers: {
|
|
428
|
+
test: defineProvider("test", {}),
|
|
429
|
+
},
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
export default app.sequence("root").task("ready", async () => ({ ready: true }));
|
|
433
|
+
`,
|
|
434
|
+
);
|
|
435
|
+
|
|
436
|
+
const engine = await createDevMachineEngine({
|
|
437
|
+
projectDir,
|
|
438
|
+
providers: [plugin],
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
await engine.load();
|
|
442
|
+
await engine.plan();
|
|
443
|
+
|
|
444
|
+
const statePath = engine.getProjectInfo().statePath;
|
|
445
|
+
const main = new Database(statePath);
|
|
446
|
+
const mainTables = main
|
|
447
|
+
.query<{ name: string }, []>("select name from sqlite_master where type = 'table' order by name")
|
|
448
|
+
.all()
|
|
449
|
+
.map((row) => row.name);
|
|
450
|
+
|
|
451
|
+
expect(mainTables).toContain("workspaces");
|
|
452
|
+
expect(mainTables).toContain("provider_state");
|
|
453
|
+
expect(mainTables).toContain("runtime_metadata");
|
|
454
|
+
expect(mainTables).not.toContain("provider_local_state");
|
|
455
|
+
|
|
456
|
+
const row = main
|
|
457
|
+
.query<{ value_json: string }, []>(
|
|
458
|
+
"select value_json from provider_state where provider_id = 'test' and key = 'ready'",
|
|
459
|
+
)
|
|
460
|
+
.get();
|
|
461
|
+
const metadataRows = main
|
|
462
|
+
.query<{ key: string; value_json: string }, []>(
|
|
463
|
+
"select key, value_json from runtime_metadata order by key",
|
|
464
|
+
)
|
|
465
|
+
.all();
|
|
466
|
+
main.close();
|
|
467
|
+
|
|
468
|
+
expect(row ? JSON.parse(row.value_json).value : undefined).toBe("provider");
|
|
469
|
+
const metadata = Object.fromEntries(metadataRows.map((item) => [item.key, JSON.parse(item.value_json)]));
|
|
470
|
+
expect(metadata["state.schemaVersion"]).toBe(RIGKIT_STATE_SCHEMA_VERSION);
|
|
471
|
+
expect(metadata["project.dir"]).toBe(projectDir);
|
|
472
|
+
expect(metadata["config.path"]).toBe(join(projectDir, "rig.config.ts"));
|
|
473
|
+
});
|
|
474
|
+
|
|
475
|
+
test("rejects task outputs that are not JSON serializable", async () => {
|
|
476
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
477
|
+
writeFileSync(
|
|
478
|
+
join(projectDir, "rig.config.ts"),
|
|
479
|
+
`
|
|
480
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
481
|
+
|
|
482
|
+
const app = workflow("test", {
|
|
483
|
+
providers: {
|
|
484
|
+
test: defineProvider("test", { token: "test-key" }),
|
|
485
|
+
},
|
|
486
|
+
});
|
|
487
|
+
|
|
488
|
+
export default app.sequence("bad").task("returns-function", async () => {
|
|
489
|
+
return { fn: () => "nope" };
|
|
490
|
+
});
|
|
491
|
+
`,
|
|
492
|
+
);
|
|
493
|
+
|
|
494
|
+
const engine = await createDevMachineEngine({
|
|
495
|
+
projectDir,
|
|
496
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
497
|
+
});
|
|
498
|
+
|
|
499
|
+
await engine.load();
|
|
500
|
+
await expect(engine.apply()).rejects.toThrow("must be JSON-serializable");
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
test("routes terminal interactions through provider runtimes", async () => {
|
|
504
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
505
|
+
writeFileSync(
|
|
506
|
+
join(projectDir, "rig.config.ts"),
|
|
507
|
+
`
|
|
508
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
509
|
+
|
|
510
|
+
const app = workflow("test", {
|
|
511
|
+
providers: {
|
|
512
|
+
test: defineProvider("test", { token: "test-key" }),
|
|
513
|
+
},
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
export default app.sequence("auth").task("login", async ({ test }) => {
|
|
517
|
+
const result = await test.openTerminal("GitHub auth", "gh auth login");
|
|
518
|
+
return { finished: result.finished };
|
|
519
|
+
});
|
|
520
|
+
`,
|
|
521
|
+
);
|
|
522
|
+
|
|
523
|
+
const interactions: InteractionPresentationRequest[] = [];
|
|
524
|
+
const engine = await createDevMachineEngine({
|
|
525
|
+
projectDir,
|
|
526
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
527
|
+
interaction: {
|
|
528
|
+
present: async (request) => {
|
|
529
|
+
interactions.push(request);
|
|
530
|
+
},
|
|
531
|
+
},
|
|
532
|
+
});
|
|
533
|
+
|
|
534
|
+
await engine.load();
|
|
535
|
+
const applied = await engine.apply();
|
|
536
|
+
|
|
537
|
+
expect(interactions).toEqual([
|
|
538
|
+
{
|
|
539
|
+
nodePath: "login",
|
|
540
|
+
id: "fake-terminal",
|
|
541
|
+
title: "GitHub auth",
|
|
542
|
+
url: "http://127.0.0.1/fake-terminal",
|
|
543
|
+
instructions: undefined,
|
|
544
|
+
},
|
|
545
|
+
]);
|
|
546
|
+
expect(applied.context.finished).toBe(true);
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
test("waits for provider-owned interaction completion before resuming tasks", async () => {
|
|
550
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
551
|
+
writeFileSync(
|
|
552
|
+
join(projectDir, "rig.config.ts"),
|
|
553
|
+
`
|
|
554
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
555
|
+
|
|
556
|
+
const app = workflow("test", {
|
|
557
|
+
providers: {
|
|
558
|
+
test: defineProvider("test", { token: "test-key" }),
|
|
559
|
+
},
|
|
560
|
+
});
|
|
561
|
+
|
|
562
|
+
export default app.sequence("auth").task("login", async ({ test }) => {
|
|
563
|
+
const result = await test.openTerminal("GitHub auth", "gh auth login");
|
|
564
|
+
return { finished: result.finished };
|
|
565
|
+
});
|
|
566
|
+
`,
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
let complete!: (result: { finished: true }) => void;
|
|
570
|
+
const completed = new Promise<{ finished: true }>((resolve) => {
|
|
571
|
+
complete = resolve;
|
|
572
|
+
});
|
|
573
|
+
const provider = new FakeWorkflowProvider({
|
|
574
|
+
terminalCompleted: completed,
|
|
575
|
+
});
|
|
576
|
+
const engine = await createDevMachineEngine({
|
|
577
|
+
projectDir,
|
|
578
|
+
providerFactory: () => provider,
|
|
579
|
+
interaction: {
|
|
580
|
+
present: async () => {},
|
|
581
|
+
},
|
|
582
|
+
});
|
|
583
|
+
|
|
584
|
+
await engine.load();
|
|
585
|
+
let applied: Awaited<ReturnType<typeof engine.apply>> | undefined;
|
|
586
|
+
const applying = engine.apply().then((result) => {
|
|
587
|
+
applied = result;
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
await new Promise((resolve) => setTimeout(resolve, 25));
|
|
591
|
+
expect(applied).toBeUndefined();
|
|
592
|
+
expect(provider.terminalStopped).toBe(0);
|
|
593
|
+
|
|
594
|
+
complete({ finished: true });
|
|
595
|
+
await applying;
|
|
596
|
+
|
|
597
|
+
expect(applied?.context.finished).toBe(true);
|
|
598
|
+
expect(provider.terminalStopped).toBe(1);
|
|
599
|
+
});
|
|
600
|
+
|
|
601
|
+
test("provider config contributes to the workflow cache fingerprint", async () => {
|
|
602
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
603
|
+
const previousToken = process.env.RIGKIT_TEST_PROVIDER_TOKEN;
|
|
604
|
+
writeFileSync(
|
|
605
|
+
join(projectDir, "rig.config.ts"),
|
|
606
|
+
`
|
|
607
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
608
|
+
|
|
609
|
+
const app = workflow("test", {
|
|
610
|
+
providers: {
|
|
611
|
+
test: defineProvider("test", { token: () => process.env.RIGKIT_TEST_PROVIDER_TOKEN }),
|
|
612
|
+
},
|
|
613
|
+
});
|
|
614
|
+
|
|
615
|
+
export default app.sequence("setup").task("touch", async ({ test }) => {
|
|
616
|
+
const vm = await test.createVm();
|
|
617
|
+
await vm.exec("touch /tmp/setup", { name: "touch setup" });
|
|
618
|
+
return { vm: await vm.snapshotRef() };
|
|
619
|
+
});
|
|
620
|
+
`,
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
try {
|
|
624
|
+
const provider = new FakeWorkflowProvider();
|
|
625
|
+
process.env.RIGKIT_TEST_PROVIDER_TOKEN = "one";
|
|
626
|
+
const first = await createDevMachineEngine({
|
|
627
|
+
projectDir,
|
|
628
|
+
providerFactory: () => provider,
|
|
629
|
+
});
|
|
630
|
+
await first.load();
|
|
631
|
+
await first.apply();
|
|
632
|
+
expect((await first.plan()).cachedNodeCount).toBe(1);
|
|
633
|
+
|
|
634
|
+
process.env.RIGKIT_TEST_PROVIDER_TOKEN = "two";
|
|
635
|
+
const second = await createDevMachineEngine({
|
|
636
|
+
projectDir,
|
|
637
|
+
providerFactory: () => provider,
|
|
638
|
+
});
|
|
639
|
+
await second.load();
|
|
640
|
+
expect((await second.plan()).cachedNodeCount).toBe(0);
|
|
641
|
+
} finally {
|
|
642
|
+
if (previousToken === undefined) {
|
|
643
|
+
delete process.env.RIGKIT_TEST_PROVIDER_TOKEN;
|
|
644
|
+
} else {
|
|
645
|
+
process.env.RIGKIT_TEST_PROVIDER_TOKEN = previousToken;
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
|
|
650
|
+
test("treats cached output schema failures as cache misses", async () => {
|
|
651
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
652
|
+
const previousMode = process.env.RIGKIT_SCHEMA_MODE;
|
|
653
|
+
writeFileSync(
|
|
654
|
+
join(projectDir, "rig.config.ts"),
|
|
655
|
+
`
|
|
656
|
+
import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
|
|
657
|
+
|
|
658
|
+
const app = workflow("test", {
|
|
659
|
+
providers: {
|
|
660
|
+
test: defineProvider("test", { token: "test-key" }),
|
|
661
|
+
},
|
|
662
|
+
});
|
|
663
|
+
|
|
664
|
+
const schema = {
|
|
665
|
+
parse(value) {
|
|
666
|
+
if (!value || typeof value !== "object") throw new Error("not an object");
|
|
667
|
+
if (process.env.RIGKIT_SCHEMA_MODE === "next" && value.next !== true) {
|
|
668
|
+
throw new Error("missing next");
|
|
669
|
+
}
|
|
670
|
+
return value;
|
|
671
|
+
},
|
|
672
|
+
};
|
|
673
|
+
|
|
674
|
+
export default app.sequence("schema").task("value", { output: schema }, async () => {
|
|
675
|
+
return process.env.RIGKIT_SCHEMA_MODE === "next"
|
|
676
|
+
? { value: "ok", next: true }
|
|
677
|
+
: { value: "ok" };
|
|
678
|
+
});
|
|
679
|
+
`,
|
|
680
|
+
);
|
|
681
|
+
|
|
682
|
+
try {
|
|
683
|
+
process.env.RIGKIT_SCHEMA_MODE = "old";
|
|
684
|
+
const first = await createDevMachineEngine({
|
|
685
|
+
projectDir,
|
|
686
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
687
|
+
});
|
|
688
|
+
await first.load();
|
|
689
|
+
await first.apply();
|
|
690
|
+
expect((await first.plan()).cachedNodeCount).toBe(1);
|
|
691
|
+
|
|
692
|
+
process.env.RIGKIT_SCHEMA_MODE = "next";
|
|
693
|
+
const second = await createDevMachineEngine({
|
|
694
|
+
projectDir,
|
|
695
|
+
providerFactory: () => new FakeWorkflowProvider(),
|
|
696
|
+
});
|
|
697
|
+
await second.load();
|
|
698
|
+
const plan = await second.plan();
|
|
699
|
+
expect(plan.cachedNodeCount).toBe(0);
|
|
700
|
+
expect(plan.nodes[0]?.status).toBe("pending");
|
|
701
|
+
} finally {
|
|
702
|
+
if (previousMode === undefined) {
|
|
703
|
+
delete process.env.RIGKIT_SCHEMA_MODE;
|
|
704
|
+
} else {
|
|
705
|
+
process.env.RIGKIT_SCHEMA_MODE = previousMode;
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
});
|
|
709
|
+
});
|
|
710
|
+
|
|
711
|
+
type FakeSnapshotRef = {
|
|
712
|
+
provider: "test";
|
|
713
|
+
kind: "vmSnapshot";
|
|
714
|
+
snapshotId: string;
|
|
715
|
+
};
|
|
716
|
+
|
|
717
|
+
type FakeVm = {
|
|
718
|
+
vmId: string;
|
|
719
|
+
exec(command: string, options?: { name?: string }): Promise<ExecResult>;
|
|
720
|
+
probe(command: string, options?: { name?: string }): Promise<ExecResult>;
|
|
721
|
+
exists(path: string): Promise<boolean>;
|
|
722
|
+
snapshotRef(): Promise<FakeSnapshotRef>;
|
|
723
|
+
};
|
|
724
|
+
|
|
725
|
+
type FakeRuntime = {
|
|
726
|
+
createVm(): Promise<FakeVm>;
|
|
727
|
+
fromSnapshot(ref: FakeSnapshotRef): Promise<FakeVm>;
|
|
728
|
+
fromWorkspace(workspace: Pick<WorkspaceRecord, "resourceId">): FakeVm;
|
|
729
|
+
openTerminal(label: string, command: string): Promise<{ finished: true }>;
|
|
730
|
+
};
|
|
731
|
+
|
|
732
|
+
class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime, { authority: string }> {
|
|
733
|
+
readonly providerId = "test";
|
|
734
|
+
snapshots: FakeSnapshotRef[] = [];
|
|
735
|
+
workspaceContextResourceIds: string[] = [];
|
|
736
|
+
private nextVm = 1;
|
|
737
|
+
private files = new Map<string, Set<string>>();
|
|
738
|
+
terminalStopped = 0;
|
|
739
|
+
|
|
740
|
+
constructor(
|
|
741
|
+
private readonly options: {
|
|
742
|
+
terminalCompleted?: Promise<{ finished: true }>;
|
|
743
|
+
} = {},
|
|
744
|
+
) {}
|
|
745
|
+
|
|
746
|
+
runtime(context: ProviderRuntimeContext): FakeRuntime {
|
|
747
|
+
return {
|
|
748
|
+
createVm: async () => this.createVm(context),
|
|
749
|
+
fromSnapshot: async () => this.createVm(context),
|
|
750
|
+
fromWorkspace: (workspace) => this.vmRuntime({ vmId: workspace.resourceId }, context),
|
|
751
|
+
openTerminal: async (label, command) => {
|
|
752
|
+
const completed = this.options.terminalCompleted ?? Promise.resolve({ finished: true as const });
|
|
753
|
+
return await context.interaction.present({
|
|
754
|
+
id: "fake-terminal",
|
|
755
|
+
title: label,
|
|
756
|
+
url: "http://127.0.0.1/fake-terminal",
|
|
757
|
+
completed,
|
|
758
|
+
stop: () => {
|
|
759
|
+
this.terminalStopped += 1;
|
|
760
|
+
},
|
|
761
|
+
});
|
|
762
|
+
},
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
validateArtifact(ref: JsonValue): boolean {
|
|
767
|
+
return isFakeSnapshotRef(ref);
|
|
768
|
+
}
|
|
769
|
+
|
|
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
|
+
hasFile(vmId: string, path: string): boolean {
|
|
807
|
+
return this.files.get(vmId)?.has(path) ?? false;
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
private async createVm(context: ProviderRuntimeContext): Promise<FakeVm> {
|
|
811
|
+
const vm = { vmId: `vm-${this.nextVm++}` };
|
|
812
|
+
this.files.set(vm.vmId, new Set());
|
|
813
|
+
context.emit({ type: "vm.created", providerId: "test", vmId: vm.vmId });
|
|
814
|
+
return this.vmRuntime(vm, context);
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
private vmRuntime(vm: { vmId: string }, _context: ProviderRuntimeContext): FakeVm {
|
|
818
|
+
return {
|
|
819
|
+
vmId: vm.vmId,
|
|
820
|
+
exec: async (command) => this.exec(vm.vmId, command, true),
|
|
821
|
+
probe: async (command) => this.exec(vm.vmId, command, false),
|
|
822
|
+
exists: async (path) => this.hasFile(vm.vmId, path),
|
|
823
|
+
snapshotRef: async () => this.createSnapshot(vm),
|
|
824
|
+
};
|
|
825
|
+
}
|
|
826
|
+
|
|
827
|
+
private exec(vmId: string, command: string, throwOnFailure: boolean): ExecResult {
|
|
828
|
+
const files = this.files.get(vmId)!;
|
|
829
|
+
const touch = /^touch (.+)$/.exec(command);
|
|
830
|
+
if (touch) files.add(touch[1]!);
|
|
831
|
+
|
|
832
|
+
const exists = /^test -e (.+)$/.exec(command);
|
|
833
|
+
if (exists) {
|
|
834
|
+
const path = exists[1]!.replace(/^'|'$/g, "");
|
|
835
|
+
return result(files.has(path));
|
|
836
|
+
}
|
|
837
|
+
|
|
838
|
+
const output = result(true);
|
|
839
|
+
if (throwOnFailure && !output.ok) throw new Error("command failed");
|
|
840
|
+
return output;
|
|
841
|
+
}
|
|
842
|
+
|
|
843
|
+
private createSnapshot(vm: { vmId: string }): FakeSnapshotRef {
|
|
844
|
+
const snapshot = {
|
|
845
|
+
provider: "test" as const,
|
|
846
|
+
kind: "vmSnapshot" as const,
|
|
847
|
+
snapshotId: `snap-${this.snapshots.length + 1}`,
|
|
848
|
+
};
|
|
849
|
+
this.snapshots.push(snapshot);
|
|
850
|
+
return snapshot;
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
function result(ok: boolean): ExecResult {
|
|
855
|
+
return { stdout: "", stderr: "", exitCode: ok ? 0 : 1, ok };
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function isFakeSnapshotRef(value: unknown): value is FakeSnapshotRef {
|
|
859
|
+
return Boolean(
|
|
860
|
+
value &&
|
|
861
|
+
typeof value === "object" &&
|
|
862
|
+
(value as FakeSnapshotRef).provider === "test" &&
|
|
863
|
+
(value as FakeSnapshotRef).kind === "vmSnapshot" &&
|
|
864
|
+
typeof (value as FakeSnapshotRef).snapshotId === "string",
|
|
865
|
+
);
|
|
866
|
+
}
|