@rigkit/engine 0.2.3 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@rigkit/engine",
3
- "version": "0.2.3",
3
+ "version": "0.2.4",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
package/src/authoring.ts CHANGED
@@ -25,7 +25,7 @@ import type {
25
25
  WorkflowWorkspaceDefinition,
26
26
  } from "./types.ts";
27
27
 
28
- const reservedTaskContextKeys = new Set(["ctx", "runtime", "providers"]);
28
+ const reservedTaskContextKeys = new Set(["ctx", "runtime", "providers", "step"]);
29
29
  const reservedHostOperationIds = new Set<string>(RESERVED_WORKFLOW_OPERATION_IDS);
30
30
 
31
31
  const readEnv = (name: string, fallback?: string): string => {
@@ -51,9 +51,17 @@ export function workflow<const Name extends string, const Providers extends Work
51
51
  providers: options.providers,
52
52
  sequence: <InputContext extends JsonObject = {}>(sequenceName: string) =>
53
53
  createSequence(app as unknown as WorkflowDefinition<string, Providers>, sequenceName, []),
54
- task: (taskName: string, optionsOrHandler: WorkflowTaskOptions | WorkflowTaskHandler<Providers, {}, any>, maybeHandler?: WorkflowTaskHandler<Providers, {}, any>) =>
54
+ task: (
55
+ taskName: string,
56
+ optionsOrHandler: WorkflowTaskOptions | WorkflowTaskHandler<Providers, {}, never, any>,
57
+ maybeHandler?: WorkflowTaskHandler<Providers, {}, never, any>,
58
+ ) =>
55
59
  createTask(app as unknown as WorkflowDefinition<string, Providers>, taskName, optionsOrHandler as any, maybeHandler as any),
56
- step: (taskName: string, optionsOrHandler: WorkflowTaskOptions | WorkflowTaskHandler<Providers, {}, any>, maybeHandler?: WorkflowTaskHandler<Providers, {}, any>) =>
60
+ step: (
61
+ taskName: string,
62
+ optionsOrHandler: WorkflowTaskOptions | WorkflowTaskHandler<Providers, {}, never, any>,
63
+ maybeHandler?: WorkflowTaskHandler<Providers, {}, never, any>,
64
+ ) =>
57
65
  createTask(app as unknown as WorkflowDefinition<string, Providers>, taskName, optionsOrHandler as any, maybeHandler as any),
58
66
  };
59
67
 
@@ -127,6 +135,7 @@ function createSequence<
127
135
  WorkspaceData extends object = JsonObject,
128
136
  OperationIds extends string = never,
129
137
  WorkspaceOperationIds extends string = never,
