adfinem 0.0.0 → 0.1.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.
- package/CHANGELOG.md +10 -0
- package/CODE_OF_CONDUCT.md +21 -0
- package/CONTRIBUTING.md +29 -0
- package/LICENSE +21 -0
- package/README.md +86 -2
- package/SECURITY.md +13 -0
- package/catalogs/.gitkeep +0 -0
- package/catalogs/api-operations.yaml +21 -0
- package/catalogs/batches.yaml +74 -0
- package/catalogs/queries.yaml +75 -0
- package/config/environments.yaml +13 -0
- package/dist/actions/assert-db.js +3 -0
- package/dist/actions/run-eod.js +3 -0
- package/dist/adapters/api/api-collections.js +296 -0
- package/dist/adapters/api/body-utils.js +9 -0
- package/dist/adapters/api/rest-client.js +557 -0
- package/dist/adapters/api/soap-client.js +5 -0
- package/dist/adapters/db/assertions.js +87 -0
- package/dist/adapters/db/oracle-client.js +115 -0
- package/dist/adapters/db/query-catalog.js +75 -0
- package/dist/adapters/unix/batch-catalog.js +71 -0
- package/dist/adapters/unix/batch-input-files.js +36 -0
- package/dist/adapters/unix/batch-runner.js +382 -0
- package/dist/adapters/unix/ssh-client.js +228 -0
- package/dist/app/server.js +826 -0
- package/dist/cli.js +465 -0
- package/dist/config/environments.js +138 -0
- package/dist/config/registry.js +18 -0
- package/dist/config/secrets.js +123 -0
- package/dist/dsl/parser.js +20 -0
- package/dist/dsl/schema.js +182 -0
- package/dist/dsl/types.js +1 -0
- package/dist/dsl/validator.js +264 -0
- package/dist/engine/captures.js +68 -0
- package/dist/engine/context.js +69 -0
- package/dist/engine/evidence.js +33 -0
- package/dist/engine/known-errors.js +129 -0
- package/dist/engine/retry.js +13 -0
- package/dist/engine/runner.js +710 -0
- package/dist/engine/step-result.js +58 -0
- package/dist/flows/catalog-normalizer.js +72 -0
- package/dist/flows/compiler.js +237 -0
- package/dist/flows/concat.js +130 -0
- package/dist/flows/parser.js +21 -0
- package/dist/flows/schema.js +142 -0
- package/dist/flows/types.js +1 -0
- package/dist/flows/validator.js +470 -0
- package/dist/reports/html-report.js +112 -0
- package/dist/reports/junit-report.js +48 -0
- package/docs/.gitkeep +0 -0
- package/docs/DB_UNIX_OPERATIONS.md +118 -0
- package/docs/FLOW_BUILDER.md +87 -0
- package/flows/account_processing_cycle.flow.yaml +88 -0
- package/flows/new_flow.flow.yaml +22 -0
- package/package.json +92 -7
- package/scenarios/smoke/account-processing-smoke.yaml +44 -0
- package/scenarios/smoke/api-db-batch-check.yaml +40 -0
- package/src/actions/assert-db.ts +6 -0
- package/src/actions/run-eod.ts +6 -0
- package/src/adapters/api/api-collections.ts +375 -0
- package/src/adapters/api/body-utils.ts +10 -0
- package/src/adapters/api/rest-client.ts +587 -0
- package/src/adapters/api/soap-client.ts +7 -0
- package/src/adapters/db/assertions.ts +83 -0
- package/src/adapters/db/oracle-client.ts +133 -0
- package/src/adapters/db/query-catalog.ts +80 -0
- package/src/adapters/unix/batch-catalog.ts +81 -0
- package/src/adapters/unix/batch-input-files.ts +39 -0
- package/src/adapters/unix/batch-runner.ts +456 -0
- package/src/adapters/unix/ssh-client.ts +248 -0
- package/src/app/server.ts +913 -0
- package/src/cli.ts +466 -0
- package/src/config/environments.ts +193 -0
- package/src/config/registry.ts +23 -0
- package/src/config/secrets.ts +128 -0
- package/src/dsl/parser.ts +24 -0
- package/src/dsl/schema.ts +189 -0
- package/src/dsl/types.ts +371 -0
- package/src/dsl/validator.ts +282 -0
- package/src/engine/captures.ts +66 -0
- package/src/engine/context.ts +76 -0
- package/src/engine/evidence.ts +35 -0
- package/src/engine/known-errors.ts +145 -0
- package/src/engine/retry.ts +11 -0
- package/src/engine/runner.ts +746 -0
- package/src/engine/step-result.ts +64 -0
- package/src/flows/catalog-normalizer.ts +86 -0
- package/src/flows/compiler.ts +247 -0
- package/src/flows/concat.ts +149 -0
- package/src/flows/parser.ts +27 -0
- package/src/flows/schema.ts +154 -0
- package/src/flows/types.ts +130 -0
- package/src/flows/validator.ts +468 -0
- package/src/llm/system-prompt.md +9 -0
- package/src/reports/html-report.ts +113 -0
- package/src/reports/junit-report.ts +55 -0
- package/src/types/oracledb.d.ts +1 -0
- package/templates/.gitkeep +0 -0
- package/templates/api/create-test-case.json +5 -0
- package/templates/api/record-test-activity.json +6 -0
- package/tsconfig.json +15 -0
- package/vite.config.ts +17 -0
- package/web/index.html +12 -0
- package/web/src/App.tsx +6588 -0
- package/web/src/main.tsx +10 -0
- package/web/src/styles.css +3147 -0
- package/index.js +0 -1
|
@@ -0,0 +1,913 @@
|
|
|
1
|
+
import { createServer, type IncomingMessage, type ServerResponse } from "node:http";
|
|
2
|
+
import { access, mkdir, readFile, readdir, stat, unlink, writeFile } from "node:fs/promises";
|
|
3
|
+
import { createReadStream } from "node:fs";
|
|
4
|
+
import { execFile } from "node:child_process";
|
|
5
|
+
import { basename, dirname, extname, isAbsolute, join, relative, resolve } from "node:path";
|
|
6
|
+
import { fileURLToPath } from "node:url";
|
|
7
|
+
import YAML from "yaml";
|
|
8
|
+
import {
|
|
9
|
+
assertValidEnvironmentName,
|
|
10
|
+
getEnvironment,
|
|
11
|
+
loadEnvironmentFile,
|
|
12
|
+
writeEnvironmentFile,
|
|
13
|
+
type EditableEnvironmentConfig
|
|
14
|
+
} from "../config/environments.js";
|
|
15
|
+
import { loadCatalogs } from "../dsl/parser.js";
|
|
16
|
+
import { batchCatalogSchema, queryCatalogSchema } from "../dsl/schema.js";
|
|
17
|
+
import { validateScenarioReferences } from "../dsl/validator.js";
|
|
18
|
+
import { ScenarioRunner, ensureEvidenceRoot } from "../engine/runner.js";
|
|
19
|
+
import { normalizeFlowCatalogParams } from "../flows/catalog-normalizer.js";
|
|
20
|
+
import { compileFlow } from "../flows/compiler.js";
|
|
21
|
+
import { concatFlows, type FlowNodePrefixMode } from "../flows/concat.js";
|
|
22
|
+
import { loadFlow, writeFlow } from "../flows/parser.js";
|
|
23
|
+
import { validateFlow } from "../flows/validator.js";
|
|
24
|
+
import type { FlowFile } from "../flows/types.js";
|
|
25
|
+
import type { BatchCatalogEntry, QueryCatalogEntry, StepResult } from "../dsl/types.js";
|
|
26
|
+
import { importPostmanCollection, loadApiCollections, previewPostmanCollection } from "../adapters/api/api-collections.js";
|
|
27
|
+
import { normalizeBindParamRecord, normalizeQueryCatalog } from "../adapters/db/query-catalog.js";
|
|
28
|
+
|
|
29
|
+
const rootDir = resolve(dirname(fileURLToPath(import.meta.url)), "../..");
|
|
30
|
+
const flowsDir = join(rootDir, "flows");
|
|
31
|
+
const evidenceDir = join(rootDir, "evidence");
|
|
32
|
+
const batchInputFilesDir = join(rootDir, "data", "batch-input-files");
|
|
33
|
+
const webDistDir = join(rootDir, "web-dist");
|
|
34
|
+
const queriesFile = join(rootDir, "catalogs", "queries.yaml");
|
|
35
|
+
const batchesFile = join(rootDir, "catalogs", "batches.yaml");
|
|
36
|
+
const catalogYamlOptions = { defaultStringType: "PLAIN", defaultKeyType: "PLAIN", lineWidth: 0 } as const;
|
|
37
|
+
const defaultPort = 4177;
|
|
38
|
+
const configuredPort = process.env.ADFINEM_RUNNER_PORT ? Number(process.env.ADFINEM_RUNNER_PORT) : undefined;
|
|
39
|
+
const port = configuredPort ?? defaultPort;
|
|
40
|
+
|
|
41
|
+
interface RunState {
|
|
42
|
+
id: string;
|
|
43
|
+
flowId: string;
|
|
44
|
+
status: "running" | "stopping" | "passed" | "failed" | "cancelled";
|
|
45
|
+
startedAt: string;
|
|
46
|
+
endedAt?: string;
|
|
47
|
+
evidenceDir?: string;
|
|
48
|
+
error?: string;
|
|
49
|
+
currentStepId?: string;
|
|
50
|
+
currentStepStartedAt?: string;
|
|
51
|
+
updatedAt?: string;
|
|
52
|
+
result?: { steps?: StepResult[] } | unknown;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const runs = new Map<string, RunState>();
|
|
56
|
+
const runControllers = new Map<string, AbortController>();
|
|
57
|
+
|
|
58
|
+
const server = createServer(async (req, res) => {
|
|
59
|
+
try {
|
|
60
|
+
if (!req.url) return sendJson(res, 404, { error: "Not found" });
|
|
61
|
+
const url = new URL(req.url, `http://${req.headers.host ?? "localhost"}`);
|
|
62
|
+
if (url.pathname.startsWith("/api/")) {
|
|
63
|
+
await handleApi(req, res, url);
|
|
64
|
+
return;
|
|
65
|
+
}
|
|
66
|
+
await serveStatic(res, url.pathname);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
69
|
+
sendJson(res, 500, { error: err.message, stack: process.env.DEBUG ? err.stack : undefined });
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
void listenWithFallback(server, port, configuredPort === undefined ? 10 : 0).catch((error) => {
|
|
74
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
75
|
+
console.error(`Could not start Adfinem app: ${err.message}`);
|
|
76
|
+
process.exitCode = 1;
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
async function listenWithFallback(appServer: ReturnType<typeof createServer>, firstPort: number, fallbackPorts: number): Promise<void> {
|
|
80
|
+
for (let offset = 0; offset <= fallbackPorts; offset++) {
|
|
81
|
+
const candidate = firstPort + offset;
|
|
82
|
+
const started = await tryListen(appServer, candidate);
|
|
83
|
+
if (started) {
|
|
84
|
+
if (candidate !== firstPort) {
|
|
85
|
+
console.warn(`Port ${firstPort} is already in use; using ${candidate} instead.`);
|
|
86
|
+
}
|
|
87
|
+
console.log(`Adfinem app: http://localhost:${candidate}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const endPort = firstPort + fallbackPorts;
|
|
93
|
+
const range = fallbackPorts > 0 ? `${firstPort}-${endPort}` : String(firstPort);
|
|
94
|
+
console.error(`Could not start Adfinem app. Port ${range} is already in use.`);
|
|
95
|
+
process.exitCode = 1;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
async function tryListen(appServer: ReturnType<typeof createServer>, candidatePort: number): Promise<boolean> {
|
|
99
|
+
return await new Promise((resolve, reject) => {
|
|
100
|
+
const onError = (error: NodeJS.ErrnoException) => {
|
|
101
|
+
appServer.off("listening", onListening);
|
|
102
|
+
if (error.code === "EADDRINUSE") {
|
|
103
|
+
resolve(false);
|
|
104
|
+
return;
|
|
105
|
+
}
|
|
106
|
+
reject(error);
|
|
107
|
+
};
|
|
108
|
+
const onListening = () => {
|
|
109
|
+
appServer.off("error", onError);
|
|
110
|
+
resolve(true);
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
appServer.once("error", onError);
|
|
114
|
+
appServer.once("listening", onListening);
|
|
115
|
+
appServer.listen(candidatePort);
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async function handleApi(req: IncomingMessage, res: ServerResponse, url: URL): Promise<void> {
|
|
120
|
+
if (req.method === "GET" && url.pathname === "/api/environments") {
|
|
121
|
+
sendJson(res, 200, { environments: environmentListResponse() });
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
const environmentMatch = /^\/api\/environments\/([^/]+)$/.exec(url.pathname);
|
|
126
|
+
if (environmentMatch) {
|
|
127
|
+
const currentName = decodeURIComponent(environmentMatch[1]);
|
|
128
|
+
if (req.method === "PUT") {
|
|
129
|
+
const body = await readJsonBody<{ name?: string; config?: EditableEnvironmentConfig } & EditableEnvironmentConfig>(req);
|
|
130
|
+
const nextName = (body.name || currentName).trim();
|
|
131
|
+
try {
|
|
132
|
+
assertValidEnvironmentName(currentName);
|
|
133
|
+
assertValidEnvironmentName(nextName);
|
|
134
|
+
} catch (error) {
|
|
135
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
136
|
+
sendJson(res, 400, { error: err.message });
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
const environments = loadEnvironmentFile(rootDir);
|
|
140
|
+
const config = body.config ?? environmentConfigFromRequest(body);
|
|
141
|
+
if (nextName !== currentName) delete environments[currentName];
|
|
142
|
+
environments[nextName] = config;
|
|
143
|
+
await writeEnvironmentFile(rootDir, environments);
|
|
144
|
+
sendJson(res, 200, { environments: environmentListResponse(), environment: { name: nextName, ...loadEnvironmentFile(rootDir)[nextName] } });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
if (req.method === "GET" && url.pathname === "/api/catalogs") {
|
|
150
|
+
const catalogs = await loadCatalogs(rootDir);
|
|
151
|
+
sendJson(res, 200, {
|
|
152
|
+
apiOperations: catalogs.apiOperations,
|
|
153
|
+
queries: catalogs.queries,
|
|
154
|
+
batches: catalogs.batches
|
|
155
|
+
});
|
|
156
|
+
return;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (req.method === "GET" && url.pathname === "/api/db-queries") {
|
|
160
|
+
sendJson(res, 200, { queries: await loadDbQueryCatalog() });
|
|
161
|
+
return;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (req.method === "POST" && url.pathname === "/api/db-queries") {
|
|
165
|
+
const body = await readJsonBody<{ id?: string; query?: QueryCatalogEntry } & Partial<QueryCatalogEntry>>(req);
|
|
166
|
+
const id = assertCatalogId(body.id);
|
|
167
|
+
const entry = normalizeQueryCatalogEntry(body.query ?? body);
|
|
168
|
+
const queries = await loadDbQueryCatalog();
|
|
169
|
+
if (queries[id]) return sendJson(res, 409, { error: `DB query '${id}' already exists.` });
|
|
170
|
+
queries[id] = entry;
|
|
171
|
+
await writeDbQueryCatalog(queries);
|
|
172
|
+
sendJson(res, 201, { id, query: entry, queries });
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
const dbQueryMatch = /^\/api\/db-queries\/([^/]+)$/.exec(url.pathname);
|
|
177
|
+
if (dbQueryMatch) {
|
|
178
|
+
const currentId = assertCatalogId(decodeURIComponent(dbQueryMatch[1]));
|
|
179
|
+
if (req.method === "PUT") {
|
|
180
|
+
const body = await readJsonBody<{ id?: string; query?: QueryCatalogEntry } & Partial<QueryCatalogEntry>>(req);
|
|
181
|
+
const nextId = assertCatalogId(body.id || currentId);
|
|
182
|
+
const entry = normalizeQueryCatalogEntry(body.query ?? body);
|
|
183
|
+
const queries = await loadDbQueryCatalog();
|
|
184
|
+
if (!queries[currentId]) return sendJson(res, 404, { error: `Unknown DB query '${currentId}'.` });
|
|
185
|
+
if (nextId !== currentId && queries[nextId]) return sendJson(res, 409, { error: `DB query '${nextId}' already exists.` });
|
|
186
|
+
delete queries[currentId];
|
|
187
|
+
queries[nextId] = entry;
|
|
188
|
+
await writeDbQueryCatalog(queries);
|
|
189
|
+
sendJson(res, 200, { id: nextId, query: entry, queries });
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
192
|
+
if (req.method === "DELETE") {
|
|
193
|
+
const queries = await loadDbQueryCatalog();
|
|
194
|
+
if (!queries[currentId]) return sendJson(res, 404, { error: `Unknown DB query '${currentId}'.` });
|
|
195
|
+
delete queries[currentId];
|
|
196
|
+
await writeDbQueryCatalog(queries);
|
|
197
|
+
sendJson(res, 200, { queries });
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
if (req.method === "GET" && url.pathname === "/api/unix-batches") {
|
|
203
|
+
sendJson(res, 200, { batches: await loadUnixBatchCatalog() });
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
if (req.method === "POST" && url.pathname === "/api/batch-input-files") {
|
|
208
|
+
const body = await readJsonBody<{
|
|
209
|
+
flowId?: string;
|
|
210
|
+
stepId?: string;
|
|
211
|
+
inputName?: string;
|
|
212
|
+
fileName?: string;
|
|
213
|
+
contentBase64?: string;
|
|
214
|
+
}>(req);
|
|
215
|
+
const upload = await saveBatchInputFile(body);
|
|
216
|
+
sendJson(res, 201, upload);
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
if (req.method === "POST" && url.pathname === "/api/unix-batches") {
|
|
221
|
+
const body = await readJsonBody<{ id?: string; batch?: BatchCatalogEntry } & Partial<BatchCatalogEntry>>(req);
|
|
222
|
+
const id = assertCatalogId(body.id);
|
|
223
|
+
const entry = normalizeUnixBatchCatalogEntry(body.batch ?? body);
|
|
224
|
+
const batches = await loadUnixBatchCatalog();
|
|
225
|
+
if (batches[id]) return sendJson(res, 409, { error: `Unix batch '${id}' already exists.` });
|
|
226
|
+
batches[id] = entry;
|
|
227
|
+
await writeUnixBatchCatalog(batches);
|
|
228
|
+
sendJson(res, 201, { id, batch: entry, batches });
|
|
229
|
+
return;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const unixBatchMatch = /^\/api\/unix-batches\/([^/]+)$/.exec(url.pathname);
|
|
233
|
+
if (unixBatchMatch) {
|
|
234
|
+
const currentId = assertCatalogId(decodeURIComponent(unixBatchMatch[1]));
|
|
235
|
+
if (req.method === "PUT") {
|
|
236
|
+
const body = await readJsonBody<{ id?: string; batch?: BatchCatalogEntry } & Partial<BatchCatalogEntry>>(req);
|
|
237
|
+
const nextId = assertCatalogId(body.id || currentId);
|
|
238
|
+
const entry = normalizeUnixBatchCatalogEntry(body.batch ?? body);
|
|
239
|
+
const batches = await loadUnixBatchCatalog();
|
|
240
|
+
if (!batches[currentId]) return sendJson(res, 404, { error: `Unknown Unix batch '${currentId}'.` });
|
|
241
|
+
if (nextId !== currentId && batches[nextId]) return sendJson(res, 409, { error: `Unix batch '${nextId}' already exists.` });
|
|
242
|
+
delete batches[currentId];
|
|
243
|
+
batches[nextId] = entry;
|
|
244
|
+
await writeUnixBatchCatalog(batches);
|
|
245
|
+
sendJson(res, 200, { id: nextId, batch: entry, batches });
|
|
246
|
+
return;
|
|
247
|
+
}
|
|
248
|
+
if (req.method === "DELETE") {
|
|
249
|
+
const batches = await loadUnixBatchCatalog();
|
|
250
|
+
if (!batches[currentId]) return sendJson(res, 404, { error: `Unknown Unix batch '${currentId}'.` });
|
|
251
|
+
delete batches[currentId];
|
|
252
|
+
await writeUnixBatchCatalog(batches);
|
|
253
|
+
sendJson(res, 200, { batches });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (req.method === "GET" && url.pathname === "/api/api-collections") {
|
|
259
|
+
sendJson(res, 200, await loadApiCollections(rootDir));
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
if (req.method === "POST" && url.pathname === "/api/api-collections/preview") {
|
|
264
|
+
const body = await readJsonBody<{ collection?: unknown }>(req);
|
|
265
|
+
sendJson(res, 200, previewPostmanCollection(body.collection ?? body));
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
if (req.method === "POST" && url.pathname === "/api/api-collections/import") {
|
|
270
|
+
const body = await readJsonBody<{ collection?: unknown }>(req);
|
|
271
|
+
const collection = await importPostmanCollection(rootDir, body.collection ?? body);
|
|
272
|
+
sendJson(res, 200, { collection, collections: (await loadApiCollections(rootDir)).collections });
|
|
273
|
+
return;
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const collectionRequestsMatch = /^\/api\/api-collections\/([^/]+)\/requests$/.exec(url.pathname);
|
|
277
|
+
if (req.method === "GET" && collectionRequestsMatch) {
|
|
278
|
+
const collectionId = decodeURIComponent(collectionRequestsMatch[1]);
|
|
279
|
+
const file = await loadApiCollections(rootDir);
|
|
280
|
+
const collection = file.collections.find((item) => item.id === collectionId);
|
|
281
|
+
sendJson(res, collection ? 200 : 404, collection ? { collection, requests: collection.requests } : { error: "Unknown API collection." });
|
|
282
|
+
return;
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
if (req.method === "POST" && url.pathname === "/api/postman-environments/import") {
|
|
286
|
+
const body = await readJsonBody<{ environment?: unknown }>(req);
|
|
287
|
+
sendJson(res, 200, parsePostmanEnvironment(body.environment ?? body));
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
if (req.method === "POST" && url.pathname === "/api/open-path") {
|
|
292
|
+
const body = await readJsonBody<{ path?: string }>(req);
|
|
293
|
+
const target = resolve(rootDir, body.path ?? "");
|
|
294
|
+
if (!isInsidePath(evidenceDir, target)) {
|
|
295
|
+
sendJson(res, 400, { error: "Path must be under the evidence directory." });
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
openLocalPath(target);
|
|
299
|
+
sendJson(res, 200, { ok: true });
|
|
300
|
+
return;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
if (req.method === "GET" && url.pathname === "/api/flows") {
|
|
304
|
+
sendJson(res, 200, { flows: await listFlows() });
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if (req.method === "POST" && url.pathname === "/api/flows/concat") {
|
|
309
|
+
const body = await readJsonBody<{
|
|
310
|
+
flowIds?: string[];
|
|
311
|
+
outputId?: string;
|
|
312
|
+
name?: string;
|
|
313
|
+
environment?: string;
|
|
314
|
+
nodePrefixMode?: FlowNodePrefixMode;
|
|
315
|
+
allowVariableOverrides?: boolean;
|
|
316
|
+
}>(req);
|
|
317
|
+
const flowIds = body.flowIds ?? [];
|
|
318
|
+
if (flowIds.length < 2) return sendJson(res, 400, { error: "Select at least two flows to concatenate." });
|
|
319
|
+
const inputFlows = await Promise.all(flowIds.map(async (id) => loadFlow(await resolveFlowPath(id))));
|
|
320
|
+
const outputId = body.outputId?.trim() || `${inputFlows.map((flow) => flow.id).join("_")}_combined`;
|
|
321
|
+
const flow = concatFlows(inputFlows, {
|
|
322
|
+
id: outputId,
|
|
323
|
+
name: body.name,
|
|
324
|
+
environment: body.environment,
|
|
325
|
+
nodePrefixMode: parsePrefixMode(body.nodePrefixMode ?? "auto"),
|
|
326
|
+
allowVariableOverrides: body.allowVariableOverrides
|
|
327
|
+
});
|
|
328
|
+
const catalogs = await loadCatalogs(rootDir);
|
|
329
|
+
const validation = await validateFlow(flow, catalogs, rootDir);
|
|
330
|
+
if (!validation.ok) return sendJson(res, 400, { error: "Generated flow is invalid.", validation });
|
|
331
|
+
const outputPath = await resolveFlowPath(outputId, true);
|
|
332
|
+
await writeFlow(outputPath, flow);
|
|
333
|
+
sendJson(res, 200, { flow, validation });
|
|
334
|
+
return;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
const flowMatch = /^\/api\/flows\/([^/]+)(?:\/(validate|compile|run))?$/.exec(url.pathname);
|
|
338
|
+
if (flowMatch) {
|
|
339
|
+
const flowId = decodeURIComponent(flowMatch[1]);
|
|
340
|
+
const action = flowMatch[2];
|
|
341
|
+
if (req.method === "GET" && !action) {
|
|
342
|
+
sendJson(res, 200, { flow: await loadFlow(await resolveFlowPath(flowId)) });
|
|
343
|
+
return;
|
|
344
|
+
}
|
|
345
|
+
if (req.method === "DELETE" && !action) {
|
|
346
|
+
await deleteFlow(flowId);
|
|
347
|
+
sendJson(res, 200, { flows: await listFlows() });
|
|
348
|
+
return;
|
|
349
|
+
}
|
|
350
|
+
if (req.method === "PUT" && !action) {
|
|
351
|
+
const body = await readJsonBody<FlowFile>(req);
|
|
352
|
+
const outputPath = await resolveFlowPath(flowId, true);
|
|
353
|
+
const catalogs = await loadCatalogs(rootDir);
|
|
354
|
+
const normalized = normalizeFlowCatalogParams({ ...body, id: body.id || flowId }, catalogs);
|
|
355
|
+
const validation = await validateFlow(normalized, catalogs, rootDir);
|
|
356
|
+
if (!validation.ok) {
|
|
357
|
+
sendJson(res, 400, {
|
|
358
|
+
error: "Flow is invalid; it was not saved.",
|
|
359
|
+
validation
|
|
360
|
+
});
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
await writeFlow(outputPath, normalized);
|
|
364
|
+
sendJson(res, 200, { flow: await loadFlow(outputPath), validation });
|
|
365
|
+
return;
|
|
366
|
+
}
|
|
367
|
+
if (req.method === "POST" && action === "validate") {
|
|
368
|
+
const catalogs = await loadCatalogs(rootDir);
|
|
369
|
+
const flow = normalizeFlowCatalogParams(await loadFlow(await resolveFlowPath(flowId)), catalogs);
|
|
370
|
+
sendJson(res, 200, await validateFlow(flow, catalogs, rootDir));
|
|
371
|
+
return;
|
|
372
|
+
}
|
|
373
|
+
if (req.method === "POST" && action === "compile") {
|
|
374
|
+
const body = await readJsonBody<{ env?: string }>(req).catch(() => ({} as { env?: string }));
|
|
375
|
+
const catalogs = await loadCatalogs(rootDir);
|
|
376
|
+
const flow = normalizeFlowCatalogParams(await loadFlow(await resolveFlowPath(flowId)), catalogs);
|
|
377
|
+
const validation = await validateFlow(flow, catalogs, rootDir, body.env ?? flow.environment);
|
|
378
|
+
if (!validation.ok) return sendJson(res, 400, validation);
|
|
379
|
+
sendJson(res, 200, compileFlow(flow, { environment: body.env }));
|
|
380
|
+
return;
|
|
381
|
+
}
|
|
382
|
+
if (req.method === "POST" && action === "run") {
|
|
383
|
+
const body = await readJsonBody<{ env?: string; dryRun?: boolean; startFrom?: string; runScope?: "from" | "only" }>(req).catch(() => ({} as { env?: string; dryRun?: boolean; startFrom?: string; runScope?: "from" | "only" }));
|
|
384
|
+
const catalogs = await loadCatalogs(rootDir);
|
|
385
|
+
const flow = normalizeFlowCatalogParams(await loadFlow(await resolveFlowPath(flowId)), catalogs);
|
|
386
|
+
const runId = `${flow.id}-${new Date().toISOString().replace(/[:.]/g, "-")}`;
|
|
387
|
+
const controller = new AbortController();
|
|
388
|
+
runControllers.set(runId, controller);
|
|
389
|
+
runs.set(runId, { id: runId, flowId: flow.id, status: "running", startedAt: new Date().toISOString(), updatedAt: new Date().toISOString(), result: { steps: [] } });
|
|
390
|
+
void runFlow(flow, body.env, Boolean(body.dryRun), runId, body.startFrom?.trim() || undefined, body.runScope, controller.signal);
|
|
391
|
+
sendJson(res, 202, { runId, status: "running" });
|
|
392
|
+
return;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
if (req.method === "GET" && url.pathname === "/api/runs/history") {
|
|
397
|
+
sendJson(res, 200, { runs: await listRunHistory(url.searchParams.get("flowId") ?? undefined) });
|
|
398
|
+
return;
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
const runMatch = /^\/api\/runs\/([^/]+)(?:\/(stop))?$/.exec(url.pathname);
|
|
402
|
+
if (runMatch) {
|
|
403
|
+
const runId = decodeURIComponent(runMatch[1]);
|
|
404
|
+
const action = runMatch[2];
|
|
405
|
+
if (req.method === "GET" && !action) {
|
|
406
|
+
const run = runs.get(runId);
|
|
407
|
+
sendJson(res, run ? 200 : 404, run ?? { error: "Unknown run." });
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
if (req.method === "POST" && action === "stop") {
|
|
411
|
+
const run = runs.get(runId);
|
|
412
|
+
if (!run) {
|
|
413
|
+
sendJson(res, 404, { error: "Unknown run." });
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
const controller = runControllers.get(runId);
|
|
417
|
+
if (controller && !controller.signal.aborted && (run.status === "running" || run.status === "stopping")) {
|
|
418
|
+
controller.abort();
|
|
419
|
+
runs.set(runId, { ...run, status: "stopping" });
|
|
420
|
+
sendJson(res, 202, { runId, status: "stopping" });
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
sendJson(res, 200, { runId, status: run.status });
|
|
424
|
+
return;
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
sendJson(res, 404, { error: "Not found" });
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
function environmentListResponse(): Array<{ name: string } & EditableEnvironmentConfig> {
|
|
431
|
+
return Object.keys(loadEnvironmentFile(rootDir)).map((name) => {
|
|
432
|
+
const env = getEnvironment(name, rootDir);
|
|
433
|
+
return {
|
|
434
|
+
name,
|
|
435
|
+
apiBaseUrl: env.apiBaseUrl,
|
|
436
|
+
apiTlsInsecure: env.apiTlsInsecure,
|
|
437
|
+
oracle: env.oracle,
|
|
438
|
+
sshHosts: env.sshHosts
|
|
439
|
+
};
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
async function loadDbQueryCatalog(): Promise<Record<string, QueryCatalogEntry>> {
|
|
444
|
+
const parsed = YAML.parse(await readFile(queriesFile, "utf8")) as unknown;
|
|
445
|
+
return normalizeQueryCatalog(queryCatalogSchema.parse(parsed ?? {}) as Record<string, QueryCatalogEntry>);
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
async function writeDbQueryCatalog(queries: Record<string, QueryCatalogEntry>): Promise<void> {
|
|
449
|
+
const validated = normalizeQueryCatalog(queryCatalogSchema.parse(queries) as Record<string, QueryCatalogEntry>);
|
|
450
|
+
await writeFile(queriesFile, YAML.stringify(validated, catalogYamlOptions), "utf8");
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
async function loadUnixBatchCatalog(): Promise<Record<string, BatchCatalogEntry>> {
|
|
454
|
+
const parsed = YAML.parse(await readFile(batchesFile, "utf8")) as unknown;
|
|
455
|
+
return batchCatalogSchema.parse(parsed ?? {}) as Record<string, BatchCatalogEntry>;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
async function writeUnixBatchCatalog(batches: Record<string, BatchCatalogEntry>): Promise<void> {
|
|
459
|
+
const validated = batchCatalogSchema.parse(batches) as Record<string, BatchCatalogEntry>;
|
|
460
|
+
await writeFile(batchesFile, YAML.stringify(validated, catalogYamlOptions), "utf8");
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
function assertCatalogId(value: unknown): string {
|
|
464
|
+
const id = String(value ?? "").trim();
|
|
465
|
+
if (!/^[A-Za-z0-9_-]+$/.test(id)) {
|
|
466
|
+
throw new Error("DB query id must use only letters, numbers, underscores, and hyphens.");
|
|
467
|
+
}
|
|
468
|
+
return id;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
function normalizeQueryCatalogEntry(value: Partial<QueryCatalogEntry>): QueryCatalogEntry {
|
|
472
|
+
const entry: QueryCatalogEntry = {
|
|
473
|
+
description: emptyToUndefined(value.description),
|
|
474
|
+
mode: value.mode === "execute" ? "execute" : value.mode === "query" ? "query" : undefined,
|
|
475
|
+
sql: String(value.sql ?? "").trim(),
|
|
476
|
+
params: normalizeOptionalBindParams(emptyRecordToUndefined(value.params)),
|
|
477
|
+
expect: value.expect,
|
|
478
|
+
captures: emptyRecordToUndefined(value.captures),
|
|
479
|
+
maxRows: value.maxRows === undefined || value.maxRows === null || value.maxRows === 0 ? undefined : Number(value.maxRows)
|
|
480
|
+
};
|
|
481
|
+
return (queryCatalogSchema.parse({ candidate: removeUndefined(entry as unknown as Record<string, unknown>) }) as Record<string, QueryCatalogEntry>).candidate;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
function normalizeUnixBatchCatalogEntry(value: Partial<BatchCatalogEntry>): BatchCatalogEntry {
|
|
485
|
+
const entry: BatchCatalogEntry = {
|
|
486
|
+
description: emptyToUndefined(value.description),
|
|
487
|
+
hostRef: String(value.hostRef ?? "").trim(),
|
|
488
|
+
command: String(value.command ?? "").trim(),
|
|
489
|
+
fixedArgs: normalizeScalarArray(value.fixedArgs),
|
|
490
|
+
workingDirectory: emptyToUndefined(value.workingDirectory),
|
|
491
|
+
useWorkingDirectory: value.useWorkingDirectory === true ? true : undefined,
|
|
492
|
+
environment: emptyRecordToUndefined(value.environment as Record<string, string | number | boolean> | undefined),
|
|
493
|
+
args: normalizeBatchArgs(value.args),
|
|
494
|
+
inputFiles: normalizeBatchInputFiles(value.inputFiles),
|
|
495
|
+
outputFiles: normalizeBatchOutputFiles(value.outputFiles),
|
|
496
|
+
timeoutSeconds: value.timeoutSeconds === undefined || value.timeoutSeconds === null || value.timeoutSeconds === 0 ? undefined : Number(value.timeoutSeconds),
|
|
497
|
+
success: normalizeBatchSuccess(value.success),
|
|
498
|
+
captures: emptyRecordToUndefined(value.captures)
|
|
499
|
+
};
|
|
500
|
+
return (batchCatalogSchema.parse({ candidate: removeUndefined(entry as unknown as Record<string, unknown>) }) as Record<string, BatchCatalogEntry>).candidate;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
function normalizeBatchInputFiles(inputFiles: BatchCatalogEntry["inputFiles"] | undefined): BatchCatalogEntry["inputFiles"] | undefined {
|
|
504
|
+
const entries = (inputFiles ?? [])
|
|
505
|
+
.map((file) => ({
|
|
506
|
+
name: String(file.name ?? "").trim(),
|
|
507
|
+
required: file.required === undefined ? undefined : Boolean(file.required),
|
|
508
|
+
remotePath: emptyToUndefined(file.remotePath),
|
|
509
|
+
paramName: emptyToUndefined(file.paramName),
|
|
510
|
+
appendAsArg: file.appendAsArg || undefined
|
|
511
|
+
}))
|
|
512
|
+
.filter((file) => file.name);
|
|
513
|
+
return entries.length ? entries : undefined;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function normalizeBatchOutputFiles(outputFiles: BatchCatalogEntry["outputFiles"] | undefined): BatchCatalogEntry["outputFiles"] | undefined {
|
|
517
|
+
const entries = (outputFiles ?? [])
|
|
518
|
+
.map((file) => ({
|
|
519
|
+
name: String(file.name ?? "").trim(),
|
|
520
|
+
required: file.required === undefined ? undefined : Boolean(file.required),
|
|
521
|
+
source: file.source,
|
|
522
|
+
pathPattern: emptyToUndefined(file.pathPattern),
|
|
523
|
+
remotePath: emptyToUndefined(file.remotePath),
|
|
524
|
+
download: file.download === undefined ? undefined : Boolean(file.download),
|
|
525
|
+
decrypt: file.decrypt ? removeUndefined({
|
|
526
|
+
command: emptyToUndefined(file.decrypt.command),
|
|
527
|
+
outputRemotePath: emptyToUndefined(file.decrypt.outputRemotePath),
|
|
528
|
+
required: file.decrypt.required === undefined ? undefined : Boolean(file.decrypt.required)
|
|
529
|
+
}) : undefined
|
|
530
|
+
}))
|
|
531
|
+
.filter((file) => file.name);
|
|
532
|
+
return entries.length ? entries as BatchCatalogEntry["outputFiles"] : undefined;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
function normalizeScalarArray(value: unknown): Array<string | number | boolean> | undefined {
|
|
536
|
+
if (!Array.isArray(value)) return undefined;
|
|
537
|
+
const items = value
|
|
538
|
+
.map((entry) => typeof entry === "number" || typeof entry === "boolean" ? entry : String(entry ?? "").trim())
|
|
539
|
+
.filter((entry) => entry !== "");
|
|
540
|
+
return items.length ? items as Array<string | number | boolean> : undefined;
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function normalizeBatchArgs(args: BatchCatalogEntry["args"] | undefined): BatchCatalogEntry["args"] | undefined {
|
|
544
|
+
const entries = (args ?? [])
|
|
545
|
+
.map((arg) => ({
|
|
546
|
+
name: String(arg.name ?? "").trim(),
|
|
547
|
+
required: arg.required === undefined ? undefined : Boolean(arg.required),
|
|
548
|
+
type: arg.type,
|
|
549
|
+
pattern: emptyToUndefined(arg.pattern),
|
|
550
|
+
luhn: arg.luhn || undefined
|
|
551
|
+
}))
|
|
552
|
+
.filter((arg) => arg.name);
|
|
553
|
+
return entries.length ? entries : undefined;
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
function normalizeBatchSuccess(success: BatchCatalogEntry["success"] | undefined): BatchCatalogEntry["success"] | undefined {
|
|
557
|
+
if (!success) return undefined;
|
|
558
|
+
const exitCodes = (success.exitCodes ?? []).map(Number).filter(Number.isInteger);
|
|
559
|
+
const requiredOutput = (success.requiredOutput ?? []).map((value) => String(value ?? "").trim()).filter(Boolean);
|
|
560
|
+
const normalized = {
|
|
561
|
+
exitCodes: exitCodes.length ? exitCodes : undefined,
|
|
562
|
+
requiredOutput: requiredOutput.length ? requiredOutput : undefined
|
|
563
|
+
};
|
|
564
|
+
return Object.keys(removeUndefined(normalized)).length ? removeUndefined(normalized) : undefined;
|
|
565
|
+
}
|
|
566
|
+
|
|
567
|
+
function normalizeOptionalBindParams<T>(value: Record<string, T> | undefined): Record<string, T> | undefined {
|
|
568
|
+
if (!value) return undefined;
|
|
569
|
+
const normalized = normalizeBindParamRecord(value);
|
|
570
|
+
return Object.keys(normalized).length ? normalized : undefined;
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
function emptyToUndefined(value: unknown): string | undefined {
|
|
574
|
+
const text = String(value ?? "").trim();
|
|
575
|
+
return text ? text : undefined;
|
|
576
|
+
}
|
|
577
|
+
|
|
578
|
+
function emptyRecordToUndefined<T>(value: Record<string, T> | undefined): Record<string, T> | undefined {
|
|
579
|
+
if (!value) return undefined;
|
|
580
|
+
const entries = Object.entries(value).filter(([key, entry]) => key.trim() && entry !== undefined && entry !== null && String(entry).trim() !== "");
|
|
581
|
+
return entries.length ? Object.fromEntries(entries) as Record<string, T> : undefined;
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
async function saveBatchInputFile(body: {
|
|
585
|
+
flowId?: string;
|
|
586
|
+
stepId?: string;
|
|
587
|
+
inputName?: string;
|
|
588
|
+
fileName?: string;
|
|
589
|
+
contentBase64?: string;
|
|
590
|
+
}): Promise<{ fileName: string; localPath: string; sizeBytes: number }> {
|
|
591
|
+
const flowId = safeStorageSegment(body.flowId, "flow");
|
|
592
|
+
const stepId = safeStorageSegment(body.stepId, "step");
|
|
593
|
+
const inputName = safeStorageSegment(body.inputName, "input");
|
|
594
|
+
const fileName = safeFileName(body.fileName);
|
|
595
|
+
const rawContent = String(body.contentBase64 ?? "");
|
|
596
|
+
if (!rawContent) throw new Error("No file content was provided.");
|
|
597
|
+
const base64 = rawContent.includes(",") ? rawContent.slice(rawContent.indexOf(",") + 1) : rawContent;
|
|
598
|
+
const buffer = Buffer.from(base64, "base64");
|
|
599
|
+
if (buffer.length === 0) throw new Error("Uploaded file is empty.");
|
|
600
|
+
const maxBytes = 50 * 1024 * 1024;
|
|
601
|
+
if (buffer.length > maxBytes) throw new Error(`Batch input file exceeds ${Math.round(maxBytes / 1024 / 1024)} MB.`);
|
|
602
|
+
|
|
603
|
+
const targetDir = resolve(batchInputFilesDir, flowId, stepId, inputName);
|
|
604
|
+
if (!isInsidePath(batchInputFilesDir, targetDir)) throw new Error("Invalid batch input file target.");
|
|
605
|
+
await mkdir(targetDir, { recursive: true });
|
|
606
|
+
const targetPath = resolve(targetDir, `${Date.now()}_${fileName}`);
|
|
607
|
+
if (!isInsidePath(batchInputFilesDir, targetPath)) throw new Error("Invalid batch input file path.");
|
|
608
|
+
await writeFile(targetPath, buffer);
|
|
609
|
+
return {
|
|
610
|
+
fileName,
|
|
611
|
+
localPath: relative(rootDir, targetPath).replace(/\\/g, "/"),
|
|
612
|
+
sizeBytes: buffer.length
|
|
613
|
+
};
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
function safeStorageSegment(value: unknown, fallback: string): string {
|
|
617
|
+
const normalized = String(value ?? "").trim().replace(/[^A-Za-z0-9_-]/g, "_").replace(/^_+|_+$/g, "");
|
|
618
|
+
return normalized || fallback;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
function safeFileName(value: unknown): string {
|
|
622
|
+
const name = basename(String(value ?? "input.dat")).replace(/[^A-Za-z0-9_.-]/g, "_").replace(/^_+|_+$/g, "");
|
|
623
|
+
return name || "input.dat";
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
function removeUndefined<T extends Record<string, unknown>>(value: T): T {
|
|
627
|
+
return Object.fromEntries(Object.entries(value).filter(([, entry]) => entry !== undefined)) as T;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function environmentConfigFromRequest(body: EditableEnvironmentConfig): EditableEnvironmentConfig {
|
|
631
|
+
return {
|
|
632
|
+
apiBaseUrl: body.apiBaseUrl,
|
|
633
|
+
apiTlsInsecure: body.apiTlsInsecure,
|
|
634
|
+
oracle: body.oracle,
|
|
635
|
+
sshHosts: body.sshHosts
|
|
636
|
+
};
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
async function listFlows(): Promise<Array<{ id: string; name?: string; environment: string; path: string }>> {
|
|
640
|
+
await mkdir(flowsDir, { recursive: true });
|
|
641
|
+
const entries = await readdir(flowsDir);
|
|
642
|
+
const flows = [];
|
|
643
|
+
for (const entry of entries.filter((name) => name.endsWith(".flow.yaml") || name.endsWith(".flow.yml"))) {
|
|
644
|
+
const path = join(flowsDir, entry);
|
|
645
|
+
const flow = await loadFlow(path).catch(() => undefined);
|
|
646
|
+
if (flow) flows.push({ id: flow.id, name: flowListName(flow), environment: flow.environment, path });
|
|
647
|
+
}
|
|
648
|
+
return flows;
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
function flowListName(flow: { id: string; name?: string }): string | undefined {
|
|
652
|
+
const name = flow.name?.trim();
|
|
653
|
+
if (!name || (name.toLowerCase() === "new flow" && flow.id !== "new_flow")) return undefined;
|
|
654
|
+
return name;
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
async function deleteFlow(flowId: string): Promise<void> {
|
|
658
|
+
const path = await resolveFlowPath(flowId);
|
|
659
|
+
const normalizedFlowsDir = resolve(flowsDir).toLowerCase();
|
|
660
|
+
const normalizedPath = resolve(path).toLowerCase();
|
|
661
|
+
if (!normalizedPath.startsWith(`${normalizedFlowsDir}\\`) && normalizedPath !== normalizedFlowsDir) {
|
|
662
|
+
throw new Error(`Refusing to delete flow outside flows directory: ${path}`);
|
|
663
|
+
}
|
|
664
|
+
await unlink(path);
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
async function listRunHistory(flowId?: string): Promise<Array<{
|
|
668
|
+
runId: string;
|
|
669
|
+
scenarioId: string;
|
|
670
|
+
status: string;
|
|
671
|
+
startedAt: string;
|
|
672
|
+
endedAt?: string;
|
|
673
|
+
durationMs?: number;
|
|
674
|
+
failedStep?: string;
|
|
675
|
+
evidenceDir: string;
|
|
676
|
+
reportPath?: string;
|
|
677
|
+
}>> {
|
|
678
|
+
if (!(await fileExists(evidenceDir))) return [];
|
|
679
|
+
const entries = await readdir(evidenceDir, { withFileTypes: true });
|
|
680
|
+
const runs = [];
|
|
681
|
+
for (const entry of entries.filter((item) => item.isDirectory())) {
|
|
682
|
+
const runResultPath = join(evidenceDir, entry.name, "run-result.json");
|
|
683
|
+
try {
|
|
684
|
+
const result = JSON.parse(await readFile(runResultPath, "utf8")) as {
|
|
685
|
+
scenarioId?: string;
|
|
686
|
+
runId?: string;
|
|
687
|
+
status?: string;
|
|
688
|
+
startedAt?: string;
|
|
689
|
+
endedAt?: string;
|
|
690
|
+
durationMs?: number;
|
|
691
|
+
evidenceDir?: string;
|
|
692
|
+
steps?: Array<{ stepId?: string; status?: string }>;
|
|
693
|
+
};
|
|
694
|
+
if (flowId && result.scenarioId !== flowId) continue;
|
|
695
|
+
runs.push({
|
|
696
|
+
runId: result.runId ?? entry.name,
|
|
697
|
+
scenarioId: result.scenarioId ?? entry.name,
|
|
698
|
+
status: result.status ?? "unknown",
|
|
699
|
+
startedAt: result.startedAt ?? "",
|
|
700
|
+
endedAt: result.endedAt,
|
|
701
|
+
durationMs: result.durationMs,
|
|
702
|
+
failedStep: result.steps?.find((step) => step.status === "failed")?.stepId,
|
|
703
|
+
evidenceDir: result.evidenceDir ?? join(evidenceDir, entry.name),
|
|
704
|
+
reportPath: join(evidenceDir, entry.name, "report.html")
|
|
705
|
+
});
|
|
706
|
+
} catch {
|
|
707
|
+
// Ignore evidence folders that are not completed runner outputs.
|
|
708
|
+
}
|
|
709
|
+
}
|
|
710
|
+
return runs.sort((a, b) => b.startedAt.localeCompare(a.startedAt)).slice(0, 50);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
function parsePostmanEnvironment(payload: unknown): { name: string; values: Record<string, unknown> } {
|
|
714
|
+
const source = payload && typeof payload === "object" && !Array.isArray(payload)
|
|
715
|
+
? payload as { name?: unknown; values?: unknown; variable?: unknown }
|
|
716
|
+
: {};
|
|
717
|
+
const rawValues = Array.isArray(source.values) ? source.values : Array.isArray(source.variable) ? source.variable : [];
|
|
718
|
+
const values = Object.fromEntries(rawValues
|
|
719
|
+
.filter((item) => item && typeof item === "object" && !Array.isArray(item))
|
|
720
|
+
.map((item) => item as { key?: unknown; value?: unknown; disabled?: unknown })
|
|
721
|
+
.filter((item) => item.key && item.disabled !== true)
|
|
722
|
+
.map((item) => [String(item.key), item.value ?? ""]));
|
|
723
|
+
return {
|
|
724
|
+
name: String(source.name || "Postman environment"),
|
|
725
|
+
values
|
|
726
|
+
};
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
async function runFlow(flow: FlowFile, envOverride: string | undefined, dryRun: boolean, runId: string, startFrom?: string, runScope: "from" | "only" = "from", signal?: AbortSignal): Promise<void> {
|
|
730
|
+
const state = runs.get(runId);
|
|
731
|
+
if (!state) return;
|
|
732
|
+
try {
|
|
733
|
+
const catalogs = await loadCatalogs(rootDir);
|
|
734
|
+
const selectedEnvironment = envOverride ?? flow.environment;
|
|
735
|
+
const validation = await validateFlow(flow, catalogs, rootDir, selectedEnvironment);
|
|
736
|
+
if (!validation.ok) throw new Error(validation.errors.join("\n"));
|
|
737
|
+
const compiled = compileFlow(flow, { environment: selectedEnvironment });
|
|
738
|
+
const scenario = sliceScenarioFromFlowNode(compiled, startFrom, runScope);
|
|
739
|
+
const scenarioValidation = validateScenarioReferences(scenario, catalogs);
|
|
740
|
+
if (!scenarioValidation.ok) throw new Error(scenarioValidation.errors.join("\n"));
|
|
741
|
+
await ensureEvidenceRoot(rootDir);
|
|
742
|
+
const env = getEnvironment(selectedEnvironment, rootDir);
|
|
743
|
+
const runner = new ScenarioRunner(scenario, catalogs, env, {
|
|
744
|
+
rootDir,
|
|
745
|
+
dryRun,
|
|
746
|
+
signal,
|
|
747
|
+
onStepStart: (event) => updateRunProgress(runId, {
|
|
748
|
+
currentStepId: event.stepId,
|
|
749
|
+
currentStepStartedAt: event.startedAt
|
|
750
|
+
}),
|
|
751
|
+
onStepResult: (step) => appendRunStepResult(runId, step)
|
|
752
|
+
});
|
|
753
|
+
const result = await runner.run();
|
|
754
|
+
runs.set(runId, {
|
|
755
|
+
...state,
|
|
756
|
+
status: result.status,
|
|
757
|
+
endedAt: new Date().toISOString(),
|
|
758
|
+
currentStepId: undefined,
|
|
759
|
+
currentStepStartedAt: undefined,
|
|
760
|
+
updatedAt: new Date().toISOString(),
|
|
761
|
+
evidenceDir: result.evidenceDir,
|
|
762
|
+
result
|
|
763
|
+
});
|
|
764
|
+
} catch (error) {
|
|
765
|
+
const err = error instanceof Error ? error : new Error(String(error));
|
|
766
|
+
runs.set(runId, {
|
|
767
|
+
...state,
|
|
768
|
+
status: err.name === "AbortError" ? "cancelled" : "failed",
|
|
769
|
+
endedAt: new Date().toISOString(),
|
|
770
|
+
error: err.message
|
|
771
|
+
});
|
|
772
|
+
} finally {
|
|
773
|
+
runControllers.delete(runId);
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function updateRunProgress(runId: string, patch: Partial<RunState>): void {
|
|
778
|
+
const current = runs.get(runId);
|
|
779
|
+
if (!current || (current.status !== "running" && current.status !== "stopping")) return;
|
|
780
|
+
runs.set(runId, {
|
|
781
|
+
...current,
|
|
782
|
+
...patch,
|
|
783
|
+
updatedAt: new Date().toISOString(),
|
|
784
|
+
result: normalizePartialRunResult(current.result)
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
function appendRunStepResult(runId: string, step: StepResult): void {
|
|
789
|
+
const current = runs.get(runId);
|
|
790
|
+
if (!current || (current.status !== "running" && current.status !== "stopping")) return;
|
|
791
|
+
const result = normalizePartialRunResult(current.result);
|
|
792
|
+
const existing = result.steps ?? [];
|
|
793
|
+
runs.set(runId, {
|
|
794
|
+
...current,
|
|
795
|
+
currentStepId: current.currentStepId === step.stepId ? undefined : current.currentStepId,
|
|
796
|
+
currentStepStartedAt: current.currentStepId === step.stepId ? undefined : current.currentStepStartedAt,
|
|
797
|
+
updatedAt: new Date().toISOString(),
|
|
798
|
+
result: {
|
|
799
|
+
...result,
|
|
800
|
+
steps: [...existing.filter((item) => item.stepId !== step.stepId), step]
|
|
801
|
+
}
|
|
802
|
+
});
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
function normalizePartialRunResult(result: unknown): { steps: StepResult[] } {
|
|
806
|
+
if (!result || typeof result !== "object") return { steps: [] };
|
|
807
|
+
const steps = Array.isArray((result as { steps?: unknown }).steps)
|
|
808
|
+
? (result as { steps: StepResult[] }).steps
|
|
809
|
+
: [];
|
|
810
|
+
return { ...(result as object), steps };
|
|
811
|
+
}
|
|
812
|
+
|
|
813
|
+
function sliceScenarioFromFlowNode(compiled: ReturnType<typeof compileFlow>, startFrom?: string, runScope: "from" | "only" = "from"): ReturnType<typeof compileFlow>["scenario"] {
|
|
814
|
+
if (!startFrom) return compiled.scenario;
|
|
815
|
+
const mapEntry = compiled.stepMap.find((entry) => entry.flowNodeId === startFrom || entry.scenarioStepId === startFrom);
|
|
816
|
+
if (!mapEntry) {
|
|
817
|
+
throw new Error(`Cannot start flow from '${startFrom}': no flow node or compiled step with that ID exists.`);
|
|
818
|
+
}
|
|
819
|
+
const startIndex = compiled.scenario.steps.findIndex((step) => step.id === mapEntry.scenarioStepId);
|
|
820
|
+
if (startIndex < 0) {
|
|
821
|
+
throw new Error(`Cannot start flow from '${startFrom}': compiled step '${mapEntry.scenarioStepId}' was not found.`);
|
|
822
|
+
}
|
|
823
|
+
return {
|
|
824
|
+
...compiled.scenario,
|
|
825
|
+
id: `${compiled.scenario.id}-${runScope === "only" ? "only" : "from"}-${safeRunSegment(startFrom)}`,
|
|
826
|
+
steps: runScope === "only" ? [compiled.scenario.steps[startIndex]] : compiled.scenario.steps.slice(startIndex)
|
|
827
|
+
};
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
function safeRunSegment(value: string): string {
|
|
831
|
+
return value.replace(/[^A-Za-z0-9_-]/g, "_").replace(/^_+|_+$/g, "") || "step";
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function resolveFlowPath(flowId: string, allowNew = false): Promise<string> {
|
|
835
|
+
const direct = flowPath(flowId);
|
|
836
|
+
if (await fileExists(direct)) return direct;
|
|
837
|
+
|
|
838
|
+
await mkdir(flowsDir, { recursive: true });
|
|
839
|
+
const entries = await readdir(flowsDir);
|
|
840
|
+
for (const entry of entries.filter((name) => name.endsWith(".flow.yaml") || name.endsWith(".flow.yml"))) {
|
|
841
|
+
const candidate = join(flowsDir, entry);
|
|
842
|
+
const flow = await loadFlow(candidate).catch(() => undefined);
|
|
843
|
+
if (flow?.id === flowId || entry === flowId) return candidate;
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
if (allowNew) return direct;
|
|
847
|
+
throw new Error(`Unknown flow '${flowId}'.`);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
function flowPath(flowId: string): string {
|
|
851
|
+
const safe = flowId.replace(/[^A-Za-z0-9_-]/g, "_");
|
|
852
|
+
return join(flowsDir, safe.endsWith(".flow.yaml") ? safe : `${safe}.flow.yaml`);
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
function parsePrefixMode(value: string): FlowNodePrefixMode {
|
|
856
|
+
if (value === "auto" || value === "always" || value === "never") return value;
|
|
857
|
+
throw new Error(`Invalid node prefix mode '${value}'.`);
|
|
858
|
+
}
|
|
859
|
+
|
|
860
|
+
function isInsidePath(parent: string, child: string): boolean {
|
|
861
|
+
const rel = relative(parent, child);
|
|
862
|
+
return rel === "" || (!!rel && !rel.startsWith("..") && !isAbsolute(rel));
|
|
863
|
+
}
|
|
864
|
+
|
|
865
|
+
function openLocalPath(target: string): void {
|
|
866
|
+
const command = process.platform === "win32" ? "cmd" : process.platform === "darwin" ? "open" : "xdg-open";
|
|
867
|
+
const args = process.platform === "win32" ? ["/c", "start", "", target] : [target];
|
|
868
|
+
execFile(command, args, { windowsHide: true }, () => undefined);
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async function fileExists(path: string): Promise<boolean> {
|
|
872
|
+
return access(path).then(() => true).catch(() => false);
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
async function readJsonBody<T>(req: IncomingMessage): Promise<T> {
|
|
876
|
+
const chunks: Buffer[] = [];
|
|
877
|
+
for await (const chunk of req) chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
|
|
878
|
+
const text = Buffer.concat(chunks).toString("utf8").trim();
|
|
879
|
+
return (text ? JSON.parse(text) : {}) as T;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
function sendJson(res: ServerResponse, status: number, value: unknown): void {
|
|
883
|
+
res.writeHead(status, { "content-type": "application/json; charset=utf-8" });
|
|
884
|
+
res.end(JSON.stringify(value, null, 2));
|
|
885
|
+
}
|
|
886
|
+
|
|
887
|
+
async function serveStatic(res: ServerResponse, pathname: string): Promise<void> {
|
|
888
|
+
const relative = pathname === "/" ? "index.html" : pathname.replace(/^\/+/, "");
|
|
889
|
+
const fullPath = resolve(webDistDir, relative);
|
|
890
|
+
if (!fullPath.startsWith(webDistDir)) return sendJson(res, 403, { error: "Forbidden" });
|
|
891
|
+
try {
|
|
892
|
+
const info = await stat(fullPath);
|
|
893
|
+
if (info.isDirectory()) return serveStatic(res, `${pathname.replace(/\/$/, "")}/index.html`);
|
|
894
|
+
res.writeHead(200, { "content-type": mimeType(fullPath) });
|
|
895
|
+
createReadStream(fullPath).pipe(res);
|
|
896
|
+
} catch {
|
|
897
|
+
if (pathname !== "/") return serveStatic(res, "/");
|
|
898
|
+
res.writeHead(200, { "content-type": "text/html; charset=utf-8" });
|
|
899
|
+
res.end("<h1>Adfinem Test Runner</h1><p>Build the web app first with <code>npm run web:build</code>.</p>");
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
function mimeType(path: string): string {
|
|
904
|
+
switch (extname(path).toLowerCase()) {
|
|
905
|
+
case ".html": return "text/html; charset=utf-8";
|
|
906
|
+
case ".js": return "text/javascript; charset=utf-8";
|
|
907
|
+
case ".css": return "text/css; charset=utf-8";
|
|
908
|
+
case ".svg": return "image/svg+xml";
|
|
909
|
+
case ".png": return "image/png";
|
|
910
|
+
case ".json": return "application/json; charset=utf-8";
|
|
911
|
+
default: return "application/octet-stream";
|
|
912
|
+
}
|
|
913
|
+
}
|