adfinem 0.0.0 → 0.1.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.
Files changed (112) hide show
  1. package/.env.example +13 -0
  2. package/CHANGELOG.md +17 -0
  3. package/CODE_OF_CONDUCT.md +21 -0
  4. package/CONTRIBUTING.md +29 -0
  5. package/LICENSE +21 -0
  6. package/README.md +97 -3
  7. package/SECURITY.md +13 -0
  8. package/catalogs/.gitkeep +0 -0
  9. package/catalogs/api-operations.yaml +21 -0
  10. package/catalogs/batches.yaml +74 -0
  11. package/catalogs/queries.yaml +75 -0
  12. package/config/environments.yaml +13 -0
  13. package/dist/actions/assert-db.js +3 -0
  14. package/dist/actions/run-eod.js +3 -0
  15. package/dist/adapters/api/api-collections.js +296 -0
  16. package/dist/adapters/api/body-utils.js +9 -0
  17. package/dist/adapters/api/rest-client.js +557 -0
  18. package/dist/adapters/api/soap-client.js +5 -0
  19. package/dist/adapters/db/assertions.js +87 -0
  20. package/dist/adapters/db/oracle-client.js +115 -0
  21. package/dist/adapters/db/query-catalog.js +75 -0
  22. package/dist/adapters/unix/batch-catalog.js +71 -0
  23. package/dist/adapters/unix/batch-input-files.js +36 -0
  24. package/dist/adapters/unix/batch-runner.js +382 -0
  25. package/dist/adapters/unix/ssh-client.js +228 -0
  26. package/dist/app/server.js +827 -0
  27. package/dist/cli.js +516 -0
  28. package/dist/config/environments.js +138 -0
  29. package/dist/config/registry.js +18 -0
  30. package/dist/config/secrets.js +123 -0
  31. package/dist/dsl/parser.js +20 -0
  32. package/dist/dsl/schema.js +182 -0
  33. package/dist/dsl/types.js +1 -0
  34. package/dist/dsl/validator.js +264 -0
  35. package/dist/engine/captures.js +68 -0
  36. package/dist/engine/context.js +69 -0
  37. package/dist/engine/evidence.js +33 -0
  38. package/dist/engine/known-errors.js +129 -0
  39. package/dist/engine/retry.js +13 -0
  40. package/dist/engine/runner.js +710 -0
  41. package/dist/engine/step-result.js +58 -0
  42. package/dist/flows/catalog-normalizer.js +72 -0
  43. package/dist/flows/compiler.js +237 -0
  44. package/dist/flows/concat.js +130 -0
  45. package/dist/flows/parser.js +21 -0
  46. package/dist/flows/schema.js +142 -0
  47. package/dist/flows/types.js +1 -0
  48. package/dist/flows/validator.js +470 -0
  49. package/dist/reports/html-report.js +112 -0
  50. package/dist/reports/junit-report.js +48 -0
  51. package/docs/.gitkeep +0 -0
  52. package/docs/DB_UNIX_OPERATIONS.md +118 -0
  53. package/docs/FLOW_BUILDER.md +87 -0
  54. package/flows/account_processing_cycle.flow.yaml +88 -0
  55. package/flows/new_flow.flow.yaml +22 -0
  56. package/package.json +98 -11
  57. package/scenarios/smoke/account-processing-smoke.yaml +44 -0
  58. package/scenarios/smoke/api-db-batch-check.yaml +40 -0
  59. package/src/actions/assert-db.ts +6 -0
  60. package/src/actions/run-eod.ts +6 -0
  61. package/src/adapters/api/api-collections.ts +375 -0
  62. package/src/adapters/api/body-utils.ts +10 -0
  63. package/src/adapters/api/rest-client.ts +587 -0
  64. package/src/adapters/api/soap-client.ts +7 -0
  65. package/src/adapters/db/assertions.ts +83 -0
  66. package/src/adapters/db/oracle-client.ts +133 -0
  67. package/src/adapters/db/query-catalog.ts +80 -0
  68. package/src/adapters/unix/batch-catalog.ts +81 -0
  69. package/src/adapters/unix/batch-input-files.ts +39 -0
  70. package/src/adapters/unix/batch-runner.ts +456 -0
  71. package/src/adapters/unix/ssh-client.ts +248 -0
  72. package/src/app/server.ts +914 -0
  73. package/src/cli.ts +517 -0
  74. package/src/config/environments.ts +193 -0
  75. package/src/config/registry.ts +23 -0
  76. package/src/config/secrets.ts +128 -0
  77. package/src/dsl/parser.ts +24 -0
  78. package/src/dsl/schema.ts +189 -0
  79. package/src/dsl/types.ts +371 -0
  80. package/src/dsl/validator.ts +282 -0
  81. package/src/engine/captures.ts +66 -0
  82. package/src/engine/context.ts +76 -0
  83. package/src/engine/evidence.ts +35 -0
  84. package/src/engine/known-errors.ts +145 -0
  85. package/src/engine/retry.ts +11 -0
  86. package/src/engine/runner.ts +746 -0
  87. package/src/engine/step-result.ts +64 -0
  88. package/src/flows/catalog-normalizer.ts +86 -0
  89. package/src/flows/compiler.ts +247 -0
  90. package/src/flows/concat.ts +149 -0
  91. package/src/flows/parser.ts +27 -0
  92. package/src/flows/schema.ts +154 -0
  93. package/src/flows/types.ts +130 -0
  94. package/src/flows/validator.ts +468 -0
  95. package/src/llm/system-prompt.md +9 -0
  96. package/src/reports/html-report.ts +113 -0
  97. package/src/reports/junit-report.ts +55 -0
  98. package/src/types/oracledb.d.ts +1 -0
  99. package/templates/.gitkeep +0 -0
  100. package/templates/api/create-test-case.json +5 -0
  101. package/templates/api/record-test-activity.json +6 -0
  102. package/tsconfig.json +15 -0
  103. package/vite.config.ts +17 -0
  104. package/web/index.html +12 -0
  105. package/web/src/App.tsx +6588 -0
  106. package/web/src/main.tsx +10 -0
  107. package/web/src/styles.css +3147 -0
  108. package/web-dist/assets/elk.bundled-ChwRCIWJ.js +24 -0
  109. package/web-dist/assets/index-CArbX4zm.css +1 -0
  110. package/web-dist/assets/index-vDCbj8xB.js +28 -0
  111. package/web-dist/index.html +13 -0
  112. package/index.js +0 -1
