@codemation/core 0.8.1 → 0.10.1
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/CHANGELOG.md +386 -0
- package/dist/{EngineRuntimeRegistration.types-BP6tsaNP.d.ts → EngineRuntimeRegistration.types-D1fyApMI.d.ts} +2 -2
- package/dist/{EngineWorkflowRunnerService-DzOCa1BW.d.cts → EngineRuntimeRegistration.types-pB3FnzqR.d.cts} +17 -17
- package/dist/{InMemoryRunDataFactory-1iz7_SnO.d.cts → InMemoryRunDataFactory-Xw7v4-sj.d.cts} +31 -29
- package/dist/InMemoryRunEventBusRegistry-VM3OWnHo.cjs +47 -0
- package/dist/InMemoryRunEventBusRegistry-VM3OWnHo.cjs.map +1 -0
- package/dist/InMemoryRunEventBusRegistry-sM4z4n_i.js +41 -0
- package/dist/InMemoryRunEventBusRegistry-sM4z4n_i.js.map +1 -0
- package/dist/{RunIntentService-BqhmdoA1.d.ts → RunIntentService-BE9CAkbf.d.ts} +966 -471
- package/dist/{RunIntentService-S-1lW-gS.d.cts → RunIntentService-siBSjaaY.d.cts} +859 -493
- package/dist/bootstrap/index.cjs +5 -2
- package/dist/bootstrap/index.d.cts +212 -135
- package/dist/bootstrap/index.d.ts +4 -4
- package/dist/bootstrap/index.js +3 -3
- package/dist/{bootstrap-Bx1u4cbS.cjs → bootstrap-Cm5ruQxx.cjs} +253 -2
- package/dist/bootstrap-Cm5ruQxx.cjs.map +1 -0
- package/dist/{bootstrap-BoknFKnw.js → bootstrap-D3r505ko.js} +236 -3
- package/dist/bootstrap-D3r505ko.js.map +1 -0
- package/dist/{index-CVs9rVhl.d.ts → index-DeLl1Tne.d.ts} +632 -230
- package/dist/index.cjs +323 -176
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +544 -91
- package/dist/index.d.ts +3 -3
- package/dist/index.js +299 -166
- package/dist/index.js.map +1 -1
- package/dist/{runtime-DUW6tIJ1.js → runtime-BGNbRnqs.js} +934 -75
- package/dist/runtime-BGNbRnqs.js.map +1 -0
- package/dist/{runtime-Dvo2ru5A.cjs → runtime-DKXJwTNv.cjs} +1028 -73
- package/dist/runtime-DKXJwTNv.cjs.map +1 -0
- package/dist/testing.cjs +4 -4
- package/dist/testing.cjs.map +1 -1
- package/dist/testing.d.cts +2 -2
- package/dist/testing.d.ts +2 -2
- package/dist/testing.js +3 -3
- package/package.json +7 -2
- package/src/ai/AiHost.ts +42 -14
- package/src/authoring/DefinedCollectionRegistry.ts +17 -0
- package/src/authoring/defineCollection.types.ts +181 -0
- package/src/authoring/definePollingTrigger.types.ts +396 -0
- package/src/authoring/definePollingTriggerInternals.ts +74 -0
- package/src/authoring/index.ts +19 -0
- package/src/bootstrap/index.ts +9 -0
- package/src/bootstrap/runtime/EngineRuntimeRegistrar.ts +8 -0
- package/src/browser.ts +1 -0
- package/src/contracts/CodemationTelemetryAttributeNames.ts +6 -0
- package/src/contracts/NoOpNodeExecutionTelemetry.ts +2 -11
- package/src/contracts/NoOpTelemetrySpanScope.ts +46 -10
- package/src/contracts/assertionTypes.ts +63 -0
- package/src/contracts/baseTypes.ts +12 -0
- package/src/contracts/collectionTypes.ts +44 -0
- package/src/contracts/credentialTypes.ts +23 -1
- package/src/contracts/executionPersistenceContracts.ts +30 -0
- package/src/contracts/index.ts +4 -0
- package/src/contracts/runTypes.ts +37 -1
- package/src/contracts/runtimeTypes.ts +42 -0
- package/src/contracts/telemetryTypes.ts +8 -0
- package/src/contracts/testTriggerTypes.ts +66 -0
- package/src/contracts/workflowTypes.ts +36 -7
- package/src/contracts.ts +59 -0
- package/src/events/ConnectionInvocationEventPublisher.ts +46 -0
- package/src/events/index.ts +1 -0
- package/src/events/runEvents.ts +74 -0
- package/src/execution/ChildExecutionScopeFactory.ts +55 -0
- package/src/execution/DefaultExecutionContextFactory.ts +6 -0
- package/src/execution/ExecutionTelemetryCostTrackingDecoratorFactory.ts +18 -0
- package/src/execution/NodeExecutor.ts +10 -2
- package/src/execution/NodeInstanceFactory.ts +13 -1
- package/src/execution/NodeInstantiationError.ts +16 -0
- package/src/execution/NodeRunStateWriter.ts +7 -0
- package/src/execution/NodeRunStateWriterFactory.ts +7 -0
- package/src/execution/WorkflowRunExecutionContextFactory.ts +3 -0
- package/src/execution/index.ts +2 -0
- package/src/index.ts +8 -0
- package/src/orchestration/AbortControllerFactory.ts +9 -0
- package/src/orchestration/NodeExecutionRequestHandlerService.ts +1 -0
- package/src/orchestration/RunContinuationService.ts +3 -0
- package/src/orchestration/RunStartService.ts +122 -3
- package/src/orchestration/TestSuiteOrchestrator.ts +350 -0
- package/src/orchestration/TestSuiteRunIdFactory.ts +11 -0
- package/src/orchestration/TriggerRuntimeService.ts +34 -7
- package/src/orchestration/index.ts +9 -0
- package/src/runtime/EngineFactory.ts +12 -0
- package/src/triggers/polling/PollingTriggerDedupWindow.ts +23 -0
- package/src/triggers/polling/PollingTriggerLogger.ts +18 -0
- package/src/triggers/polling/PollingTriggerRuntime.ts +122 -0
- package/src/triggers/polling/index.ts +5 -0
- package/src/types/index.ts +12 -9
- package/src/workflow/definition/NodeIterationIdFactory.ts +26 -0
- package/src/workflow/dsl/NodeIdSlugifier.ts +18 -0
- package/src/workflow/dsl/WorkflowBuilder.ts +71 -3
- package/src/workflow/dsl/WorkflowDefinitionError.ts +15 -0
- package/src/workflow/index.ts +3 -0
- package/dist/InMemoryRunEventBusRegistry-B0_C4OnP.cjs +0 -262
- package/dist/InMemoryRunEventBusRegistry-B0_C4OnP.cjs.map +0 -1
- package/dist/InMemoryRunEventBusRegistry-C2U83Hmv.js +0 -238
- package/dist/InMemoryRunEventBusRegistry-C2U83Hmv.js.map +0 -1
- package/dist/bootstrap-BoknFKnw.js.map +0 -1
- package/dist/bootstrap-Bx1u4cbS.cjs.map +0 -1
- package/dist/runtime-DUW6tIJ1.js.map +0 -1
- package/dist/runtime-Dvo2ru5A.cjs.map +0 -1
|
@@ -0,0 +1,350 @@
|
|
|
1
|
+
import type { CredentialResolverFactory } from "../execution/CredentialResolverFactory";
|
|
2
|
+
import type { RunEventBus, TestCaseRunStatus, TestSuiteRunStatus } from "../events/runEvents";
|
|
3
|
+
import type {
|
|
4
|
+
Item,
|
|
5
|
+
Items,
|
|
6
|
+
NodeId,
|
|
7
|
+
ParentExecutionRef,
|
|
8
|
+
RunExecutionOptions,
|
|
9
|
+
RunId,
|
|
10
|
+
RunResult,
|
|
11
|
+
TestSuiteRunId,
|
|
12
|
+
TestTriggerNodeConfig,
|
|
13
|
+
TestTriggerSetupContext,
|
|
14
|
+
TriggerNodeConfig,
|
|
15
|
+
WorkflowDefinition,
|
|
16
|
+
WorkflowId,
|
|
17
|
+
} from "../types";
|
|
18
|
+
|
|
19
|
+
import type { AbortControllerFactory } from "./AbortControllerFactory";
|
|
20
|
+
import { TestSuiteRunIdFactory } from "./TestSuiteRunIdFactory";
|
|
21
|
+
|
|
22
|
+
const DEFAULT_CONCURRENCY = 4;
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Engine-facade subset the orchestrator needs. Kept narrow on purpose so unit tests can
|
|
26
|
+
* substitute a fake without depending on the full Engine wiring.
|
|
27
|
+
*/
|
|
28
|
+
export interface TestSuiteOrchestratorEngine {
|
|
29
|
+
runWorkflow(
|
|
30
|
+
wf: WorkflowDefinition,
|
|
31
|
+
startAt: NodeId,
|
|
32
|
+
items: Items,
|
|
33
|
+
parent?: ParentExecutionRef,
|
|
34
|
+
executionOptions?: RunExecutionOptions,
|
|
35
|
+
): Promise<RunResult>;
|
|
36
|
+
waitForCompletion(runId: RunId): Promise<Extract<RunResult, { status: "completed" | "failed" }>>;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface TestSuiteCaseOutcome {
|
|
40
|
+
readonly testCaseIndex: number;
|
|
41
|
+
readonly runId: RunId;
|
|
42
|
+
readonly status: TestCaseRunStatus;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export interface TestSuiteRunResult {
|
|
46
|
+
readonly testSuiteRunId: TestSuiteRunId;
|
|
47
|
+
readonly workflowId: WorkflowId;
|
|
48
|
+
readonly triggerNodeId: NodeId;
|
|
49
|
+
readonly status: TestSuiteRunStatus;
|
|
50
|
+
readonly totalCases: number;
|
|
51
|
+
readonly passedCases: number;
|
|
52
|
+
readonly failedCases: number;
|
|
53
|
+
readonly cases: ReadonlyArray<TestSuiteCaseOutcome>;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface RunTestSuiteArgs {
|
|
57
|
+
readonly workflow: WorkflowDefinition;
|
|
58
|
+
readonly triggerNodeId: NodeId;
|
|
59
|
+
readonly testSuiteRunId?: TestSuiteRunId;
|
|
60
|
+
readonly concurrency?: number;
|
|
61
|
+
readonly signal?: AbortSignal;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Drives a {@link TestTriggerNodeConfig.generateItems} iterable into one workflow run per item,
|
|
66
|
+
* with bounded concurrency. Pure engine logic — no persistence, no HTTP, no UI. Hosts adapt by
|
|
67
|
+
* subscribing to {@link RunEventBus} and writing rows on `testSuite*` / `testCase*` / `nodeCompleted`.
|
|
68
|
+
*
|
|
69
|
+
* Cancellation: the supplied `AbortSignal` aborts the source iterable (so credentialed pulls bail)
|
|
70
|
+
* and stops scheduling further cases. In-flight cases are awaited; engine-level cancellation of
|
|
71
|
+
* an already-dispatched run is not yet wired (Phase 2).
|
|
72
|
+
*/
|
|
73
|
+
export class TestSuiteOrchestrator {
|
|
74
|
+
constructor(
|
|
75
|
+
private readonly engine: TestSuiteOrchestratorEngine,
|
|
76
|
+
private readonly testSuiteRunIdFactory: TestSuiteRunIdFactory,
|
|
77
|
+
private readonly credentialResolverFactory: CredentialResolverFactory,
|
|
78
|
+
private readonly abortControllerFactory: AbortControllerFactory,
|
|
79
|
+
private readonly eventBus: RunEventBus | undefined,
|
|
80
|
+
private readonly currentDate: () => Date = () => new Date(),
|
|
81
|
+
) {}
|
|
82
|
+
|
|
83
|
+
async runSuite(args: RunTestSuiteArgs): Promise<TestSuiteRunResult> {
|
|
84
|
+
const triggerNodeId = args.triggerNodeId;
|
|
85
|
+
const definition = args.workflow.nodes.find((n) => n.id === triggerNodeId);
|
|
86
|
+
if (!definition) {
|
|
87
|
+
throw new Error(`Unknown trigger nodeId: ${triggerNodeId}`);
|
|
88
|
+
}
|
|
89
|
+
if (definition.kind !== "trigger") {
|
|
90
|
+
throw new Error(`Node ${triggerNodeId} is not a trigger`);
|
|
91
|
+
}
|
|
92
|
+
const triggerConfig = definition.config as TriggerNodeConfig;
|
|
93
|
+
if (triggerConfig.triggerKind !== "test") {
|
|
94
|
+
throw new Error(
|
|
95
|
+
`Node ${triggerNodeId} is not a test trigger (triggerKind="${triggerConfig.triggerKind ?? "live"}")`,
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
const testTriggerConfig = triggerConfig as TestTriggerNodeConfig<unknown>;
|
|
99
|
+
if (typeof testTriggerConfig.generateItems !== "function") {
|
|
100
|
+
throw new Error(`Test trigger ${triggerNodeId} is missing a generateItems implementation`);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
const testSuiteRunId = args.testSuiteRunId ?? this.testSuiteRunIdFactory.makeTestSuiteRunId();
|
|
104
|
+
const concurrency = Math.max(1, args.concurrency ?? testTriggerConfig.concurrency ?? DEFAULT_CONCURRENCY);
|
|
105
|
+
const externalSignal = args.signal;
|
|
106
|
+
const internalAbort = this.abortControllerFactory.create();
|
|
107
|
+
const onExternalAbort = () => internalAbort.abort(externalSignal?.reason);
|
|
108
|
+
if (externalSignal) {
|
|
109
|
+
if (externalSignal.aborted) {
|
|
110
|
+
internalAbort.abort(externalSignal.reason);
|
|
111
|
+
} else {
|
|
112
|
+
externalSignal.addEventListener("abort", onExternalAbort, { once: true });
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const triggerNodeName = definition.name ?? testTriggerConfig.name;
|
|
117
|
+
|
|
118
|
+
await this.publish({
|
|
119
|
+
kind: "testSuiteStarted",
|
|
120
|
+
testSuiteRunId,
|
|
121
|
+
workflowId: args.workflow.id,
|
|
122
|
+
triggerNodeId,
|
|
123
|
+
...(triggerNodeName ? { triggerNodeName } : {}),
|
|
124
|
+
concurrency,
|
|
125
|
+
at: this.now(),
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
const setupContext: TestTriggerSetupContext = {
|
|
129
|
+
workflowId: args.workflow.id,
|
|
130
|
+
nodeId: triggerNodeId,
|
|
131
|
+
config: testTriggerConfig,
|
|
132
|
+
testSuiteRunId,
|
|
133
|
+
getCredential: this.credentialResolverFactory.create(args.workflow.id, triggerNodeId, testTriggerConfig),
|
|
134
|
+
signal: internalAbort.signal,
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
const cases: TestSuiteCaseOutcome[] = [];
|
|
138
|
+
let nextIndex = 0;
|
|
139
|
+
let inFlight = 0;
|
|
140
|
+
let waitForSlot: Promise<void> | undefined;
|
|
141
|
+
let releaseSlot: (() => void) | undefined;
|
|
142
|
+
const queue: Array<Promise<void>> = [];
|
|
143
|
+
let generationError: Error | undefined;
|
|
144
|
+
|
|
145
|
+
const acquireSlot = async (): Promise<void> => {
|
|
146
|
+
while (inFlight >= concurrency) {
|
|
147
|
+
if (!waitForSlot) {
|
|
148
|
+
waitForSlot = new Promise<void>((resolve) => {
|
|
149
|
+
releaseSlot = resolve;
|
|
150
|
+
});
|
|
151
|
+
}
|
|
152
|
+
await waitForSlot;
|
|
153
|
+
}
|
|
154
|
+
inFlight += 1;
|
|
155
|
+
};
|
|
156
|
+
|
|
157
|
+
const release = (): void => {
|
|
158
|
+
inFlight -= 1;
|
|
159
|
+
if (releaseSlot) {
|
|
160
|
+
const fn = releaseSlot;
|
|
161
|
+
releaseSlot = undefined;
|
|
162
|
+
waitForSlot = undefined;
|
|
163
|
+
fn();
|
|
164
|
+
}
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
try {
|
|
168
|
+
for await (const item of testTriggerConfig.generateItems(setupContext) as AsyncIterable<Item<unknown>>) {
|
|
169
|
+
if (internalAbort.signal.aborted) {
|
|
170
|
+
break;
|
|
171
|
+
}
|
|
172
|
+
await acquireSlot();
|
|
173
|
+
if (internalAbort.signal.aborted) {
|
|
174
|
+
release();
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
const testCaseIndex = nextIndex++;
|
|
178
|
+
const testCaseLabel = this.resolveCaseLabel(testTriggerConfig, item);
|
|
179
|
+
queue.push(
|
|
180
|
+
this.runOneCase({
|
|
181
|
+
workflow: args.workflow,
|
|
182
|
+
triggerNodeId,
|
|
183
|
+
testSuiteRunId,
|
|
184
|
+
testCaseIndex,
|
|
185
|
+
testCaseLabel,
|
|
186
|
+
item,
|
|
187
|
+
})
|
|
188
|
+
.then((outcome) => {
|
|
189
|
+
cases.push(outcome);
|
|
190
|
+
})
|
|
191
|
+
.finally(release),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
} catch (err) {
|
|
195
|
+
generationError = err instanceof Error ? err : new Error(String(err));
|
|
196
|
+
} finally {
|
|
197
|
+
if (externalSignal) {
|
|
198
|
+
externalSignal.removeEventListener("abort", onExternalAbort);
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
await Promise.all(queue);
|
|
203
|
+
|
|
204
|
+
cases.sort((a, b) => a.testCaseIndex - b.testCaseIndex);
|
|
205
|
+
const totalCases = cases.length;
|
|
206
|
+
const passedCases = cases.filter((c) => c.status === "succeeded").length;
|
|
207
|
+
const failedCases = cases.filter((c) => c.status === "failed").length;
|
|
208
|
+
const status: TestSuiteRunStatus = this.deriveSuiteStatus({
|
|
209
|
+
generationError,
|
|
210
|
+
cancelled: internalAbort.signal.aborted,
|
|
211
|
+
totalCases,
|
|
212
|
+
passedCases,
|
|
213
|
+
failedCases,
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
await this.publish({
|
|
217
|
+
kind: "testSuiteFinished",
|
|
218
|
+
testSuiteRunId,
|
|
219
|
+
workflowId: args.workflow.id,
|
|
220
|
+
status,
|
|
221
|
+
totalCases,
|
|
222
|
+
passedCases,
|
|
223
|
+
failedCases,
|
|
224
|
+
at: this.now(),
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
if (generationError && status === "errored") {
|
|
228
|
+
throw generationError;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
return {
|
|
232
|
+
testSuiteRunId,
|
|
233
|
+
workflowId: args.workflow.id,
|
|
234
|
+
triggerNodeId,
|
|
235
|
+
status,
|
|
236
|
+
totalCases,
|
|
237
|
+
passedCases,
|
|
238
|
+
failedCases,
|
|
239
|
+
cases,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
private async runOneCase(args: {
|
|
244
|
+
workflow: WorkflowDefinition;
|
|
245
|
+
triggerNodeId: NodeId;
|
|
246
|
+
testSuiteRunId: TestSuiteRunId;
|
|
247
|
+
testCaseIndex: number;
|
|
248
|
+
testCaseLabel: string | undefined;
|
|
249
|
+
item: Item<unknown>;
|
|
250
|
+
}): Promise<TestSuiteCaseOutcome> {
|
|
251
|
+
const executionOptions: RunExecutionOptions = {
|
|
252
|
+
testContext: {
|
|
253
|
+
testSuiteRunId: args.testSuiteRunId,
|
|
254
|
+
testCaseIndex: args.testCaseIndex,
|
|
255
|
+
...(args.testCaseLabel !== undefined ? { testCaseLabel: args.testCaseLabel } : {}),
|
|
256
|
+
},
|
|
257
|
+
};
|
|
258
|
+
|
|
259
|
+
const initial = await this.engine.runWorkflow(
|
|
260
|
+
args.workflow,
|
|
261
|
+
args.triggerNodeId,
|
|
262
|
+
[args.item],
|
|
263
|
+
undefined,
|
|
264
|
+
executionOptions,
|
|
265
|
+
);
|
|
266
|
+
|
|
267
|
+
const runId = initial.runId;
|
|
268
|
+
await this.publish({
|
|
269
|
+
kind: "testCaseStarted",
|
|
270
|
+
testSuiteRunId: args.testSuiteRunId,
|
|
271
|
+
testCaseIndex: args.testCaseIndex,
|
|
272
|
+
runId,
|
|
273
|
+
workflowId: args.workflow.id,
|
|
274
|
+
at: this.now(),
|
|
275
|
+
...(args.testCaseLabel !== undefined ? { testCaseLabel: args.testCaseLabel } : {}),
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
let terminal: Extract<RunResult, { status: "completed" | "failed" }>;
|
|
279
|
+
if (initial.status === "completed" || initial.status === "failed") {
|
|
280
|
+
terminal = initial;
|
|
281
|
+
} else {
|
|
282
|
+
terminal = await this.engine.waitForCompletion(runId);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// RunResult.status from the engine narrows to "completed" | "failed" here; widening to
|
|
286
|
+
// "errored" / "cancelled" happens outside this code path (tracker downgrade for assertion
|
|
287
|
+
// failures; outer abort handling for cancelled).
|
|
288
|
+
const status: TestCaseRunStatus = terminal.status === "completed" ? "succeeded" : "failed";
|
|
289
|
+
await this.publish({
|
|
290
|
+
kind: "testCaseCompleted",
|
|
291
|
+
testSuiteRunId: args.testSuiteRunId,
|
|
292
|
+
testCaseIndex: args.testCaseIndex,
|
|
293
|
+
runId,
|
|
294
|
+
workflowId: args.workflow.id,
|
|
295
|
+
status,
|
|
296
|
+
at: this.now(),
|
|
297
|
+
});
|
|
298
|
+
return { testCaseIndex: args.testCaseIndex, runId, status };
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
private deriveSuiteStatus(args: {
|
|
302
|
+
generationError: Error | undefined;
|
|
303
|
+
cancelled: boolean;
|
|
304
|
+
totalCases: number;
|
|
305
|
+
passedCases: number;
|
|
306
|
+
failedCases: number;
|
|
307
|
+
}): TestSuiteRunStatus {
|
|
308
|
+
if (args.generationError && args.totalCases === 0) {
|
|
309
|
+
return "errored";
|
|
310
|
+
}
|
|
311
|
+
if (args.cancelled) {
|
|
312
|
+
return "cancelled";
|
|
313
|
+
}
|
|
314
|
+
if (args.generationError) {
|
|
315
|
+
return "errored";
|
|
316
|
+
}
|
|
317
|
+
if (args.totalCases === 0) {
|
|
318
|
+
return "succeeded";
|
|
319
|
+
}
|
|
320
|
+
if (args.failedCases === 0) {
|
|
321
|
+
return "succeeded";
|
|
322
|
+
}
|
|
323
|
+
if (args.passedCases === 0) {
|
|
324
|
+
return "failed";
|
|
325
|
+
}
|
|
326
|
+
return "partial";
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
private now(): string {
|
|
330
|
+
return this.currentDate().toISOString();
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/** Defensive label resolver — author-supplied callbacks throw / return non-strings; we tolerate both. */
|
|
334
|
+
private resolveCaseLabel(config: TestTriggerNodeConfig<unknown>, item: Item<unknown>): string | undefined {
|
|
335
|
+
if (typeof config.caseLabel !== "function") return undefined;
|
|
336
|
+
try {
|
|
337
|
+
const result = config.caseLabel(item);
|
|
338
|
+
if (typeof result !== "string") return undefined;
|
|
339
|
+
const trimmed = result.trim();
|
|
340
|
+
return trimmed.length === 0 ? undefined : trimmed;
|
|
341
|
+
} catch {
|
|
342
|
+
return undefined;
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
private async publish(event: Parameters<RunEventBus["publish"]>[0]): Promise<void> {
|
|
347
|
+
if (!this.eventBus) return;
|
|
348
|
+
await this.eventBus.publish(event);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { TestSuiteRunId } from "../contracts/testTriggerTypes";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Mints unique TestSuiteRun identifiers. Separated from {@link import("../types").RunIdFactory}
|
|
5
|
+
* so suite ids and per-case workflow run ids never alias.
|
|
6
|
+
*/
|
|
7
|
+
export class TestSuiteRunIdFactory {
|
|
8
|
+
makeTestSuiteRunId(): TestSuiteRunId {
|
|
9
|
+
return `tsr_${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 10)}`;
|
|
10
|
+
}
|
|
11
|
+
}
|
|
@@ -24,6 +24,9 @@ import type {
|
|
|
24
24
|
import { CredentialResolverFactory } from "../execution/CredentialResolverFactory";
|
|
25
25
|
import type { NodeRunStateWriterFactory } from "../execution/NodeRunStateWriterFactory";
|
|
26
26
|
import type { EngineExecutionLimitsPolicy } from "../policies/executionLimits/EngineExecutionLimitsPolicy";
|
|
27
|
+
import type { PollingTriggerRuntime } from "../triggers/polling/PollingTriggerRuntime";
|
|
28
|
+
import type { PollingTriggerDedupWindow } from "../triggers/polling/PollingTriggerDedupWindow";
|
|
29
|
+
import type { PollingTriggerHandle } from "../contracts/runtimeTypes";
|
|
27
30
|
|
|
28
31
|
export interface TriggerEmitHandler {
|
|
29
32
|
emit(workflow: WorkflowDefinition, triggerNodeId: NodeId, items: Items): Promise<void>;
|
|
@@ -45,7 +48,9 @@ export class TriggerRuntimeService {
|
|
|
45
48
|
private readonly triggerSetupStateRepository: TriggerSetupStateRepository,
|
|
46
49
|
private readonly emitHandler: TriggerEmitHandler,
|
|
47
50
|
private readonly executionLimitsPolicy: EngineExecutionLimitsPolicy,
|
|
48
|
-
private readonly diagnostics
|
|
51
|
+
private readonly diagnostics: TriggerRuntimeDiagnostics | undefined,
|
|
52
|
+
private readonly pollingTriggerRuntime: PollingTriggerRuntime,
|
|
53
|
+
private readonly pollingTriggerDedupWindow: PollingTriggerDedupWindow,
|
|
49
54
|
) {
|
|
50
55
|
this.credentialResolverFactory = credentialResolverFactory;
|
|
51
56
|
}
|
|
@@ -124,12 +129,16 @@ export class TriggerRuntimeService {
|
|
|
124
129
|
private async startTriggersForWorkflow(wf: WorkflowDefinition): Promise<void> {
|
|
125
130
|
for (const def of wf.nodes) {
|
|
126
131
|
if (def.kind !== "trigger") continue;
|
|
132
|
+
if ((def.config as TriggerNodeConfig).triggerKind === "test") continue;
|
|
127
133
|
const node = this.nodeResolver.resolve(def.type) as TriggerNode;
|
|
128
134
|
const data = this.runDataFactory.create();
|
|
129
135
|
const triggerRunId = this.runIdFactory.makeRunId();
|
|
130
136
|
const trigger = { workflowId: wf.id, nodeId: def.id } as const;
|
|
131
137
|
await this.stopTrigger(trigger);
|
|
132
138
|
const previousState = await this.triggerSetupStateRepository.load(trigger);
|
|
139
|
+
const emit = this.emitHandler.emit.bind(this.emitHandler, wf, def.id);
|
|
140
|
+
const registerCleanup = this.registerTriggerCleanupHandle.bind(this, trigger);
|
|
141
|
+
const polling = this.buildPollingHandle(trigger, emit);
|
|
133
142
|
let nextState: unknown;
|
|
134
143
|
try {
|
|
135
144
|
nextState = await node.setup({
|
|
@@ -142,12 +151,9 @@ export class TriggerRuntimeService {
|
|
|
142
151
|
trigger,
|
|
143
152
|
config: def.config as TriggerNodeConfig,
|
|
144
153
|
previousState: previousState?.state as never,
|
|
145
|
-
registerCleanup
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
emit: async (items) => {
|
|
149
|
-
await this.emitHandler.emit(wf, def.id, items);
|
|
150
|
-
},
|
|
154
|
+
registerCleanup,
|
|
155
|
+
emit,
|
|
156
|
+
polling,
|
|
151
157
|
} satisfies TriggerSetupContext<TriggerNodeConfig>);
|
|
152
158
|
} catch (triggerError: unknown) {
|
|
153
159
|
await this.stopTrigger(trigger);
|
|
@@ -172,6 +178,9 @@ export class TriggerRuntimeService {
|
|
|
172
178
|
if (node.kind !== "trigger") {
|
|
173
179
|
continue;
|
|
174
180
|
}
|
|
181
|
+
if ((node.config as TriggerNodeConfig).triggerKind === "test") {
|
|
182
|
+
continue;
|
|
183
|
+
}
|
|
175
184
|
await this.stopTrigger({
|
|
176
185
|
workflowId: workflow.id,
|
|
177
186
|
nodeId: node.id,
|
|
@@ -226,6 +235,9 @@ export class TriggerRuntimeService {
|
|
|
226
235
|
if (def.kind !== "trigger") {
|
|
227
236
|
continue;
|
|
228
237
|
}
|
|
238
|
+
if ((def.config as TriggerNodeConfig).triggerKind === "test") {
|
|
239
|
+
continue;
|
|
240
|
+
}
|
|
229
241
|
out.push(this.describeTriggerNode(def));
|
|
230
242
|
}
|
|
231
243
|
return out;
|
|
@@ -254,6 +266,21 @@ export class TriggerRuntimeService {
|
|
|
254
266
|
}
|
|
255
267
|
}
|
|
256
268
|
|
|
269
|
+
private buildPollingHandle(trigger: TriggerInstanceId, emit: (items: Items) => Promise<void>): PollingTriggerHandle {
|
|
270
|
+
const runtime = this.pollingTriggerRuntime;
|
|
271
|
+
return {
|
|
272
|
+
dedup: this.pollingTriggerDedupWindow,
|
|
273
|
+
start: async (args) => {
|
|
274
|
+
this.registerTriggerCleanupHandle(trigger, {
|
|
275
|
+
stop: async () => {
|
|
276
|
+
await runtime.stop(trigger);
|
|
277
|
+
},
|
|
278
|
+
});
|
|
279
|
+
return runtime.start({ trigger, emit, ...args });
|
|
280
|
+
},
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
|
|
257
284
|
private isTestableTriggerNode(node: TriggerNode): node is TestableTriggerNode<TriggerNodeConfig> {
|
|
258
285
|
return typeof (node as Partial<TestableTriggerNode<TriggerNodeConfig>>).getTestItems === "function";
|
|
259
286
|
}
|
|
@@ -1,5 +1,14 @@
|
|
|
1
|
+
export { AbortControllerFactory } from "./AbortControllerFactory";
|
|
1
2
|
export { Engine } from "./Engine";
|
|
2
3
|
export { EngineWaiters } from "./EngineWaiters";
|
|
3
4
|
export { RunContinuationService } from "./RunContinuationService";
|
|
4
5
|
export { RunStartService } from "./RunStartService";
|
|
6
|
+
export { TestSuiteOrchestrator } from "./TestSuiteOrchestrator";
|
|
7
|
+
export type {
|
|
8
|
+
TestSuiteOrchestratorEngine,
|
|
9
|
+
TestSuiteRunResult,
|
|
10
|
+
TestSuiteCaseOutcome,
|
|
11
|
+
RunTestSuiteArgs,
|
|
12
|
+
} from "./TestSuiteOrchestrator";
|
|
13
|
+
export { TestSuiteRunIdFactory } from "./TestSuiteRunIdFactory";
|
|
5
14
|
export { TriggerRuntimeService } from "./TriggerRuntimeService";
|
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type { EngineDeps } from "../types";
|
|
2
2
|
|
|
3
|
+
import { PollingTriggerDedupWindow } from "../triggers/polling/PollingTriggerDedupWindow";
|
|
4
|
+
import { PollingTriggerRuntime } from "../triggers/polling/PollingTriggerRuntime";
|
|
5
|
+
import { NoOpPollingTriggerLogger } from "../triggers/polling/PollingTriggerLogger";
|
|
3
6
|
import { MissingRuntimeFallbacks } from "../workflowSnapshots/MissingRuntimeFallbacksFactory";
|
|
4
7
|
import { MissingRuntimeExecutionMarker } from "../workflowSnapshots/MissingRuntimeExecutionMarker";
|
|
5
8
|
import { WorkflowSnapshotCodec } from "../workflowSnapshots/WorkflowSnapshotCodec";
|
|
@@ -49,6 +52,7 @@ export class EngineFactory {
|
|
|
49
52
|
const nodeStatePublisherFactory = new NodeRunStateWriterFactory(
|
|
50
53
|
deps.workflowExecutionRepository,
|
|
51
54
|
nodeEventPublisher,
|
|
55
|
+
deps.eventBus,
|
|
52
56
|
);
|
|
53
57
|
const planningFactory = new EngineWorkflowPlanningFactory(deps.workflowNodeInstanceFactory);
|
|
54
58
|
const executionLimitsPolicy = deps.executionLimitsPolicy ?? new EngineExecutionLimitsPolicy();
|
|
@@ -99,6 +103,8 @@ export class EngineFactory {
|
|
|
99
103
|
waiters,
|
|
100
104
|
deps.workflowPolicyRuntimeDefaults,
|
|
101
105
|
executionLimitsPolicy,
|
|
106
|
+
nodeEventPublisher,
|
|
107
|
+
persistedRunStateTerminalBuilder,
|
|
102
108
|
);
|
|
103
109
|
const runContinuationService = new RunContinuationService(
|
|
104
110
|
deps.activationIdFactory,
|
|
@@ -131,6 +137,10 @@ export class EngineFactory {
|
|
|
131
137
|
executionLimitsPolicy,
|
|
132
138
|
);
|
|
133
139
|
|
|
140
|
+
const pollingTriggerLogger = deps.pollingTriggerLogger ?? new NoOpPollingTriggerLogger();
|
|
141
|
+
const pollingTriggerDedupWindow = new PollingTriggerDedupWindow();
|
|
142
|
+
const pollingTriggerRuntime = new PollingTriggerRuntime(deps.triggerSetupStateRepository, pollingTriggerLogger);
|
|
143
|
+
|
|
134
144
|
const triggerRuntime = new TriggerRuntimeService(
|
|
135
145
|
deps.workflowRepository,
|
|
136
146
|
deps.workflowActivationPolicy,
|
|
@@ -148,6 +158,8 @@ export class EngineFactory {
|
|
|
148
158
|
},
|
|
149
159
|
executionLimitsPolicy,
|
|
150
160
|
deps.triggerRuntimeDiagnostics,
|
|
161
|
+
pollingTriggerRuntime,
|
|
162
|
+
pollingTriggerDedupWindow,
|
|
151
163
|
);
|
|
152
164
|
|
|
153
165
|
const engine = new Engine({
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Merges processed-ID windows for polling triggers, capping the total to avoid unbounded growth.
|
|
3
|
+
* Plugin code receives an instance of this class via {@link PollingTriggerHandle.dedup}.
|
|
4
|
+
*/
|
|
5
|
+
export class PollingTriggerDedupWindow {
|
|
6
|
+
static readonly defaultCapN = 2000;
|
|
7
|
+
|
|
8
|
+
merge(
|
|
9
|
+
previous: ReadonlyArray<string>,
|
|
10
|
+
incoming: ReadonlyArray<string>,
|
|
11
|
+
capN: number = PollingTriggerDedupWindow.defaultCapN,
|
|
12
|
+
): ReadonlyArray<string> {
|
|
13
|
+
const merged = new Set(previous);
|
|
14
|
+
for (const id of incoming) {
|
|
15
|
+
merged.add(id);
|
|
16
|
+
}
|
|
17
|
+
const result = [...merged];
|
|
18
|
+
if (result.length <= capN) {
|
|
19
|
+
return result;
|
|
20
|
+
}
|
|
21
|
+
return result.slice(result.length - capN);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal logger surface for the polling-trigger runtime.
|
|
3
|
+
* Hosts supply this via {@link EngineDeps.pollingTriggerLogger};
|
|
4
|
+
* when absent the runtime is silent.
|
|
5
|
+
*/
|
|
6
|
+
export interface PollingTriggerLogger {
|
|
7
|
+
info(message: string): void;
|
|
8
|
+
warn(message: string): void;
|
|
9
|
+
error(message: string, exception?: Error): void;
|
|
10
|
+
debug(message: string): void;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export class NoOpPollingTriggerLogger implements PollingTriggerLogger {
|
|
14
|
+
info(): void {}
|
|
15
|
+
warn(): void {}
|
|
16
|
+
error(): void {}
|
|
17
|
+
debug(): void {}
|
|
18
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
import type { Items, TriggerInstanceId, TriggerSetupStateRepository } from "../../types";
|
|
2
|
+
import type { PollingTriggerLogger } from "./PollingTriggerLogger";
|
|
3
|
+
|
|
4
|
+
export interface PollingRunCycleArgs<TState> {
|
|
5
|
+
previousState: TState | undefined;
|
|
6
|
+
signal: AbortSignal;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export interface PollingRunCycleResult<TState, TItem> {
|
|
10
|
+
items: Items<TItem>;
|
|
11
|
+
nextState: TState;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface PollingTriggerStartArgs<TState, TItem> {
|
|
15
|
+
trigger: TriggerInstanceId;
|
|
16
|
+
intervalMs: number;
|
|
17
|
+
seedState?: TState;
|
|
18
|
+
runCycle: (cycleCtx: PollingRunCycleArgs<TState>) => Promise<PollingRunCycleResult<TState, TItem>>;
|
|
19
|
+
emit: (items: Items) => Promise<void>;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Generic polling-trigger runtime. Owns the set-interval loop, overlap guard, and persistence.
|
|
24
|
+
* Constructed by {@link import("../../runtime/EngineFactory").EngineFactory} and exposed to plugin
|
|
25
|
+
* authors via {@link import("../../contracts/runtimeTypes").TriggerSetupContext}.polling.
|
|
26
|
+
*/
|
|
27
|
+
export class PollingTriggerRuntime {
|
|
28
|
+
private readonly activeTriggers = new Set<string>();
|
|
29
|
+
private readonly intervalsByTrigger = new Map<string, ReturnType<typeof setInterval>>();
|
|
30
|
+
private readonly busyTriggers = new Set<string>();
|
|
31
|
+
|
|
32
|
+
constructor(
|
|
33
|
+
private readonly triggerSetupStateRepository: TriggerSetupStateRepository,
|
|
34
|
+
private readonly logger: PollingTriggerLogger,
|
|
35
|
+
) {}
|
|
36
|
+
|
|
37
|
+
async start<TState, TItem>(args: PollingTriggerStartArgs<TState, TItem>): Promise<TState | undefined> {
|
|
38
|
+
let first: TState | undefined;
|
|
39
|
+
try {
|
|
40
|
+
first = await this.runCycle(args, { seedState: args.seedState });
|
|
41
|
+
} catch (err: unknown) {
|
|
42
|
+
this.logError(`Polling trigger initial cycle failed for ${this.describe(args.trigger)}`, err);
|
|
43
|
+
}
|
|
44
|
+
this.ensureLoop(args);
|
|
45
|
+
return first;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async stop(trigger: TriggerInstanceId): Promise<void> {
|
|
49
|
+
const key = this.toKey(trigger);
|
|
50
|
+
const interval = this.intervalsByTrigger.get(key);
|
|
51
|
+
if (interval !== undefined) {
|
|
52
|
+
clearInterval(interval);
|
|
53
|
+
this.intervalsByTrigger.delete(key);
|
|
54
|
+
}
|
|
55
|
+
this.busyTriggers.delete(key);
|
|
56
|
+
this.activeTriggers.delete(key);
|
|
57
|
+
this.logger.debug(`Polling trigger stopped for ${this.describe(trigger)}`);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
private ensureLoop<TState, TItem>(args: PollingTriggerStartArgs<TState, TItem>): void {
|
|
61
|
+
const key = this.toKey(args.trigger);
|
|
62
|
+
if (this.activeTriggers.has(key)) {
|
|
63
|
+
this.logger.debug(`Polling trigger already active for ${this.describe(args.trigger)}`);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
this.activeTriggers.add(key);
|
|
67
|
+
const intervalMs = Math.max(args.intervalMs, 25);
|
|
68
|
+
const interval = setInterval(() => {
|
|
69
|
+
void this.runCycle(args, { seedState: undefined }).catch((err: unknown) => {
|
|
70
|
+
this.logError(`Polling trigger cycle failed for ${this.describe(args.trigger)}`, err);
|
|
71
|
+
});
|
|
72
|
+
}, intervalMs);
|
|
73
|
+
this.intervalsByTrigger.set(key, interval);
|
|
74
|
+
this.logger.info(`Polling trigger started for ${this.describe(args.trigger)} (interval ${intervalMs}ms)`);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private async runCycle<TState, TItem>(
|
|
78
|
+
args: PollingTriggerStartArgs<TState, TItem>,
|
|
79
|
+
opts: { seedState: TState | undefined },
|
|
80
|
+
): Promise<TState | undefined> {
|
|
81
|
+
const key = this.toKey(args.trigger);
|
|
82
|
+
if (this.busyTriggers.has(key)) {
|
|
83
|
+
this.logger.debug(`Polling trigger skipping overlapping tick for ${this.describe(args.trigger)}`);
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
this.busyTriggers.add(key);
|
|
87
|
+
try {
|
|
88
|
+
const loaded = await this.triggerSetupStateRepository.load(args.trigger);
|
|
89
|
+
const previousState = loaded !== undefined ? (loaded.state as TState | undefined) : opts.seedState;
|
|
90
|
+
const controller = new AbortController();
|
|
91
|
+
const { items, nextState } = await args.runCycle({ previousState, signal: controller.signal });
|
|
92
|
+
await this.triggerSetupStateRepository.save({
|
|
93
|
+
trigger: args.trigger,
|
|
94
|
+
updatedAt: new Date().toISOString(),
|
|
95
|
+
state: nextState as never,
|
|
96
|
+
});
|
|
97
|
+
if (items.length > 0) {
|
|
98
|
+
this.logger.info(`Polling trigger emitting ${items.length} item(s) for ${this.describe(args.trigger)}`);
|
|
99
|
+
await args.emit(items);
|
|
100
|
+
}
|
|
101
|
+
return nextState;
|
|
102
|
+
} finally {
|
|
103
|
+
this.busyTriggers.delete(key);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
private toKey(trigger: TriggerInstanceId): string {
|
|
108
|
+
return `${trigger.workflowId}:${trigger.nodeId}`;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
private describe(trigger: TriggerInstanceId): string {
|
|
112
|
+
return `${trigger.workflowId}.${trigger.nodeId}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
private logError(message: string, error: unknown): void {
|
|
116
|
+
if (error instanceof Error) {
|
|
117
|
+
this.logger.error(message, error);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
120
|
+
this.logger.error(`${message}: ${String(error)}`);
|
|
121
|
+
}
|
|
122
|
+
}
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export { PollingTriggerDedupWindow } from "./PollingTriggerDedupWindow";
|
|
2
|
+
export { PollingTriggerRuntime } from "./PollingTriggerRuntime";
|
|
3
|
+
export type { PollingTriggerLogger } from "./PollingTriggerLogger";
|
|
4
|
+
export { NoOpPollingTriggerLogger } from "./PollingTriggerLogger";
|
|
5
|
+
export type { PollingRunCycleArgs, PollingRunCycleResult, PollingTriggerStartArgs } from "./PollingTriggerRuntime";
|