@rigkit/engine 0.2.1 → 0.2.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/authoring.ts +82 -50
- package/src/authoring.typecheck.ts +67 -0
- package/src/db/schema/core.ts +3 -7
- package/src/engine.test.ts +127 -103
- package/src/engine.ts +309 -475
- package/src/provider/types.ts +2 -30
- package/src/state.ts +6 -14
- package/src/types.ts +223 -109
- package/src/version.ts +1 -1
package/src/engine.test.ts
CHANGED
|
@@ -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
|
|
14
|
+
import type { DevMachineEvent, ExecResult, JsonValue } from "./types.ts";
|
|
16
15
|
|
|
17
16
|
describe("DevMachineEngine workflow runtime", () => {
|
|
18
17
|
test("plans, applies graph nodes, reuses graph cache, and forks workspaces", async () => {
|
|
@@ -58,38 +57,48 @@ describe("DevMachineEngine workflow runtime", () => {
|
|
|
58
57
|
return { vm: ctx.left.vm, summary: ctx.right.data };
|
|
59
58
|
})
|
|
60
59
|
.workspace({
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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("
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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.
|
|
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
|
-
|
|
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.
|
|
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.
|
|
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(["
|
|
148
|
-
expect(provider.
|
|
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
|
|
152
|
-
expect(
|
|
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",
|
|
157
|
-
expect(opened).toEqual(["
|
|
158
|
-
expect(provider.hasFile("
|
|
165
|
+
expect(marked).toEqual({ workspace: "work", label: "marked", repoPath: "/workspace/repo", summary: "right-ready" });
|
|
166
|
+
expect(opened).toEqual(["created://work", "mark://work"]);
|
|
167
|
+
expect(provider.hasFile("vm-3", "/tmp/mark-work")).toBe(true);
|
|
159
168
|
|
|
160
|
-
|
|
161
|
-
expect(
|
|
162
|
-
expect(provider.
|
|
163
|
-
expect(provider.hasFile("workspace-work", "/tmp/open-work")).toBe(true);
|
|
169
|
+
await engine.runRuntimeOperation({ operation: "work/open" });
|
|
170
|
+
expect(opened).toEqual(["created://work", "mark://work", "open://work"]);
|
|
171
|
+
expect(provider.hasFile("vm-3", "/tmp/open-work")).toBe(true);
|
|
164
172
|
|
|
165
|
-
|
|
166
|
-
expect(workspaceSnapshot.metadata.snapshotId).toBe("snap-3");
|
|
167
|
-
expect(workspaceSnapshot.nodeName).toBe("verified-work");
|
|
168
|
-
|
|
169
|
-
await engine.deleteWorkspace({ workspace: "work" });
|
|
173
|
+
await engine.runRuntimeOperation({ operation: "work/remove" });
|
|
170
174
|
expect(engine.listWorkspaces()).toHaveLength(0);
|
|
175
|
+
expect(provider.hasFile("vm-3", "/tmp/remove-work")).toBe(true);
|
|
171
176
|
});
|
|
172
177
|
|
|
173
|
-
test("creates workspaces from
|
|
178
|
+
test("creates workspaces from workspace definitions and exposes persisted workspace context", async () => {
|
|
174
179
|
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
175
180
|
writeFileSync(
|
|
176
181
|
join(projectDir, "rig.config.ts"),
|
|
@@ -188,27 +193,36 @@ describe("DevMachineEngine workflow runtime", () => {
|
|
|
188
193
|
repoPath: "/workspace/repo",
|
|
189
194
|
};
|
|
190
195
|
})
|
|
191
|
-
.
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
196
|
+
.workspace({
|
|
197
|
+
create: async ({ workflow, workspace, providers }) => {
|
|
198
|
+
const vm = await providers.test.fromSnapshot(workflow.ctx.vm);
|
|
199
|
+
await vm.exec("touch /tmp/create-" + workspace.name, { name: "create workspace" });
|
|
200
|
+
return {
|
|
201
|
+
name: workspace.name,
|
|
202
|
+
vmId: vm.vmId,
|
|
203
|
+
sourceSnapshot: workflow.ctx.vm,
|
|
204
|
+
repoPath: workflow.ctx.repoPath,
|
|
205
|
+
ready: true,
|
|
206
|
+
};
|
|
207
|
+
},
|
|
208
|
+
remove: async () => {},
|
|
201
209
|
})
|
|
202
210
|
.operation("inspect", {
|
|
203
211
|
input: (workflow) => workflow.workspaceInput({ name: "workspace", position: 0 }),
|
|
204
212
|
run: async ({ input, local }) => {
|
|
205
213
|
await local.open("created://" + input.workspace.name);
|
|
206
214
|
return {
|
|
207
|
-
vmId: input.workspace.
|
|
208
|
-
repoPath: input.workspace.
|
|
209
|
-
ready: input.workspace.
|
|
215
|
+
vmId: input.workspace.ctx.vmId,
|
|
216
|
+
repoPath: input.workspace.ctx.repoPath,
|
|
217
|
+
ready: input.workspace.ctx.ready,
|
|
210
218
|
};
|
|
211
219
|
},
|
|
220
|
+
})
|
|
221
|
+
.workspaceOperation("status", {
|
|
222
|
+
run: async ({ workspace }) => ({
|
|
223
|
+
workspace: workspace.name,
|
|
224
|
+
vmId: workspace.ctx.vmId,
|
|
225
|
+
}),
|
|
212
226
|
});
|
|
213
227
|
|
|
214
228
|
export default defineConfig({
|
|
@@ -238,9 +252,7 @@ describe("DevMachineEngine workflow runtime", () => {
|
|
|
238
252
|
|
|
239
253
|
const workspace = await engine.fork({ name: "created" });
|
|
240
254
|
expect(workspace.name).toBe("created");
|
|
241
|
-
expect(workspace.
|
|
242
|
-
expect(workspace.resourceId).toBe("vm-2");
|
|
243
|
-
expect(workspace.metadata).toMatchObject({
|
|
255
|
+
expect(workspace.ctx).toMatchObject({
|
|
244
256
|
name: "created",
|
|
245
257
|
vmId: "vm-2",
|
|
246
258
|
repoPath: "/workspace/repo",
|
|
@@ -255,6 +267,9 @@ describe("DevMachineEngine workflow runtime", () => {
|
|
|
255
267
|
ready: true,
|
|
256
268
|
});
|
|
257
269
|
expect(opened).toEqual(["created://created"]);
|
|
270
|
+
|
|
271
|
+
const status = await engine.runRuntimeOperation({ operation: "created/status" });
|
|
272
|
+
expect(status).toEqual({ workspace: "created", vmId: "vm-2" });
|
|
258
273
|
});
|
|
259
274
|
|
|
260
275
|
test("loads multiple workflows from defineConfig", async () => {
|
|
@@ -391,23 +406,69 @@ describe("DevMachineEngine workflow runtime", () => {
|
|
|
391
406
|
state.saveWorkspace({
|
|
392
407
|
id: "workspace-2",
|
|
393
408
|
name: "demo",
|
|
394
|
-
providerId: "freestyle",
|
|
395
409
|
workflow: "smoke",
|
|
396
|
-
|
|
397
|
-
snapshotId: "snap-2",
|
|
398
|
-
sourceRef: { snapshotId: "snap-2" },
|
|
399
|
-
context: { ready: true },
|
|
410
|
+
workflowCtx: { ready: true },
|
|
400
411
|
createdAt: now,
|
|
401
412
|
updatedAt: now,
|
|
402
|
-
|
|
413
|
+
ctx: { ready: true },
|
|
403
414
|
});
|
|
404
415
|
expect(state.getWorkspace("demo")).toMatchObject({
|
|
405
416
|
name: "demo",
|
|
406
417
|
workflow: "smoke",
|
|
407
|
-
|
|
418
|
+
ctx: { ready: true },
|
|
408
419
|
});
|
|
409
420
|
});
|
|
410
421
|
|
|
422
|
+
test("invalidates task cache when handler source changes", async () => {
|
|
423
|
+
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-handler-cache-"));
|
|
424
|
+
const statePath = join(projectDir, ".rigkit", "state.sqlite");
|
|
425
|
+
mkdirSync(join(projectDir, ".rigkit"));
|
|
426
|
+
const writeConfig = (configPath: string, value: string) =>
|
|
427
|
+
writeFileSync(
|
|
428
|
+
configPath,
|
|
429
|
+
`
|
|
430
|
+
import { workflow } from "${import.meta.dir}/index.ts";
|
|
431
|
+
|
|
432
|
+
const app = workflow("handler-cache", { providers: {} });
|
|
433
|
+
|
|
434
|
+
export default app.sequence("root").task("value", async () => {
|
|
435
|
+
return { value: "${value}" };
|
|
436
|
+
});
|
|
437
|
+
`,
|
|
438
|
+
);
|
|
439
|
+
|
|
440
|
+
const firstConfigPath = join(projectDir, "rig.one.config.ts");
|
|
441
|
+
const secondConfigPath = join(projectDir, "rig.two.config.ts");
|
|
442
|
+
writeConfig(firstConfigPath, "one");
|
|
443
|
+
writeConfig(secondConfigPath, "two");
|
|
444
|
+
|
|
445
|
+
const first = await createDevMachineEngine({
|
|
446
|
+
projectDir,
|
|
447
|
+
configPath: firstConfigPath,
|
|
448
|
+
statePath,
|
|
449
|
+
});
|
|
450
|
+
await first.load();
|
|
451
|
+
const applied = await first.apply();
|
|
452
|
+
expect(applied.context.value).toBe("one");
|
|
453
|
+
|
|
454
|
+
const cached = await first.plan();
|
|
455
|
+
expect(cached.cachedNodeCount).toBe(1);
|
|
456
|
+
|
|
457
|
+
const second = await createDevMachineEngine({
|
|
458
|
+
projectDir,
|
|
459
|
+
configPath: secondConfigPath,
|
|
460
|
+
statePath,
|
|
461
|
+
});
|
|
462
|
+
await second.load();
|
|
463
|
+
const changed = await second.plan();
|
|
464
|
+
expect(changed.cachedNodeCount).toBe(0);
|
|
465
|
+
expect(changed.nodes[0]?.status).toBe("pending");
|
|
466
|
+
|
|
467
|
+
const reapplied = await second.apply();
|
|
468
|
+
expect(reapplied.context.value).toBe("two");
|
|
469
|
+
expect(second.listNodeRuns()).toHaveLength(2);
|
|
470
|
+
});
|
|
471
|
+
|
|
411
472
|
test("stores provider JSON state in Rigkit-owned provider storage", async () => {
|
|
412
473
|
const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
|
|
413
474
|
const plugin: BaseProviderPlugin = {
|
|
@@ -725,14 +786,13 @@ type FakeVm = {
|
|
|
725
786
|
type FakeRuntime = {
|
|
726
787
|
createVm(): Promise<FakeVm>;
|
|
727
788
|
fromSnapshot(ref: FakeSnapshotRef): Promise<FakeVm>;
|
|
728
|
-
|
|
789
|
+
fromId(vmId: string): FakeVm;
|
|
729
790
|
openTerminal(label: string, command: string): Promise<{ finished: true }>;
|
|
730
791
|
};
|
|
731
792
|
|
|
732
|
-
class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime
|
|
793
|
+
class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime> {
|
|
733
794
|
readonly providerId = "test";
|
|
734
795
|
snapshots: FakeSnapshotRef[] = [];
|
|
735
|
-
workspaceContextResourceIds: string[] = [];
|
|
736
796
|
private nextVm = 1;
|
|
737
797
|
private files = new Map<string, Set<string>>();
|
|
738
798
|
terminalStopped = 0;
|
|
@@ -747,7 +807,7 @@ class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime, {
|
|
|
747
807
|
return {
|
|
748
808
|
createVm: async () => this.createVm(context),
|
|
749
809
|
fromSnapshot: async () => this.createVm(context),
|
|
750
|
-
|
|
810
|
+
fromId: (vmId) => this.vmRuntime({ vmId }, context),
|
|
751
811
|
openTerminal: async (label, command) => {
|
|
752
812
|
const completed = this.options.terminalCompleted ?? Promise.resolve({ finished: true as const });
|
|
753
813
|
return await context.interaction.present({
|
|
@@ -767,42 +827,6 @@ class FakeWorkflowProvider implements WorkflowProviderController<FakeRuntime, {
|
|
|
767
827
|
return isFakeSnapshotRef(ref);
|
|
768
828
|
}
|
|
769
829
|
|
|
770
|
-
workspace = {
|
|
771
|
-
canUse: (ref: JsonValue) => isFakeSnapshotRef(ref),
|
|
772
|
-
createWorkspace: async (ref: JsonValue, input: { name: string }) => {
|
|
773
|
-
if (!isFakeSnapshotRef(ref)) throw new Error("bad ref");
|
|
774
|
-
const resourceId = `workspace-${input.name}`;
|
|
775
|
-
this.files.set(resourceId, new Set());
|
|
776
|
-
return {
|
|
777
|
-
providerId: "test",
|
|
778
|
-
resourceId,
|
|
779
|
-
snapshotId: ref.snapshotId,
|
|
780
|
-
sourceRef: ref,
|
|
781
|
-
};
|
|
782
|
-
},
|
|
783
|
-
deleteWorkspace: async () => {},
|
|
784
|
-
snapshotWorkspace: async (workspace: WorkspaceRecord) => {
|
|
785
|
-
const ref = this.createSnapshot({ vmId: workspace.resourceId });
|
|
786
|
-
return {
|
|
787
|
-
providerId: "test",
|
|
788
|
-
resourceId: workspace.resourceId,
|
|
789
|
-
snapshotId: ref.snapshotId,
|
|
790
|
-
sourceRef: ref,
|
|
791
|
-
};
|
|
792
|
-
},
|
|
793
|
-
ssh: async (workspaceOrResourceId: string): Promise<SshConnection> => ({
|
|
794
|
-
kind: "ssh",
|
|
795
|
-
host: "fake",
|
|
796
|
-
username: workspaceOrResourceId,
|
|
797
|
-
auth: { type: "token", token: "fake" },
|
|
798
|
-
command: `ssh ${workspaceOrResourceId}`,
|
|
799
|
-
}),
|
|
800
|
-
workspaceContext: (workspace: WorkspaceRecord) => {
|
|
801
|
-
this.workspaceContextResourceIds.push(workspace.resourceId);
|
|
802
|
-
return { authority: "fake-authority" };
|
|
803
|
-
},
|
|
804
|
-
};
|
|
805
|
-
|
|
806
830
|
hasFile(vmId: string, path: string): boolean {
|
|
807
831
|
return this.files.get(vmId)?.has(path) ?? false;
|
|
808
832
|
}
|