138
+ PreviousTaskIds extends string = never,
130
139
  >(
131
140
  app: WorkflowDefinition<string, Providers>,
132
141
  name: string,
@@ -140,7 +149,8 @@ function createSequence<
140
149
  OutputContext,
141
150
  WorkspaceData,
142
151
  OperationIds,
143
- WorkspaceOperationIds
152
+ WorkspaceOperationIds,
153
+ PreviousTaskIds
144
154
  > {
145
155
  const node = {
146
156
  kind: "rigkit.workflow-node" as const,
@@ -153,16 +163,16 @@ function createSequence<
153
163
  workspaceOperations,
154
164
  task: (
155
165
  taskName: string,
156
- optionsOrHandler: WorkflowTaskOptions | WorkflowTaskHandler<Providers, OutputContext, any>,
157
- maybeHandler?: WorkflowTaskHandler<Providers, OutputContext, any>,
166
+ optionsOrHandler: WorkflowTaskOptions | WorkflowTaskHandler<Providers, OutputContext, PreviousTaskIds, any>,
167
+ maybeHandler?: WorkflowTaskHandler<Providers, OutputContext, PreviousTaskIds, any>,
158
168
  ) => {
159
169
  const task = createTask(app, taskName, optionsOrHandler as any, maybeHandler as any);
160
170
  return createSequence(app, name, [...children, task], workspace, operations, workspaceOperations);
161
171
  },
162
172
  step: (
163
173
  taskName: string,
164
- optionsOrHandler: WorkflowTaskOptions | WorkflowTaskHandler<Providers, OutputContext, any>,
165
- maybeHandler?: WorkflowTaskHandler<Providers, OutputContext, any>,
174
+ optionsOrHandler: WorkflowTaskOptions | WorkflowTaskHandler<Providers, OutputContext, PreviousTaskIds, any>,
175
+ maybeHandler?: WorkflowTaskHandler<Providers, OutputContext, PreviousTaskIds, any>,
166
176
  ) => {
167
177
  const task = createTask(app, taskName, optionsOrHandler as any, maybeHandler as any);
168
178
  return createSequence(app, name, [...children, task], workspace, operations, workspaceOperations);
@@ -209,7 +219,8 @@ function createSequence<
209
219
  OutputContext,
210
220
  WorkspaceData,
211
221
  OperationIds,
212
- WorkspaceOperationIds
222
+ WorkspaceOperationIds,
223
+ PreviousTaskIds
213
224
  >;
214
225
  }
215
226
 
@@ -319,15 +330,19 @@ function createOperationInputBuilder<Input extends object>(
319
330
  } as WorkflowOperationInputBuilder<Input>;
320
331
  }
321
332
 
322
- function createTask<Providers extends WorkflowProviderMap, InputContext extends JsonObject>(
333
+ function createTask<
334
+ Providers extends WorkflowProviderMap,
335
+ InputContext extends JsonObject,
336
+ PreviousTaskIds extends string = string,
337
+ >(
323
338
  app: WorkflowDefinition<string, Providers>,
324
339
  name: string,
325
- optionsOrHandler: WorkflowTaskOptions | WorkflowTaskHandler<Providers, InputContext, any>,
326
- maybeHandler?: WorkflowTaskHandler<Providers, InputContext, any>,
340
+ optionsOrHandler: WorkflowTaskOptions | WorkflowTaskHandler<Providers, InputContext, PreviousTaskIds, any>,
341
+ maybeHandler?: WorkflowTaskHandler<Providers, InputContext, PreviousTaskIds, any>,
327
342
  ): WorkflowTaskNode<Providers, InputContext, any> {
328
343
  const options = typeof optionsOrHandler === "function" ? undefined : optionsOrHandler;
329
344
  const handler = (typeof optionsOrHandler === "function" ? optionsOrHandler : maybeHandler) as
330
- | WorkflowTaskHandler<Providers, InputContext, any>
345
+ | WorkflowTaskHandler<Providers, InputContext, PreviousTaskIds, any>
331
346
  | undefined;
332
347
  if (!handler) throw new Error(`Task ${name} is missing a handler`);
333
348
 
@@ -10,7 +10,7 @@ sequence("normal-operation-ids")
10
10
  });
11
11
 
12
12
  sequence("workspace-operation-ids")
13
- .step("prepare", async () => ({ snapshotId: "snap-1" }))
13
+ .step("prepare", async () => ({ ctx: { snapshotId: "snap-1" } }))
14
14
  .workspace({
15
15
  create: async ({ workflow, workspace }) => {
16
16
  const snapshotId: string = workflow.ctx.snapshotId;
@@ -44,6 +44,26 @@ sequence("workspace-operation-ids")
44
44
  run: async () => null,
45
45
  });
46
46
 
47
+ sequence("typed-step-invalidation")
48
+ .step("github-auth", async () => ({ ctx: { token: "ok" } }))
49
+ .step("check-auth", async ({ step }) => {
50
+ const token: string = step.ctx.token;
51
+ void token;
52
+ return step.invalidate("github-auth");
53
+ });
54
+
55
+ sequence("typed-step-invalidation-targets")
56
+ .step("github-auth", async () => ({ ctx: { token: "ok" } }))
57
+ .step("check-auth", async ({ step }) => {
58
+ // @ts-expect-error invalidation target must be a previous task id
59
+ return step.invalidate("missing-auth");
60
+ });
61
+
62
+ sequence("duplicate-step-id")
63
+ .step("prepare" as const, async () => ({ ctx: { snapshotId: "snap-1" } }))
64
+ // @ts-expect-error duplicate task ids are rejected for literal ids
65
+ .step("prepare" as const, async ({ step }) => ({ ctx: step.ctx }));
66
+
47
67
  sequence("reserved-operation-id")
48
68
  // @ts-expect-error reserved operation ids are rejected for literal ids
49
69
  .operation("create" as const, {
@@ -1,6 +1,6 @@
1
1
  import { describe, expect, test } from "bun:test";
2
2
  import { Database } from "bun:sqlite";
3
- import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs";
3
+ import { mkdirSync, mkdtempSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
4
4
  import { tmpdir } from "node:os";
5
5
  import { join } from "node:path";
6
6
  import { createDevMachineEngine, type InteractionPresentationRequest } from "./engine.ts";
@@ -27,34 +27,34 @@ describe("DevMachineEngine workflow runtime", () => {
27
27
  },
28
28
  });
29
29
 
30
- const base = app.sequence("base").task("first", async ({ runtime, test }) => {
31
- runtime.log("preparing base\\n", { label: "setup" });
30
+ const base = app.sequence("base").task("first", async ({ step, test }) => {
31
+ step.log("preparing base\\n", { label: "setup" });
32
32
  const vm = await test.createVm();
33
33
  await vm.exec("touch /tmp/first", { name: "touch first" });
34
34
  if (!(await vm.exists("/tmp/first"))) throw new Error("first was not created");
35
- return { first: true, vm: await vm.snapshotRef() };
35
+ return { ctx: { first: true, vm: await vm.snapshotRef() } };
36
36
  });
37
37
 
38
- const left = app.sequence("left").task("second", async ({ ctx, test }) => {
39
- if (!ctx.first) throw new Error("missing first context");
40
- const vm = await test.fromSnapshot(ctx.vm);
38
+ const left = app.sequence("left").task("second", async ({ step, test }) => {
39
+ if (!step.ctx.first) throw new Error("missing first context");
40
+ const vm = await test.fromSnapshot(step.ctx.vm);
41
41
  await vm.exec("touch /tmp/second", { name: "touch second" });
42
- return { second: true, vm: await vm.snapshotRef() };
42
+ return { ctx: { second: true, vm: await vm.snapshotRef() } };
43
43
  });
44
44
 
45
- const right = app.sequence("right").task("data", async ({ ctx }) => {
46
- if (!ctx.first) throw new Error("missing first context");
47
- return { data: "right-ready" };
45
+ const right = app.sequence("right").task("data", async ({ step }) => {
46
+ if (!step.ctx.first) throw new Error("missing first context");
47
+ return { ctx: { data: "right-ready" } };
48
48
  });
49
49
 
50
50
  export default app
51
51
  .sequence("root")
52
52
  .add(base)
53
53
  .parallel({ left, right })
54
- .task("join", async ({ ctx }) => {
55
- if (!ctx.left.second) throw new Error("missing left context");
56
- if (ctx.right.data !== "right-ready") throw new Error("missing right context");
57
- return { vm: ctx.left.vm, summary: ctx.right.data };
54
+ .task("join", async ({ step }) => {
55
+ if (!step.ctx.left.second) throw new Error("missing left context");
56
+ if (step.ctx.right.data !== "right-ready") throw new Error("missing right context");
57
+ return { ctx: { vm: step.ctx.left.vm, summary: step.ctx.right.data } };
58
58
  })
59
59
  .workspace({
60
60
  create: async ({ providers, workflow, workspace, local }) => {
@@ -189,8 +189,10 @@ describe("DevMachineEngine workflow runtime", () => {
189
189
  const vm = await providers.test.createVm();
190
190
  await vm.exec("touch /tmp/template", { name: "prepare template" });
191
191
  return {
192
- vm: await vm.snapshotRef(),
193
- repoPath: "/workspace/repo",
192
+ ctx: {
193
+ vm: await vm.snapshotRef(),
194
+ repoPath: "/workspace/repo",
195
+ },
194
196
  };
195
197
  })
196
198
  .workspace({
@@ -279,8 +281,8 @@ describe("DevMachineEngine workflow runtime", () => {
279
281
  `
280
282
  import { defineConfig, sequence } from "${import.meta.dir}/index.ts";
281
283
 
282
- const api = sequence("api").step("ready", async () => ({ api: true }));
283
- const web = sequence("web").step("ready", async () => ({ web: true }));
284
+ const api = sequence("api").step("ready", async () => ({ ctx: { api: true } }));
285
+ const web = sequence("web").step("ready", async () => ({ ctx: { web: true } }));
284
286
 
285
287
  export default defineConfig({
286
288
  providers: {},
@@ -310,7 +312,7 @@ describe("DevMachineEngine workflow runtime", () => {
310
312
  `
311
313
  import { defineConfig, sequence } from "${import.meta.dir}/index.ts";
312
314
 
313
- const root = sequence("factory-test").step("ready", async () => ({ ready: true }));
315
+ const root = sequence("factory-test").step("ready", async () => ({ ctx: { ready: true } }));
314
316
 
315
317
  export default defineConfig({
316
318
  providers: {},
@@ -432,7 +434,7 @@ describe("DevMachineEngine workflow runtime", () => {
432
434
  const app = workflow("handler-cache", { providers: {} });
433
435
 
434
436
  export default app.sequence("root").task("value", async () => {
435
- return { value: "${value}" };
437
+ return { ctx: { value: "${value}" } };
436
438
  });
437
439
  `,
438
440
  );
@@ -490,7 +492,7 @@ describe("DevMachineEngine workflow runtime", () => {
490
492
  },
491
493
  });
492
494
 
493
- export default app.sequence("root").task("ready", async () => ({ ready: true }));
495
+ export default app.sequence("root").task("ready", async () => ({ ctx: { ready: true } }));
494
496
  `,
495
497
  );
496
498
 
@@ -533,6 +535,73 @@ describe("DevMachineEngine workflow runtime", () => {
533
535
  expect(metadata["config.path"]).toBe(join(projectDir, "rig.config.ts"));
534
536
  });
535
537
 
538
+ test("stores provider host JSON state outside project state", async () => {
539
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
540
+ const hostStorageDir = join(projectDir, ".host-storage");
541
+ const opened: string[] = [];
542
+ const plugin: BaseProviderPlugin = {
543
+ providerId: "test",
544
+ async createProvider({ storage, hostStorage, local }) {
545
+ storage.set("project", { value: "state" });
546
+ hostStorage.set("token", { value: "secret" });
547
+ await local.open("rigkit://provider-auth");
548
+ return new FakeWorkflowProvider();
549
+ },
550
+ };
551
+
552
+ writeFileSync(
553
+ join(projectDir, "rig.config.ts"),
554
+ `
555
+ import { defineProvider, workflow } from "${import.meta.dir}/index.ts";
556
+
557
+ const app = workflow("provider-host-storage", {
558
+ providers: {
559
+ test: defineProvider("test", {}),
560
+ },
561
+ });
562
+
563
+ export default app.sequence("root").task("ready", async () => ({ ctx: { ready: true } }));
564
+ `,
565
+ );
566
+
567
+ const engine = await createDevMachineEngine({
568
+ projectDir,
569
+ hostStorageDir,
570
+ providers: [plugin],
571
+ local: {
572
+ open: async (target) => {
573
+ opened.push(target);
574
+ },
575
+ },
576
+ });
577
+
578
+ await engine.load();
579
+ await engine.plan();
580
+
581
+ expect(opened).toEqual(["rigkit://provider-auth"]);
582
+
583
+ const files = readdirSync(hostStorageDir);
584
+ expect(files).toHaveLength(1);
585
+ const hostState = JSON.parse(readFileSync(join(hostStorageDir, files[0]!), "utf8"));
586
+ expect(hostState.records.token.value.value).toBe("secret");
587
+
588
+ const main = new Database(engine.getProjectInfo().statePath);
589
+ const projectRow = main
590
+ .query<{ value_json: string }, []>(
591
+ "select value_json from provider_state where provider_id = 'test' and key = 'project'",
592
+ )
593
+ .get();
594
+ const leakedHostRow = main
595
+ .query<{ value_json: string }, []>(
596
+ "select value_json from provider_state where provider_id = 'test' and key = 'token'",
597
+ )
598
+ .get();
599
+ main.close();
600
+
601
+ expect(projectRow ? JSON.parse(projectRow.value_json).value : undefined).toBe("state");
602
+ expect(leakedHostRow).toBeNull();
603
+ });
604
+
536
605
  test("rejects task outputs that are not JSON serializable", async () => {
537
606
  const projectDir = mkdtempSync(join(tmpdir(), "rigkit-"));
538
607
  writeFileSync(
@@ -547,7 +616,7 @@ describe("DevMachineEngine workflow runtime", () => {
547
616
  });
548
617
 
549
618
  export default app.sequence("bad").task("returns-function", async () => {
550
- return { fn: () => "nope" };
619
+ return { ctx: { fn: () => "nope" } };
551
620
  });
552
621
  `,
553
622
  );
@@ -576,7 +645,7 @@ describe("DevMachineEngine workflow runtime", () => {
576
645
 
577
646
  export default app.sequence("auth").task("login", async ({ test }) => {
578
647
  const result = await test.openTerminal("GitHub auth", "gh auth login");
579
- return { finished: result.finished };
648
+ return { ctx: { finished: result.finished } };
580
649
  });
581
650
  `,
582
651
  );
@@ -622,7 +691,7 @@ describe("DevMachineEngine workflow runtime", () => {
622
691
 
623
692
  export default app.sequence("auth").task("login", async ({ test }) => {
624
693
  const result = await test.openTerminal("GitHub auth", "gh auth login");
625
- return { finished: result.finished };
694
+ return { ctx: { finished: result.finished } };
626
695
  });
627
696
  `,
628
697
  );
@@ -676,7 +745,7 @@ describe("DevMachineEngine workflow runtime", () => {
676
745
  export default app.sequence("setup").task("touch", async ({ test }) => {
677
746
  const vm = await test.createVm();
678
747
  await vm.exec("touch /tmp/setup", { name: "touch setup" });
679
- return { vm: await vm.snapshotRef() };
748
+ return { ctx: { vm: await vm.snapshotRef() } };
680
749
  });
681
750
  `,
682
751
  );
@@ -734,8 +803,8 @@ describe("DevMachineEngine workflow runtime", () => {
734
803
 
735
804
  export default app.sequence("schema").task("value", { output: schema }, async () => {
736
805
  return process.env.RIGKIT_SCHEMA_MODE === "next"
737
- ? { value: "ok", next: true }
738
- : { value: "ok" };
806
+ ? { ctx: { value: "ok", next: true } }
807
+ : { ctx: { value: "ok" } };
739
808
  });
740
809
  `,
741
810
  );
@@ -767,6 +836,94 @@ describe("DevMachineEngine workflow runtime", () => {
767
836
  }
768
837
  }
769
838
  });
839
+
840
+ test("expires task cache when cacheTTL has elapsed", async () => {
841
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-cache-ttl-"));
842
+ writeFileSync(
843
+ join(projectDir, "rig.config.ts"),
844
+ `
845
+ import { sequence } from "${import.meta.dir}/index.ts";
846
+
847
+ export default sequence("ttl").task("daily-check", { cacheTTL: "1d" }, async () => {
848
+ return { ctx: { checked: true } };
849
+ });
850
+ `,
851
+ );
852
+
853
+ const engine = await createDevMachineEngine({ projectDir });
854
+ await engine.load();
855
+ await engine.apply();
856
+ expect((await engine.plan()).cachedNodeCount).toBe(1);
857
+
858
+ const db = new Database(engine.getProjectInfo().statePath);
859
+ db.run("update workflow_node_runs set created_at = ?", [
860
+ new Date(Date.now() - 2 * 24 * 60 * 60 * 1000).toISOString(),
861
+ ]);
862
+ db.close();
863
+
864
+ const expired = await engine.plan();
865
+ expect(expired.cachedNodeCount).toBe(0);
866
+ expect(expired.nodes[0]?.status).toBe("pending");
867
+ });
868
+
869
+ test("step.invalidate invalidates a previous task and replays from that point", async () => {
870
+ const projectDir = mkdtempSync(join(tmpdir(), "rigkit-invalidate-"));
871
+ const previous = {
872
+ authCount: process.env.RIGKIT_AUTH_COUNT,
873
+ checkCount: process.env.RIGKIT_CHECK_COUNT,
874
+ forceReauth: process.env.RIGKIT_FORCE_REAUTH,
875
+ };
876
+ process.env.RIGKIT_AUTH_COUNT = "0";
877
+ process.env.RIGKIT_CHECK_COUNT = "0";
878
+ process.env.RIGKIT_FORCE_REAUTH = "0";
879
+
880
+ writeFileSync(
881
+ join(projectDir, "rig.config.ts"),
882
+ `
883
+ import { sequence } from "${import.meta.dir}/index.ts";
884
+
885
+ export default sequence("reauth")
886
+ .task("prepare", async () => ({ ctx: { prepared: true } }))
887
+ .task("github-auth", async () => {
888
+ const count = Number(process.env.RIGKIT_AUTH_COUNT ?? "0") + 1;
889
+ process.env.RIGKIT_AUTH_COUNT = String(count);
890
+ return { ctx: { token: "token-" + count } };
891
+ })
892
+ .task("check-auth", { cacheTTL: 0 }, async ({ step }) => {
893
+ const count = Number(process.env.RIGKIT_CHECK_COUNT ?? "0") + 1;
894
+ process.env.RIGKIT_CHECK_COUNT = String(count);
895
+ if (process.env.RIGKIT_FORCE_REAUTH === "1") {
896
+ process.env.RIGKIT_FORCE_REAUTH = "0";
897
+ return step.invalidate("github-auth");
898
+ }
899
+ return { ctx: step.ctx };
900
+ });
901
+ `,
902
+ );
903
+
904
+ try {
905
+ const engine = await createDevMachineEngine({ projectDir });
906
+ await engine.load();
907
+
908
+ const first = await engine.apply();
909
+ expect(first.context.token).toBe("token-1");
910
+ expect(process.env.RIGKIT_AUTH_COUNT).toBe("1");
911
+ expect(process.env.RIGKIT_CHECK_COUNT).toBe("1");
912
+
913
+ process.env.RIGKIT_FORCE_REAUTH = "1";
914
+ const second = await engine.apply();
915
+ expect(second.context.token).toBe("token-2");
916
+ expect(process.env.RIGKIT_AUTH_COUNT).toBe("2");
917
+ expect(process.env.RIGKIT_CHECK_COUNT).toBe("3");
918
+
919
+ const validRuns = engine.listNodeRuns().filter((run) => !run.invalidated);
920
+ expect(validRuns.map((run) => run.nodePath).sort()).toEqual(["check-auth", "github-auth", "prepare"]);
921
+ } finally {
922
+ restoreEnv("RIGKIT_AUTH_COUNT", previous.authCount);
923
+ restoreEnv("RIGKIT_CHECK_COUNT", previous.checkCount);
924
+ restoreEnv("RIGKIT_FORCE_REAUTH", previous.forceReauth);
925
+ }
926
+ });
770
927
  });
771
928
 
772
929
  type FakeSnapshotRef = {
@@ -879,6 +1036,14 @@ function result(ok: boolean): ExecResult {
879
1036
  return { stdout: "", stderr: "", exitCode: ok ? 0 : 1, ok };
880
1037
  }
881
1038
 
1039
+ function restoreEnv(name: string, value: string | undefined): void {
1040
+ if (value === undefined) {
1041
+ delete process.env[name];
1042
+ } else {
1043
+ process.env[name] = value;
1044
+ }
1045
+ }
1046
+
882
1047
  function isFakeSnapshotRef(value: unknown): value is FakeSnapshotRef {
883
1048
  return Boolean(
884
1049
  value &&