@claushaas/ergon-engine 0.1.2
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/LICENSE +1 -0
- package/README.md +13 -0
- package/dist/defaults.d.ts +11 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/defaults.js +27 -0
- package/dist/defaults.js.map +1 -0
- package/dist/executors/agent.d.ts +12 -0
- package/dist/executors/agent.d.ts.map +1 -0
- package/dist/executors/agent.js +80 -0
- package/dist/executors/agent.js.map +1 -0
- package/dist/executors/artifact.d.ts +7 -0
- package/dist/executors/artifact.d.ts.map +1 -0
- package/dist/executors/artifact.js +148 -0
- package/dist/executors/artifact.js.map +1 -0
- package/dist/executors/condition.d.ts +7 -0
- package/dist/executors/condition.d.ts.map +1 -0
- package/dist/executors/condition.js +53 -0
- package/dist/executors/condition.js.map +1 -0
- package/dist/executors/exec.d.ts +32 -0
- package/dist/executors/exec.d.ts.map +1 -0
- package/dist/executors/exec.js +169 -0
- package/dist/executors/exec.js.map +1 -0
- package/dist/executors/index.d.ts +54 -0
- package/dist/executors/index.d.ts.map +1 -0
- package/dist/executors/index.js +49 -0
- package/dist/executors/index.js.map +1 -0
- package/dist/executors/manual.d.ts +7 -0
- package/dist/executors/manual.d.ts.map +1 -0
- package/dist/executors/manual.js +25 -0
- package/dist/executors/manual.js.map +1 -0
- package/dist/executors/notify.d.ts +48 -0
- package/dist/executors/notify.d.ts.map +1 -0
- package/dist/executors/notify.js +313 -0
- package/dist/executors/notify.js.map +1 -0
- package/dist/index.d.ts +12 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +12 -0
- package/dist/index.js.map +1 -0
- package/dist/runner.d.ts +11 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +1061 -0
- package/dist/runner.js.map +1 -0
- package/dist/templating/index.d.ts +43 -0
- package/dist/templating/index.d.ts.map +1 -0
- package/dist/templating/index.js +778 -0
- package/dist/templating/index.js.map +1 -0
- package/dist/worker.d.ts +26 -0
- package/dist/worker.d.ts.map +1 -0
- package/dist/worker.js +320 -0
- package/dist/worker.js.map +1 -0
- package/dist/workflowIdentity.d.ts +3 -0
- package/dist/workflowIdentity.d.ts.map +1 -0
- package/dist/workflowIdentity.js +12 -0
- package/dist/workflowIdentity.js.map +1 -0
- package/package.json +44 -0
|
@@ -0,0 +1,778 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync } from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { PROVIDERS, STEP_KINDS } from '@claushaas/ergon-shared';
|
|
4
|
+
import { parse } from 'yaml';
|
|
5
|
+
const INPUT_TYPES = new Set([
|
|
6
|
+
'array',
|
|
7
|
+
'boolean',
|
|
8
|
+
'number',
|
|
9
|
+
'object',
|
|
10
|
+
'string',
|
|
11
|
+
]);
|
|
12
|
+
const AGENT_OUTPUT_TYPES = new Set([
|
|
13
|
+
'analysis',
|
|
14
|
+
'json',
|
|
15
|
+
'plan',
|
|
16
|
+
'text',
|
|
17
|
+
]);
|
|
18
|
+
const YAML_EXTENSIONS = new Set(['.yaml', '.yml']);
|
|
19
|
+
const STEP_KIND_SET = new Set(STEP_KINDS);
|
|
20
|
+
const PROVIDER_SET = new Set(PROVIDERS);
|
|
21
|
+
const NOTIFY_CHANNEL_SET = new Set(['openclaw', 'stdout', 'webhook']);
|
|
22
|
+
function asRecord(value) {
|
|
23
|
+
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
24
|
+
return null;
|
|
25
|
+
}
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
function asInputType(value) {
|
|
29
|
+
if (typeof value !== 'string' || !INPUT_TYPES.has(value)) {
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return value;
|
|
33
|
+
}
|
|
34
|
+
function cloneValue(value) {
|
|
35
|
+
if (value === undefined) {
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
return structuredClone(value);
|
|
39
|
+
}
|
|
40
|
+
function normalizeWorkflow(rawWorkflow) {
|
|
41
|
+
const workflow = asRecord(rawWorkflow) ?? {};
|
|
42
|
+
const tags = Array.isArray(workflow.tags)
|
|
43
|
+
? workflow.tags.filter((tag) => typeof tag === 'string')
|
|
44
|
+
: undefined;
|
|
45
|
+
let version = 0;
|
|
46
|
+
if (typeof workflow.version === 'number' &&
|
|
47
|
+
Number.isFinite(workflow.version)) {
|
|
48
|
+
version = Math.trunc(workflow.version);
|
|
49
|
+
}
|
|
50
|
+
else if (typeof workflow.version === 'string') {
|
|
51
|
+
const parsed = Number.parseInt(workflow.version, 10);
|
|
52
|
+
if (!Number.isNaN(parsed)) {
|
|
53
|
+
version = parsed;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return {
|
|
57
|
+
author: typeof workflow.author === 'string' ? workflow.author : undefined,
|
|
58
|
+
description: typeof workflow.description === 'string'
|
|
59
|
+
? workflow.description
|
|
60
|
+
: undefined,
|
|
61
|
+
id: typeof workflow.id === 'string' ? workflow.id : '',
|
|
62
|
+
tags,
|
|
63
|
+
version,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
function normalizeInputs(rawInputs) {
|
|
67
|
+
const inputs = asRecord(rawInputs);
|
|
68
|
+
if (!inputs) {
|
|
69
|
+
return {};
|
|
70
|
+
}
|
|
71
|
+
const normalized = {};
|
|
72
|
+
for (const [name, rawSpec] of Object.entries(inputs)) {
|
|
73
|
+
const shorthandType = asInputType(rawSpec);
|
|
74
|
+
if (shorthandType) {
|
|
75
|
+
normalized[name] = shorthandType;
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
const inputSpec = asRecord(rawSpec);
|
|
79
|
+
if (!inputSpec) {
|
|
80
|
+
continue;
|
|
81
|
+
}
|
|
82
|
+
const explicitType = asInputType(inputSpec.type);
|
|
83
|
+
if (!explicitType) {
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
normalized[name] = {
|
|
87
|
+
default: inputSpec.default,
|
|
88
|
+
description: typeof inputSpec.description === 'string'
|
|
89
|
+
? inputSpec.description
|
|
90
|
+
: undefined,
|
|
91
|
+
required: typeof inputSpec.required === 'boolean'
|
|
92
|
+
? inputSpec.required
|
|
93
|
+
: undefined,
|
|
94
|
+
type: explicitType,
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
return normalized;
|
|
98
|
+
}
|
|
99
|
+
function normalizeArtifacts(rawArtifacts) {
|
|
100
|
+
const artifacts = asRecord(rawArtifacts);
|
|
101
|
+
if (!artifacts) {
|
|
102
|
+
return undefined;
|
|
103
|
+
}
|
|
104
|
+
const normalized = {};
|
|
105
|
+
for (const [name, rawArtifact] of Object.entries(artifacts)) {
|
|
106
|
+
const artifact = asRecord(rawArtifact);
|
|
107
|
+
if (!artifact || typeof artifact.type !== 'string') {
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
normalized[name] = { type: artifact.type };
|
|
111
|
+
}
|
|
112
|
+
return normalized;
|
|
113
|
+
}
|
|
114
|
+
function normalizeOutputs(rawOutputs) {
|
|
115
|
+
const outputs = asRecord(rawOutputs);
|
|
116
|
+
if (!outputs) {
|
|
117
|
+
return {};
|
|
118
|
+
}
|
|
119
|
+
const normalized = {};
|
|
120
|
+
for (const [key, rawValue] of Object.entries(outputs)) {
|
|
121
|
+
if (typeof rawValue === 'string') {
|
|
122
|
+
normalized[key] = rawValue;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return normalized;
|
|
126
|
+
}
|
|
127
|
+
function normalizeSteps(rawSteps) {
|
|
128
|
+
if (!Array.isArray(rawSteps)) {
|
|
129
|
+
return [];
|
|
130
|
+
}
|
|
131
|
+
return rawSteps
|
|
132
|
+
.map((rawStep) => {
|
|
133
|
+
const step = asRecord(rawStep);
|
|
134
|
+
if (!step ||
|
|
135
|
+
typeof step.id !== 'string' ||
|
|
136
|
+
typeof step.kind !== 'string') {
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
139
|
+
const dependsOn = Array.isArray(step.depends_on)
|
|
140
|
+
? step.depends_on.filter((item) => typeof item === 'string')
|
|
141
|
+
: undefined;
|
|
142
|
+
return {
|
|
143
|
+
...step,
|
|
144
|
+
depends_on: dependsOn,
|
|
145
|
+
output: normalizeAgentStepOutput(step.output),
|
|
146
|
+
};
|
|
147
|
+
})
|
|
148
|
+
.filter((step) => step !== null);
|
|
149
|
+
}
|
|
150
|
+
function normalizeAgentStepOutput(rawOutput) {
|
|
151
|
+
const output = asRecord(rawOutput);
|
|
152
|
+
if (!output) {
|
|
153
|
+
return undefined;
|
|
154
|
+
}
|
|
155
|
+
if (typeof output.name !== 'string' || typeof output.type !== 'string') {
|
|
156
|
+
return undefined;
|
|
157
|
+
}
|
|
158
|
+
return {
|
|
159
|
+
name: output.name,
|
|
160
|
+
type: output.type,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
export function normalizeTemplate(rawTemplate) {
|
|
164
|
+
const template = asRecord(rawTemplate) ?? {};
|
|
165
|
+
return {
|
|
166
|
+
artifacts: normalizeArtifacts(template.artifacts),
|
|
167
|
+
inputs: normalizeInputs(template.inputs),
|
|
168
|
+
outputs: normalizeOutputs(template.outputs),
|
|
169
|
+
steps: normalizeSteps(template.steps),
|
|
170
|
+
workflow: normalizeWorkflow(template.workflow),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export function loadTemplateFromFile(templatePath) {
|
|
174
|
+
const content = readFileSync(templatePath, 'utf8');
|
|
175
|
+
const parsed = parse(content);
|
|
176
|
+
return {
|
|
177
|
+
template: normalizeTemplate(parsed),
|
|
178
|
+
templatePath: path.resolve(templatePath),
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
export function loadTemplatesFromDir(templatesDir) {
|
|
182
|
+
if (!existsSync(templatesDir)) {
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
const entries = readdirSync(templatesDir, { withFileTypes: true })
|
|
186
|
+
.filter((entry) => entry.isFile())
|
|
187
|
+
.filter((entry) => YAML_EXTENSIONS.has(path.extname(entry.name).toLowerCase()))
|
|
188
|
+
.sort((left, right) => left.name.localeCompare(right.name));
|
|
189
|
+
return entries.map((entry) => loadTemplateFromFile(path.join(templatesDir, entry.name)));
|
|
190
|
+
}
|
|
191
|
+
export function loadTemplatesFromWorkspace(rootDir = process.cwd()) {
|
|
192
|
+
const templatesDir = path.join(rootDir, 'library', 'workflows');
|
|
193
|
+
return loadTemplatesFromDir(templatesDir);
|
|
194
|
+
}
|
|
195
|
+
function isNonEmptyString(value) {
|
|
196
|
+
return typeof value === 'string' && value.trim().length > 0;
|
|
197
|
+
}
|
|
198
|
+
function pushError(errors, path, message) {
|
|
199
|
+
errors.push({ message, path });
|
|
200
|
+
}
|
|
201
|
+
function getInputSpecType(inputSpec) {
|
|
202
|
+
return typeof inputSpec === 'string' ? inputSpec : inputSpec.type;
|
|
203
|
+
}
|
|
204
|
+
function isPlainObject(value) {
|
|
205
|
+
return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
|
|
206
|
+
}
|
|
207
|
+
function matchesInputType(type, value) {
|
|
208
|
+
switch (type) {
|
|
209
|
+
case 'array':
|
|
210
|
+
return Array.isArray(value);
|
|
211
|
+
case 'boolean':
|
|
212
|
+
return typeof value === 'boolean';
|
|
213
|
+
case 'number':
|
|
214
|
+
return typeof value === 'number' && Number.isFinite(value);
|
|
215
|
+
case 'object':
|
|
216
|
+
return isPlainObject(value);
|
|
217
|
+
case 'string':
|
|
218
|
+
return typeof value === 'string';
|
|
219
|
+
default: {
|
|
220
|
+
const exhaustive = type;
|
|
221
|
+
void exhaustive;
|
|
222
|
+
return false;
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
function getInterpolationReferences(templateValue) {
|
|
227
|
+
return Array.from(templateValue.matchAll(INTERPOLATION_PATTERN), (match) => match[1]?.trim() ?? '').filter((reference) => reference.length > 0);
|
|
228
|
+
}
|
|
229
|
+
function isBareReference(value) {
|
|
230
|
+
return /^(artifacts|inputs)\.[A-Za-z0-9_.-]+$/.test(value.trim());
|
|
231
|
+
}
|
|
232
|
+
function validateReferenceAgainstContract(reference, availableInputs, availableArtifacts) {
|
|
233
|
+
const normalizedReference = reference.trim();
|
|
234
|
+
if (normalizedReference.startsWith('inputs.')) {
|
|
235
|
+
const inputName = normalizedReference.slice('inputs.'.length).split('.')[0];
|
|
236
|
+
if (!availableInputs.has(inputName)) {
|
|
237
|
+
return `unknown input reference "${normalizedReference}"`;
|
|
238
|
+
}
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
if (normalizedReference.startsWith('artifacts.')) {
|
|
242
|
+
const artifactReference = normalizedReference.slice('artifacts.'.length);
|
|
243
|
+
const parts = artifactReference.split('.');
|
|
244
|
+
for (let index = parts.length; index > 0; index -= 1) {
|
|
245
|
+
const candidate = parts.slice(0, index).join('.');
|
|
246
|
+
if (availableArtifacts.has(candidate)) {
|
|
247
|
+
return null;
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return `unknown artifact reference "${normalizedReference}"`;
|
|
251
|
+
}
|
|
252
|
+
return `unsupported interpolation source "${normalizedReference}" (allowed: inputs.*, artifacts.*)`;
|
|
253
|
+
}
|
|
254
|
+
function parseArtifactOperationSpec(step) {
|
|
255
|
+
const [rawType, ...rawArgs] = step.operation.split(':');
|
|
256
|
+
const type = rawType?.trim();
|
|
257
|
+
const args = rawArgs.map((part) => part.trim());
|
|
258
|
+
switch (type) {
|
|
259
|
+
case 'copy':
|
|
260
|
+
return {
|
|
261
|
+
errors: [],
|
|
262
|
+
mergeInputs: [],
|
|
263
|
+
outputName: args[0] || step.id,
|
|
264
|
+
};
|
|
265
|
+
case 'rename':
|
|
266
|
+
return {
|
|
267
|
+
errors: args[0] ? [] : ['rename operation requires a target name'],
|
|
268
|
+
mergeInputs: [],
|
|
269
|
+
outputName: args[0] || null,
|
|
270
|
+
};
|
|
271
|
+
case 'extract':
|
|
272
|
+
return {
|
|
273
|
+
errors: args[0] ? [] : ['extract operation requires a field path'],
|
|
274
|
+
mergeInputs: [],
|
|
275
|
+
outputName: args[1] || step.id,
|
|
276
|
+
};
|
|
277
|
+
case 'merge': {
|
|
278
|
+
const mergeInputs = (args[0] ?? '')
|
|
279
|
+
.split(',')
|
|
280
|
+
.map((value) => value.trim())
|
|
281
|
+
.filter((value) => value.length > 0);
|
|
282
|
+
return {
|
|
283
|
+
errors: mergeInputs.length > 0
|
|
284
|
+
? []
|
|
285
|
+
: ['merge operation requires at least one artifact'],
|
|
286
|
+
mergeInputs,
|
|
287
|
+
outputName: args[1] || step.id,
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
default:
|
|
291
|
+
return {
|
|
292
|
+
errors: [`unsupported operation "${step.operation}"`],
|
|
293
|
+
mergeInputs: [],
|
|
294
|
+
outputName: null,
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
function getDefaultAgentArtifactName(step) {
|
|
299
|
+
if (step.output?.name) {
|
|
300
|
+
return step.output.name;
|
|
301
|
+
}
|
|
302
|
+
if (step.id === 'analyze') {
|
|
303
|
+
return 'analysis';
|
|
304
|
+
}
|
|
305
|
+
return step.id;
|
|
306
|
+
}
|
|
307
|
+
function getStepArtifactNames(step) {
|
|
308
|
+
switch (step.kind) {
|
|
309
|
+
case 'agent':
|
|
310
|
+
return [getDefaultAgentArtifactName(step)];
|
|
311
|
+
case 'artifact': {
|
|
312
|
+
const operation = parseArtifactOperationSpec(step);
|
|
313
|
+
return operation.outputName ? [operation.outputName] : [];
|
|
314
|
+
}
|
|
315
|
+
case 'exec':
|
|
316
|
+
return [`${step.id}.result`, `${step.id}.stderr`, `${step.id}.stdout`];
|
|
317
|
+
case 'notify':
|
|
318
|
+
return ['run.summary'];
|
|
319
|
+
case 'condition':
|
|
320
|
+
case 'manual':
|
|
321
|
+
return [];
|
|
322
|
+
default: {
|
|
323
|
+
const exhaustive = step;
|
|
324
|
+
void exhaustive;
|
|
325
|
+
return [];
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
function validateInterpolatedValue(errors, templateValue, templatePath, availableInputs, availableArtifacts) {
|
|
330
|
+
for (const reference of getInterpolationReferences(templateValue)) {
|
|
331
|
+
const error = validateReferenceAgainstContract(reference, availableInputs, availableArtifacts);
|
|
332
|
+
if (error) {
|
|
333
|
+
pushError(errors, templatePath, error);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
function validateStepRequiredFields(step, index, errors) {
|
|
338
|
+
const stepPath = `steps[${index}]`;
|
|
339
|
+
if (step.timeout_ms !== undefined &&
|
|
340
|
+
(!Number.isInteger(step.timeout_ms) || step.timeout_ms <= 0)) {
|
|
341
|
+
pushError(errors, `${stepPath}.timeout_ms`, 'timeout_ms must be a positive integer');
|
|
342
|
+
}
|
|
343
|
+
switch (step.kind) {
|
|
344
|
+
case 'agent': {
|
|
345
|
+
if (!isNonEmptyString(step.provider)) {
|
|
346
|
+
pushError(errors, `${stepPath}.provider`, 'agent step requires a non-empty provider');
|
|
347
|
+
return;
|
|
348
|
+
}
|
|
349
|
+
if (!PROVIDER_SET.has(step.provider)) {
|
|
350
|
+
pushError(errors, `${stepPath}.provider`, `agent provider "${step.provider}" is not supported`);
|
|
351
|
+
}
|
|
352
|
+
if (step.output) {
|
|
353
|
+
if (!isNonEmptyString(step.output.name)) {
|
|
354
|
+
pushError(errors, `${stepPath}.output.name`, 'agent step output.name must be a non-empty string');
|
|
355
|
+
}
|
|
356
|
+
if (!AGENT_OUTPUT_TYPES.has(step.output.type)) {
|
|
357
|
+
pushError(errors, `${stepPath}.output.type`, 'agent step output.type must be one of: analysis, json, plan, text');
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return;
|
|
361
|
+
}
|
|
362
|
+
case 'artifact': {
|
|
363
|
+
if (!isNonEmptyString(step.input)) {
|
|
364
|
+
pushError(errors, `${stepPath}.input`, 'artifact step requires a non-empty input');
|
|
365
|
+
}
|
|
366
|
+
if (!isNonEmptyString(step.operation)) {
|
|
367
|
+
pushError(errors, `${stepPath}.operation`, 'artifact step requires a non-empty operation');
|
|
368
|
+
}
|
|
369
|
+
return;
|
|
370
|
+
}
|
|
371
|
+
case 'condition': {
|
|
372
|
+
if (!isNonEmptyString(step.expression)) {
|
|
373
|
+
pushError(errors, `${stepPath}.expression`, 'condition step requires a non-empty expression');
|
|
374
|
+
}
|
|
375
|
+
return;
|
|
376
|
+
}
|
|
377
|
+
case 'exec': {
|
|
378
|
+
if (!isNonEmptyString(step.command)) {
|
|
379
|
+
pushError(errors, `${stepPath}.command`, 'exec step requires a non-empty command');
|
|
380
|
+
}
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
case 'notify': {
|
|
384
|
+
if (!isNonEmptyString(step.channel)) {
|
|
385
|
+
pushError(errors, `${stepPath}.channel`, 'notify step requires a non-empty channel');
|
|
386
|
+
}
|
|
387
|
+
else if (getInterpolationReferences(step.channel).length === 0 &&
|
|
388
|
+
!NOTIFY_CHANNEL_SET.has(step.channel)) {
|
|
389
|
+
pushError(errors, `${stepPath}.channel`, 'notify step channel must be one of: openclaw, stdout, webhook');
|
|
390
|
+
}
|
|
391
|
+
if (!isNonEmptyString(step.message)) {
|
|
392
|
+
pushError(errors, `${stepPath}.message`, 'notify step requires a non-empty message');
|
|
393
|
+
}
|
|
394
|
+
return;
|
|
395
|
+
}
|
|
396
|
+
case 'manual':
|
|
397
|
+
return;
|
|
398
|
+
default: {
|
|
399
|
+
const exhaustive = step;
|
|
400
|
+
void exhaustive;
|
|
401
|
+
return;
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
}
|
|
405
|
+
function detectDependencyCycles(template, errors) {
|
|
406
|
+
const dependencies = new Map();
|
|
407
|
+
for (const step of template.steps) {
|
|
408
|
+
dependencies.set(step.id, step.depends_on ?? []);
|
|
409
|
+
}
|
|
410
|
+
const visited = new Set();
|
|
411
|
+
const inStack = new Set();
|
|
412
|
+
const pathStack = [];
|
|
413
|
+
const visit = (stepId) => {
|
|
414
|
+
if (inStack.has(stepId)) {
|
|
415
|
+
const cycleStart = pathStack.indexOf(stepId);
|
|
416
|
+
const cyclePath = cycleStart >= 0 ? pathStack.slice(cycleStart).concat(stepId) : [stepId];
|
|
417
|
+
pushError(errors, 'steps', `circular dependency detected: ${cyclePath.join(' -> ')}`);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
if (visited.has(stepId)) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
visited.add(stepId);
|
|
424
|
+
inStack.add(stepId);
|
|
425
|
+
pathStack.push(stepId);
|
|
426
|
+
const nextSteps = dependencies.get(stepId) ?? [];
|
|
427
|
+
for (const nextStepId of nextSteps) {
|
|
428
|
+
if (!dependencies.has(nextStepId)) {
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
visit(nextStepId);
|
|
432
|
+
}
|
|
433
|
+
pathStack.pop();
|
|
434
|
+
inStack.delete(stepId);
|
|
435
|
+
};
|
|
436
|
+
for (const stepId of dependencies.keys()) {
|
|
437
|
+
visit(stepId);
|
|
438
|
+
}
|
|
439
|
+
}
|
|
440
|
+
export function validateTemplate(template) {
|
|
441
|
+
const errors = [];
|
|
442
|
+
const availableInputs = new Set(Object.keys(template.inputs ?? {}));
|
|
443
|
+
if (!isNonEmptyString(template.workflow.id)) {
|
|
444
|
+
pushError(errors, 'workflow.id', 'workflow.id is required');
|
|
445
|
+
}
|
|
446
|
+
if (!Number.isInteger(template.workflow.version) ||
|
|
447
|
+
template.workflow.version <= 0) {
|
|
448
|
+
pushError(errors, 'workflow.version', 'workflow.version must be a positive integer');
|
|
449
|
+
}
|
|
450
|
+
if (!Array.isArray(template.steps) || template.steps.length === 0) {
|
|
451
|
+
pushError(errors, 'steps', 'at least one step is required');
|
|
452
|
+
}
|
|
453
|
+
for (const [name, inputSpec] of Object.entries(template.inputs ?? {})) {
|
|
454
|
+
if (!isNonEmptyString(name)) {
|
|
455
|
+
pushError(errors, 'inputs', 'input names must be non-empty strings');
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
const inputType = getInputSpecType(inputSpec);
|
|
459
|
+
if (typeof inputSpec === 'object' &&
|
|
460
|
+
inputSpec.default !== undefined &&
|
|
461
|
+
!matchesInputType(inputType, inputSpec.default)) {
|
|
462
|
+
pushError(errors, `inputs.${name}.default`, `input default must match declared type "${inputType}"`);
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
const stepIds = new Set();
|
|
466
|
+
for (const [index, step] of template.steps.entries()) {
|
|
467
|
+
const stepPath = `steps[${index}]`;
|
|
468
|
+
const stepId = step.id;
|
|
469
|
+
const stepKind = step.kind;
|
|
470
|
+
if (!isNonEmptyString(stepId)) {
|
|
471
|
+
pushError(errors, `${stepPath}.id`, 'step.id is required');
|
|
472
|
+
}
|
|
473
|
+
else if (stepIds.has(stepId)) {
|
|
474
|
+
pushError(errors, `${stepPath}.id`, `duplicate step id "${stepId}" is not allowed`);
|
|
475
|
+
}
|
|
476
|
+
else {
|
|
477
|
+
stepIds.add(stepId);
|
|
478
|
+
}
|
|
479
|
+
if (!isNonEmptyString(stepKind)) {
|
|
480
|
+
pushError(errors, `${stepPath}.kind`, 'step.kind is required');
|
|
481
|
+
continue;
|
|
482
|
+
}
|
|
483
|
+
if (!STEP_KIND_SET.has(stepKind)) {
|
|
484
|
+
pushError(errors, `${stepPath}.kind`, `step.kind "${stepKind}" is not supported`);
|
|
485
|
+
continue;
|
|
486
|
+
}
|
|
487
|
+
validateStepRequiredFields(step, index, errors);
|
|
488
|
+
}
|
|
489
|
+
for (const [index, step] of template.steps.entries()) {
|
|
490
|
+
const stepPath = `steps[${index}]`;
|
|
491
|
+
const dependsOn = step.depends_on;
|
|
492
|
+
if (!dependsOn) {
|
|
493
|
+
continue;
|
|
494
|
+
}
|
|
495
|
+
if (!Array.isArray(dependsOn)) {
|
|
496
|
+
pushError(errors, `${stepPath}.depends_on`, 'depends_on must be an array of step ids');
|
|
497
|
+
continue;
|
|
498
|
+
}
|
|
499
|
+
for (const [dependsIndex, dependency] of dependsOn.entries()) {
|
|
500
|
+
const dependencyPath = `${stepPath}.depends_on[${dependsIndex}]`;
|
|
501
|
+
if (!isNonEmptyString(dependency)) {
|
|
502
|
+
pushError(errors, dependencyPath, 'dependency id must be a non-empty string');
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
if (!stepIds.has(dependency)) {
|
|
506
|
+
pushError(errors, dependencyPath, `depends_on references unknown step "${dependency}"`);
|
|
507
|
+
}
|
|
508
|
+
const dependencyIndex = template.steps.findIndex((candidate) => candidate.id === dependency);
|
|
509
|
+
if (dependencyIndex > index) {
|
|
510
|
+
pushError(errors, dependencyPath, 'depends_on must reference only earlier steps in the workflow');
|
|
511
|
+
}
|
|
512
|
+
if (dependency === step.id) {
|
|
513
|
+
pushError(errors, dependencyPath, 'step cannot depend on itself');
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
detectDependencyCycles(template, errors);
|
|
518
|
+
const availableArtifacts = new Set();
|
|
519
|
+
for (const [index, step] of template.steps.entries()) {
|
|
520
|
+
const stepPath = `steps[${index}]`;
|
|
521
|
+
switch (step.kind) {
|
|
522
|
+
case 'agent':
|
|
523
|
+
if (step.prompt) {
|
|
524
|
+
validateInterpolatedValue(errors, step.prompt, `${stepPath}.prompt`, availableInputs, availableArtifacts);
|
|
525
|
+
}
|
|
526
|
+
break;
|
|
527
|
+
case 'artifact': {
|
|
528
|
+
if (!availableArtifacts.has(step.input)) {
|
|
529
|
+
pushError(errors, `${stepPath}.input`, `artifact step references unknown artifact "${step.input}"`);
|
|
530
|
+
}
|
|
531
|
+
const operation = parseArtifactOperationSpec(step);
|
|
532
|
+
for (const message of operation.errors) {
|
|
533
|
+
pushError(errors, `${stepPath}.operation`, message);
|
|
534
|
+
}
|
|
535
|
+
for (const artifactName of operation.mergeInputs) {
|
|
536
|
+
if (!availableArtifacts.has(artifactName)) {
|
|
537
|
+
pushError(errors, `${stepPath}.operation`, `merge operation references unknown artifact "${artifactName}"`);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
break;
|
|
541
|
+
}
|
|
542
|
+
case 'condition':
|
|
543
|
+
validateInterpolatedValue(errors, step.expression, `${stepPath}.expression`, availableInputs, availableArtifacts);
|
|
544
|
+
break;
|
|
545
|
+
case 'exec':
|
|
546
|
+
validateInterpolatedValue(errors, step.command, `${stepPath}.command`, availableInputs, availableArtifacts);
|
|
547
|
+
if (step.cwd) {
|
|
548
|
+
validateInterpolatedValue(errors, step.cwd, `${stepPath}.cwd`, availableInputs, availableArtifacts);
|
|
549
|
+
}
|
|
550
|
+
for (const [key, value] of Object.entries(step.env ?? {})) {
|
|
551
|
+
validateInterpolatedValue(errors, value, `${stepPath}.env.${key}`, availableInputs, availableArtifacts);
|
|
552
|
+
}
|
|
553
|
+
break;
|
|
554
|
+
case 'manual':
|
|
555
|
+
if (step.message) {
|
|
556
|
+
validateInterpolatedValue(errors, step.message, `${stepPath}.message`, availableInputs, availableArtifacts);
|
|
557
|
+
}
|
|
558
|
+
break;
|
|
559
|
+
case 'notify':
|
|
560
|
+
validateInterpolatedValue(errors, step.channel, `${stepPath}.channel`, availableInputs, availableArtifacts);
|
|
561
|
+
validateInterpolatedValue(errors, step.message, `${stepPath}.message`, availableInputs, availableArtifacts);
|
|
562
|
+
if (step.target) {
|
|
563
|
+
validateInterpolatedValue(errors, step.target, `${stepPath}.target`, availableInputs, availableArtifacts);
|
|
564
|
+
}
|
|
565
|
+
break;
|
|
566
|
+
default: {
|
|
567
|
+
const exhaustive = step;
|
|
568
|
+
void exhaustive;
|
|
569
|
+
break;
|
|
570
|
+
}
|
|
571
|
+
}
|
|
572
|
+
for (const artifactName of getStepArtifactNames(step)) {
|
|
573
|
+
availableArtifacts.add(artifactName);
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
for (const [key, value] of Object.entries(template.outputs ?? {})) {
|
|
577
|
+
if (!isNonEmptyString(value)) {
|
|
578
|
+
pushError(errors, `outputs.${key}`, 'workflow outputs must be non-empty strings');
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
if (isBareReference(value)) {
|
|
582
|
+
const error = validateReferenceAgainstContract(value, availableInputs, availableArtifacts);
|
|
583
|
+
if (error) {
|
|
584
|
+
pushError(errors, `outputs.${key}`, error);
|
|
585
|
+
}
|
|
586
|
+
continue;
|
|
587
|
+
}
|
|
588
|
+
validateInterpolatedValue(errors, value, `outputs.${key}`, availableInputs, availableArtifacts);
|
|
589
|
+
}
|
|
590
|
+
return {
|
|
591
|
+
errors,
|
|
592
|
+
valid: errors.length === 0,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
export function assertValidTemplate(template) {
|
|
596
|
+
const validation = validateTemplate(template);
|
|
597
|
+
if (validation.valid) {
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
const summary = validation.errors
|
|
601
|
+
.map((error) => `${error.path}: ${error.message}`)
|
|
602
|
+
.join('; ');
|
|
603
|
+
throw new Error(`Template validation failed: ${summary}`);
|
|
604
|
+
}
|
|
605
|
+
export function loadAndValidateTemplateFromFile(templatePath) {
|
|
606
|
+
const loaded = loadTemplateFromFile(templatePath);
|
|
607
|
+
assertValidTemplate(loaded.template);
|
|
608
|
+
return loaded;
|
|
609
|
+
}
|
|
610
|
+
const INTERPOLATION_PATTERN = /{{\s*([^{}]+?)\s*}}/g;
|
|
611
|
+
function getPathValue(record, pathParts) {
|
|
612
|
+
let current = record;
|
|
613
|
+
for (const pathPart of pathParts) {
|
|
614
|
+
if (!current || typeof current !== 'object' || Array.isArray(current)) {
|
|
615
|
+
return undefined;
|
|
616
|
+
}
|
|
617
|
+
current = current[pathPart];
|
|
618
|
+
}
|
|
619
|
+
return current;
|
|
620
|
+
}
|
|
621
|
+
function getArtifactReferenceValue(artifacts, pathParts) {
|
|
622
|
+
if (pathParts.length === 0) {
|
|
623
|
+
return undefined;
|
|
624
|
+
}
|
|
625
|
+
const exactMatchKey = pathParts.join('.');
|
|
626
|
+
if (Object.hasOwn(artifacts, exactMatchKey)) {
|
|
627
|
+
return artifacts[exactMatchKey];
|
|
628
|
+
}
|
|
629
|
+
for (let index = pathParts.length - 1; index > 0; index -= 1) {
|
|
630
|
+
const artifactName = pathParts.slice(0, index).join('.');
|
|
631
|
+
if (!Object.hasOwn(artifacts, artifactName)) {
|
|
632
|
+
continue;
|
|
633
|
+
}
|
|
634
|
+
const artifactValue = artifacts[artifactName];
|
|
635
|
+
if (!artifactValue ||
|
|
636
|
+
typeof artifactValue !== 'object' ||
|
|
637
|
+
Array.isArray(artifactValue)) {
|
|
638
|
+
return undefined;
|
|
639
|
+
}
|
|
640
|
+
return getPathValue(artifactValue, pathParts.slice(index));
|
|
641
|
+
}
|
|
642
|
+
return getPathValue(artifacts, pathParts);
|
|
643
|
+
}
|
|
644
|
+
function stringifyInterpolationValue(value) {
|
|
645
|
+
if (typeof value === 'string') {
|
|
646
|
+
return value;
|
|
647
|
+
}
|
|
648
|
+
const serialized = JSON.stringify(value);
|
|
649
|
+
if (typeof serialized === 'string') {
|
|
650
|
+
return serialized;
|
|
651
|
+
}
|
|
652
|
+
return String(value);
|
|
653
|
+
}
|
|
654
|
+
function escapeShellArgument(value) {
|
|
655
|
+
return `'${value.replaceAll("'", "'\"'\"'")}'`;
|
|
656
|
+
}
|
|
657
|
+
function sanitizePromptValue(value) {
|
|
658
|
+
const normalized = value.replaceAll('\u0000', '');
|
|
659
|
+
return JSON.stringify(normalized);
|
|
660
|
+
}
|
|
661
|
+
function formatInterpolationValue(value, target) {
|
|
662
|
+
const rendered = stringifyInterpolationValue(value);
|
|
663
|
+
if (target === 'shell') {
|
|
664
|
+
return escapeShellArgument(rendered);
|
|
665
|
+
}
|
|
666
|
+
if (target === 'prompt') {
|
|
667
|
+
return sanitizePromptValue(rendered);
|
|
668
|
+
}
|
|
669
|
+
return rendered;
|
|
670
|
+
}
|
|
671
|
+
export function resolveTemplateReference(reference, context) {
|
|
672
|
+
const normalizedReference = reference.trim();
|
|
673
|
+
if (!normalizedReference.startsWith('inputs.') &&
|
|
674
|
+
!normalizedReference.startsWith('artifacts.')) {
|
|
675
|
+
throw new Error(`unsupported interpolation source "${normalizedReference}" (allowed: inputs.*, artifacts.*)`);
|
|
676
|
+
}
|
|
677
|
+
const [root, ...pathParts] = normalizedReference.split('.');
|
|
678
|
+
if (pathParts.length === 0) {
|
|
679
|
+
throw new Error(`invalid interpolation reference "${normalizedReference}"`);
|
|
680
|
+
}
|
|
681
|
+
const value = root === 'inputs'
|
|
682
|
+
? getPathValue(context.inputs, pathParts)
|
|
683
|
+
: getArtifactReferenceValue(context.artifacts ?? {}, pathParts);
|
|
684
|
+
if (value === undefined) {
|
|
685
|
+
throw new Error(`unknown interpolation reference "${normalizedReference}"`);
|
|
686
|
+
}
|
|
687
|
+
return value;
|
|
688
|
+
}
|
|
689
|
+
export function interpolateTemplateString(templateValue, context, target = 'text') {
|
|
690
|
+
return templateValue.replace(INTERPOLATION_PATTERN, (_match, reference) => {
|
|
691
|
+
const value = resolveTemplateReference(reference, context);
|
|
692
|
+
return formatInterpolationValue(value, target);
|
|
693
|
+
});
|
|
694
|
+
}
|
|
695
|
+
export function renderStepRequestPayload(step, context) {
|
|
696
|
+
switch (step.kind) {
|
|
697
|
+
case 'agent':
|
|
698
|
+
return {
|
|
699
|
+
prompt: step.prompt
|
|
700
|
+
? interpolateTemplateString(step.prompt, context, 'prompt')
|
|
701
|
+
: undefined,
|
|
702
|
+
};
|
|
703
|
+
case 'exec':
|
|
704
|
+
return {
|
|
705
|
+
command: interpolateTemplateString(step.command, context, 'shell'),
|
|
706
|
+
};
|
|
707
|
+
case 'notify':
|
|
708
|
+
return {
|
|
709
|
+
message: interpolateTemplateString(step.message, context, 'text'),
|
|
710
|
+
};
|
|
711
|
+
case 'manual':
|
|
712
|
+
return {
|
|
713
|
+
message: step.message
|
|
714
|
+
? interpolateTemplateString(step.message, context, 'text').trim() ||
|
|
715
|
+
undefined
|
|
716
|
+
: undefined,
|
|
717
|
+
};
|
|
718
|
+
default: {
|
|
719
|
+
const payloadLessKind = step.kind;
|
|
720
|
+
void payloadLessKind;
|
|
721
|
+
return {};
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
}
|
|
725
|
+
export function renderTemplateStepRequests(template, context) {
|
|
726
|
+
return template.steps.map((step) => ({
|
|
727
|
+
kind: step.kind,
|
|
728
|
+
payload: renderStepRequestPayload(step, context),
|
|
729
|
+
stepId: step.id,
|
|
730
|
+
}));
|
|
731
|
+
}
|
|
732
|
+
export function validateTemplateInterpolation(template, context) {
|
|
733
|
+
const errors = [];
|
|
734
|
+
for (const [index, step] of template.steps.entries()) {
|
|
735
|
+
try {
|
|
736
|
+
renderStepRequestPayload(step, context);
|
|
737
|
+
}
|
|
738
|
+
catch (error) {
|
|
739
|
+
pushError(errors, `steps[${index}]`, error instanceof Error ? error.message : 'interpolation failed');
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
return {
|
|
743
|
+
errors,
|
|
744
|
+
valid: errors.length === 0,
|
|
745
|
+
};
|
|
746
|
+
}
|
|
747
|
+
export function resolveWorkflowInputs(template, providedInputs) {
|
|
748
|
+
const inputDefinitions = template.inputs ?? {};
|
|
749
|
+
const resolved = {};
|
|
750
|
+
for (const key of Object.keys(providedInputs)) {
|
|
751
|
+
if (!Object.hasOwn(inputDefinitions, key)) {
|
|
752
|
+
throw new Error(`Unknown workflow input "${key}"`);
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
for (const [name, inputDefinition] of Object.entries(inputDefinitions)) {
|
|
756
|
+
const inputType = getInputSpecType(inputDefinition);
|
|
757
|
+
const hasProvidedValue = Object.hasOwn(providedInputs, name);
|
|
758
|
+
const explicitSpec = typeof inputDefinition === 'string' ? undefined : inputDefinition;
|
|
759
|
+
const required = explicitSpec?.required ?? explicitSpec?.default === undefined;
|
|
760
|
+
const value = hasProvidedValue
|
|
761
|
+
? providedInputs[name]
|
|
762
|
+
: explicitSpec?.default !== undefined
|
|
763
|
+
? cloneValue(explicitSpec.default)
|
|
764
|
+
: undefined;
|
|
765
|
+
if (value === undefined) {
|
|
766
|
+
if (required) {
|
|
767
|
+
throw new Error(`Missing required workflow input "${name}"`);
|
|
768
|
+
}
|
|
769
|
+
continue;
|
|
770
|
+
}
|
|
771
|
+
if (!matchesInputType(inputType, value)) {
|
|
772
|
+
throw new Error(`Workflow input "${name}" must be of type "${inputType}"`);
|
|
773
|
+
}
|
|
774
|
+
resolved[name] = cloneValue(value);
|
|
775
|
+
}
|
|
776
|
+
return resolved;
|
|
777
|
+
}
|
|
778
|
+
//# sourceMappingURL=index.js.map
|