patchwork-os 0.2.0-alpha.27 → 0.2.0-alpha.29
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/deploy/deploy-dashboard.sh +14 -4
- package/dist/activationMetrics.d.ts +67 -0
- package/dist/activationMetrics.js +255 -0
- package/dist/activationMetrics.js.map +1 -0
- package/dist/approvalHttp.d.ts +13 -0
- package/dist/approvalHttp.js +52 -0
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalQueue.d.ts +4 -0
- package/dist/approvalQueue.js +19 -0
- package/dist/approvalQueue.js.map +1 -1
- package/dist/automation.js +19 -1
- package/dist/automation.js.map +1 -1
- package/dist/bridge.js +85 -7
- package/dist/bridge.js.map +1 -1
- package/dist/commands/recipe.d.ts +15 -13
- package/dist/commands/recipe.js +248 -431
- package/dist/commands/recipe.js.map +1 -1
- package/dist/config.js +8 -0
- package/dist/config.js.map +1 -1
- package/dist/index.js +58 -11
- package/dist/index.js.map +1 -1
- package/dist/recipes/chainedRunner.d.ts +35 -5
- package/dist/recipes/chainedRunner.js +153 -21
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/legacyRecipeCompat.js +5 -0
- package/dist/recipes/legacyRecipeCompat.js.map +1 -1
- package/dist/recipes/scheduler.js +3 -7
- package/dist/recipes/scheduler.js.map +1 -1
- package/dist/recipes/schema.d.ts +17 -2
- package/dist/recipes/schemaGenerator.js +85 -4
- package/dist/recipes/schemaGenerator.js.map +1 -1
- package/dist/recipes/validation.d.ts +13 -0
- package/dist/recipes/validation.js +433 -0
- package/dist/recipes/validation.js.map +1 -0
- package/dist/recipes/yamlRunner.d.ts +8 -0
- package/dist/recipes/yamlRunner.js +147 -64
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +20 -5
- package/dist/recipesHttp.js +266 -13
- package/dist/recipesHttp.js.map +1 -1
- package/dist/schemas/recipe.v1.json +285 -5
- package/dist/server.d.ts +11 -0
- package/dist/server.js +107 -2
- package/dist/server.js.map +1 -1
- package/package.json +2 -1
- package/templates/recipes/morning-brief.yaml +2 -1
- package/templates/recipes/project-health-check.yaml +50 -0
package/dist/commands/recipe.js
CHANGED
|
@@ -7,16 +7,16 @@ import { existsSync, mkdirSync, readFileSync, statSync, watch, writeFileSync, }
|
|
|
7
7
|
import os from "node:os";
|
|
8
8
|
import { basename, dirname, join, resolve } from "node:path";
|
|
9
9
|
import { fileURLToPath } from "node:url";
|
|
10
|
-
import { Ajv } from "ajv";
|
|
11
10
|
import { parse as parseYaml, stringify as stringifyYaml } from "yaml";
|
|
12
11
|
import "../recipes/tools/index.js";
|
|
13
12
|
import { loadFixtureLibrary } from "../connectors/fixtureLibrary.js";
|
|
14
13
|
import { MockConnector } from "../connectors/mockConnector.js";
|
|
15
|
-
import { FLAG_SCHEMA_LINT, isEnabled } from "../featureFlags.js";
|
|
16
14
|
import { normalizeRecipeForRuntime } from "../recipes/legacyRecipeCompat.js";
|
|
17
15
|
import { generateSchemaSet, writeSchemas } from "../recipes/schemaGenerator.js";
|
|
18
|
-
import { getTool, isConnectorNamespace,
|
|
16
|
+
import { getTool, isConnectorNamespace, seedToolOutputPreviewContext, } from "../recipes/toolRegistry.js";
|
|
17
|
+
import { validateRecipeDefinition, } from "../recipes/validation.js";
|
|
19
18
|
import { buildChainedDeps, dispatchRecipe, loadYamlRecipe, render, runYamlRecipe, } from "../recipes/yamlRunner.js";
|
|
19
|
+
import { findYamlRecipePath } from "../recipesHttp.js";
|
|
20
20
|
const RECIPES_DIR = join(os.homedir(), ".patchwork", "recipes");
|
|
21
21
|
const FIXTURES_DIR = join(os.homedir(), ".patchwork", "fixtures");
|
|
22
22
|
const RECIPE_SCHEMA_HEADER = "# yaml-language-server: $schema=https://patchworkos.com/schema/recipe.v1.json";
|
|
@@ -133,7 +133,6 @@ export async function runSchema(outputDir) {
|
|
|
133
133
|
* Falls back to basic YAML parsing if schema linting is disabled.
|
|
134
134
|
*/
|
|
135
135
|
export function runLint(recipePath) {
|
|
136
|
-
const issues = [];
|
|
137
136
|
// Check file exists
|
|
138
137
|
if (!existsSync(recipePath)) {
|
|
139
138
|
return {
|
|
@@ -143,9 +142,7 @@ export function runLint(recipePath) {
|
|
|
143
142
|
errors: 1,
|
|
144
143
|
};
|
|
145
144
|
}
|
|
146
|
-
// Read and parse
|
|
147
145
|
let content;
|
|
148
|
-
let recipe;
|
|
149
146
|
try {
|
|
150
147
|
content = readFileSync(recipePath, "utf-8");
|
|
151
148
|
}
|
|
@@ -162,8 +159,9 @@ export function runLint(recipePath) {
|
|
|
162
159
|
errors: 1,
|
|
163
160
|
};
|
|
164
161
|
}
|
|
162
|
+
let parsed;
|
|
165
163
|
try {
|
|
166
|
-
|
|
164
|
+
parsed = parseYaml(content);
|
|
167
165
|
}
|
|
168
166
|
catch (err) {
|
|
169
167
|
return {
|
|
@@ -178,427 +176,147 @@ export function runLint(recipePath) {
|
|
|
178
176
|
errors: 1,
|
|
179
177
|
};
|
|
180
178
|
}
|
|
181
|
-
const
|
|
182
|
-
//
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
issues.push({
|
|
191
|
-
level: "error",
|
|
192
|
-
message: "Missing or invalid 'name' field",
|
|
193
|
-
});
|
|
179
|
+
const result = validateRecipeDefinition(parsed);
|
|
180
|
+
// For chained recipes, check that chain: file references resolve on disk.
|
|
181
|
+
const chainIssues = lintChainRefs(parsed, recipePath);
|
|
182
|
+
if (chainIssues.length > 0) {
|
|
183
|
+
result.issues.push(...chainIssues);
|
|
184
|
+
result.errors += chainIssues.filter((i) => i.level === "error").length;
|
|
185
|
+
result.warnings += chainIssues.filter((i) => i.level === "warning").length;
|
|
186
|
+
if (result.errors > 0) {
|
|
187
|
+
result.valid = false;
|
|
194
188
|
}
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
189
|
+
}
|
|
190
|
+
return result;
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Walk chained recipe steps, check that chain:/recipe: refs resolve on disk,
|
|
194
|
+
* and recursively lint any child recipe that does resolve.
|
|
195
|
+
*
|
|
196
|
+
* `visited` tracks absolute paths already linted in this call chain to prevent
|
|
197
|
+
* infinite recursion when two recipes chain each other.
|
|
198
|
+
*/
|
|
199
|
+
function lintChainRefs(parsed, recipePath, visited = new Set()) {
|
|
200
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
201
|
+
return [];
|
|
202
|
+
const r = parsed;
|
|
203
|
+
const trigger = r.trigger && typeof r.trigger === "object"
|
|
204
|
+
? r.trigger
|
|
205
|
+
: undefined;
|
|
206
|
+
if (trigger?.type !== "chained")
|
|
207
|
+
return [];
|
|
208
|
+
const steps = Array.isArray(r.steps)
|
|
209
|
+
? r.steps
|
|
210
|
+
: [];
|
|
211
|
+
const recipeDir = dirname(recipePath);
|
|
212
|
+
const issues = [];
|
|
213
|
+
// Mark the current recipe as visited before descending.
|
|
214
|
+
const absPath = resolve(recipePath);
|
|
215
|
+
visited.add(absPath);
|
|
216
|
+
for (let i = 0; i < steps.length; i++) {
|
|
217
|
+
const step = steps[i];
|
|
218
|
+
if (!step)
|
|
219
|
+
continue;
|
|
220
|
+
issues.push(...lintStep(step, i + 1, recipeDir, visited));
|
|
221
|
+
}
|
|
222
|
+
return issues;
|
|
223
|
+
}
|
|
224
|
+
/**
|
|
225
|
+
* Check a single step (or recurse into its parallel: children).
|
|
226
|
+
* `stepLabel` is the 1-based position string used in issue messages.
|
|
227
|
+
*/
|
|
228
|
+
function lintStep(step, stepLabel, recipeDir, visited) {
|
|
229
|
+
const issues = [];
|
|
230
|
+
// Recurse into parallel: groups — each child is checked independently.
|
|
231
|
+
if (Array.isArray(step.parallel)) {
|
|
232
|
+
for (let j = 0; j < step.parallel.length; j++) {
|
|
233
|
+
const child = step.parallel[j];
|
|
234
|
+
if (!child || typeof child !== "object" || Array.isArray(child))
|
|
235
|
+
continue;
|
|
236
|
+
issues.push(...lintStep(child, stepLabel, recipeDir, visited));
|
|
203
237
|
}
|
|
204
|
-
|
|
238
|
+
return issues;
|
|
239
|
+
}
|
|
240
|
+
const ref = typeof step.chain === "string"
|
|
241
|
+
? step.chain
|
|
242
|
+
: typeof step.recipe === "string"
|
|
243
|
+
? step.recipe
|
|
244
|
+
: null;
|
|
245
|
+
if (!ref)
|
|
246
|
+
return issues;
|
|
247
|
+
const field = typeof step.chain === "string" ? "chain" : "recipe";
|
|
248
|
+
// Refs that look like file paths (extension or separator) → resolve relative to recipe dir.
|
|
249
|
+
const looksLikePath = /\.ya?ml$/i.test(ref) ||
|
|
250
|
+
ref.startsWith("./") ||
|
|
251
|
+
ref.startsWith("../") ||
|
|
252
|
+
/[\\/]/.test(ref);
|
|
253
|
+
if (looksLikePath) {
|
|
254
|
+
const resolved = /^\//.test(ref) ? ref : resolve(recipeDir, ref);
|
|
255
|
+
const candidates = /\.ya?ml$/i.test(resolved)
|
|
256
|
+
? [resolved]
|
|
257
|
+
: [`${resolved}.yaml`, `${resolved}.yml`, resolved];
|
|
258
|
+
const childPath = candidates.find(existsSync) ?? null;
|
|
259
|
+
if (!childPath) {
|
|
205
260
|
issues.push({
|
|
206
261
|
level: "error",
|
|
207
|
-
message:
|
|
262
|
+
message: `Step ${stepLabel}: '${field}: ${ref}' — file not found relative to recipe directory (${recipeDir})`,
|
|
208
263
|
});
|
|
264
|
+
return issues;
|
|
209
265
|
}
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
"chained",
|
|
221
|
-
];
|
|
222
|
-
if (!trigger.type || !validTypes.includes(trigger.type)) {
|
|
223
|
-
issues.push({
|
|
224
|
-
level: "error",
|
|
225
|
-
message: `Invalid trigger.type. Must be one of: ${validTypes.join(", ")}`,
|
|
226
|
-
});
|
|
227
|
-
}
|
|
228
|
-
if (trigger.type === "cron" && !trigger.at) {
|
|
229
|
-
issues.push({
|
|
230
|
-
level: "warning",
|
|
231
|
-
message: "cron trigger should have 'at' (cron expression)",
|
|
232
|
-
});
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
if (!Array.isArray(r.steps) || r.steps.length === 0) {
|
|
266
|
+
issues.push(...lintChildRecipe(childPath, field, ref, stepLabel, visited));
|
|
267
|
+
return issues;
|
|
268
|
+
}
|
|
269
|
+
// Named ref (no extension, no separator) → check ~/.patchwork/recipes/.
|
|
270
|
+
// Emit a warning rather than error: the recipe may be installed on the
|
|
271
|
+
// deploy target but not the author's machine.
|
|
272
|
+
if (existsSync(RECIPES_DIR)) {
|
|
273
|
+
const found = findYamlRecipePath(RECIPES_DIR, ref) ??
|
|
274
|
+
(existsSync(join(RECIPES_DIR, ref)) ? join(RECIPES_DIR, ref) : null);
|
|
275
|
+
if (!found) {
|
|
236
276
|
issues.push({
|
|
237
|
-
level: "
|
|
238
|
-
message:
|
|
277
|
+
level: "warning",
|
|
278
|
+
message: `Step ${stepLabel}: '${field}: ${ref}' — recipe not found in ${RECIPES_DIR}`,
|
|
239
279
|
});
|
|
240
280
|
}
|
|
241
281
|
else {
|
|
242
|
-
|
|
243
|
-
? r.trigger.type
|
|
244
|
-
: undefined;
|
|
245
|
-
const allowNestedRecipeSteps = triggerType === "chained";
|
|
246
|
-
// Validate each step
|
|
247
|
-
for (let i = 0; i < r.steps.length; i++) {
|
|
248
|
-
const step = r.steps[i];
|
|
249
|
-
const hasTool = typeof step.tool === "string";
|
|
250
|
-
const hasAgent = !!step.agent;
|
|
251
|
-
const hasNestedRecipe = allowNestedRecipeSteps && typeof step.recipe === "string";
|
|
252
|
-
if (!hasTool && !hasAgent && !hasNestedRecipe) {
|
|
253
|
-
issues.push({
|
|
254
|
-
level: "error",
|
|
255
|
-
message: `Step ${i + 1}: Must have 'tool' or 'agent' field${allowNestedRecipeSteps ? " (or 'recipe' for chained recipes)" : ""}`,
|
|
256
|
-
});
|
|
257
|
-
}
|
|
258
|
-
if (step.agent && typeof step.agent === "object") {
|
|
259
|
-
const agent = step.agent;
|
|
260
|
-
if (!agent.prompt || typeof agent.prompt !== "string") {
|
|
261
|
-
issues.push({
|
|
262
|
-
level: "error",
|
|
263
|
-
message: `Step ${i + 1}: Agent step missing 'prompt'`,
|
|
264
|
-
});
|
|
265
|
-
}
|
|
266
|
-
}
|
|
267
|
-
}
|
|
268
|
-
validateTemplateReferences(r, issues);
|
|
282
|
+
issues.push(...lintChildRecipe(found, field, ref, stepLabel, visited));
|
|
269
283
|
}
|
|
270
284
|
}
|
|
271
|
-
|
|
272
|
-
if (isEnabled(FLAG_SCHEMA_LINT)) {
|
|
273
|
-
issues.push(...validateRecipeSchema(normalizedRecipe));
|
|
274
|
-
}
|
|
275
|
-
const errors = issues.filter((i) => i.level === "error").length;
|
|
276
|
-
const warnings = issues.filter((i) => i.level === "warning").length;
|
|
277
|
-
return {
|
|
278
|
-
valid: errors === 0,
|
|
279
|
-
issues,
|
|
280
|
-
warnings,
|
|
281
|
-
errors,
|
|
282
|
-
};
|
|
285
|
+
return issues;
|
|
283
286
|
}
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (validationReady.trigger &&
|
|
295
|
-
typeof validationReady.trigger === "object" &&
|
|
296
|
-
!Array.isArray(validationReady.trigger)) {
|
|
297
|
-
validationReady.trigger = normalizeValidationTrigger(validationReady.trigger);
|
|
298
|
-
}
|
|
299
|
-
if (Array.isArray(validationReady.steps)) {
|
|
300
|
-
validationReady.steps = flattenValidationSteps(validationReady.steps);
|
|
301
|
-
}
|
|
302
|
-
return validationReady;
|
|
303
|
-
}
|
|
304
|
-
function normalizeValidationTrigger(trigger) {
|
|
305
|
-
const normalized = { ...trigger };
|
|
306
|
-
if (normalized.type === "event") {
|
|
307
|
-
normalized.type = "webhook";
|
|
308
|
-
normalized.legacyType = "event";
|
|
309
|
-
if (typeof normalized.on === "string") {
|
|
310
|
-
normalized.eventSource = normalized.on;
|
|
311
|
-
}
|
|
312
|
-
delete normalized.on;
|
|
313
|
-
if (normalized.filter !== undefined &&
|
|
314
|
-
typeof normalized.filter !== "string") {
|
|
315
|
-
normalized.eventFilter = normalized.filter;
|
|
316
|
-
delete normalized.filter;
|
|
317
|
-
}
|
|
318
|
-
if (normalized.lead_time_hours !== undefined) {
|
|
319
|
-
normalized.eventLeadTimeHours = normalized.lead_time_hours;
|
|
320
|
-
delete normalized.lead_time_hours;
|
|
321
|
-
}
|
|
322
|
-
if (normalized.lead_time_minutes !== undefined) {
|
|
323
|
-
normalized.eventLeadTimeMinutes = normalized.lead_time_minutes;
|
|
324
|
-
delete normalized.lead_time_minutes;
|
|
325
|
-
}
|
|
326
|
-
}
|
|
327
|
-
return normalized;
|
|
328
|
-
}
|
|
329
|
-
function flattenValidationSteps(steps) {
|
|
330
|
-
const normalizedSteps = [];
|
|
331
|
-
for (const step of steps) {
|
|
332
|
-
normalizedSteps.push(...flattenValidationStep(step));
|
|
333
|
-
}
|
|
334
|
-
return normalizedSteps;
|
|
335
|
-
}
|
|
336
|
-
function flattenValidationStep(step) {
|
|
337
|
-
if (!step || typeof step !== "object" || Array.isArray(step)) {
|
|
338
|
-
return [step];
|
|
339
|
-
}
|
|
340
|
-
const record = step;
|
|
341
|
-
if (Array.isArray(record.parallel)) {
|
|
342
|
-
const parallelSteps = [];
|
|
343
|
-
for (const nestedStep of record.parallel) {
|
|
344
|
-
parallelSteps.push(...flattenValidationStep(nestedStep));
|
|
345
|
-
}
|
|
346
|
-
return parallelSteps;
|
|
347
|
-
}
|
|
348
|
-
if (Array.isArray(record.branch)) {
|
|
349
|
-
const branchSteps = [];
|
|
350
|
-
for (const branchStep of record.branch) {
|
|
351
|
-
if (!branchStep ||
|
|
352
|
-
typeof branchStep !== "object" ||
|
|
353
|
-
Array.isArray(branchStep)) {
|
|
354
|
-
continue;
|
|
355
|
-
}
|
|
356
|
-
const branchRecord = branchStep;
|
|
357
|
-
const otherwiseStep = branchRecord.otherwise;
|
|
358
|
-
if (otherwiseStep &&
|
|
359
|
-
typeof otherwiseStep === "object" &&
|
|
360
|
-
!Array.isArray(otherwiseStep)) {
|
|
361
|
-
branchSteps.push(...flattenValidationStep(otherwiseStep));
|
|
362
|
-
continue;
|
|
363
|
-
}
|
|
364
|
-
branchSteps.push(...flattenValidationStep(branchRecord));
|
|
365
|
-
}
|
|
366
|
-
return branchSteps.length > 0 ? branchSteps : [record];
|
|
367
|
-
}
|
|
368
|
-
return [record];
|
|
369
|
-
}
|
|
370
|
-
function validateRecipeSchema(recipe) {
|
|
287
|
+
/**
|
|
288
|
+
* Read, parse, and validate a resolved child recipe path. Skips the file if
|
|
289
|
+
* it has already been visited (cycle). Issues are prefixed with the parent
|
|
290
|
+
* step context so the author knows where the problem originates.
|
|
291
|
+
*/
|
|
292
|
+
function lintChildRecipe(childPath, field, ref, stepNumber, visited) {
|
|
293
|
+
const absChild = resolve(childPath);
|
|
294
|
+
if (visited.has(absChild))
|
|
295
|
+
return []; // cycle — already linted
|
|
296
|
+
let childParsed;
|
|
371
297
|
try {
|
|
372
|
-
|
|
373
|
-
const ajv = new Ajv({ strict: false, allErrors: true });
|
|
374
|
-
for (const schema of Object.values(schemas.namespaces)) {
|
|
375
|
-
ajv.addSchema(schema);
|
|
376
|
-
}
|
|
377
|
-
const validate = ajv.compile(schemas.recipe);
|
|
378
|
-
const valid = validate(recipe);
|
|
379
|
-
if (valid) {
|
|
380
|
-
return [];
|
|
381
|
-
}
|
|
382
|
-
return (validate.errors ?? []).map(toSchemaLintIssue);
|
|
298
|
+
childParsed = parseYaml(readFileSync(childPath, "utf-8"));
|
|
383
299
|
}
|
|
384
300
|
catch (err) {
|
|
385
301
|
return [
|
|
386
302
|
{
|
|
387
303
|
level: "error",
|
|
388
|
-
message: `
|
|
304
|
+
message: `Step ${stepNumber}: '${field}: ${ref}' — could not read child recipe: ${err instanceof Error ? err.message : String(err)}`,
|
|
389
305
|
},
|
|
390
306
|
];
|
|
391
307
|
}
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
}
|
|
405
|
-
if (trigger?.type === "on_test_run") {
|
|
406
|
-
availableKeys.add("runner");
|
|
407
|
-
availableKeys.add("failed");
|
|
408
|
-
availableKeys.add("passed");
|
|
409
|
-
availableKeys.add("total");
|
|
410
|
-
availableKeys.add("failures");
|
|
411
|
-
}
|
|
412
|
-
if (trigger?.legacyType === "event") {
|
|
413
|
-
availableKeys.add("event");
|
|
414
|
-
}
|
|
415
|
-
if (Array.isArray(trigger?.vars)) {
|
|
416
|
-
for (const item of trigger.vars) {
|
|
417
|
-
if (item && typeof item === "object" && typeof item.name === "string") {
|
|
418
|
-
availableKeys.add(item.name);
|
|
419
|
-
}
|
|
420
|
-
}
|
|
421
|
-
}
|
|
422
|
-
if (Array.isArray(trigger?.inputs)) {
|
|
423
|
-
for (const item of trigger.inputs) {
|
|
424
|
-
if (item && typeof item === "object" && typeof item.name === "string") {
|
|
425
|
-
availableKeys.add(item.name);
|
|
426
|
-
}
|
|
427
|
-
}
|
|
428
|
-
}
|
|
429
|
-
if (Array.isArray(recipe.context)) {
|
|
430
|
-
for (const block of recipe.context) {
|
|
431
|
-
if (!block || typeof block !== "object") {
|
|
432
|
-
continue;
|
|
433
|
-
}
|
|
434
|
-
const typedBlock = block;
|
|
435
|
-
if (typedBlock.type === "env" && Array.isArray(typedBlock.keys)) {
|
|
436
|
-
for (const key of typedBlock.keys) {
|
|
437
|
-
if (typeof key === "string") {
|
|
438
|
-
availableKeys.add(key);
|
|
439
|
-
}
|
|
440
|
-
}
|
|
441
|
-
}
|
|
442
|
-
}
|
|
443
|
-
}
|
|
444
|
-
}
|
|
445
|
-
function toSchemaLintIssue(error) {
|
|
446
|
-
const path = error.instancePath
|
|
447
|
-
? error.instancePath.slice(1).replace(/\//g, ".")
|
|
448
|
-
: "recipe";
|
|
449
|
-
return {
|
|
450
|
-
level: "error",
|
|
451
|
-
message: `Schema validation: ${path} ${error.message ?? "is invalid"}`,
|
|
452
|
-
};
|
|
453
|
-
}
|
|
454
|
-
function validateTemplateReferences(recipe, issues) {
|
|
455
|
-
const BUILTIN_KEYS = new Set([
|
|
456
|
-
"date",
|
|
457
|
-
"time",
|
|
458
|
-
"YYYY-MM",
|
|
459
|
-
"YYYY-MM-DD",
|
|
460
|
-
"ISO_NOW",
|
|
461
|
-
]);
|
|
462
|
-
const availableKeys = new Set(BUILTIN_KEYS);
|
|
463
|
-
registerRecipeContextKeys(recipe, availableKeys);
|
|
464
|
-
const triggerType = recipe.trigger && typeof recipe.trigger === "object"
|
|
465
|
-
? recipe.trigger.type
|
|
466
|
-
: undefined;
|
|
467
|
-
const isChainedRecipe = triggerType === "chained";
|
|
468
|
-
const steps = Array.isArray(recipe.steps)
|
|
469
|
-
? recipe.steps
|
|
470
|
-
: [];
|
|
471
|
-
// Track all `into` keys produced so far for duplicate detection
|
|
472
|
-
const seenIntoKeys = new Map(); // key → first step (1-indexed)
|
|
473
|
-
for (let index = 0; index < steps.length; index++) {
|
|
474
|
-
const step = steps[index] ?? {};
|
|
475
|
-
const templates = collectRenderedTemplates(step, isChainedRecipe);
|
|
476
|
-
for (const template of templates) {
|
|
477
|
-
for (const expression of extractTemplateExpressions(template.value)) {
|
|
478
|
-
for (const identifier of extractTemplateIdentifiers(expression)) {
|
|
479
|
-
if (!availableKeys.has(identifier)) {
|
|
480
|
-
issues.push({
|
|
481
|
-
level: "error",
|
|
482
|
-
message: `Step ${index + 1}: Unknown template reference '{{${expression}}}' in ${template.label}`,
|
|
483
|
-
});
|
|
484
|
-
break;
|
|
485
|
-
}
|
|
486
|
-
}
|
|
487
|
-
}
|
|
488
|
-
}
|
|
489
|
-
// Validate the `into` key produced by this step
|
|
490
|
-
const intoKey = resolveStepIntoKey(step, isChainedRecipe);
|
|
491
|
-
if (intoKey) {
|
|
492
|
-
if (BUILTIN_KEYS.has(intoKey)) {
|
|
493
|
-
issues.push({
|
|
494
|
-
level: "error",
|
|
495
|
-
message: `Step ${index + 1}: 'into: ${intoKey}' shadows a built-in context key`,
|
|
496
|
-
});
|
|
497
|
-
}
|
|
498
|
-
else {
|
|
499
|
-
const firstSeen = seenIntoKeys.get(intoKey);
|
|
500
|
-
if (firstSeen !== undefined) {
|
|
501
|
-
issues.push({
|
|
502
|
-
level: "warning",
|
|
503
|
-
message: `Step ${index + 1}: 'into: ${intoKey}' overwrites value already written by step ${firstSeen}`,
|
|
504
|
-
});
|
|
505
|
-
}
|
|
506
|
-
else {
|
|
507
|
-
seenIntoKeys.set(intoKey, index + 1);
|
|
508
|
-
}
|
|
509
|
-
}
|
|
510
|
-
}
|
|
511
|
-
registerStepContextKeys(step, availableKeys, isChainedRecipe);
|
|
512
|
-
}
|
|
513
|
-
}
|
|
514
|
-
/** Return the context key a step writes to (its `into` value), or null. */
|
|
515
|
-
function resolveStepIntoKey(step, isChainedRecipe) {
|
|
516
|
-
if (step.agent && typeof step.agent === "object") {
|
|
517
|
-
const agent = step.agent;
|
|
518
|
-
return typeof agent.into === "string" ? agent.into : "agent_output";
|
|
519
|
-
}
|
|
520
|
-
if (typeof step.into === "string")
|
|
521
|
-
return step.into;
|
|
522
|
-
if (isChainedRecipe && typeof step.id === "string")
|
|
523
|
-
return step.id;
|
|
524
|
-
return null;
|
|
525
|
-
}
|
|
526
|
-
function collectRenderedTemplates(step, isChainedRecipe) {
|
|
527
|
-
const templates = [];
|
|
528
|
-
for (const [key, value] of Object.entries(step)) {
|
|
529
|
-
if (key === "tool" || key === "into" || key === "agent") {
|
|
530
|
-
continue;
|
|
531
|
-
}
|
|
532
|
-
if (typeof value === "string") {
|
|
533
|
-
templates.push({ label: key, value });
|
|
534
|
-
}
|
|
535
|
-
}
|
|
536
|
-
if (step.agent && typeof step.agent === "object") {
|
|
537
|
-
const agent = step.agent;
|
|
538
|
-
if (typeof agent.prompt === "string") {
|
|
539
|
-
templates.push({ label: "agent.prompt", value: agent.prompt });
|
|
540
|
-
}
|
|
541
|
-
}
|
|
542
|
-
if (isChainedRecipe && step.vars && typeof step.vars === "object") {
|
|
543
|
-
for (const [key, value] of Object.entries(step.vars)) {
|
|
544
|
-
if (typeof value === "string") {
|
|
545
|
-
templates.push({ label: `vars.${key}`, value });
|
|
546
|
-
}
|
|
547
|
-
}
|
|
548
|
-
}
|
|
549
|
-
return templates;
|
|
550
|
-
}
|
|
551
|
-
function extractTemplateExpressions(template) {
|
|
552
|
-
const matches = template.matchAll(/\{\{\s*([^}]+?)\s*\}\}/g);
|
|
553
|
-
const expressions = [];
|
|
554
|
-
for (const match of matches) {
|
|
555
|
-
const expression = match[1]?.trim();
|
|
556
|
-
if (expression) {
|
|
557
|
-
expressions.push(expression);
|
|
558
|
-
}
|
|
559
|
-
}
|
|
560
|
-
return expressions;
|
|
561
|
-
}
|
|
562
|
-
function extractTemplateIdentifiers(expression) {
|
|
563
|
-
const reserved = new Set(["true", "false", "null"]);
|
|
564
|
-
const identifiers = new Set();
|
|
565
|
-
for (const match of expression.matchAll(/[A-Za-z_][A-Za-z0-9_-]*(?:\.[A-Za-z0-9_-]+)*/g)) {
|
|
566
|
-
const rawIdentifier = match[0];
|
|
567
|
-
if (!rawIdentifier) {
|
|
568
|
-
continue;
|
|
569
|
-
}
|
|
570
|
-
const rootIdentifier = rawIdentifier.split(".")[0] ?? rawIdentifier;
|
|
571
|
-
if (!reserved.has(rootIdentifier)) {
|
|
572
|
-
identifiers.add(rootIdentifier);
|
|
573
|
-
}
|
|
574
|
-
}
|
|
575
|
-
return Array.from(identifiers);
|
|
576
|
-
}
|
|
577
|
-
function registerStepContextKeys(step, availableKeys, isChainedRecipe = false) {
|
|
578
|
-
if (isChainedRecipe) {
|
|
579
|
-
const stepId = typeof step.id === "string" ? step.id : undefined;
|
|
580
|
-
if (stepId) {
|
|
581
|
-
availableKeys.add(stepId);
|
|
582
|
-
}
|
|
583
|
-
}
|
|
584
|
-
if (step.agent && typeof step.agent === "object") {
|
|
585
|
-
const agent = step.agent;
|
|
586
|
-
const intoKey = typeof agent.into === "string" ? agent.into : "agent_output";
|
|
587
|
-
availableKeys.add(intoKey);
|
|
588
|
-
return;
|
|
589
|
-
}
|
|
590
|
-
const intoKey = typeof step.into === "string" ? step.into : undefined;
|
|
591
|
-
if (!intoKey) {
|
|
592
|
-
return;
|
|
593
|
-
}
|
|
594
|
-
availableKeys.add(intoKey);
|
|
595
|
-
const toolId = typeof step.tool === "string" ? step.tool : undefined;
|
|
596
|
-
if (!toolId) {
|
|
597
|
-
return;
|
|
598
|
-
}
|
|
599
|
-
for (const key of listToolOutputContextKeys(toolId, intoKey)) {
|
|
600
|
-
availableKeys.add(key);
|
|
601
|
-
}
|
|
308
|
+
const childResult = validateRecipeDefinition(childParsed);
|
|
309
|
+
const childChainIssues = lintChainRefs(childParsed, childPath, visited);
|
|
310
|
+
return [
|
|
311
|
+
...childResult.issues.map((issue) => ({
|
|
312
|
+
...issue,
|
|
313
|
+
message: `Step ${stepNumber}: '${field}: ${ref}' — child recipe invalid: ${issue.message}`,
|
|
314
|
+
})),
|
|
315
|
+
...childChainIssues.map((issue) => ({
|
|
316
|
+
...issue,
|
|
317
|
+
message: `Step ${stepNumber}: '${field}: ${ref}' — ${issue.message}`,
|
|
318
|
+
})),
|
|
319
|
+
];
|
|
602
320
|
}
|
|
603
321
|
/**
|
|
604
322
|
* Format/normalize a recipe file.
|
|
@@ -696,6 +414,7 @@ export async function runRecipe(recipeRef, options = {}) {
|
|
|
696
414
|
const result = await dispatchRecipe(recipeToRun, {
|
|
697
415
|
...runnerDeps,
|
|
698
416
|
chainedDeps: buildChainedDeps(runnerDeps),
|
|
417
|
+
chainedOptions: { sourcePath: recipePath },
|
|
699
418
|
}, options.vars ?? {});
|
|
700
419
|
return {
|
|
701
420
|
recipe,
|
|
@@ -730,6 +449,68 @@ export function summarizeRecipeExecution(result) {
|
|
|
730
449
|
skipped: result.summary.skipped,
|
|
731
450
|
};
|
|
732
451
|
}
|
|
452
|
+
/**
|
|
453
|
+
* Normalize either a yamlRunner RunResult or a chainedRunner ChainedRunResult
|
|
454
|
+
* into the RunStepResult[] shape expected by RecipeRunLog.appendDirect.
|
|
455
|
+
* Returns undefined when the result has no step-level detail.
|
|
456
|
+
*/
|
|
457
|
+
export function extractRunLogStepResults(result) {
|
|
458
|
+
if ("stepsRun" in result) {
|
|
459
|
+
// yamlRunner: stepResults is already StepResult[]
|
|
460
|
+
if (!Array.isArray(result.stepResults))
|
|
461
|
+
return undefined;
|
|
462
|
+
return result.stepResults.map((s) => ({
|
|
463
|
+
id: s.id,
|
|
464
|
+
...(s.tool ? { tool: s.tool } : {}),
|
|
465
|
+
status: s.status,
|
|
466
|
+
...(s.error ? { error: s.error } : {}),
|
|
467
|
+
durationMs: s.durationMs,
|
|
468
|
+
}));
|
|
469
|
+
}
|
|
470
|
+
// chainedRunner: stepResults is Map<string, ChainedStepRunResult>
|
|
471
|
+
return [...result.stepResults.entries()].map(([id, s]) => ({
|
|
472
|
+
id,
|
|
473
|
+
status: s.skipped ? "skipped" : s.success ? "ok" : "error",
|
|
474
|
+
durationMs: s.durationMs ?? 0,
|
|
475
|
+
...(s.error ? { error: s.error.message } : {}),
|
|
476
|
+
}));
|
|
477
|
+
}
|
|
478
|
+
export function formatRunReport(result, recipeName) {
|
|
479
|
+
const lines = [];
|
|
480
|
+
const hr = "─".repeat(48);
|
|
481
|
+
if ("stepsRun" in result) {
|
|
482
|
+
// Simple (non-chained) recipe — compact summary
|
|
483
|
+
const ok = !result.errorMessage;
|
|
484
|
+
lines.push(`${ok ? "✓" : "✗"} ${recipeName} — ${result.stepsRun} step(s)`);
|
|
485
|
+
if (result.outputs.length > 0) {
|
|
486
|
+
for (const o of result.outputs)
|
|
487
|
+
lines.push(` → ${o}`);
|
|
488
|
+
}
|
|
489
|
+
if (result.errorMessage)
|
|
490
|
+
lines.push(` Error: ${result.errorMessage}`);
|
|
491
|
+
return lines.join("\n");
|
|
492
|
+
}
|
|
493
|
+
// Chained recipe — per-step table
|
|
494
|
+
const { stepResults, summary } = result;
|
|
495
|
+
const overallOk = result.success;
|
|
496
|
+
lines.push(hr);
|
|
497
|
+
lines.push(`Recipe: ${recipeName}`);
|
|
498
|
+
lines.push(hr);
|
|
499
|
+
for (const [id, step] of stepResults) {
|
|
500
|
+
const icon = step.skipped ? "↷" : step.success ? "✓" : "✗";
|
|
501
|
+
const dur = step.durationMs !== undefined ? ` (${step.durationMs}ms)` : "";
|
|
502
|
+
const err = step.error ? ` → ${step.error.message}` : "";
|
|
503
|
+
lines.push(` ${icon} ${id}${dur}${err}`);
|
|
504
|
+
}
|
|
505
|
+
lines.push(hr);
|
|
506
|
+
const parts = [`${summary.succeeded} ok`];
|
|
507
|
+
if (summary.skipped > 0)
|
|
508
|
+
parts.push(`${summary.skipped} skipped`);
|
|
509
|
+
if (summary.failed > 0)
|
|
510
|
+
parts.push(`${summary.failed} failed`);
|
|
511
|
+
lines.push(`${overallOk ? "✓" : "✗"} ${parts.join(" · ")}`);
|
|
512
|
+
return lines.join("\n");
|
|
513
|
+
}
|
|
733
514
|
export async function runWatchedRecipe(recipePath, options = {}) {
|
|
734
515
|
const lint = runLint(recipePath);
|
|
735
516
|
if (!lint.valid) {
|
|
@@ -1117,34 +898,70 @@ export async function runTest(recipePath, options = {}) {
|
|
|
1117
898
|
if (issues.every((issue) => issue.level !== "error")) {
|
|
1118
899
|
try {
|
|
1119
900
|
const recipe = loadYamlRecipe(recipePath);
|
|
1120
|
-
const
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
|
|
1129
|
-
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1140
|
-
issues.push({ level: "error", message:
|
|
901
|
+
const triggerType = recipe.trigger?.type;
|
|
902
|
+
if (triggerType === "chained") {
|
|
903
|
+
// Chained recipes: run through chainedRunner with mocked tool + agent executors
|
|
904
|
+
const { runChainedRecipe } = await import("../recipes/chainedRunner.js");
|
|
905
|
+
const { evaluateExpect } = await import("../recipes/yamlRunner.js");
|
|
906
|
+
const chainedRecipe = recipe;
|
|
907
|
+
const recipeRecord = recipe;
|
|
908
|
+
const run = await runChainedRecipe(chainedRecipe, {
|
|
909
|
+
env: process.env,
|
|
910
|
+
maxConcurrency: recipeRecord.maxConcurrency ?? 4,
|
|
911
|
+
maxDepth: recipeRecord.maxDepth ?? 3,
|
|
912
|
+
dryRun: false,
|
|
913
|
+
sourcePath: recipePath,
|
|
914
|
+
}, {
|
|
915
|
+
executeTool: async (tool) => `[mock:${tool}]`,
|
|
916
|
+
executeAgent: async () => "[mock agent output]",
|
|
917
|
+
loadNestedRecipe: async () => null,
|
|
918
|
+
});
|
|
919
|
+
stepsRun = run.summary.total;
|
|
920
|
+
if (run.errorMessage) {
|
|
921
|
+
issues.push({ level: "error", message: run.errorMessage });
|
|
922
|
+
}
|
|
923
|
+
// Evaluate expect: block against chained run results
|
|
924
|
+
const expectBlock = recipeRecord.expect;
|
|
925
|
+
if (expectBlock) {
|
|
926
|
+
const failures = evaluateExpect({
|
|
927
|
+
stepsRun: run.summary.total,
|
|
928
|
+
outputs: [],
|
|
929
|
+
context: run.context,
|
|
930
|
+
errorMessage: run.errorMessage,
|
|
931
|
+
}, expectBlock);
|
|
932
|
+
assertionFailures = failures;
|
|
933
|
+
for (const failure of failures) {
|
|
934
|
+
issues.push({ level: "error", message: failure.message });
|
|
935
|
+
}
|
|
1141
936
|
}
|
|
1142
937
|
}
|
|
1143
|
-
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
938
|
+
else {
|
|
939
|
+
const mockConnectors = createMockToolConnectors(recipe.steps, fixturesDir);
|
|
940
|
+
const run = await runYamlRecipe(recipe, {
|
|
941
|
+
testMode: true,
|
|
942
|
+
mockConnectors,
|
|
943
|
+
readFile: (filePath) => readFileSync(filePath, "utf-8"),
|
|
944
|
+
writeFile: () => { },
|
|
945
|
+
appendFile: () => { },
|
|
946
|
+
mkdir: () => { },
|
|
947
|
+
gitLogSince: () => "[mock git log]",
|
|
948
|
+
gitStaleBranches: () => "[mock stale branches]",
|
|
949
|
+
getDiagnostics: () => "[mock diagnostics]",
|
|
950
|
+
claudeFn: async () => "[mock agent output]",
|
|
951
|
+
claudeCodeFn: async () => "[mock agent output]",
|
|
952
|
+
providerDriverFn: async () => "[mock agent output]",
|
|
1147
953
|
});
|
|
954
|
+
stepsRun = run.stepsRun;
|
|
955
|
+
outputs = run.outputs;
|
|
956
|
+
if (run.assertionFailures && run.assertionFailures.length > 0) {
|
|
957
|
+
assertionFailures = run.assertionFailures;
|
|
958
|
+
for (const failure of run.assertionFailures) {
|
|
959
|
+
issues.push({ level: "error", message: failure.message });
|
|
960
|
+
}
|
|
961
|
+
}
|
|
962
|
+
if (run.errorMessage) {
|
|
963
|
+
issues.push({ level: "error", message: run.errorMessage });
|
|
964
|
+
}
|
|
1148
965
|
}
|
|
1149
966
|
}
|
|
1150
967
|
catch (err) {
|