@@ -0,0 +1,710 @@
1
+ import { mkdir } from "node:fs/promises";
2
+ import { basename, join } from "node:path";
3
+ import { fileURLToPath } from "node:url";
4
+ import { EvidenceWriter } from "./evidence.js";
5
+ import { RunContext } from "./context.js";
6
+ import { cancelledStep, durationBetween, failedStep, hashInput, isCancellationError } from "./step-result.js";
7
+ import { RestClient } from "../adapters/api/rest-client.js";
8
+ import { SoapClient } from "../adapters/api/soap-client.js";
9
+ import { OracleClient } from "../adapters/db/oracle-client.js";
10
+ import { assertQueryResult } from "../adapters/db/assertions.js";
11
+ import { SshClient } from "../adapters/unix/ssh-client.js";
12
+ import { BatchRunner } from "../adapters/unix/batch-runner.js";
13
+ import { runBatch } from "../actions/run-eod.js";
14
+ import { writeHtmlReport } from "../reports/html-report.js";
15
+ import { writeJunitReport } from "../reports/junit-report.js";
16
+ import { extractCaptures, mergeCaptureSpecs } from "./captures.js";
17
+ import { applyEvidenceVisibility, evidenceVisibilityMode } from "../config/secrets.js";
18
+ export class ScenarioRunner {
19
+ scenario;
20
+ catalogs;
21
+ env;
22
+ options;
23
+ context;
24
+ evidence;
25
+ restClient;
26
+ soapClient;
27
+ oracleClient;
28
+ batchRunner;
29
+ visibility;
30
+ constructor(scenario, catalogs, env, options) {
31
+ this.scenario = scenario;
32
+ this.catalogs = catalogs;
33
+ this.env = env;
34
+ this.options = options;
35
+ this.context = new RunContext(scenario);
36
+ const runId = `${scenario.id}-${new Date().toISOString().replace(/[:.]/g, "-")}`;
37
+ this.visibility = evidenceVisibilityMode();
38
+ this.evidence = new EvidenceWriter(join(options.rootDir, "evidence", runId), this.visibility);
39
+ this.restClient = new RestClient(env, options.rootDir);
40
+ this.soapClient = new SoapClient();
41
+ this.oracleClient = new OracleClient(env);
42
+ this.batchRunner = new BatchRunner(new SshClient(env), options.rootDir);
43
+ }
44
+ async run() {
45
+ await this.evidence.init();
46
+ const startedAt = new Date().toISOString();
47
+ const steps = [];
48
+ let cursor = 0;
49
+ for (const step of this.scenario.steps) {
50
+ if (this.isStopped()) {
51
+ steps.push(cancelledStep(step.id, this.layerFor(step), new Date().toISOString(), step.input ?? step.params ?? {}));
52
+ for (const skipped of this.scenario.steps.slice(steps.length)) {
53
+ steps.push(this.skipStep(skipped, `Skipped because run was stopped before step '${step.id}'.`));
54
+ }
55
+ break;
56
+ }
57
+ const results = await this.executePlanStep(step, cursor);
58
+ cursor += results.length;
59
+ steps.push(...results);
60
+ const controlResult = results[0] ?? results[results.length - 1];
61
+ if (results.some((result) => result.status === "cancelled")) {
62
+ for (const skipped of this.scenario.steps.slice(steps.length)) {
63
+ steps.push(this.skipStep(skipped, `Skipped because run was stopped during step '${step.id}'.`));
64
+ }
65
+ break;
66
+ }
67
+ if (controlResult?.status === "failed" && !step.continueOnFailure) {
68
+ for (const skipped of this.scenario.steps.slice(steps.length)) {
69
+ steps.push(this.skipStep(skipped, `Skipped because previous step '${step.id}' failed.`));
70
+ }
71
+ break;
72
+ }
73
+ }
74
+ const endedAt = new Date().toISOString();
75
+ const startedMs = Date.parse(startedAt);
76
+ const endedMs = Date.parse(endedAt);
77
+ const durationMs = Number.isFinite(startedMs) && Number.isFinite(endedMs)
78
+ ? Math.max(0, endedMs - startedMs)
79
+ : undefined;
80
+ const result = {
81
+ scenarioId: this.scenario.id,
82
+ runId: basename(this.evidence.runDir) || this.scenario.id,
83
+ status: steps.some((step) => step.status === "cancelled")
84
+ ? "cancelled"
85
+ : steps.some((step) => step.status === "failed") ? "failed" : "passed",
86
+ startedAt,
87
+ endedAt,
88
+ evidenceDir: this.evidence.runDir,
89
+ steps,
90
+ durationMs
91
+ };
92
+ const visibleResult = applyEvidenceVisibility(result, this.visibility);
93
+ const runResultPath = await this.evidence.writeJson("run-result.json", visibleResult);
94
+ const reportPath = await writeHtmlReport(visibleResult);
95
+ const junitPath = await writeJunitReport(visibleResult);
96
+ await this.evidence.writeJson("evidence-manifest.json", buildEvidenceManifest(result, {
97
+ runResultPath,
98
+ reportPath,
99
+ junitPath
100
+ }));
101
+ return visibleResult;
102
+ }
103
+ async executePlanStep(step, index) {
104
+ if (step.control === "parallel" || step.action === "__parallel")
105
+ return this.executeParallelStep(step, index);
106
+ if (step.control === "loop" || step.action === "__loop")
107
+ return this.executeLoopStep(step, index);
108
+ return [await this.executeStep(step, index)];
109
+ }
110
+ async executeParallelStep(step, index) {
111
+ const startedAt = new Date().toISOString();
112
+ const branches = step.branches ?? [];
113
+ const branchResults = await Promise.all(branches.map(async (branch, branchIndex) => {
114
+ const results = [];
115
+ let localIndex = index + branchIndex + 1;
116
+ for (const branchStep of branch.steps) {
117
+ if (this.isStopped()) {
118
+ results.push(cancelledStep(branchStep.id, this.layerFor(branchStep), new Date().toISOString(), branchStep.input ?? branchStep.params ?? {}));
119
+ break;
120
+ }
121
+ const next = await this.executePlanStep(branchStep, localIndex);
122
+ localIndex += next.length;
123
+ results.push(...next);
124
+ const failed = next[0]?.status === "failed" || next.some((result) => result.status === "cancelled");
125
+ if (failed && !branchStep.continueOnFailure)
126
+ break;
127
+ }
128
+ return { branch, results };
129
+ }));
130
+ const flattened = branchResults.flatMap((branch) => branch.results);
131
+ const join = step.join ?? "all";
132
+ const branchPassed = branchResults.map((branch) => branch.results.length > 0 && !branch.results.some((result) => result.status === "failed" || result.status === "cancelled"));
133
+ const passed = join === "any" ? branchPassed.some(Boolean) : branchPassed.every(Boolean);
134
+ const endedAt = new Date().toISOString();
135
+ const group = {
136
+ stepId: step.id,
137
+ layer: "engine",
138
+ status: flattened.some((result) => result.status === "cancelled") ? "cancelled" : passed ? "passed" : "failed",
139
+ startedAt,
140
+ endedAt,
141
+ durationMs: durationBetween(startedAt, endedAt),
142
+ inputHash: hashInput({ branches: branches.map((branch) => branch.id), join }),
143
+ captures: {},
144
+ evidence: [],
145
+ error: passed ? undefined : {
146
+ message: `Parallel block '${step.id}' failed using '${join}' join semantics.`
147
+ }
148
+ };
149
+ return [group, ...flattened];
150
+ }
151
+ async executeLoopStep(step, index) {
152
+ const startedAt = new Date().toISOString();
153
+ const iterations = this.loopIterations(step);
154
+ const allResults = [];
155
+ const aggregate = new Map();
156
+ let failed = false;
157
+ const loopSteps = step.steps ?? [];
158
+ for (let i = 0; i < iterations.items.length; i += 1) {
159
+ if (this.isStopped()) {
160
+ allResults.push(cancelledStep(`${step.id}_${i}`, "engine", new Date().toISOString(), {}));
161
+ failed = true;
162
+ break;
163
+ }
164
+ this.context.set(`${step.id}.index`, i);
165
+ this.context.set(`${step.id}.number`, i + 1);
166
+ this.context.set(`${step.id}.total`, iterations.items.length);
167
+ this.context.set(`${step.id}.item`, iterations.items[i]);
168
+ if (iterations.itemName) {
169
+ this.context.set(iterations.itemName, iterations.items[i]);
170
+ this.context.set(`${step.id}.${iterations.itemName}`, iterations.items[i]);
171
+ }
172
+ const dateCursor = step.loop?.dateCursor;
173
+ if (dateCursor) {
174
+ const outputName = loopDateOutputName(dateCursor);
175
+ const value = computeLoopDateCursorValue(dateCursor, i, (candidate) => this.context.resolve(candidate));
176
+ this.context.set(outputName, value);
177
+ this.context.set(`${step.id}.${outputName}`, value);
178
+ this.context.set(`${step.id}[${i}].${outputName}`, value);
179
+ this.context.set(`${step.id}.${i}.${outputName}`, value);
180
+ this.context.set(`${step.id}.last.${outputName}`, value);
181
+ const aggregateKey = `${step.id}.all.${outputName}`;
182
+ const values = aggregate.get(aggregateKey) ?? [];
183
+ values.push(value);
184
+ aggregate.set(aggregateKey, values);
185
+ this.context.set(aggregateKey, values);
186
+ }
187
+ for (const child of loopSteps) {
188
+ const scopedChild = scopedLoopStep(child, step.id, i);
189
+ const childResults = await this.executePlanStep(scopedChild, index + allResults.length + 1);
190
+ allResults.push(...childResults);
191
+ const publishable = childResults.filter((result) => result.status === "passed");
192
+ for (const result of publishable) {
193
+ for (const [capture, value] of Object.entries(result.captures)) {
194
+ this.context.set(`${child.id}.${capture}`, value);
195
+ this.context.set(`${step.id}[${i}].${child.id}.${capture}`, value);
196
+ this.context.set(`${step.id}.${i}.${child.id}.${capture}`, value);
197
+ this.context.set(`${step.id}.last.${child.id}.${capture}`, value);
198
+ const aggregateKey = `${step.id}.all.${child.id}.${capture}`;
199
+ const values = aggregate.get(aggregateKey) ?? [];
200
+ values.push(value);
201
+ aggregate.set(aggregateKey, values);
202
+ this.context.set(aggregateKey, values);
203
+ }
204
+ }
205
+ if (childResults[0]?.status === "failed" && !child.continueOnFailure) {
206
+ failed = true;
207
+ break;
208
+ }
209
+ }
210
+ if (failed && !step.continueOnFailure)
211
+ break;
212
+ }
213
+ const endedAt = new Date().toISOString();
214
+ const group = {
215
+ stepId: step.id,
216
+ layer: "engine",
217
+ status: allResults.some((result) => result.status === "cancelled") ? "cancelled" : failed ? "failed" : "passed",
218
+ startedAt,
219
+ endedAt,
220
+ durationMs: durationBetween(startedAt, endedAt),
221
+ inputHash: hashInput(step.loop ?? {}),
222
+ captures: Object.fromEntries(aggregate),
223
+ evidence: [],
224
+ error: failed ? { message: `Loop '${step.id}' failed before completing all iterations.` } : undefined
225
+ };
226
+ return [group, ...allResults];
227
+ }
228
+ loopIterations(step) {
229
+ const spec = step.loop ?? { mode: "count", count: 1 };
230
+ const maxIterations = spec.maxIterations ?? 1000;
231
+ if (spec.mode === "count") {
232
+ const resolved = this.context.resolve(spec.count ?? 1);
233
+ const count = Number(resolved);
234
+ if (!Number.isInteger(count) || count < 0)
235
+ throw new Error(`Loop '${step.id}' count must resolve to a non-negative integer.`);
236
+ if (count > maxIterations)
237
+ throw new Error(`Loop '${step.id}' count ${count} exceeds maxIterations ${maxIterations}.`);
238
+ return { items: Array.from({ length: count }, (_value, index) => index), itemName: spec.itemName };
239
+ }
240
+ const resolved = this.context.resolve(spec.items);
241
+ const items = Array.isArray(resolved) ? resolved : resolved === undefined || resolved === null ? [] : [resolved];
242
+ if (items.length > maxIterations)
243
+ throw new Error(`Loop '${step.id}' foreach item count ${items.length} exceeds maxIterations ${maxIterations}.`);
244
+ return { items, itemName: spec.itemName ?? "item" };
245
+ }
246
+ async executeStep(step, index) {
247
+ const startedAt = new Date().toISOString();
248
+ const layer = this.layerFor(step);
249
+ let resolvedInput = step.input ?? step.params ?? {};
250
+ const evidence = [];
251
+ let inputEvidenceWritten = false;
252
+ this.options.onStepStart?.({ stepId: step.id, layer, startedAt, index });
253
+ const finish = (result) => {
254
+ this.options.onStepResult?.(result);
255
+ return result;
256
+ };
257
+ try {
258
+ resolvedInput = this.context.resolve(step.input ?? step.params ?? {});
259
+ evidence.push(await this.evidence.writeJson(`${step.id}.input.json`, resolvedInput));
260
+ inputEvidenceWritten = true;
261
+ if (this.options.dryRun) {
262
+ const captures = await this.dryRunCaptures(step);
263
+ this.publishCaptures(step.id, captures);
264
+ const dryPayload = { step, resolvedInput, captures, context: this.context.snapshot() };
265
+ evidence.push(await this.evidence.writeJson(`${step.id}.dry-run.json`, dryPayload));
266
+ return finish(this.passedStep(step.id, layer, startedAt, resolvedInput, captures, evidence));
267
+ }
268
+ const result = await this.dispatch(step, resolvedInput, evidence, index);
269
+ if (result.status === "failed") {
270
+ return finish(this.failedEvaluatedStep(step.id, layer, startedAt, resolvedInput, result.captures, evidence, result.message ?? "Step failed.", result.api, result.unix));
271
+ }
272
+ this.publishCaptures(step.id, result.captures);
273
+ return finish(this.passedStep(step.id, layer, startedAt, resolvedInput, result.captures, evidence, result.api, result.unix));
274
+ }
275
+ catch (error) {
276
+ if (!inputEvidenceWritten) {
277
+ const unresolvedInput = await this.evidence.writeJson(`${step.id}.input-unresolved.json`, resolvedInput).catch(() => undefined);
278
+ if (unresolvedInput)
279
+ evidence.push(unresolvedInput);
280
+ }
281
+ if (isCancellationError(error)) {
282
+ return finish(cancelledStep(step.id, layer, startedAt, resolvedInput, evidence));
283
+ }
284
+ const failureEvidence = await this.writeFailureCheckpoint(step, startedAt, resolvedInput, error).catch(() => undefined);
285
+ if (failureEvidence)
286
+ evidence.push(failureEvidence);
287
+ return finish(failedStep(step.id, layer, startedAt, resolvedInput, error, evidence));
288
+ }
289
+ }
290
+ async dispatch(step, input, evidence, index) {
291
+ if (step.via === "api") {
292
+ const operation = this.catalogs.apiOperations[step.action];
293
+ if (!operation)
294
+ throw new Error(`Unknown API operation '${step.action}'.`);
295
+ const request = step.request ? this.context.resolve(step.request) : undefined;
296
+ const result = operation.type === "soap"
297
+ ? await this.soapClient.execute(operation, input)
298
+ : await this.restClient.execute(operation, input, request, step.assertions ?? [], step.capture ?? {}, {
299
+ expectedOutcome: step.expectedOutcome,
300
+ captureOnFailure: step.captureOnFailure,
301
+ visibility: this.visibility
302
+ });
303
+ if (operation.type === "soap") {
304
+ const captures = { ...result.captures, ...pickExplicitCaptures(result.response, step.capture ?? {}) };
305
+ evidence.push(await this.evidence.writeJson(`${step.id}.api.json`, result.evidencePayload));
306
+ return { status: "passed", captures };
307
+ }
308
+ const api = result.apiEvidence;
309
+ const stepFolder = stepEvidenceFolder(index, step.id);
310
+ evidence.push(await this.evidence.writeJson(`${step.id}.api.json`, result.evidencePayload));
311
+ if (api.request)
312
+ evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/request.json`, { request: api.request, resolvedRequest: api.resolvedRequest }));
313
+ if (api.response)
314
+ evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/response.json`, api.response));
315
+ evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/assertions.json`, api.assertionResults));
316
+ evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/captures.json`, api.evidenceCaptures));
317
+ return {
318
+ status: api.finalStatus,
319
+ captures: result.captures,
320
+ api,
321
+ message: api.failureReason
322
+ };
323
+ }
324
+ if (step.action === "db_assert" || step.action === "db_query" || step.action === "db_execute") {
325
+ if (!step.query)
326
+ throw new Error(`${step.action} requires query.`);
327
+ const entry = this.catalogs.queries[step.query];
328
+ const stepFolder = stepEvidenceFolder(index, step.id);
329
+ if (step.action === "db_execute") {
330
+ if (entry.mode !== "execute")
331
+ throw new Error(`DB Action Library template '${step.query}' must be marked mode: execute.`);
332
+ const result = await this.oracleClient.execute(entry, input);
333
+ const payload = { query: step.query, ...result };
334
+ const captureResult = evaluateCaptures(payload, mergeCaptureSpecs(entry.captures, step.capture));
335
+ const evidencePayload = { ...payload, captures: captureResult.captures, captureError: errorSummary(captureResult.error) };
336
+ evidence.push(await this.evidence.writeJson(`${step.id}.db-execute.json`, evidencePayload));
337
+ evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/db-execute.json`, evidencePayload));
338
+ if (captureResult.error) {
339
+ return { status: "failed", captures: {}, message: dbCaptureFailureMessage(payload, captureResult.error) };
340
+ }
341
+ return { status: "passed", captures: captureResult.captures };
342
+ }
343
+ const rows = await this.oracleClient.query(entry, input);
344
+ const payload = { query: step.query, rowCount: rows.length, rows };
345
+ const assertionResult = evaluateDbExpectation(step.action, entry, rows);
346
+ const captureResult = evaluateCaptures(payload, mergeCaptureSpecs(entry.captures, step.capture));
347
+ const evidencePayload = {
348
+ ...payload,
349
+ assertionError: errorSummary(assertionResult.error),
350
+ captures: captureResult.captures,
351
+ captureError: errorSummary(captureResult.error)
352
+ };
353
+ evidence.push(await this.evidence.writeJson(`${step.id}.db.json`, evidencePayload));
354
+ evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/db.json`, evidencePayload));
355
+ if (assertionResult.error) {
356
+ return { status: "failed", captures: {}, message: dbQueryFailureMessage(payload, assertionResult.error) };
357
+ }
358
+ if (captureResult.error) {
359
+ return { status: "failed", captures: {}, message: dbCaptureFailureMessage(payload, captureResult.error) };
360
+ }
361
+ return { status: "passed", captures: captureResult.captures };
362
+ }
363
+ if (step.action === "unix_batch") {
364
+ if (!step.batch)
365
+ throw new Error("unix_batch requires batch.");
366
+ const stepFolder = stepEvidenceFolder(index, step.id);
367
+ const result = await runBatch(this.batchRunner, this.catalogs.batches[step.batch], input, {
368
+ attempts: step.retry?.attempts,
369
+ delayMs: step.retry?.delaySeconds === undefined ? undefined : step.retry.delaySeconds * 1000,
370
+ downloadDir: join(this.evidence.runDir, ...stepFolder.split("/"), "files"),
371
+ signal: this.options.signal
372
+ });
373
+ evidence.push(await this.evidence.writeJson(`${step.id}.unix.json`, result));
374
+ evidence.push(await this.evidence.writeJsonPath(`${stepFolder}/unix.json`, result));
375
+ if (result.status !== "passed") {
376
+ const last = result.attempts[result.attempts.length - 1];
377
+ return {
378
+ status: "failed",
379
+ captures: {},
380
+ unix: result,
381
+ message: last?.error ?? `Batch '${step.batch}' failed.`
382
+ };
383
+ }
384
+ const captures = extractCaptures(result, mergeCaptureSpecs(this.catalogs.batches[step.batch].captures, step.capture));
385
+ if (Object.keys(captures).length > 0) {
386
+ evidence.push(await this.evidence.writeJson(`${step.id}.unix-captures.json`, captures));
387
+ }
388
+ return { status: "passed", captures, unix: result };
389
+ }
390
+ throw new Error(`No executor for action '${step.action}' via '${step.via ?? ""}'.`);
391
+ }
392
+ passedStep(stepId, layer, startedAt, input, captures, evidence, api, unix) {
393
+ const endedAt = new Date().toISOString();
394
+ return {
395
+ stepId,
396
+ layer,
397
+ status: "passed",
398
+ startedAt,
399
+ endedAt,
400
+ durationMs: durationBetween(startedAt, endedAt),
401
+ inputHash: hashInput(input),
402
+ captures,
403
+ evidence,
404
+ api,
405
+ unix
406
+ };
407
+ }
408
+ failedEvaluatedStep(stepId, layer, startedAt, input, captures, evidence, message, api, unix) {
409
+ const endedAt = new Date().toISOString();
410
+ return {
411
+ stepId,
412
+ layer,
413
+ status: "failed",
414
+ startedAt,
415
+ endedAt,
416
+ durationMs: durationBetween(startedAt, endedAt),
417
+ inputHash: hashInput(input),
418
+ captures,
419
+ evidence,
420
+ api,
421
+ unix,
422
+ error: {
423
+ message,
424
+ rawOutput: api?.response
425
+ ? `HTTP ${api.response.status} ${api.response.statusText}`
426
+ : unix
427
+ ? unixFailureSummary(unix)
428
+ : undefined
429
+ }
430
+ };
431
+ }
432
+ skipStep(step, reason) {
433
+ const now = new Date().toISOString();
434
+ return {
435
+ stepId: step.id,
436
+ layer: this.layerFor(step),
437
+ status: "skipped",
438
+ startedAt: now,
439
+ endedAt: now,
440
+ durationMs: 0,
441
+ inputHash: hashInput({}),
442
+ captures: {},
443
+ evidence: [],
444
+ error: {
445
+ message: reason
446
+ }
447
+ };
448
+ }
449
+ async writeFailureCheckpoint(step, startedAt, input, error) {
450
+ const err = error instanceof Error ? error : new Error(String(error));
451
+ const context = this.context.snapshot();
452
+ return await this.evidence.writeJson(`${step.id}.failure.json`, {
453
+ step,
454
+ input,
455
+ error: {
456
+ message: err.message,
457
+ stack: err.stack
458
+ },
459
+ context,
460
+ startedAt
461
+ });
462
+ }
463
+ layerFor(step) {
464
+ if (step.control || step.via === "control")
465
+ return "engine";
466
+ if (step.action === "db_assert" || step.action === "db_query" || step.action === "db_execute")
467
+ return "db";
468
+ if (step.action === "unix_batch")
469
+ return "unix";
470
+ if (step.via === "api")
471
+ return "api";
472
+ return "engine";
473
+ }
474
+ async dryRunCaptures(step) {
475
+ const captures = {};
476
+ if (step.via === "api") {
477
+ for (const name of Object.keys(this.catalogs.apiOperations[step.action]?.captures ?? {})) {
478
+ captures[name] = `<dry-run:${name}>`;
479
+ }
480
+ }
481
+ if (step.query) {
482
+ for (const name of Object.keys(this.catalogs.queries[step.query]?.captures ?? {})) {
483
+ captures[name] = `<dry-run:${name}>`;
484
+ }
485
+ }
486
+ if (step.batch) {
487
+ for (const name of Object.keys(this.catalogs.batches[step.batch]?.captures ?? {})) {
488
+ captures[name] = `<dry-run:${name}>`;
489
+ }
490
+ }
491
+ for (const name of Object.keys(step.capture ?? {})) {
492
+ captures[name] = `<dry-run:${name}>`;
493
+ }
494
+ return captures;
495
+ }
496
+ publishCaptures(stepId, captures) {
497
+ for (const [name, value] of Object.entries(captures)) {
498
+ this.context.set(name, value);
499
+ this.context.set(`${stepId}.${name}`, value);
500
+ }
501
+ }
502
+ isStopped() {
503
+ return Boolean(this.options.signal?.aborted);
504
+ }
505
+ }
506
+ function scopedLoopStep(step, loopId, index) {
507
+ const scopedId = `${loopId}_${index}_${step.id}`;
508
+ if (step.control === "parallel" || step.control === "loop" || step.action === "__parallel" || step.action === "__loop") {
509
+ return {
510
+ ...step,
511
+ id: scopedId,
512
+ branches: step.branches?.map((branch) => ({
513
+ ...branch,
514
+ steps: branch.steps.map((child) => scopedLoopStep(child, loopId, index))
515
+ })),
516
+ steps: step.steps?.map((child) => scopedLoopStep(child, loopId, index))
517
+ };
518
+ }
519
+ return {
520
+ ...step,
521
+ id: scopedId
522
+ };
523
+ }
524
+ function loopDateOutputName(cursor) {
525
+ return cursor.outputName?.trim() || "business_date";
526
+ }
527
+ function computeLoopDateCursorValue(cursor, index, resolve) {
528
+ const startValue = resolve(cursor.start ?? "");
529
+ if (typeof startValue !== "string" || !startValue.trim()) {
530
+ throw new Error("Loop business date start value is required.");
531
+ }
532
+ const inputFormat = cursor.inputFormat ?? detectLoopDateFormat(startValue);
533
+ const outputFormat = cursor.outputFormat ?? inputFormat;
534
+ const base = parseLoopDate(startValue, inputFormat);
535
+ const advance = cursor.advance ?? { mode: "months", amount: 1 };
536
+ const amount = Math.max(1, Number(advance.amount ?? 1));
537
+ let next;
538
+ if (advance.mode === "days") {
539
+ next = addDays(base, amount * index);
540
+ }
541
+ else if (advance.mode === "months") {
542
+ next = addMonthsClamped(base, amount * index);
543
+ }
544
+ else {
545
+ const monthBase = addMonthsClamped(new Date(Date.UTC(base.getUTCFullYear(), base.getUTCMonth(), 1)), amount * index);
546
+ if (advance.mode === "first_of_month") {
547
+ next = monthBase;
548
+ }
549
+ else if (advance.mode === "end_of_month") {
550
+ next = new Date(Date.UTC(monthBase.getUTCFullYear(), monthBase.getUTCMonth(), daysInMonth(monthBase.getUTCFullYear(), monthBase.getUTCMonth())));
551
+ }
552
+ else {
553
+ const requestedDay = Math.max(1, Math.min(31, Number(advance.day ?? base.getUTCDate())));
554
+ next = new Date(Date.UTC(monthBase.getUTCFullYear(), monthBase.getUTCMonth(), Math.min(requestedDay, daysInMonth(monthBase.getUTCFullYear(), monthBase.getUTCMonth()))));
555
+ }
556
+ }
557
+ return formatLoopDate(next, outputFormat);
558
+ }
559
+ function detectLoopDateFormat(value) {
560
+ if (/^\d{4}-\d{2}-\d{2}$/.test(value))
561
+ return "YYYY-MM-DD";
562
+ return "DD/MM/YYYY";
563
+ }
564
+ function parseLoopDate(value, format) {
565
+ const trimmed = value.trim();
566
+ let year;
567
+ let month;
568
+ let day;
569
+ if (format === "YYYY-MM-DD") {
570
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(trimmed);
571
+ if (!match)
572
+ throw new Error(`Loop business date '${value}' must match YYYY-MM-DD.`);
573
+ year = Number(match[1]);
574
+ month = Number(match[2]);
575
+ day = Number(match[3]);
576
+ }
577
+ else {
578
+ const match = /^(\d{2})\/(\d{2})\/(\d{4})$/.exec(trimmed);
579
+ if (!match)
580
+ throw new Error(`Loop business date '${value}' must match ${format}.`);
581
+ year = Number(match[3]);
582
+ if (format === "DD/MM/YYYY") {
583
+ day = Number(match[1]);
584
+ month = Number(match[2]);
585
+ }
586
+ else {
587
+ month = Number(match[1]);
588
+ day = Number(match[2]);
589
+ }
590
+ }
591
+ const maxDay = daysInMonth(year, month - 1);
592
+ if (month < 1 || month > 12 || day < 1 || day > maxDay) {
593
+ throw new Error(`Loop business date '${value}' is not a valid calendar date.`);
594
+ }
595
+ return new Date(Date.UTC(year, month - 1, day));
596
+ }
597
+ function formatLoopDate(date, format) {
598
+ const year = String(date.getUTCFullYear()).padStart(4, "0");
599
+ const month = String(date.getUTCMonth() + 1).padStart(2, "0");
600
+ const day = String(date.getUTCDate()).padStart(2, "0");
601
+ if (format === "YYYY-MM-DD")
602
+ return `${year}-${month}-${day}`;
603
+ if (format === "MM/DD/YYYY")
604
+ return `${month}/${day}/${year}`;
605
+ return `${day}/${month}/${year}`;
606
+ }
607
+ function addDays(date, days) {
608
+ return new Date(Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), date.getUTCDate() + days));
609
+ }
610
+ function addMonthsClamped(date, months) {
611
+ const targetMonth = date.getUTCMonth() + months;
612
+ const first = new Date(Date.UTC(date.getUTCFullYear(), targetMonth, 1));
613
+ const day = Math.min(date.getUTCDate(), daysInMonth(first.getUTCFullYear(), first.getUTCMonth()));
614
+ return new Date(Date.UTC(first.getUTCFullYear(), first.getUTCMonth(), day));
615
+ }
616
+ function daysInMonth(year, zeroBasedMonth) {
617
+ return new Date(Date.UTC(year, zeroBasedMonth + 1, 0)).getUTCDate();
618
+ }
619
+ function unixFailureSummary(unix) {
620
+ const last = unix.attempts[unix.attempts.length - 1];
621
+ if (!last)
622
+ return undefined;
623
+ const displayCommand = last.displayCommand ?? unix.displayCommand ?? last.command;
624
+ const parts = [
625
+ `Command: ${displayCommand}`,
626
+ last.command && last.command !== displayCommand
627
+ ? `Shell-safe command: ${last.command}`
628
+ : undefined,
629
+ last.exitCode === undefined ? undefined : `Exit code: ${last.exitCode}`,
630
+ last.tracePath ? `Script trace path: ${last.tracePath}` : undefined,
631
+ last.errno ? `Script ERRNO: ${last.errno}` : undefined,
632
+ last.stdout ? `stdout:\n${last.stdout}` : undefined,
633
+ last.stderr ? `stderr:\n${last.stderr}` : undefined
634
+ ].filter(Boolean);
635
+ return parts.length ? parts.join("\n\n") : undefined;
636
+ }
637
+ function stepEvidenceFolder(index, stepId) {
638
+ return `steps/${String(index + 1).padStart(2, "0")}_${stepId}`;
639
+ }
640
+ function pickExplicitCaptures(data, captureSpec) {
641
+ if (!captureSpec)
642
+ return {};
643
+ return extractCaptures(data, captureSpec);
644
+ }
645
+ function evaluateCaptures(payload, captureSpec) {
646
+ try {
647
+ return { captures: extractCaptures(payload, captureSpec) };
648
+ }
649
+ catch (error) {
650
+ return { captures: {}, error: error instanceof Error ? error : new Error(String(error)) };
651
+ }
652
+ }
653
+ function evaluateDbExpectation(action, entry, rows) {
654
+ if (action !== "db_assert")
655
+ return {};
656
+ try {
657
+ assertQueryResult(entry, rows);
658
+ return {};
659
+ }
660
+ catch (error) {
661
+ return { error: error instanceof Error ? error : new Error(String(error)) };
662
+ }
663
+ }
664
+ function errorSummary(error) {
665
+ return error ? { message: error.message, stack: error.stack } : undefined;
666
+ }
667
+ function dbQueryFailureMessage(payload, error) {
668
+ return `${error.message} ${dbPayloadDetails(payload)}`;
669
+ }
670
+ function dbCaptureFailureMessage(payload, error) {
671
+ return `${error.message} ${dbPayloadDetails(payload)}`;
672
+ }
673
+ function dbPayloadDetails(payload) {
674
+ const rowCount = payload.rowCount ?? payload.rows?.length;
675
+ const columns = availableColumns(payload.rows ?? []);
676
+ const parts = [`DB query '${payload.query}' returned ${rowCount ?? 0} row${rowCount === 1 ? "" : "s"}.`];
677
+ if ((rowCount ?? 0) === 0)
678
+ parts.push("No rows were available to capture from.");
679
+ if (columns.length > 0)
680
+ parts.push(`Available columns: ${columns.join(", ")}.`);
681
+ return parts.join(" ");
682
+ }
683
+ function availableColumns(rows) {
684
+ return [...new Set(rows.flatMap((row) => Object.keys(row)))];
685
+ }
686
+ export async function ensureEvidenceRoot(rootDir) {
687
+ await mkdir(join(rootDir, "evidence"), { recursive: true });
688
+ }
689
+ export function defaultRootDir() {
690
+ return join(fileURLToPath(new URL("../..", import.meta.url)));
691
+ }
692
+ function buildEvidenceManifest(result, paths) {
693
+ return {
694
+ version: 1,
695
+ scenarioId: result.scenarioId,
696
+ runId: result.runId,
697
+ status: result.status,
698
+ evidenceModel: {
699
+ flowEvidenceDir: result.evidenceDir,
700
+ note: "Flow evidence contains scenario reports and per-step API, DB, and Unix evidence."
701
+ },
702
+ topLevelFiles: paths,
703
+ steps: result.steps.map((step) => ({
704
+ stepId: step.stepId,
705
+ layer: step.layer,
706
+ status: step.status,
707
+ evidence: step.evidence
708
+ }))
709
+ };
710
+ }