@rigkit/engine 0.2.7 → 0.2.9

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.7",
3
+ "version": "0.2.9",
4
4
  "type": "module",
5
5
  "repository": {
6
6
  "type": "git",
package/src/authoring.ts CHANGED
@@ -17,6 +17,7 @@ import type {
17
17
  WorkflowNodeDefinition,
18
18
  WorkflowProviderDefinition,
19
19
  WorkflowProviderMap,
20
+ WorkflowCacheScope,
20
21
  WorkflowSequenceBuilder,
21
22
  WorkflowTaskHandler,
22
23
  WorkflowTaskNode,
@@ -25,9 +26,14 @@ import type {
25
26
  WorkflowWorkspaceDefinition,
26
27
  } from "./types.ts";
27
28
 
28
- const reservedTaskContextKeys = new Set(["ctx", "runtime", "providers", "step"]);
29
+ const reservedTaskContextKeys = new Set(["config", "ctx", "runtime", "providers", "step"]);
29
30
  const reservedHostOperationIds = new Set<string>(RESERVED_WORKFLOW_OPERATION_IDS);
30
31
 
32
+ type WorkflowNodeAuthoringOptions = {
33
+ cacheScope?: WorkflowCacheScope;
34
+ config?: JsonObject;
35
+ };
36
+
31
37
  const readEnv = (name: string, fallback?: string): string => {
32
38
  const value = process.env[name];
33
39
  if (value !== undefined && value !== "") return value;
@@ -136,6 +142,7 @@ function createSequence<
136
142
  OperationIds extends string = never,
137
143
  WorkspaceOperationIds extends string = never,
138
144
  PreviousTaskIds extends string = never,
145
+ Config extends JsonObject = {},
139
146
  >(
140
147
  app: WorkflowDefinition<string, Providers>,
141
148
  name: string,
@@ -143,6 +150,7 @@ function createSequence<
143
150
  workspace?: WorkflowWorkspaceDefinition<Providers, OutputContext, any>,
144
151
  operations: readonly WorkflowOperationDefinition<Providers, any>[] = [],
145
152
  workspaceOperations: readonly WorkflowWorkspaceOperationDefinition<Providers, OutputContext, WorkspaceData, any>[] = [],
153
+ nodeOptions: WorkflowNodeAuthoringOptions = {},
146
154
  ): WorkflowSequenceBuilder<
147
155
  Providers,
148
156
  InputContext,
@@ -150,13 +158,16 @@ function createSequence<
150
158
  WorkspaceData,
151
159
  OperationIds,
152
160
  WorkspaceOperationIds,
153
- PreviousTaskIds
161
+ PreviousTaskIds,
162
+ Config
154
163
  > {
155
164
  const node = {
156
165
  kind: "rigkit.workflow-node" as const,
157
166
  nodeKind: "sequence" as const,
158
167
  name,
159
168
  workflow: app,
169
+ cacheScope: nodeOptions.cacheScope,
170
+ config: nodeOptions.config,
160
171
  children,
161
172
  workspaceDefinition: workspace,
162
173
  operations,
@@ -167,7 +178,7 @@ function createSequence<
167
178
  maybeHandler?: WorkflowTaskHandler<Providers, OutputContext, PreviousTaskIds, any>,
168
179
  ) => {
169
180
  const task = createTask(app, taskName, optionsOrHandler as any, maybeHandler as any);
170
- return createSequence(app, name, [...children, task], workspace, operations, workspaceOperations);
181
+ return createSequence(app, name, [...children, task], workspace, operations, workspaceOperations, nodeOptions);
171
182
  },
172
183
  step: (
173
184
  taskName: string,
@@ -175,42 +186,56 @@ function createSequence<
175
186
  maybeHandler?: WorkflowTaskHandler<Providers, OutputContext, PreviousTaskIds, any>,
176
187
  ) => {
177
188
  const task = createTask(app, taskName, optionsOrHandler as any, maybeHandler as any);
178
- return createSequence(app, name, [...children, task], workspace, operations, workspaceOperations);
189
+ return createSequence(app, name, [...children, task], workspace, operations, workspaceOperations, nodeOptions);
179
190
  },
180
191
  add: (child: WorkflowNodeDefinition<Providers, any, any>) => {
181
- assertSameWorkflow(app, child);
182
- return createSequence(app, name, [...children, child], workspace, operations, workspaceOperations);
192
+ return createSequence(
193
+ app,
194
+ name,
195
+ [...children, attachWorkflowForAuthoring(app, child)],
196
+ workspace,
197
+ operations,
198
+ workspaceOperations,
199
+ nodeOptions,
200
+ );
183
201
  },
184
202
  parallel: (branches: Record<string, WorkflowNodeDefinition<Providers, any, any>>) => {
203
+ const attachedBranches: Record<string, WorkflowNodeDefinition<Providers, any, any>> = {};
185
204
  for (const [branchName, branch] of Object.entries(branches)) {
186
- assertSameWorkflow(app, branch);
187
205
  if (!branchName) throw new Error(`Parallel branch names must be non-empty`);
206
+ attachedBranches[branchName] = attachWorkflowForAuthoring(app, branch);
188
207
  }
189
208
 
190
- const parallelNode: WorkflowNodeDefinition<Providers, OutputContext, any> & {
191
- nodeKind: "parallel";
192
- branches: Record<string, WorkflowNodeDefinition<Providers, any, any>>;
193
- } = {
194
- kind: "rigkit.workflow-node",
195
- nodeKind: "parallel",
196
- name: "parallel",
197
- workflow: app,
198
- branches,
199
- };
200
- return createSequence(app, name, [...children, parallelNode], workspace, operations, workspaceOperations);
209
+ const parallelNode = createParallel(app, "parallel", attachedBranches);
210
+ return createSequence(app, name, [...children, parallelNode], workspace, operations, workspaceOperations, nodeOptions);
201
211
  },
202
212
  workspace: (definition: WorkflowWorkspaceDefinition<Providers, OutputContext, any>) =>
203
- createSequence(app, name, children, definition, operations, workspaceOperations),
213
+ createSequence(app, name, children, definition, operations, workspaceOperations, nodeOptions),
204
214
  operation: (id: string, options: WorkflowOperationOptions<Providers, any>) => {
205
215
  const operation = createOperation(id, options);
206
216
  assertUniqueOperationId(operations, operation.id, "Operation");
207
- return createSequence(app, name, children, workspace, [...operations, operation], workspaceOperations);
217
+ return createSequence(app, name, children, workspace, [...operations, operation], workspaceOperations, nodeOptions);
208
218
  },
209
219
  workspaceOperation: (id: string, options: WorkflowWorkspaceOperationOptions<Providers, OutputContext, any, any>) => {
210
220
  const operation = createWorkspaceOperation(id, options);
211
221
  assertUniqueOperationId(workspaceOperations, operation.id, "Workspace operation");
212
- return createSequence(app, name, children, workspace, operations, [...workspaceOperations, operation]);
222
+ return createSequence(app, name, children, workspace, operations, [...workspaceOperations, operation], nodeOptions);
213
223
  },
224
+ global: () =>
225
+ createSequence(app, name, children, workspace, operations, workspaceOperations, {
226
+ ...nodeOptions,
227
+ cacheScope: "global",
228
+ }),
229
+ local: () =>
230
+ createSequence(app, name, children, workspace, operations, workspaceOperations, {
231
+ ...nodeOptions,
232
+ cacheScope: "local",
233
+ }),
234
+ configure: (config: JsonObject) =>
235
+ createSequence(app, name, children, workspace, operations, workspaceOperations, {
236
+ ...nodeOptions,
237
+ config: mergeConfig(nodeOptions.config, config),
238
+ }),
214
239
  };
215
240
 
216
241
  return node as unknown as WorkflowSequenceBuilder<
@@ -220,7 +245,8 @@ function createSequence<
220
245
  WorkspaceData,
221
246
  OperationIds,
222
247
  WorkspaceOperationIds,
223
- PreviousTaskIds
248
+ PreviousTaskIds,
249
+ Config
224
250
  >;
225
251
  }
226
252
 
@@ -339,6 +365,7 @@ function createTask<
339
365
  name: string,
340
366
  optionsOrHandler: WorkflowTaskOptions | WorkflowTaskHandler<Providers, InputContext, PreviousTaskIds, any>,
341
367
  maybeHandler?: WorkflowTaskHandler<Providers, InputContext, PreviousTaskIds, any>,
368
+ nodeOptions: WorkflowNodeAuthoringOptions = {},
342
369
  ): WorkflowTaskNode<Providers, InputContext, any> {
343
370
  const options = typeof optionsOrHandler === "function" ? undefined : optionsOrHandler;
344
371
  const handler = (typeof optionsOrHandler === "function" ? optionsOrHandler : maybeHandler) as
@@ -351,11 +378,100 @@ function createTask<
351
378
  nodeKind: "task",
352
379
  name,
353
380
  workflow: app,
381
+ cacheScope: nodeOptions.cacheScope,
382
+ config: nodeOptions.config,
354
383
  options,
355
384
  handler,
385
+ global: (() => createTask(app, name, options ?? handler, options ? handler : undefined, {
386
+ ...nodeOptions,
387
+ cacheScope: "global",
388
+ })) as WorkflowTaskNode<Providers, InputContext, any>["global"],
389
+ local: (() => createTask(app, name, options ?? handler, options ? handler : undefined, {
390
+ ...nodeOptions,
391
+ cacheScope: "local",
392
+ })) as WorkflowTaskNode<Providers, InputContext, any>["local"],
393
+ configure: ((config: JsonObject) => createTask(app, name, options ?? handler, options ? handler : undefined, {
394
+ ...nodeOptions,
395
+ config: mergeConfig(nodeOptions.config, config),
396
+ })) as WorkflowTaskNode<Providers, InputContext, any>["configure"],
397
+ };
398
+ }
399
+
400
+ function createParallel<Providers extends WorkflowProviderMap, InputContext extends JsonObject>(
401
+ app: WorkflowDefinition<string, Providers>,
402
+ name: string,
403
+ branches: Record<string, WorkflowNodeDefinition<Providers, any, any>>,
404
+ nodeOptions: WorkflowNodeAuthoringOptions = {},
405
+ ): WorkflowNodeDefinition<Providers, InputContext, any> & {
406
+ nodeKind: "parallel";
407
+ branches: Record<string, WorkflowNodeDefinition<Providers, any, any>>;
408
+ } {
409
+ const node = {
410
+ kind: "rigkit.workflow-node" as const,
411
+ nodeKind: "parallel" as const,
412
+ name,
413
+ workflow: app,
414
+ cacheScope: nodeOptions.cacheScope,
415
+ config: nodeOptions.config,
416
+ branches,
417
+ global: () => createParallel(app, name, branches, {
418
+ ...nodeOptions,
419
+ cacheScope: "global",
420
+ }),
421
+ local: () => createParallel(app, name, branches, {
422
+ ...nodeOptions,
423
+ cacheScope: "local",
424
+ }),
425
+ configure: (config: JsonObject) => createParallel(app, name, branches, {
426
+ ...nodeOptions,
427
+ config: mergeConfig(nodeOptions.config, config),
428
+ }),
429
+ };
430
+ return node as unknown as WorkflowNodeDefinition<Providers, InputContext, any> & {
431
+ nodeKind: "parallel";
432
+ branches: Record<string, WorkflowNodeDefinition<Providers, any, any>>;
356
433
  };
357
434
  }
