patchwork-os 0.2.0-beta.5.canary.13 → 0.2.0-beta.5.canary.131
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/README.md +1 -1
- package/dist/analyticsPrefs.d.ts +3 -0
- package/dist/analyticsPrefs.js +7 -0
- package/dist/analyticsPrefs.js.map +1 -1
- package/dist/approvalHttp.js +15 -0
- package/dist/approvalHttp.js.map +1 -1
- package/dist/approvalQueue.d.ts +26 -0
- package/dist/approvalQueue.js +17 -0
- package/dist/approvalQueue.js.map +1 -1
- package/dist/bridge.js +16 -0
- package/dist/bridge.js.map +1 -1
- package/dist/commands/recipeInstall.js +5 -1
- package/dist/commands/recipeInstall.js.map +1 -1
- package/dist/commands/tools.d.ts +20 -1
- package/dist/commands/tools.js +112 -3
- package/dist/commands/tools.js.map +1 -1
- package/dist/haltPushDispatch.d.ts +33 -0
- package/dist/haltPushDispatch.js +103 -0
- package/dist/haltPushDispatch.js.map +1 -0
- package/dist/inboxRoutes.d.ts +22 -0
- package/dist/inboxRoutes.js +61 -1
- package/dist/inboxRoutes.js.map +1 -1
- package/dist/index.js +8 -0
- package/dist/index.js.map +1 -1
- package/dist/recipes/chainedRunner.js +51 -2
- package/dist/recipes/chainedRunner.js.map +1 -1
- package/dist/recipes/yamlRunner.d.ts +23 -1
- package/dist/recipes/yamlRunner.js +482 -260
- package/dist/recipes/yamlRunner.js.map +1 -1
- package/dist/recipesHttp.d.ts +2 -0
- package/dist/recipesHttp.js +20 -0
- package/dist/recipesHttp.js.map +1 -1
- package/dist/runLog.d.ts +22 -0
- package/dist/runLog.js +12 -1
- package/dist/runLog.js.map +1 -1
- package/dist/server.d.ts +14 -0
- package/dist/server.js +211 -11
- package/dist/server.js.map +1 -1
- package/dist/tools/batchLsp.d.ts +3 -0
- package/dist/tools/cancelClaudeTask.d.ts +1 -0
- package/dist/tools/clipboard.d.ts +2 -0
- package/dist/tools/closeTabs.d.ts +1 -0
- package/dist/tools/codeLens.d.ts +1 -0
- package/dist/tools/createIssueFromAIComment.d.ts +1 -0
- package/dist/tools/ctxSaveTrace.d.ts +1 -0
- package/dist/tools/debug.d.ts +4 -0
- package/dist/tools/decorations.d.ts +2 -0
- package/dist/tools/documentLinks.d.ts +1 -0
- package/dist/tools/editText.d.ts +1 -0
- package/dist/tools/enrichCommit.d.ts +1 -0
- package/dist/tools/explainDiagnostic.d.ts +1 -0
- package/dist/tools/explainSymbol.d.ts +1 -0
- package/dist/tools/fileOperations.d.ts +3 -0
- package/dist/tools/fileWatcher.d.ts +2 -0
- package/dist/tools/findFiles.d.ts +1 -0
- package/dist/tools/fixAllLintErrors.d.ts +1 -0
- package/dist/tools/foldingRanges.d.ts +1 -0
- package/dist/tools/formatDocument.d.ts +1 -0
- package/dist/tools/generateTests.d.ts +1 -0
- package/dist/tools/getAIComments.d.ts +1 -0
- package/dist/tools/getBufferContent.d.ts +1 -0
- package/dist/tools/getChangeImpact.d.ts +1 -0
- package/dist/tools/getClaudeTaskStatus.d.ts +1 -0
- package/dist/tools/getCodeCoverage.d.ts +1 -0
- package/dist/tools/getCommitsForIssue.d.ts +1 -0
- package/dist/tools/getDebugState.d.ts +1 -0
- package/dist/tools/getDocumentSymbols.d.ts +1 -0
- package/dist/tools/getGitHotspots.d.ts +1 -0
- package/dist/tools/getImportedSignatures.d.ts +1 -0
- package/dist/tools/getPRTemplate.d.ts +1 -0
- package/dist/tools/getSymbolHistory.d.ts +1 -0
- package/dist/tools/getTypeSignature.d.ts +1 -0
- package/dist/tools/getWorkspaceSettings.d.ts +1 -0
- package/dist/tools/gitWrite.d.ts +11 -0
- package/dist/tools/github/actions.d.ts +2 -0
- package/dist/tools/github/composite.d.ts +3 -0
- package/dist/tools/github/issues.d.ts +4 -0
- package/dist/tools/github/pr.d.ts +7 -0
- package/dist/tools/handoffNote.d.ts +1 -0
- package/dist/tools/hoverAtCursor.d.ts +1 -0
- package/dist/tools/httpClient.d.ts +2 -0
- package/dist/tools/inlayHints.d.ts +1 -0
- package/dist/tools/launchQuickTask.d.ts +1 -0
- package/dist/tools/listClaudeTasks.d.ts +1 -0
- package/dist/tools/listTerminals.d.ts +1 -0
- package/dist/tools/lsp.d.ts +15 -0
- package/dist/tools/navigateToSymbolByName.d.ts +1 -0
- package/dist/tools/openDiff.d.ts +1 -0
- package/dist/tools/openFile.d.ts +1 -0
- package/dist/tools/organizeImports.d.ts +1 -0
- package/dist/tools/planPersistence.d.ts +3 -0
- package/dist/tools/previewEdit.d.ts +1 -0
- package/dist/tools/refactorAnalyze.d.ts +1 -0
- package/dist/tools/refactorPreview.d.ts +1 -0
- package/dist/tools/replaceBlock.d.ts +1 -0
- package/dist/tools/resumeClaudeTask.d.ts +1 -0
- package/dist/tools/runClaudeTask.d.ts +1 -0
- package/dist/tools/screenshot.d.ts +1 -0
- package/dist/tools/searchAndReplace.d.ts +1 -0
- package/dist/tools/searchWorkspace.d.ts +1 -0
- package/dist/tools/selectionRanges.d.ts +1 -0
- package/dist/tools/semanticTokens.d.ts +1 -0
- package/dist/tools/signatureHelp.d.ts +1 -0
- package/dist/tools/terminal.d.ts +6 -0
- package/dist/tools/testTraceToSource.d.ts +1 -0
- package/dist/tools/transaction.d.ts +4 -0
- package/dist/tools/typeHierarchy.d.ts +1 -0
- package/dist/tools/utils.d.ts +18 -0
- package/dist/tools/utils.js +28 -6
- package/dist/tools/utils.js.map +1 -1
- package/dist/tools/vscodeCommands.d.ts +2 -0
- package/dist/tools/vscodeTasks.d.ts +2 -0
- package/dist/tools/workspaceSettings.d.ts +1 -0
- package/dist/wireHaltPushDispatch.d.ts +38 -0
- package/dist/wireHaltPushDispatch.js +71 -0
- package/dist/wireHaltPushDispatch.js.map +1 -0
- package/package.json +1 -1
|
@@ -42,6 +42,7 @@ import { findYamlRecipePath } from "../recipesHttp.js";
|
|
|
42
42
|
import { sanitizeParsedJson as sanitizeParsed } from "../sanitizeParsedJson.js";
|
|
43
43
|
import { ensureCmdShim } from "../winShim.js";
|
|
44
44
|
import { executeAgent as _executeAgent, } from "./agentExecutor.js";
|
|
45
|
+
import { categoriseHaltReason } from "./haltCategory.js";
|
|
45
46
|
import { assertValidManualRunId, deriveScopeKey, WriteEffectLedger, } from "./idempotencyKey.js";
|
|
46
47
|
import { buildJudgeArtefactBlock, JUDGE_PROMPT_SUFFIX, parseJudgeVerdict, } from "./judgeVerdict.js";
|
|
47
48
|
import { defaultDeprecationWarn, normalizeRecipeForRuntime, } from "./migrations/index.js";
|
|
@@ -127,6 +128,26 @@ export function evaluateExpect(result, expect) {
|
|
|
127
128
|
return failures;
|
|
128
129
|
}
|
|
129
130
|
// Strip tool-call narration some models (e.g. Gemini) prepend before the markdown block.
|
|
131
|
+
/**
|
|
132
|
+
* Phase 0β — separator-agnostic inbox-path detector. Extracted so the
|
|
133
|
+
* Windows path-separator behaviour can be unit-tested by injecting
|
|
134
|
+
* `path.win32` / `path.posix` without booting a real recipe runner.
|
|
135
|
+
*
|
|
136
|
+
* Returns true when `candidate` resolves to a direct child of
|
|
137
|
+
* `inboxDirAbs`, isn't a dotfile, and lives in (not above) the inbox
|
|
138
|
+
* dir. Both arguments must already be platform-appropriate absolute
|
|
139
|
+
* paths (resolve them with the same path module before calling).
|
|
140
|
+
*/
|
|
141
|
+
export function isInboxPathFor(candidate, inboxDirAbs, pathMod) {
|
|
142
|
+
const target = pathMod.resolve(candidate);
|
|
143
|
+
const rel = pathMod.relative(inboxDirAbs, target);
|
|
144
|
+
if (!rel || rel.startsWith("..") || pathMod.isAbsolute(rel))
|
|
145
|
+
return false;
|
|
146
|
+
if (pathMod.basename(target).startsWith("."))
|
|
147
|
+
return false;
|
|
148
|
+
// Only direct children — `~/.patchwork/inbox/foo.md`, not nested.
|
|
149
|
+
return !rel.includes(pathMod.sep);
|
|
150
|
+
}
|
|
130
151
|
function stripLeadingNarration(text) {
|
|
131
152
|
const lines = text.split("\n");
|
|
132
153
|
const firstMarkdown = lines.findIndex((l) => /^(#|>|`|\||[-*+] |\d+\. |\*\*)/.test(l.trimStart()));
|
|
@@ -238,6 +259,84 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
238
259
|
...seedContext,
|
|
239
260
|
};
|
|
240
261
|
const stepDeps = resolveStepDeps(deps, { recipeName: recipe.name });
|
|
262
|
+
// Phase 0β — inbox provenance. When a recipe `file.write` / `file.append`
|
|
263
|
+
// step targets `~/.patchwork/inbox/`, prepend a YAML frontmatter block
|
|
264
|
+
// (first write only) recording recipe + run + trigger, and accumulate the
|
|
265
|
+
// delivered filename onto the run record's `inboxOutputs`. Old recipes /
|
|
266
|
+
// non-inbox paths pass through unchanged.
|
|
267
|
+
//
|
|
268
|
+
// Windows path-separator fix (CI repro 2026-05-20): the original
|
|
269
|
+
// implementation built the prefix as `${os.homedir()}/.patchwork/inbox/`
|
|
270
|
+
// and compared with `startsWith`, which failed on Windows where
|
|
271
|
+
// resolved absolute paths use `\` separators and `os.homedir()` returns
|
|
272
|
+
// `C:\Users\...`. Now we resolve both sides through `path.resolve()`
|
|
273
|
+
// and use `path.relative()` to detect containment so the comparison is
|
|
274
|
+
// separator-agnostic. Also case-insensitive on Win32 (NTFS).
|
|
275
|
+
const inboxDirAbs = path.resolve(path.join(os.homedir(), ".patchwork", "inbox"));
|
|
276
|
+
const inboxOutputs = [];
|
|
277
|
+
const isInboxPath = (abs) => isInboxPathFor(abs, inboxDirAbs, path);
|
|
278
|
+
const buildFrontmatter = () => {
|
|
279
|
+
const triggerKindAtWrite = yamlTriggerKind;
|
|
280
|
+
const lines = ["---", `recipe: ${recipe.name}`];
|
|
281
|
+
if (runSeq !== undefined)
|
|
282
|
+
lines.push(`runSeq: ${runSeq}`);
|
|
283
|
+
lines.push(`trigger: ${triggerKindAtWrite}`, `deliveredAt: ${new Date().toISOString()}`, "---", "", "");
|
|
284
|
+
return lines.join("\n");
|
|
285
|
+
};
|
|
286
|
+
const recordInboxDelivery = (abs) => {
|
|
287
|
+
inboxOutputs.push({
|
|
288
|
+
filename: path.basename(abs),
|
|
289
|
+
deliveredAt: Date.now(),
|
|
290
|
+
});
|
|
291
|
+
};
|
|
292
|
+
// Atomic read-or-default: a single `readFileSync` in a try/catch. No
|
|
293
|
+
// `existsSync`/`statSync` probe around the write — on Windows a stat
|
|
294
|
+
// immediately before write can race a concurrent fd holder and surface
|
|
295
|
+
// `EBUSY`/`EPERM`. The read either succeeds (file present) or throws
|
|
296
|
+
// ENOENT (treated as new file). Either way we never stat the same path
|
|
297
|
+
// we're about to write.
|
|
298
|
+
const readExistingOrEmpty = (abs) => {
|
|
299
|
+
try {
|
|
300
|
+
return readFileSync(abs, "utf-8");
|
|
301
|
+
}
|
|
302
|
+
catch {
|
|
303
|
+
return "";
|
|
304
|
+
}
|
|
305
|
+
};
|
|
306
|
+
const originalWrite = stepDeps.writeFile;
|
|
307
|
+
const originalAppend = stepDeps.appendFile;
|
|
308
|
+
stepDeps.writeFile = (p, content) => {
|
|
309
|
+
if (isInboxPath(p)) {
|
|
310
|
+
// First-write detection by content shape, not by stat. Empty string
|
|
311
|
+
// (ENOENT) and any file that does NOT already begin with `---\n`
|
|
312
|
+
// gets frontmatter; pre-frontmattered files are overwritten as-is
|
|
313
|
+
// so consumers can replay a recipe without doubling the header.
|
|
314
|
+
const existing = readExistingOrEmpty(p);
|
|
315
|
+
const hasFm = existing.startsWith("---\n");
|
|
316
|
+
const final = hasFm ? content : buildFrontmatter() + content;
|
|
317
|
+
originalWrite(p, final);
|
|
318
|
+
recordInboxDelivery(p);
|
|
319
|
+
return;
|
|
320
|
+
}
|
|
321
|
+
originalWrite(p, content);
|
|
322
|
+
};
|
|
323
|
+
stepDeps.appendFile = (p, content) => {
|
|
324
|
+
if (isInboxPath(p)) {
|
|
325
|
+
// file.append: never re-prepend. If file is brand-new, seed one
|
|
326
|
+
// frontmatter block so an append-only recipe still gets
|
|
327
|
+
// provenance. Same atomic read-or-default — no stat probe.
|
|
328
|
+
const existing = readExistingOrEmpty(p);
|
|
329
|
+
if (existing.length === 0) {
|
|
330
|
+
originalWrite(p, buildFrontmatter() + content);
|
|
331
|
+
}
|
|
332
|
+
else {
|
|
333
|
+
originalAppend(p, content);
|
|
334
|
+
}
|
|
335
|
+
recordInboxDelivery(p);
|
|
336
|
+
return;
|
|
337
|
+
}
|
|
338
|
+
originalAppend(p, content);
|
|
339
|
+
};
|
|
241
340
|
// PR2b: one per-run budget shared across all agent steps. Absent
|
|
242
341
|
// `recipe.budget` → no enforcement, no overhead.
|
|
243
342
|
const runBudget = new RunBudget(recipe.budget);
|
|
@@ -272,6 +371,30 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
272
371
|
const stepResults = [];
|
|
273
372
|
let stepsRun = 0;
|
|
274
373
|
let runError;
|
|
374
|
+
// Live-tail SSE broadcaster. Wrapped in a try/catch on every call so a
|
|
375
|
+
// misbehaving listener can never break the run (mirrors chainedRunner).
|
|
376
|
+
// No-ops when `activityLog` isn't wired (CLI runs, tests, mocks).
|
|
377
|
+
const broadcast = deps.activityLog;
|
|
378
|
+
const emit = (event, metadata) => {
|
|
379
|
+
if (!broadcast || runSeq === undefined || stepDeps.testMode)
|
|
380
|
+
return;
|
|
381
|
+
try {
|
|
382
|
+
broadcast.recordEvent(event, metadata);
|
|
383
|
+
}
|
|
384
|
+
catch {
|
|
385
|
+
/* live-tail must not break a recipe run */
|
|
386
|
+
}
|
|
387
|
+
};
|
|
388
|
+
// Emit recipe_started as soon as we have a runSeq. The dashboard
|
|
389
|
+
// RecipeRunInline component watches for this event to flip a row
|
|
390
|
+
// from "queued" to "running" without waiting for the first step.
|
|
391
|
+
emit("recipe_started", {
|
|
392
|
+
runSeq,
|
|
393
|
+
recipeName: recipe.name,
|
|
394
|
+
trigger: yamlTriggerKind,
|
|
395
|
+
totalSteps: recipe.steps.length,
|
|
396
|
+
ts: recipeStartedAt,
|
|
397
|
+
});
|
|
275
398
|
// Push live step results into the run-log ring so the dashboard's
|
|
276
399
|
// `/runs/[seq]` page surfaces verdicts + haltReasons mid-flight,
|
|
277
400
|
// instead of waiting for the whole recipe to finish via
|
|
@@ -288,305 +411,380 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
288
411
|
/* live-tail is best-effort; never break a recipe run for it */
|
|
289
412
|
}
|
|
290
413
|
};
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
}
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
414
|
+
// Track per-step start timestamps so done events carry durationMs
|
|
415
|
+
// without a second roundtrip.
|
|
416
|
+
const stepStartTs = new Map();
|
|
417
|
+
// Emit recipe_step_done for the step result just pushed onto
|
|
418
|
+
// `stepResults`. Every loop branch (skip / budget / agent / tool)
|
|
419
|
+
// pushes exactly one result before it ends, so the last element is
|
|
420
|
+
// always the current step. `stepId` mirrors recipe_step_start's
|
|
421
|
+
// `stepIdForEmit` so live consumers can correlate start↔done — the
|
|
422
|
+
// pushed result's own id can diverge for agent steps without `into`.
|
|
423
|
+
const emitStepDone = (stepIdForEmit) => {
|
|
424
|
+
const justPushed = stepResults[stepResults.length - 1];
|
|
425
|
+
if (!justPushed)
|
|
426
|
+
return;
|
|
427
|
+
const haltReason = justPushed.haltReason;
|
|
428
|
+
emit("recipe_step_done", {
|
|
429
|
+
runSeq,
|
|
430
|
+
recipeName: recipe.name,
|
|
431
|
+
stepId: stepIdForEmit,
|
|
432
|
+
tool: justPushed.tool,
|
|
433
|
+
status: justPushed.status,
|
|
434
|
+
durationMs: justPushed.durationMs,
|
|
435
|
+
...(justPushed.error !== undefined && { error: justPushed.error }),
|
|
436
|
+
...(haltReason !== undefined && {
|
|
437
|
+
haltReason,
|
|
438
|
+
haltCategory: categoriseHaltReason(haltReason),
|
|
439
|
+
}),
|
|
440
|
+
ts: Date.now(),
|
|
441
|
+
});
|
|
442
|
+
};
|
|
443
|
+
// The step loop is wrapped so an uncaught throw from any unguarded
|
|
444
|
+
// call site (a `when`/prompt render on a malformed step, a path-jail
|
|
445
|
+
// re-check, etc.) cannot escape `runYamlRecipe` and strand the
|
|
446
|
+
// run-log entry at "running" forever. On throw we capture the
|
|
447
|
+
// message into `runError` and fall through to the normal
|
|
448
|
+
// finalization path, which marks the run "error".
|
|
449
|
+
try {
|
|
450
|
+
for (const step of recipe.steps) {
|
|
451
|
+
const stepIdForEmit = step.into ?? step.agent?.into ?? `step_${stepsRun}`;
|
|
452
|
+
const stepTs = Date.now();
|
|
453
|
+
stepStartTs.set(stepIdForEmit, stepTs);
|
|
454
|
+
emit("recipe_step_start", {
|
|
455
|
+
runSeq,
|
|
456
|
+
recipeName: recipe.name,
|
|
457
|
+
stepId: stepIdForEmit,
|
|
458
|
+
tool: step.agent ? "agent" : step.tool,
|
|
459
|
+
ts: stepTs,
|
|
460
|
+
});
|
|
461
|
+
// Evaluate `when` guard before running anything. Mirrors
|
|
462
|
+
// chainedRunner.ts:248-266 — render the template, then truthy-check the
|
|
463
|
+
// result (empty string, "0", "false", "null", "undefined" are falsy).
|
|
464
|
+
// A falsy guard records the step as `skipped`, increments stepsRun, and
|
|
465
|
+
// continues — it is NOT a failure. Bridge-dev iMessage recipes rely on
|
|
466
|
+
// this to suppress the iMessage agent step when phone is empty.
|
|
467
|
+
if (typeof step.when === "string" && step.when.length > 0) {
|
|
468
|
+
const rendered = render(step.when, ctx).trim().toLowerCase();
|
|
469
|
+
const truthy = !!rendered &&
|
|
470
|
+
rendered !== "0" &&
|
|
471
|
+
rendered !== "false" &&
|
|
472
|
+
rendered !== "null" &&
|
|
473
|
+
rendered !== "undefined";
|
|
474
|
+
if (!truthy) {
|
|
475
|
+
const skipId = step.into ?? step.agent?.into ?? `step_${stepsRun}`;
|
|
476
|
+
stepResults.push({
|
|
477
|
+
id: skipId,
|
|
478
|
+
tool: step.agent ? "agent" : step.tool,
|
|
479
|
+
status: "skipped",
|
|
480
|
+
durationMs: 0,
|
|
481
|
+
});
|
|
482
|
+
stepsRun++;
|
|
483
|
+
persistLiveStepResults();
|
|
484
|
+
emit("recipe_step_done", {
|
|
485
|
+
runSeq,
|
|
486
|
+
recipeName: recipe.name,
|
|
487
|
+
stepId: skipId,
|
|
488
|
+
tool: step.agent ? "agent" : step.tool,
|
|
489
|
+
status: "skipped",
|
|
490
|
+
durationMs: 0,
|
|
491
|
+
ts: Date.now(),
|
|
492
|
+
});
|
|
493
|
+
continue;
|
|
329
494
|
}
|
|
330
|
-
renderedPrompt += JUDGE_PROMPT_SUFFIX;
|
|
331
495
|
}
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
const agentReturn = await _executeAgent({
|
|
360
|
-
prompt: renderedPrompt,
|
|
361
|
-
driver: agentCfg.driver === "api" ? "anthropic" : agentCfg.driver,
|
|
362
|
-
model: agentCfg.model,
|
|
363
|
-
...(agentCfg.mcpAccess !== undefined && {
|
|
364
|
-
mcpAccess: agentCfg.mcpAccess,
|
|
365
|
-
}),
|
|
366
|
-
}, buildAgentExecutorDeps(stepDeps, deps));
|
|
367
|
-
agentResult = agentReturn.text;
|
|
368
|
-
runBudget.reconcile(agentCfg.driver === "api" ? "anthropic" : (agentCfg.driver ?? "auto"), agentReturn.usage);
|
|
369
|
-
// Catch both `[agent step failed: ...]` (existing) and the
|
|
370
|
-
// silent-fail patterns `[agent step skipped: ...]` etc. via the
|
|
371
|
-
// shared detector. Per-step opt-out via `silentFailDetection: false`.
|
|
372
|
-
const agentSilentFail = step.silentFailDetection !== false
|
|
373
|
-
? detectSilentFail(agentResult)
|
|
374
|
-
: null;
|
|
375
|
-
if (agentResult.startsWith("[agent step failed:") || agentSilentFail) {
|
|
376
|
-
const reason = agentSilentFail
|
|
377
|
-
? `silent-fail detected (${agentSilentFail.reason}): ${agentSilentFail.matched}`
|
|
378
|
-
: agentResult;
|
|
496
|
+
// Handle agent steps separately
|
|
497
|
+
if (step.agent) {
|
|
498
|
+
const agentCfg = step.agent;
|
|
499
|
+
const isJudge = agentCfg.kind === "judge";
|
|
500
|
+
// PR3a: judge prompt convention. Append the structured-verdict
|
|
501
|
+
// suffix and, when `reviews: <stepId>` is set, inject the
|
|
502
|
+
// upstream step's output as an <artefact> block.
|
|
503
|
+
let renderedPrompt = render(agentCfg.prompt, ctx);
|
|
504
|
+
if (isJudge) {
|
|
505
|
+
if (agentCfg.reviews) {
|
|
506
|
+
renderedPrompt += buildJudgeArtefactBlock(ctx[agentCfg.reviews]);
|
|
507
|
+
}
|
|
508
|
+
renderedPrompt += JUDGE_PROMPT_SUFFIX;
|
|
509
|
+
}
|
|
510
|
+
const intoKey = agentCfg.into ?? "agent_output";
|
|
511
|
+
const stepId = intoKey;
|
|
512
|
+
const stepStart = Date.now();
|
|
513
|
+
let agentResult;
|
|
514
|
+
// PR2b: per-recipe token budget. Admission check before dispatch;
|
|
515
|
+
// reconcile actual consumption after. Subscription drivers
|
|
516
|
+
// (Claude CLI, provider subprocess) report `usage === undefined`
|
|
517
|
+
// — `RunBudget.reconcile` records a fail-open warning per driver
|
|
518
|
+
// per run and continues.
|
|
519
|
+
const admission = runBudget.admit();
|
|
520
|
+
if (!admission.admitted) {
|
|
521
|
+
const reason = admission.reason ??
|
|
522
|
+
"Run exceeded its token budget — budget_exceeded.";
|
|
379
523
|
runError = runError ?? reason;
|
|
380
524
|
stepResults.push({
|
|
381
525
|
id: stepId,
|
|
382
526
|
tool: "agent",
|
|
383
527
|
status: "error",
|
|
384
528
|
error: reason,
|
|
385
|
-
haltReason:
|
|
386
|
-
|
|
387
|
-
: `Agent step "${stepId}" reported failure.`,
|
|
388
|
-
durationMs: Date.now() - stepStart,
|
|
529
|
+
haltReason: reason,
|
|
530
|
+
durationMs: 0,
|
|
389
531
|
});
|
|
532
|
+
stepsRun++;
|
|
533
|
+
persistLiveStepResults();
|
|
534
|
+
emitStepDone(stepIdForEmit);
|
|
535
|
+
continue;
|
|
390
536
|
}
|
|
391
|
-
|
|
392
|
-
const
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
537
|
+
try {
|
|
538
|
+
const agentReturn = await _executeAgent({
|
|
539
|
+
prompt: renderedPrompt,
|
|
540
|
+
driver: agentCfg.driver === "api" ? "anthropic" : agentCfg.driver,
|
|
541
|
+
model: agentCfg.model,
|
|
542
|
+
...(agentCfg.mcpAccess !== undefined && {
|
|
543
|
+
mcpAccess: agentCfg.mcpAccess,
|
|
544
|
+
}),
|
|
545
|
+
}, buildAgentExecutorDeps(stepDeps, deps));
|
|
546
|
+
agentResult = agentReturn.text;
|
|
547
|
+
runBudget.reconcile(agentCfg.driver === "api"
|
|
548
|
+
? "anthropic"
|
|
549
|
+
: (agentCfg.driver ?? "auto"), agentReturn.usage);
|
|
550
|
+
// Catch both `[agent step failed: ...]` (existing) and the
|
|
551
|
+
// silent-fail patterns `[agent step skipped: ...]` etc. via the
|
|
552
|
+
// shared detector. Per-step opt-out via `silentFailDetection: false`.
|
|
553
|
+
const agentSilentFail = step.silentFailDetection !== false
|
|
554
|
+
? detectSilentFail(agentResult)
|
|
555
|
+
: null;
|
|
556
|
+
if (agentResult.startsWith("[agent step failed:") ||
|
|
557
|
+
agentSilentFail) {
|
|
558
|
+
const reason = agentSilentFail
|
|
559
|
+
? `silent-fail detected (${agentSilentFail.reason}): ${agentSilentFail.matched}`
|
|
560
|
+
: agentResult;
|
|
561
|
+
runError = runError ?? reason;
|
|
396
562
|
stepResults.push({
|
|
397
563
|
id: stepId,
|
|
398
564
|
tool: "agent",
|
|
399
565
|
status: "error",
|
|
400
|
-
error:
|
|
401
|
-
haltReason:
|
|
566
|
+
error: reason,
|
|
567
|
+
haltReason: agentSilentFail
|
|
568
|
+
? `Agent step "${stepId}" returned no usable output (silent-fail: ${agentSilentFail.reason}).`
|
|
569
|
+
: `Agent step "${stepId}" reported failure.`,
|
|
402
570
|
durationMs: Date.now() - stepStart,
|
|
403
571
|
});
|
|
404
572
|
}
|
|
405
573
|
else {
|
|
406
|
-
|
|
574
|
+
const stripped = stripLeadingNarration(agentResult);
|
|
575
|
+
if (!stripped.trim()) {
|
|
576
|
+
const errMsg = `[agent step failed: ${agentCfg.driver ?? "agent"} returned only narration or whitespace — no content]`;
|
|
577
|
+
runError = runError ?? errMsg;
|
|
578
|
+
stepResults.push({
|
|
579
|
+
id: stepId,
|
|
580
|
+
tool: "agent",
|
|
581
|
+
status: "error",
|
|
582
|
+
error: errMsg,
|
|
583
|
+
haltReason: `Agent step "${stepId}" returned only narration or whitespace — no content.`,
|
|
584
|
+
durationMs: Date.now() - stepStart,
|
|
585
|
+
});
|
|
586
|
+
}
|
|
587
|
+
else {
|
|
588
|
+
// Try to parse as JSON so dot-notation ({{meeting.field}}) works
|
|
589
|
+
try {
|
|
590
|
+
const jsonMatch = /```(?:json)?\s*([\s\S]*?)```/.exec(stripped) ?? [null, stripped];
|
|
591
|
+
const parsed = sanitizeParsed(JSON.parse((jsonMatch[1] ?? "").trim()));
|
|
592
|
+
ctx[intoKey] = parsed;
|
|
593
|
+
}
|
|
594
|
+
catch {
|
|
595
|
+
ctx[intoKey] = stripped;
|
|
596
|
+
}
|
|
597
|
+
outputs.push(intoKey);
|
|
598
|
+
// PR3a: parse + stash the judge verdict on the step result.
|
|
599
|
+
// Augment-only: a `request_changes` verdict still yields
|
|
600
|
+
// `status: "ok"`. The verdict surfaces via the runlog +
|
|
601
|
+
// future PR3b dashboard panel, but never gates the run.
|
|
602
|
+
const judgeVerdict = isJudge
|
|
603
|
+
? parseJudgeVerdict(stripped)
|
|
604
|
+
: undefined;
|
|
605
|
+
stepResults.push({
|
|
606
|
+
id: stepId,
|
|
607
|
+
tool: "agent",
|
|
608
|
+
status: "ok",
|
|
609
|
+
...(judgeVerdict !== undefined && { judgeVerdict }),
|
|
610
|
+
durationMs: Date.now() - stepStart,
|
|
611
|
+
});
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
catch (err) {
|
|
616
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
617
|
+
runError = runError ?? `agent step "${stepId}" failed: ${msg}`;
|
|
618
|
+
stepResults.push({
|
|
619
|
+
id: stepId,
|
|
620
|
+
tool: "agent",
|
|
621
|
+
status: "error",
|
|
622
|
+
error: msg,
|
|
623
|
+
haltReason: `Agent step "${stepId}" threw before completing: ${msg}`,
|
|
624
|
+
durationMs: Date.now() - stepStart,
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
stepsRun++;
|
|
628
|
+
persistLiveStepResults();
|
|
629
|
+
emitStepDone(stepIdForEmit);
|
|
630
|
+
continue;
|
|
631
|
+
}
|
|
632
|
+
const stepStart = Date.now();
|
|
633
|
+
const stepId = step.into ?? `step_${stepsRun}`;
|
|
634
|
+
// Resolve retry policy: step-level overrides recipe-level.
|
|
635
|
+
const retryCount = step.retry ?? recipe.on_error?.retry ?? 0;
|
|
636
|
+
const retryDelayMs = step.retryDelay ?? recipe.on_error?.retryDelay ?? 1000;
|
|
637
|
+
let result = null;
|
|
638
|
+
let stepError;
|
|
639
|
+
let thrownError;
|
|
640
|
+
let thrownErrorCode;
|
|
641
|
+
for (let attempt = 0; attempt <= retryCount; attempt++) {
|
|
642
|
+
if (attempt > 0) {
|
|
643
|
+
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
644
|
+
}
|
|
645
|
+
stepError = undefined;
|
|
646
|
+
thrownError = undefined;
|
|
647
|
+
thrownErrorCode = undefined;
|
|
648
|
+
try {
|
|
649
|
+
result = await executeStep(step, ctx, stepDeps);
|
|
650
|
+
// Detect tool-level errors reported as JSON {ok: false, error: ...}
|
|
651
|
+
if (result !== null) {
|
|
407
652
|
try {
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
653
|
+
const parsed = JSON.parse(result);
|
|
654
|
+
if (parsed.ok === false && typeof parsed.error === "string") {
|
|
655
|
+
stepError = parsed.error;
|
|
656
|
+
}
|
|
411
657
|
}
|
|
412
658
|
catch {
|
|
413
|
-
|
|
659
|
+
/* non-JSON result is fine */
|
|
414
660
|
}
|
|
415
|
-
outputs.push(intoKey);
|
|
416
|
-
// PR3a: parse + stash the judge verdict on the step result.
|
|
417
|
-
// Augment-only: a `request_changes` verdict still yields
|
|
418
|
-
// `status: "ok"`. The verdict surfaces via the runlog +
|
|
419
|
-
// future PR3b dashboard panel, but never gates the run.
|
|
420
|
-
const judgeVerdict = isJudge
|
|
421
|
-
? parseJudgeVerdict(stripped)
|
|
422
|
-
: undefined;
|
|
423
|
-
stepResults.push({
|
|
424
|
-
id: stepId,
|
|
425
|
-
tool: "agent",
|
|
426
|
-
status: "ok",
|
|
427
|
-
...(judgeVerdict !== undefined && { judgeVerdict }),
|
|
428
|
-
durationMs: Date.now() - stepStart,
|
|
429
|
-
});
|
|
430
661
|
}
|
|
662
|
+
// Silent-fail detection: tools that return string placeholders
|
|
663
|
+
// (`(git branches unavailable)`, `[agent step skipped: ...]`)
|
|
664
|
+
// or empty list-tool error shapes (`{count:0,error:"..."}`)
|
|
665
|
+
// succeed with bad data — flag them as `error` so the runner
|
|
666
|
+
// doesn't quietly hand garbage to a downstream agent. Per-step
|
|
667
|
+
// opt-out via `silentFailDetection: false`.
|
|
668
|
+
if (!stepError &&
|
|
669
|
+
result !== null &&
|
|
670
|
+
step.silentFailDetection !== false) {
|
|
671
|
+
const detected = detectSilentFail(result);
|
|
672
|
+
if (detected) {
|
|
673
|
+
stepError = `silent-fail detected (${detected.reason}): ${detected.matched}`;
|
|
674
|
+
}
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
thrownError = err instanceof Error ? err.message : String(err);
|
|
679
|
+
// Preserve structured error codes (e.g. recipe_path_jail_escape)
|
|
680
|
+
// so callers and tests can branch on `err.code` per R2 M-4
|
|
681
|
+
// without scraping the message string.
|
|
682
|
+
const code = err?.code;
|
|
683
|
+
if (typeof code === "string")
|
|
684
|
+
thrownErrorCode = code;
|
|
685
|
+
result = null;
|
|
431
686
|
}
|
|
687
|
+
if (!stepError && !thrownError)
|
|
688
|
+
break;
|
|
432
689
|
}
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
690
|
+
// Recipe-level fallback: log_only / deliver_original treat step failure
|
|
691
|
+
// as non-fatal (fail-open) — same semantics as step-level optional: true.
|
|
692
|
+
const fallback = recipe.on_error?.fallback;
|
|
693
|
+
const fallbackFailOpen = fallback === "log_only" || fallback === "deliver_original";
|
|
694
|
+
const failOpen = step.optional === true || fallbackFailOpen;
|
|
695
|
+
if (thrownError) {
|
|
696
|
+
const retryNote = retryCount > 0 ? ` after ${retryCount + 1} attempts` : "";
|
|
436
697
|
stepResults.push({
|
|
437
698
|
id: stepId,
|
|
438
|
-
tool:
|
|
699
|
+
tool: step.tool,
|
|
439
700
|
status: "error",
|
|
440
|
-
error:
|
|
441
|
-
|
|
701
|
+
error: thrownError,
|
|
702
|
+
...(thrownErrorCode ? { errorCode: thrownErrorCode } : {}),
|
|
703
|
+
haltReason: `Tool "${step.tool ?? "?"}" in step "${stepId}" threw${retryNote}: ${thrownError}`,
|
|
442
704
|
durationMs: Date.now() - stepStart,
|
|
443
705
|
});
|
|
706
|
+
if (!failOpen) {
|
|
707
|
+
runError = runError ?? `${step.tool} failed: ${thrownError}`;
|
|
708
|
+
}
|
|
709
|
+
else if (fallbackFailOpen && !step.optional) {
|
|
710
|
+
console.warn(`step ${stepId} failed but on_error.fallback=${fallback} — treating as non-fatal: ${thrownError}`);
|
|
711
|
+
}
|
|
444
712
|
}
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
let thrownError;
|
|
457
|
-
let thrownErrorCode;
|
|
458
|
-
for (let attempt = 0; attempt <= retryCount; attempt++) {
|
|
459
|
-
if (attempt > 0) {
|
|
460
|
-
await new Promise((r) => setTimeout(r, retryDelayMs));
|
|
461
|
-
}
|
|
462
|
-
stepError = undefined;
|
|
463
|
-
thrownError = undefined;
|
|
464
|
-
thrownErrorCode = undefined;
|
|
465
|
-
try {
|
|
466
|
-
result = await executeStep(step, ctx, stepDeps);
|
|
467
|
-
// Detect tool-level errors reported as JSON {ok: false, error: ...}
|
|
468
|
-
if (result !== null) {
|
|
469
|
-
try {
|
|
470
|
-
const parsed = JSON.parse(result);
|
|
471
|
-
if (parsed.ok === false && typeof parsed.error === "string") {
|
|
472
|
-
stepError = parsed.error;
|
|
713
|
+
else {
|
|
714
|
+
const finalStatus = result === null ? "skipped" : stepError ? "error" : "ok";
|
|
715
|
+
const retryNote = retryCount > 0 ? ` after ${retryCount + 1} attempts` : "";
|
|
716
|
+
stepResults.push({
|
|
717
|
+
id: stepId,
|
|
718
|
+
tool: step.tool,
|
|
719
|
+
status: finalStatus,
|
|
720
|
+
error: stepError,
|
|
721
|
+
...(finalStatus === "error" && stepError
|
|
722
|
+
? {
|
|
723
|
+
haltReason: `Tool "${step.tool ?? "?"}" in step "${stepId}" reported an error${retryNote}: ${stepError}`,
|
|
473
724
|
}
|
|
725
|
+
: {}),
|
|
726
|
+
durationMs: Date.now() - stepStart,
|
|
727
|
+
});
|
|
728
|
+
if (stepError) {
|
|
729
|
+
if (!failOpen) {
|
|
730
|
+
runError = runError ?? `${step.tool} failed: ${stepError}`;
|
|
474
731
|
}
|
|
475
|
-
|
|
476
|
-
|
|
732
|
+
else if (fallbackFailOpen && !step.optional) {
|
|
733
|
+
console.warn(`step ${stepId} failed but on_error.fallback=${fallback} — treating as non-fatal: ${stepError}`);
|
|
477
734
|
}
|
|
478
735
|
}
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
//
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
stepError = `silent-fail detected (${detected.reason}): ${detected.matched}`;
|
|
736
|
+
}
|
|
737
|
+
stepsRun++;
|
|
738
|
+
if (result !== null) {
|
|
739
|
+
// Apply transform if present — render template with $result injected
|
|
740
|
+
if (step.transform) {
|
|
741
|
+
try {
|
|
742
|
+
result = render(step.transform, { ...ctx, $result: result });
|
|
743
|
+
}
|
|
744
|
+
catch (err) {
|
|
745
|
+
// warn but fall through with original result
|
|
746
|
+
console.warn(`transform failed for step ${step.into ?? step.tool ?? "?"}: ${err}`);
|
|
491
747
|
}
|
|
492
748
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
// so callers and tests can branch on `err.code` per R2 M-4
|
|
498
|
-
// without scraping the message string.
|
|
499
|
-
const code = err?.code;
|
|
500
|
-
if (typeof code === "string")
|
|
501
|
-
thrownErrorCode = code;
|
|
502
|
-
result = null;
|
|
503
|
-
}
|
|
504
|
-
if (!stepError && !thrownError)
|
|
505
|
-
break;
|
|
506
|
-
}
|
|
507
|
-
// Recipe-level fallback: log_only / deliver_original treat step failure
|
|
508
|
-
// as non-fatal (fail-open) — same semantics as step-level optional: true.
|
|
509
|
-
const fallback = recipe.on_error?.fallback;
|
|
510
|
-
const fallbackFailOpen = fallback === "log_only" || fallback === "deliver_original";
|
|
511
|
-
const failOpen = step.optional === true || fallbackFailOpen;
|
|
512
|
-
if (thrownError) {
|
|
513
|
-
const retryNote = retryCount > 0 ? ` after ${retryCount + 1} attempts` : "";
|
|
514
|
-
stepResults.push({
|
|
515
|
-
id: stepId,
|
|
516
|
-
tool: step.tool,
|
|
517
|
-
status: "error",
|
|
518
|
-
error: thrownError,
|
|
519
|
-
...(thrownErrorCode ? { errorCode: thrownErrorCode } : {}),
|
|
520
|
-
haltReason: `Tool "${step.tool ?? "?"}" in step "${stepId}" threw${retryNote}: ${thrownError}`,
|
|
521
|
-
durationMs: Date.now() - stepStart,
|
|
522
|
-
});
|
|
523
|
-
if (!failOpen) {
|
|
524
|
-
runError = runError ?? `${step.tool} failed: ${thrownError}`;
|
|
525
|
-
}
|
|
526
|
-
else if (fallbackFailOpen && !step.optional) {
|
|
527
|
-
console.warn(`step ${stepId} failed but on_error.fallback=${fallback} — treating as non-fatal: ${thrownError}`);
|
|
528
|
-
}
|
|
529
|
-
}
|
|
530
|
-
else {
|
|
531
|
-
const finalStatus = result === null ? "skipped" : stepError ? "error" : "ok";
|
|
532
|
-
const retryNote = retryCount > 0 ? ` after ${retryCount + 1} attempts` : "";
|
|
533
|
-
stepResults.push({
|
|
534
|
-
id: stepId,
|
|
535
|
-
tool: step.tool,
|
|
536
|
-
status: finalStatus,
|
|
537
|
-
error: stepError,
|
|
538
|
-
...(finalStatus === "error" && stepError
|
|
539
|
-
? {
|
|
540
|
-
haltReason: `Tool "${step.tool ?? "?"}" in step "${stepId}" reported an error${retryNote}: ${stepError}`,
|
|
749
|
+
if (step.into) {
|
|
750
|
+
ctx[step.into] = result;
|
|
751
|
+
if (step.tool) {
|
|
752
|
+
applyToolOutputContext(step.tool, step.into, result, ctx);
|
|
541
753
|
}
|
|
542
|
-
: {}),
|
|
543
|
-
durationMs: Date.now() - stepStart,
|
|
544
|
-
});
|
|
545
|
-
if (stepError) {
|
|
546
|
-
if (!failOpen) {
|
|
547
|
-
runError = runError ?? `${step.tool} failed: ${stepError}`;
|
|
548
754
|
}
|
|
549
|
-
|
|
550
|
-
|
|
755
|
+
if (step.tool === "file.write" || step.tool === "file.append") {
|
|
756
|
+
// R2 C-1 / F-02: re-validate the rendered path against the jail so a
|
|
757
|
+
// template substitution that survived earlier checks (e.g. via a
|
|
758
|
+
// chained sub-recipe deps override) cannot smuggle an out-of-jail
|
|
759
|
+
// path into the run log / dashboard outputs list.
|
|
760
|
+
const renderedPath = render(step.path, ctx);
|
|
761
|
+
outputs.push(resolveRecipePath(renderedPath, {
|
|
762
|
+
workspace: stepDeps.workdir,
|
|
763
|
+
write: true,
|
|
764
|
+
}));
|
|
551
765
|
}
|
|
552
766
|
}
|
|
767
|
+
persistLiveStepResults();
|
|
768
|
+
emitStepDone(stepIdForEmit);
|
|
553
769
|
}
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
}
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
}
|
|
571
|
-
}
|
|
572
|
-
if (step.tool === "file.write" || step.tool === "file.append") {
|
|
573
|
-
// R2 C-1 / F-02: re-validate the rendered path against the jail so a
|
|
574
|
-
// template substitution that survived earlier checks (e.g. via a
|
|
575
|
-
// chained sub-recipe deps override) cannot smuggle an out-of-jail
|
|
576
|
-
// path into the run log / dashboard outputs list.
|
|
577
|
-
const renderedPath = render(step.path, ctx);
|
|
578
|
-
outputs.push(resolveRecipePath(renderedPath, {
|
|
579
|
-
workspace: stepDeps.workdir,
|
|
580
|
-
write: true,
|
|
581
|
-
}));
|
|
582
|
-
}
|
|
770
|
+
}
|
|
771
|
+
catch (err) {
|
|
772
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
773
|
+
runError = runError ?? `recipe run aborted: ${msg}`;
|
|
774
|
+
}
|
|
775
|
+
// Evaluate expect block before persisting so failures are stored in the
|
|
776
|
+
// run log. Guarded: a throw here must not skip finalization and strand
|
|
777
|
+
// the run at "running".
|
|
778
|
+
let assertionFailures = [];
|
|
779
|
+
if (recipe.expect) {
|
|
780
|
+
try {
|
|
781
|
+
assertionFailures = evaluateExpect({ stepsRun, outputs, context: ctx, errorMessage: runError }, recipe.expect);
|
|
782
|
+
}
|
|
783
|
+
catch (err) {
|
|
784
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
785
|
+
runError = runError ?? `expect evaluation failed: ${msg}`;
|
|
583
786
|
}
|
|
584
|
-
persistLiveStepResults();
|
|
585
787
|
}
|
|
586
|
-
// Evaluate expect block before persisting so failures are stored in the run log
|
|
587
|
-
const assertionFailures = recipe.expect
|
|
588
|
-
? evaluateExpect({ stepsRun, outputs, context: ctx, errorMessage: runError }, recipe.expect)
|
|
589
|
-
: [];
|
|
590
788
|
// Write to RecipeRunLog so the dashboard Runs page shows this execution.
|
|
591
789
|
// Bridge path: completeRun on the running entry opened above (live-tail).
|
|
592
790
|
// CLI path: construct a local log + appendDirect (no live-tail).
|
|
@@ -615,6 +813,23 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
615
813
|
outputTail,
|
|
616
814
|
...(runError !== undefined && { errorMessage: runError }),
|
|
617
815
|
...(assertionFailures.length > 0 ? { assertionFailures } : {}),
|
|
816
|
+
...(inboxOutputs.length > 0 ? { inboxOutputs } : {}),
|
|
817
|
+
});
|
|
818
|
+
emit("recipe_done", {
|
|
819
|
+
runSeq,
|
|
820
|
+
recipeName: recipe.name,
|
|
821
|
+
status: runError ? "error" : "done",
|
|
822
|
+
durationMs: doneAt - recipeStartedAt,
|
|
823
|
+
stepCount: finalStepResults.length,
|
|
824
|
+
// A `done` run can still carry step errors — the runner
|
|
825
|
+
// continues past a non-fatal step failure. Surface it so
|
|
826
|
+
// live consumers can show "completed with errors".
|
|
827
|
+
hadStepErrors: finalStepResults.some((s) => s.status === "error"),
|
|
828
|
+
...(runError !== undefined && { errorMessage: runError }),
|
|
829
|
+
...(assertionFailures.length > 0 && {
|
|
830
|
+
assertionFailureCount: assertionFailures.length,
|
|
831
|
+
}),
|
|
832
|
+
ts: doneAt,
|
|
618
833
|
});
|
|
619
834
|
}
|
|
620
835
|
else {
|
|
@@ -635,6 +850,7 @@ export async function runYamlRecipe(recipe, deps = {}, seedContext = {}) {
|
|
|
635
850
|
errorMessage: runError,
|
|
636
851
|
stepResults: finalStepResults,
|
|
637
852
|
...(assertionFailures.length > 0 ? { assertionFailures } : {}),
|
|
853
|
+
...(inboxOutputs.length > 0 ? { inboxOutputs } : {}),
|
|
638
854
|
});
|
|
639
855
|
}
|
|
640
856
|
}
|
|
@@ -1359,11 +1575,17 @@ export async function dispatchRecipe(recipe, deps, seedContext = {}) {
|
|
|
1359
1575
|
}
|
|
1360
1576
|
return runChainedRecipe(chainedRecipe, options, deps.chainedDeps);
|
|
1361
1577
|
}
|
|
1362
|
-
// For non-chained recipes, lift `runLog`
|
|
1363
|
-
// RunnerDeps so runYamlRecipe gets the
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1578
|
+
// For non-chained recipes, lift `runLog` AND `activityLog` from
|
|
1579
|
+
// chainedOptions onto the RunnerDeps so runYamlRecipe gets the
|
|
1580
|
+
// bridge's singletons too. The activityLog is what powers
|
|
1581
|
+
// recipe_started / recipe_step_start / recipe_step_done /
|
|
1582
|
+
// recipe_done SSE emission to dashboard subscribers.
|
|
1583
|
+
const lifted = { ...deps };
|
|
1584
|
+
if (deps.chainedOptions?.runLog)
|
|
1585
|
+
lifted.runLog = deps.chainedOptions.runLog;
|
|
1586
|
+
if (deps.chainedOptions?.activityLog)
|
|
1587
|
+
lifted.activityLog = deps.chainedOptions.activityLog;
|
|
1588
|
+
return runYamlRecipe(recipe, lifted, seedContext);
|
|
1367
1589
|
}
|
|
1368
1590
|
/** List all YAML recipes in a directory. Returns names. */
|
|
1369
1591
|
export function listYamlRecipes(recipesDir) {
|