@codemation/core 1.0.1 → 2.0.0

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.
Files changed (86) hide show
  1. package/CHANGELOG.md +293 -0
  2. package/dist/{EngineRuntimeRegistration.types-kxQA5NLt.d.ts → EngineRuntimeRegistration.types-D1fyApMI.d.ts} +2 -2
  3. package/dist/{EngineWorkflowRunnerService-Ba2AvBnL.d.cts → EngineRuntimeRegistration.types-pB3FnzqR.d.cts} +17 -17
  4. package/dist/{InMemoryRunDataFactory-Ou4tQUOS.d.cts → InMemoryRunDataFactory-Xw7v4-sj.d.cts} +31 -29
  5. package/dist/InMemoryRunEventBusRegistry-VM3OWnHo.cjs +47 -0
  6. package/dist/InMemoryRunEventBusRegistry-VM3OWnHo.cjs.map +1 -0
  7. package/dist/InMemoryRunEventBusRegistry-sM4z4n_i.js +41 -0
  8. package/dist/InMemoryRunEventBusRegistry-sM4z4n_i.js.map +1 -0
  9. package/dist/{RunIntentService-dteLjNiT.d.ts → RunIntentService-BE9CAkbf.d.ts} +602 -213
  10. package/dist/{RunIntentService-Dyh_dH0k.d.cts → RunIntentService-siBSjaaY.d.cts} +430 -125
  11. package/dist/bootstrap/index.cjs +5 -2
  12. package/dist/bootstrap/index.d.cts +212 -135
  13. package/dist/bootstrap/index.d.ts +4 -4
  14. package/dist/bootstrap/index.js +3 -3
  15. package/dist/{bootstrap-Cko6udwL.cjs → bootstrap-Cm5ruQxx.cjs} +253 -3
  16. package/dist/bootstrap-Cm5ruQxx.cjs.map +1 -0
  17. package/dist/{bootstrap-CL68rqWg.js → bootstrap-D3r505ko.js} +236 -4
  18. package/dist/bootstrap-D3r505ko.js.map +1 -0
  19. package/dist/{index-CyfGTfU1.d.ts → index-DeLl1Tne.d.ts} +574 -242
  20. package/dist/index.cjs +328 -180
  21. package/dist/index.cjs.map +1 -1
  22. package/dist/index.d.cts +441 -103
  23. package/dist/index.d.ts +3 -3
  24. package/dist/index.js +305 -163
  25. package/dist/index.js.map +1 -1
  26. package/dist/{runtime-284ok0cm.js → runtime-BGNbRnqs.js} +764 -75
  27. package/dist/runtime-BGNbRnqs.js.map +1 -0
  28. package/dist/{runtime-B3Og-_St.cjs → runtime-DKXJwTNv.cjs} +841 -80
  29. package/dist/runtime-DKXJwTNv.cjs.map +1 -0
  30. package/dist/testing.cjs +4 -4
  31. package/dist/testing.cjs.map +1 -1
  32. package/dist/testing.d.cts +2 -2
  33. package/dist/testing.d.ts +2 -2
  34. package/dist/testing.js +3 -3
  35. package/package.json +7 -2
  36. package/src/authoring/DefinedCollectionRegistry.ts +17 -0
  37. package/src/authoring/defineCollection.types.ts +181 -0
  38. package/src/authoring/definePollingTrigger.types.ts +396 -0
  39. package/src/authoring/definePollingTriggerInternals.ts +74 -0
  40. package/src/authoring/index.ts +19 -0
  41. package/src/bootstrap/index.ts +9 -0
  42. package/src/bootstrap/runtime/EngineRuntimeRegistrar.ts +5 -1
  43. package/src/contracts/assertionTypes.ts +63 -0
  44. package/src/contracts/baseTypes.ts +12 -0
  45. package/src/contracts/collectionTypes.ts +44 -0
  46. package/src/contracts/credentialTypes.ts +23 -1
  47. package/src/contracts/index.ts +4 -0
  48. package/src/contracts/runTypes.ts +27 -1
  49. package/src/contracts/runtimeTypes.ts +34 -0
  50. package/src/contracts/testTriggerTypes.ts +66 -0
  51. package/src/contracts/workflowTypes.ts +30 -7
  52. package/src/contracts.ts +59 -0
  53. package/src/events/runEvents.ts +49 -0
  54. package/src/execution/ChildExecutionScopeFactory.ts +4 -7
  55. package/src/execution/DefaultExecutionContextFactory.ts +6 -0
  56. package/src/execution/NodeInstanceFactory.ts +13 -1
  57. package/src/execution/NodeInstantiationError.ts +16 -0
  58. package/src/execution/WorkflowRunExecutionContextFactory.ts +3 -0
  59. package/src/execution/index.ts +1 -0
  60. package/src/index.ts +7 -0
  61. package/src/orchestration/AbortControllerFactory.ts +9 -0
  62. package/src/orchestration/NodeExecutionRequestHandlerService.ts +1 -0
  63. package/src/orchestration/RunContinuationService.ts +3 -0
  64. package/src/orchestration/RunStartService.ts +114 -2
  65. package/src/orchestration/TestSuiteOrchestrator.ts +350 -0
  66. package/src/orchestration/TestSuiteRunIdFactory.ts +11 -0
  67. package/src/orchestration/TriggerRuntimeService.ts +34 -7
  68. package/src/orchestration/index.ts +9 -0
  69. package/src/runtime/EngineFactory.ts +11 -0
  70. package/src/triggers/polling/PollingTriggerDedupWindow.ts +23 -0
  71. package/src/triggers/polling/PollingTriggerLogger.ts +18 -0
  72. package/src/triggers/polling/PollingTriggerRuntime.ts +122 -0
  73. package/src/triggers/polling/index.ts +5 -0
  74. package/src/types/index.ts +12 -9
  75. package/src/workflow/dsl/NodeIdSlugifier.ts +18 -0
  76. package/src/workflow/dsl/WorkflowBuilder.ts +71 -3
  77. package/src/workflow/dsl/WorkflowDefinitionError.ts +15 -0
  78. package/src/workflow/index.ts +2 -0
  79. package/dist/InMemoryRunEventBusRegistry-B0_C4OnP.cjs +0 -262
  80. package/dist/InMemoryRunEventBusRegistry-B0_C4OnP.cjs.map +0 -1
  81. package/dist/InMemoryRunEventBusRegistry-C2U83Hmv.js +0 -238
  82. package/dist/InMemoryRunEventBusRegistry-C2U83Hmv.js.map +0 -1
  83. package/dist/bootstrap-CL68rqWg.js.map +0 -1
  84. package/dist/bootstrap-Cko6udwL.cjs.map +0 -1
  85. package/dist/runtime-284ok0cm.js.map +0 -1
  86. package/dist/runtime-B3Og-_St.cjs.map +0 -1