358
435
 
436
+ function mergeConfig(previous: JsonObject | undefined, next: JsonObject): JsonObject {
437
+ assertJsonObject(next, "configure input");
438
+ return previous ? { ...previous, ...next } : { ...next };
439
+ }
440
+
441
+ function assertJsonObject(value: JsonObject, label: string): void {
442
+ try {
443
+ JSON.stringify(value);
444
+ } catch (cause) {
445
+ throw new Error(`${label} must be JSON-serializable`, { cause });
446
+ }
447
+ assertJsonValue(value, label);
448
+ }
449
+
450
+ function assertJsonValue(value: unknown, label: string): void {
451
+ if (
452
+ value === null ||
453
+ typeof value === "string" ||
454
+ typeof value === "boolean" ||
455
+ typeof value === "number"
456
+ ) {
457
+ if (typeof value === "number" && !Number.isFinite(value)) {
458
+ throw new Error(`${label} must be JSON-serializable`);
459
+ }
460
+ return;
461
+ }
462
+ if (Array.isArray(value)) {
463
+ value.forEach((item, index) => assertJsonValue(item, `${label}[${index}]`));
464
+ return;
465
+ }
466
+ if (value && typeof value === "object") {
467
+ for (const [key, item] of Object.entries(value)) {
468
+ assertJsonValue(item, `${label}.${key}`);
469
+ }
470
+ return;
471
+ }
472
+ throw new Error(`${label} must be JSON-serializable`);
473
+ }
474
+
359
475
  function validateProviders(providers: WorkflowProviderMap): void {
360
476
  for (const [name, provider] of Object.entries(providers)) {
361
477
  if (reservedTaskContextKeys.has(name)) {
@@ -367,13 +483,46 @@ function validateProviders(providers: WorkflowProviderMap): void {
367
483
  }
368
484
  }
369
485
 
370
- function assertSameWorkflow(
371
- app: WorkflowDefinition<string, any>,
372
- node: WorkflowNodeDefinition<any, any, any>,
373
- ): void {
374
- if (node.workflow !== app) {
375
- throw new Error(`Node ${node.name} belongs to a different workflow`);
486
+ function attachWorkflowForAuthoring<Providers extends WorkflowProviderMap>(
487
+ app: WorkflowDefinition<string, Providers>,
488
+ node: WorkflowNodeDefinition<Providers, any, any>,
489
+ ): WorkflowNodeDefinition<Providers, any, any> {
490
+ if (node.workflow === app) return node;
491
+ if (node.nodeKind === "parallel") {
492
+ return {
493
+ ...node,
494
+ workflow: app,
495
+ branches: Object.fromEntries(
496
+ Object.entries(parallelBranchesForAuthoring(node)).map(([name, branch]) => [
497
+ name,
498
+ attachWorkflowForAuthoring(app, branch),
499
+ ]),
500
+ ),
501
+ } as WorkflowNodeDefinition<Providers, any, any>;
376
502
  }
503
+ if (node.nodeKind === "sequence") {
504
+ return {
505
+ ...node,
506
+ workflow: app,
507
+ children: sequenceChildrenForAuthoring(node).map((child) => attachWorkflowForAuthoring(app, child)),
508
+ } as WorkflowNodeDefinition<Providers, any, any>;
509
+ }
510
+ return {
511
+ ...node,
512
+ workflow: app,
513
+ } as WorkflowNodeDefinition<Providers, any, any>;
514
+ }
515
+
516
+ function sequenceChildrenForAuthoring(
517
+ node: WorkflowNodeDefinition<any, any, any>,
518
+ ): readonly WorkflowNodeDefinition<any, any, any>[] {
519
+ return (node as { children?: readonly WorkflowNodeDefinition<any, any, any>[] }).children ?? [];
520
+ }
521
+
522
+ function parallelBranchesForAuthoring(
523
+ node: WorkflowNodeDefinition<any, any, any>,
524
+ ): Record<string, WorkflowNodeDefinition<any, any, any>> {
525
+ return (node as { branches?: Record<string, WorkflowNodeDefinition<any, any, any>> }).branches ?? {};
377
526
  }
378
527
 
379
528
  function getKind(value: object): unknown {
@@ -59,6 +59,33 @@ sequence("typed-step-invalidation-targets")
59
59
  return step.invalidate("missing-auth");
60
60
  });
61
61
 
62
+ sequence("typed-config")
63
+ .configure({
64
+ nodeMajor: 22,
65
+ tools: {
66
+ codex: true,
67
+ claude: false,
68
+ },
69
+ })
70
+ .step("read-config", async ({ config }) => {
71
+ const nodeMajor: number = config.nodeMajor;
72
+ const codex: boolean = config.tools.codex;
73
+ void nodeMajor;
74
+ void codex;
75
+ // @ts-expect-error missing config keys are rejected
76
+ config.missing;
77
+ return { ctx: { ready: true } };
78
+ })
79
+ .configure({
80
+ tools: {
81
+ claude: true,
82
+ },
83
+ })
84
+ .step("merged-config", async ({ config }) => {
85
+ const claude: boolean = config.tools.claude;
86
+ void claude;
87
+ });
88
+
62
89
  sequence("duplicate-step-id")
63
90
  .step("prepare" as const, async () => ({ ctx: { snapshotId: "snap-1" } }))
64
91
  // @ts-expect-error duplicate task ids are rejected for literal ids
@@ -0,0 +1,121 @@
1
+ import { afterEach, describe, expect, test } from "bun:test";
2
+ import {
3
+ __resetConsoleInterceptForTests,
4
+ runWithStepConsole,
5
+ type ConsoleLevel,
6
+ type StepConsoleSink,
7
+ } from "./console-intercept.ts";
8
+
9
+ type Captured = { level: ConsoleLevel; message: string };
10
+
11
+ function collector(): { sink: StepConsoleSink; entries: Captured[] } {
12
+ const entries: Captured[] = [];
13
+ return { sink: (input) => entries.push(input), entries };
14
+ }
15
+
16
+ afterEach(() => {
17
+ __resetConsoleInterceptForTests();
18
+ delete process.env.RIGKIT_NO_CONSOLE_INTERCEPT;
19
+ });
20
+
21
+ describe("runWithStepConsole", () => {
22
+ test("captures console.{log,info,debug,warn,error} with the right level", async () => {
23
+ const { sink, entries } = collector();
24
+ await runWithStepConsole(sink, async () => {
25
+ console.log("a log line");
26
+ console.info("an info line");
27
+ console.debug("a debug line");
28
+ console.warn("a warn line");
29
+ console.error("an error line");
30
+ });
31
+
32
+ expect(entries).toEqual([
33
+ { level: "log", message: "a log line" },
34
+ { level: "info", message: "an info line" },
35
+ { level: "debug", message: "a debug line" },
36
+ { level: "warn", message: "a warn line" },
37
+ { level: "error", message: "an error line" },
38
+ ]);
39
+ });
40
+
41
+ test("formats objects and printf args like the real console", async () => {
42
+ const { sink, entries } = collector();
43
+ await runWithStepConsole(sink, async () => {
44
+ console.log("counts:", { a: 1, b: 2 });
45
+ console.log("user %s scored %d", "alice", 42);
46
+ });
47
+
48
+ expect(entries[0]!.message).toBe("counts: { a: 1, b: 2 }");
49
+ expect(entries[1]!.message).toBe("user alice scored 42");
50
+ });
51
+
52
+ test("preserves the scope across async boundaries", async () => {
53
+ const { sink, entries } = collector();
54
+ const inner = async () => {
55
+ await Promise.resolve();
56
+ console.log("inside async helper");
57
+ };
58
+ await runWithStepConsole(sink, async () => {
59
+ await inner();
60
+ });
61
+
62
+ expect(entries).toEqual([{ level: "log", message: "inside async helper" }]);
63
+ });
64
+
65
+ test("isolates concurrent step contexts", async () => {
66
+ const a = collector();
67
+ const b = collector();
68
+ await Promise.all([
69
+ runWithStepConsole(a.sink, async () => {
70
+ await Promise.resolve();
71
+ console.log("from A");
72
+ }),
73
+ runWithStepConsole(b.sink, async () => {
74
+ await Promise.resolve();
75
+ console.warn("from B");
76
+ }),
77
+ ]);
78
+
79
+ expect(a.entries).toEqual([{ level: "log", message: "from A" }]);
80
+ expect(b.entries).toEqual([{ level: "warn", message: "from B" }]);
81
+ });
82
+
83
+ test("RIGKIT_NO_CONSOLE_INTERCEPT=1 disables capture", async () => {
84
+ process.env.RIGKIT_NO_CONSOLE_INTERCEPT = "1";
85
+ const { sink, entries } = collector();
86
+ const consoleLogSpy: string[] = [];
87
+ const originalLog = console.log;
88
+ console.log = (...args: unknown[]) => consoleLogSpy.push(String(args[0] ?? ""));
89
+ try {
90
+ await runWithStepConsole(sink, async () => {
91
+ console.log("should fall through to original");
92
+ });
93
+ } finally {
94
+ console.log = originalLog;
95
+ }
96
+
97
+ expect(entries).toEqual([]);
98
+ expect(consoleLogSpy).toEqual(["should fall through to original"]);
99
+ });
100
+
101
+ test("outside the scope, console.* falls through to its original implementation", async () => {
102
+ const { sink, entries } = collector();
103
+ // Install the patch by running a scoped call once.
104
+ await runWithStepConsole(sink, async () => {
105
+ console.log("inside");
106
+ });
107
+
108
+ // Now call outside — should not show up in entries.
109
+ const originalLog = console.log;
110
+ const outsideCalls: unknown[][] = [];
111
+ console.log = (...args: unknown[]) => outsideCalls.push(args);
112
+ try {
113
+ console.log("outside");
114
+ } finally {
115
+ console.log = originalLog;
116
+ }
117
+
118
+ expect(entries).toEqual([{ level: "log", message: "inside" }]);
119
+ expect(outsideCalls).toEqual([["outside"]]);
120
+ });
121
+ });
@@ -0,0 +1,75 @@
1
+ // Captures `console.log` / `info` / `debug` / `warn` / `error` invoked inside a
2
+ // step handler and routes the output through that step's logger. This lets
3
+ // users write `console.log("foo")` instead of threading `step.log` through
4
+ // every helper, and surfaces third-party SDK output for free.
5
+ //
6
+ // Scoped via AsyncLocalStorage so:
7
+ // - Engine/runtime code that itself uses `console.*` is never touched.
8
+ // - Concurrent step executions each get their own logger.
9
+ //
10
+ // Disabled by `RIGKIT_NO_CONSOLE_INTERCEPT=1`.
11
+
12
+ import { AsyncLocalStorage } from "node:async_hooks";
13
+ import { formatWithOptions } from "node:util";
14
+
15
+ export type ConsoleLevel = "debug" | "info" | "log" | "warn" | "error";
16
+
17
+ export type StepConsoleSink = (input: { level: ConsoleLevel; message: string }) => void;
18
+
19
+ type ConsoleMethod = "debug" | "info" | "log" | "warn" | "error";
20
+
21
+ const METHODS: readonly ConsoleMethod[] = ["debug", "info", "log", "warn", "error"] as const;
22
+ const STORAGE = new AsyncLocalStorage<StepConsoleSink>();
23
+
24
+ let installed = false;
25
+ const originalMethods: Partial<Record<ConsoleMethod, (...args: unknown[]) => void>> = {};
26
+
27
+ export function runWithStepConsole<T>(sink: StepConsoleSink, fn: () => Promise<T> | T): Promise<T> | T {
28
+ if (process.env.RIGKIT_NO_CONSOLE_INTERCEPT === "1") return fn();
29
+ ensureInstalled();
30
+ return STORAGE.run(sink, fn);
31
+ }
32
+
33
+ function ensureInstalled(): void {
34
+ if (installed) return;
35
+ installed = true;
36
+
37
+ // util.formatWithOptions colors based on the second arg. We disable colors so
38
+ // the captured output is plain (the CLI presenter colors it on the render
39
+ // side based on level, which is the right place for terminal styling).
40
+ const formatOptions = { colors: false, depth: 4, breakLength: 80 } as const;
41
+
42
+ for (const method of METHODS) {
43
+ const original = console[method].bind(console);
44
+ originalMethods[method] = original;
45
+ console[method] = (...args: unknown[]) => {
46
+ const sink = STORAGE.getStore();
47
+ if (!sink) {
48
+ original(...args);
49
+ return;
50
+ }
51
+ try {
52
+ const message = formatWithOptions(formatOptions, ...args);
53
+ sink({ level: methodToLevel(method), message });
54
+ } catch {
55
+ // If formatting fails, fall back to the original so users at least see
56
+ // something instead of swallowing their log.
57
+ original(...args);
58
+ }
59
+ };
60
+ }
61
+ }
62
+
63
+ function methodToLevel(method: ConsoleMethod): ConsoleLevel {
64
+ return method;
65
+ }
66
+
67
+ // Test-only: restore the original console methods. Not exported from the
68
+ // package's public surface but used by engine.test.ts.
69
+ export function __resetConsoleInterceptForTests(): void {
70
+ for (const method of METHODS) {
71
+ const original = originalMethods[method];
72
+ if (original) console[method] = original;
73
+ }
74
+ installed = false;
75
+ }