@rigkit/engine 0.2.6 → 0.2.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/package.json +1 -1
- package/src/authoring.ts +177 -28
- package/src/authoring.typecheck.ts +27 -0
- package/src/console-intercept.test.ts +121 -0
- package/src/console-intercept.ts +75 -0
- package/src/engine.test.ts +161 -3
- package/src/engine.ts +515 -67
- package/src/index.ts +7 -0
- package/src/state.ts +36 -0
- package/src/types.ts +80 -18
- package/src/version.ts +1 -1
package/package.json
CHANGED
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
|
-
|
|
182
|
-
|
|
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
|
|
191
|
-
|
|
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
|
|
371
|
-
app: WorkflowDefinition<string,
|
|
372
|
-
node: WorkflowNodeDefinition<
|
|
373
|
-
):
|
|
374
|
-
if (node.workflow
|
|
375
|
-
|
|
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
|
+
}
|