@@ -34,8 +34,12 @@ import { RunPolicySnapshotFactory } from "../policies/storage/RunPolicySnapshotF
34
34
  import { ActivationEnqueueService } from "../execution/ActivationEnqueueService";
35
35
  import { NodeRunStateWriterFactory } from "../execution/NodeRunStateWriterFactory";
36
36
  import { NodeActivationRequestComposer } from "../execution/NodeActivationRequestComposer";
37
+ import { NodeExecutionSnapshotFactory } from "../execution/NodeExecutionSnapshotFactory";
38
+ import { NodeInstantiationError } from "../execution/NodeInstantiationError";
39
+ import { PersistedRunStateTerminalBuilder } from "../execution/PersistedRunStateTerminalBuilder";
37
40
  import { RunStateSemantics } from "../execution/RunStateSemantics";
38
41
  import { WorkflowRunExecutionContextFactory } from "../execution/WorkflowRunExecutionContextFactory";
42
+ import { NodeEventPublisher } from "../events/NodeEventPublisher";
39
43
 
40
44
  export class RunStartService {
41
45
  constructor(
@@ -52,6 +56,8 @@ export class RunStartService {
52
56
  private readonly waiters: EngineWaiters,
53
57
  private readonly workflowPolicyRuntimeDefaults: WorkflowPolicyRuntimeDefaults | undefined,
54
58
  private readonly executionLimitsPolicy: EngineExecutionLimitsPolicy,
59
+ private readonly nodeEventPublisher: NodeEventPublisher,
60
+ private readonly persistedRunStateTerminalBuilder: PersistedRunStateTerminalBuilder,
55
61
  ) {}
56
62
 
57
63
  async runWorkflow(
@@ -96,8 +102,29 @@ export class RunStartService {
96
102
  engineMaxSubworkflowDepth: mergedExecutionOptions.maxSubworkflowDepth!,
97
103
  data,
98
104
  nodeState: this.nodeStatePublisherFactory.create(runId, workflow.id, parent),
105
+ testContext: mergedExecutionOptions.testContext,
99
106
  });
100
- const { topology, planner } = this.planningFactory.create(workflow);
107
+ let planning: Readonly<ReturnType<EngineWorkflowPlanningFactory["create"]>>;
108
+ try {
109
+ planning = this.planningFactory.create(workflow);
110
+ } catch (err) {
111
+ if (err instanceof NodeInstantiationError) {
112
+ return await this.failRunDuringPlanning({
113
+ runId,
114
+ workflowId: workflow.id,
115
+ startedAt,
116
+ parent,
117
+ executionOptions: mergedExecutionOptions,
118
+ control: undefined,
119
+ workflowSnapshot,
120
+ mutableState,
121
+ policySnapshot,
122
+ err,
123
+ });
124
+ }
125
+ throw err;
126
+ }
127
+ const { topology, planner } = planning;
101
128
  const startDefinition = topology.defsById.get(startAt);
102
129
  if (!startDefinition) {
103
130
  throw new Error(`Unknown start nodeId: ${startAt}`);
@@ -183,7 +210,27 @@ export class RunStartService {
183
210
  engineCounters: { completedNodeActivations: 0 },
184
211
  });
185
212
 
186
- const { topology, planner } = this.planningFactory.create(request.workflow);
213
+ let planningFromState: Readonly<ReturnType<EngineWorkflowPlanningFactory["create"]>>;
214
+ try {
215
+ planningFromState = this.planningFactory.create(request.workflow);
216
+ } catch (err) {
217
+ if (err instanceof NodeInstantiationError) {
218
+ return await this.failRunDuringPlanning({
219
+ runId,
220
+ workflowId: request.workflow.id,
221
+ startedAt,
222
+ parent: request.parent,
223
+ executionOptions: mergedExecutionOptions,
224
+ control,
225
+ workflowSnapshot,
226
+ mutableState,
227
+ policySnapshot,
228
+ err,
229
+ });
230
+ }
231
+ throw err;
232
+ }
233
+ const { topology, planner } = planningFromState;
187
234
  const plan = CurrentStateFrontierPlanner.createFromTopology(topology).plan({
188
235
  currentState: this.createRunCurrentState(request.currentState, mutableState),
189
236
  stopCondition: control.stopCondition,
@@ -206,6 +253,7 @@ export class RunStartService {
206
253
  engineMaxSubworkflowDepth: mergedExecutionOptions.maxSubworkflowDepth!,
207
254
  data,
208
255
  nodeState: this.nodeStatePublisherFactory.create(runId, request.workflow.id, request.parent),
256
+ testContext: mergedExecutionOptions.testContext,
209
257
  });
210
258
 
211
259
  return await this.scheduleInitialPlan({
@@ -455,4 +503,68 @@ export class RunStartService {
455
503
  this.waiters.resolveRunCompletion(result);
456
504
  return result;
457
505
  }
506
+
507
+ private async failRunDuringPlanning(args: {
508
+ runId: RunId;
509
+ workflowId: WorkflowId;
510
+ startedAt: string;
511
+ parent?: ParentExecutionRef;
512
+ executionOptions?: RunExecutionOptions;
513
+ control?: PersistedRunControlState;
514
+ workflowSnapshot: NonNullable<Awaited<ReturnType<WorkflowExecutionRepository["load"]>>>["workflowSnapshot"];
515
+ mutableState: NonNullable<Awaited<ReturnType<WorkflowExecutionRepository["load"]>>>["mutableState"];
516
+ policySnapshot: NonNullable<Awaited<ReturnType<WorkflowExecutionRepository["load"]>>>["policySnapshot"];
517
+ err: NodeInstantiationError;
518
+ }): Promise<RunResult> {
519
+ const finishedAt = new Date().toISOString();
520
+ const failedSnapshot = NodeExecutionSnapshotFactory.failed({
521
+ previous: undefined,
522
+ runId: args.runId,
523
+ workflowId: args.workflowId,
524
+ nodeId: args.err.nodeId,
525
+ activationId: "planning_failure",
526
+ parent: args.parent,
527
+ finishedAt,
528
+ inputsByPort: {},
529
+ error: args.err,
530
+ });
531
+ const failedState = this.persistedRunStateTerminalBuilder.mergeTerminal({
532
+ state: {
533
+ runId: args.runId,
534
+ workflowId: args.workflowId,
535
+ startedAt: args.startedAt,
536
+ parent: args.parent,
537
+ executionOptions: args.executionOptions,
538
+ control: args.control,
539
+ workflowSnapshot: args.workflowSnapshot,
540
+ mutableState: args.mutableState,
541
+ policySnapshot: args.policySnapshot,
542
+ engineCounters: { completedNodeActivations: 0 },
543
+ status: "pending",
544
+ pending: undefined,
545
+ queue: [],
546
+ outputsByNode: {},
547
+ nodeSnapshotsByNodeId: {},
548
+ connectionInvocations: [],
549
+ },
550
+ engineCounters: { completedNodeActivations: 0 },
551
+ status: "failed",
552
+ queue: [],
553
+ outputsByNode: {},
554
+ nodeSnapshotsByNodeId: { [args.err.nodeId]: failedSnapshot },
555
+ finishedAtIso: finishedAt,
556
+ });
557
+ await this.workflowExecutionRepository.save(failedState);
558
+ await this.nodeEventPublisher.publish("nodeFailed", failedSnapshot);
559
+
560
+ const result: RunResult = {
561
+ runId: args.runId,
562
+ workflowId: args.workflowId,
563
+ startedAt: args.startedAt,
564
+ status: "failed",
565
+ error: { message: args.err.message },
566
+ };
567
+ this.waiters.resolveRunCompletion(result);
568
+ return result;
569
+ }
458
570
  }
@@ -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?: TriggerRuntimeDiagnostics,
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: (cleanup) => {
146
- this.registerTriggerCleanupHandle(trigger, cleanup);
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";
@@ -100,6 +103,8 @@ export class EngineFactory {
100
103
  waiters,
101
104
  deps.workflowPolicyRuntimeDefaults,
102
105
  executionLimitsPolicy,
106
+ nodeEventPublisher,
107
+ persistedRunStateTerminalBuilder,
103
108
  );
104
109
  const runContinuationService = new RunContinuationService(
105
110
  deps.activationIdFactory,
@@ -132,6 +137,10 @@ export class EngineFactory {
132
137
  executionLimitsPolicy,
133
138
  );
134
139
 
140
+ const pollingTriggerLogger = deps.pollingTriggerLogger ?? new NoOpPollingTriggerLogger();
141
+ const pollingTriggerDedupWindow = new PollingTriggerDedupWindow();
142
+ const pollingTriggerRuntime = new PollingTriggerRuntime(deps.triggerSetupStateRepository, pollingTriggerLogger);
143
+
135
144
  const triggerRuntime = new TriggerRuntimeService(
136
145
  deps.workflowRepository,
137
146
  deps.workflowActivationPolicy,
@@ -149,6 +158,8 @@ export class EngineFactory {
149
158
  },
150
159
  executionLimitsPolicy,
151
160
  deps.triggerRuntimeDiagnostics,
161
+ pollingTriggerRuntime,
162
+ pollingTriggerDedupWindow,
152
163
  );
153
164
 
154
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
+ }